diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 60a842428..651edd4b3 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -11,6 +11,8 @@ 장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤만. 자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. +로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 · +책 — 차례 · 무게 · 상태는 그대로. 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). @@ -214,15 +216,35 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A if alias and alias.get("갈래") in SKIP: return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} if alias and alias.get("갈래") == "짝" and alias.get("짝"): - return {**out, **_aliased(line, ctx, alias, entry)} - if kind in hm.MATERIAL_KINDS: + got = _aliased(line, ctx, alias, entry) + elif kind in hm.MATERIAL_KINDS: got = _material(line, ctx, full) elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): got = _numeric(line, ctx, entry) if full else _brief(entry) else: why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) - return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)} + got = _loose(got, loose, line, ctx) if loose and full else got + return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)} + + +def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: + """묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 · + 차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫).""" + mine = hm.words(line.get("이름")) + theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or []) + table = hopyo_basis.ours() + + def mark(c: dict) -> dict: + one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직")) + if one is None: + return c + same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9 + book = (our or {}).get("책", "") + return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book} + + named = [mark(c) for c in hm.name_cands(line, ctx.got)] + return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 3bc65074d..5f77d3b4e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -292,10 +292,14 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st def candidates( - line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 + line: dict[str, Any], + pool: str, + got: dict[str, Any] | None = None, + limit: int | None = 5, + fold: bool = False, ) -> list[dict[str, Any]]: """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` - (None = 모두).""" + (None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -313,7 +317,8 @@ def candidates( scored.sort(key=lambda x: -x[0]) out = [] for score, one in scored: - label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음 + label = (one["이름"], one.get("규격"), one["단위"]) + label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면) if label in seen: continue seen.add(label) @@ -323,6 +328,19 @@ def candidates( return out +def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 + + 이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만).""" + got = got or targets() + same = [_public(x) for x in _exact(line, "로직", got)] + rest = [ + c + for c in candidates(line, "로직", got, 5, fold=True) + if {k: v for k, v in c.items() if k != "점수"} not in same + ] + return ([{**x, "점수": 9} for x in same] + rest)[:5] + + # ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ───────────────────── _OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 21202d055..02306d1ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -212,6 +212,17 @@ gap: var(--spacing-4); } +/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */ +.m02-hopyo__cand.is-weak { + color: var(--color-text-muted); +} + +.m02-hopyo__group > summary { + cursor: pointer; + color: var(--color-text-secondary); + margin-bottom: var(--spacing-4); +} + /* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */ .m02-hopyo-ledger__row.is-upper { color: var(--color-text-muted); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 818aec359..2b1d01c28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -92,6 +92,10 @@ export interface HopyoTarget { /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ 판정?: "맞음" | "다른 점" | "후보"; 글?: string; + /** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */ + 뜻겹침?: boolean; + 출처같음?: boolean; + 책?: string; } /** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ @@ -124,6 +128,8 @@ export interface HopyoMatch { 체크: boolean; 대상: HopyoPart[]; 후보?: HopyoTarget[]; + /** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */ + 이름후보?: HopyoTarget[]; 글?: string; 꼬리표?: string[]; 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index 26046f7fc..cf8022f09 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -1,10 +1,11 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Choose.ts - * 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. - * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행) - * · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음 - * · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 - * · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말) + * 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. + * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행) + * · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 · + * 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록) + * · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 + * · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2) * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ @@ -15,6 +16,7 @@ import type { HopyoBasisHit, HopyoLine, HopyoLineBasis, + HopyoMatch, HopyoQty, HopyoTarget, } from "./M02_MasterTemplete_Hopyo_Api"; @@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string => ].join(" · ") : ""; -/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */ -export function searchHint(line: Pick): string { - const clause = line.근거?.출처?.find((x) => x[1])?.[1]; - if (clause) return clause; +/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */ +export function searchHint(line: Pick): string { return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? ""; } +/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */ +export interface CandGroup { + 제목: string; + 접힘: boolean; + 후보: HopyoTarget[]; +} + +/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */ +export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false; + +/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) / + * 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */ +export function candGroups( + m: Pick, + material = false, +): CandGroup[] { + const all = m.후보 ?? []; + const seen = new Set(); + const take = (list: HopyoTarget[]): HopyoTarget[] => + list.filter((c) => { + const id = c.로직 ?? c.자재 ?? c.이름 ?? ""; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + const top = take(all.slice(0, 1)); + if (material) + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) }, + ].filter((g) => g.후보.length); + const named = m.이름후보 ?? []; + const source = take([...all.slice(1), ...named].filter((c) => c.출처같음)); + const name = take(named); + const rest = take(all.slice(1)); + rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b))); + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "출처 같음", 접힘: false, 후보: source }, + { 제목: "이름 닮은 로직", 접힘: false, 후보: name }, + { 제목: "숫자만 닮음", 접힘: true, 후보: rest }, + ].filter((g) => g.후보.length); +} + +/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */ +export function sameNames(groups: CandGroup[]): Set { + const count = new Map(); + for (const c of groups.flatMap((g) => g.후보)) + count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1); + return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name)); +} + +/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */ +export const candText = (c: HopyoTarget, twins: Set): string => + `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; + +/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */ +export const canPickMaterial = (line: Pick): boolean => + MATERIAL.has(line.갈래) || !!line.단가?.재료비; + /** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => picked.map(({ c, 몫 }) => ({ @@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): export interface ChooseOptions { line: HopyoLine; - /** [엑셀에서 보기] — 단가산출 그 행 */ + /** [엑셀 보기] — 단가산출 그 행 */ openSheet?: (행: [number, number]) => void; done: (targets: BuildTarget[]) => void; } -/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */ +/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */ export function openHopyoChoose(opt: ChooseOptions): void { const { line } = opt; const material = MATERIAL.has(line.갈래); @@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { opt.done(targets); }; const picked = new Map(); - const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true }); + const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true }); const paint = (): void => { - together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`; + together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`; together.disabled = picked.size < 2; }; together.addEventListener("click", () => @@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { ), ), ); - const list = (line.맞춤.후보 ?? []).map((c) => { + const groups = candGroups(line.맞춤, material); + const twins = sameNames(groups); + const row = (c: HopyoTarget): HTMLElement => { const share = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" }, @@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void { }); const tag = basisTag(c.근거); return el("div", { - className: "m02-hopyo__cand", + className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`, attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, children: [ box, createButton({ - label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + label: candText(c, twins), variant: "ghost", size: "sm", onClick: () => finish(pickedTargets([{ c, 몫: null }])), @@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ] : []), ...(c.글 ? [note(c.글)] : []), + ...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []), ...(tag && c.근거 ? [ el("span", { @@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void { share, ], }); + }; + const sections = groups.map((g) => { + const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) }); + const title = `${g.제목} (${g.후보.length})`; + if (!g.접힘) + return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] }); + const fold = el("details", { + className: "m02-hopyo__group", + attrs: { "data-group": g.제목 }, + children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list], + }); + return fold; }); const excel = qtyText(line.맞춤.품?.엑셀); const basis = line.근거; @@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ...(basis && opt.openSheet ? [ createButton({ - label: "엑셀에서 보기", + label: "엑셀 보기", variant: "ghost", size: "sm", onClick: () => opt.openSheet?.(basis.행), @@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void { : []), ], }); - const more = createButton({ - label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + const findMaterial = createButton({ + label: "다른 자재 검색…", size: "sm", onClick: () => { close(); - if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); - else - openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { - query: searchHint(line), - }); + pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); }, }); + const findLogic = createButton({ + label: "다른 로직 검색…", + size: "sm", + onClick: () => { + close(); + openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { + query: searchHint(line), + }); + }, + }); + const more = [ + ...(material ? [] : [findLogic]), + ...(canPickMaterial(line) ? [findMaterial] : []), + ]; body.append( ...(head.childElementCount ? [head] : []), - note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"), - el("div", { className: "m02-hopyo__cands", children: list }), - el("div", { className: "m02-hopyo__bar", children: [together, more] }), + note( + groups.length + ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]" + : "가까운 것 없음 — 아래 검색", + ), + ...sections, + el("div", { className: "m02-hopyo__bar", children: [together, ...more] }), ); }, }); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index dc55d7522..3bfc7ab22 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -159,6 +159,42 @@ let out0 = null; word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })], qty: choose.qtyText(lines[1].맞춤.품.엑셀), target: choose.targetText({ 호표: 46, 단위: "㎡" }), + // 358-1 후보 묶음 — 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(약한 후보 끝) · 같은 이름 책 + groups: (() => { + const m = { + 후보: [ + { 로직: "A", 이름: "되메우기", 판정: "맞음", 뜻겹침: true, 책: "산림품셈" }, + { 로직: "B", 이름: "야면석 채집", 판정: "후보", 뜻겹침: false }, + { + 로직: "C", + 이름: "되메우기", + 판정: "후보", + 뜻겹침: true, + 출처같음: true, + 책: "건설품셈", + }, + { 로직: "D", 이름: "표토펴기", 판정: "다른 점", 뜻겹침: false }, + ], + 이름후보: [ + { 로직: "A", 이름: "되메우기" }, + { 로직: "E", 이름: "되메우기(노상재)" }, + ], + }; + const g = choose.candGroups(m); + const twins = choose.sameNames(g); + return { + groups: g.map((x) => [x.제목, x.접힘, x.후보.map((c) => c.로직)]), + text: g.flatMap((x) => x.후보).map((c) => choose.candText(c, twins)), + material: choose + .candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true) + .map((x) => x.제목), + pick: [ + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }), + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }), + choose.canPickMaterial({ 갈래: "별도계상", 단가: {} }), + ], + }; + })(), }, ledger: (() => { const rows = input.ledger; diff --git a/resources/tester/test_m02_hopyo_golden.py b/resources/tester/test_m02_hopyo_golden.py index 1006a4aa4..76a4a915c 100644 --- a/resources/tester/test_m02_hopyo_golden.py +++ b/resources/tester/test_m02_hopyo_golden.py @@ -80,3 +80,18 @@ def test_씨앗_끔_1순위(no_seeds): def test_원본_그대로_셈_무게(run): assert master_hash() == run["원본"] assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분 + + +def test_이름후보_묶음_덧(run): + """358-1 — 이름후보(옛 324 다섯) = 묶음 덧 · 숫자 차례 그대로 → 다섯 안 ∪ 이름후보 ≥ 다섯 안.""" + have = [ + r for r in run["결과"]["줄"].values() if r["lab"]["무리"] == "사람" and r["lab"]["정답"] + ] + got = hm.targets() + five = [set(r["셈"]["차례"][:5]) for r in have] + named = [{c["로직"] for c in hm.name_cands(r["lab"], got)} for r in have] + near = sum(bool(f & set(r["lab"]["정답"])) for f, r in zip(five, have, strict=True)) + both = sum( + bool((f | n) & set(r["lab"]["정답"])) for f, n, r in zip(five, named, have, strict=True) + ) + assert both >= near >= 122 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index e593dd4d1..adc1a1ba7 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -245,3 +245,26 @@ def test_뜻_낱말이_안_겹치면_후보_아님(): assert got["상태"] == "없음" and got["top"] == [] fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침 + + +# ── 이름 닮은 로직(358-1 · 옛 324 데스크탑 다섯 그대로) ───────────────────── +def test_이름후보_옛_데스크탑_다섯(): + fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + got = [c["로직"] for c in hm.name_cands(fill)] + assert got == ["GF000113", "GF000194", "GC000108", "GC000109", "GC000451"] # 되메우기 다섯 + dowel = {"이름": "다웰바설치", "규격": "Φ32×1000", "단위": "EA", "갈래": "단가산출"} + assert [c["로직"] for c in hm.name_cands(dowel)][:3] == ["GC000226", "GF000201", "GF000202"] + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(soil) == [] # 뜻 낱말 안 겹치면 후보 아님 그대로 + same = {"이름": "되메우기", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(same)[0] == {**hm.name_cands(same)[0], "로직": "GF000113", "점수": 9} + + +def test_이름후보_뜻겹침_출처_표시(client): + _ready(client) + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert dig["이름후보"] and all({"뜻겹침", "출처같음", "책"} <= set(c) for c in dig["이름후보"]) + assert dig["후보"][0]["출처같음"] is True and dig["후보"][0]["책"] == "건설품셈" # 3-2-1 + assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만) + mat = _hopyo(client, 1)["레미콘(25-21-120)"] + assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 0a421e7fd..fd729e0b4 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -136,7 +136,16 @@ def test_확인_창_체크한_줄만_몸(): def test_고르기_창_도우미(): got = _check()["choose"] - assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 + assert got["hint"] == ["레미콘타설", "고임돌"] # 품명 첫 뜻 낱말(358-1 · 조항 번호 아님) + assert got["groups"]["groups"] == [ + ["1위", False, ["A"]], + ["출처 같음", False, ["C"]], + ["이름 닮은 로직", False, ["E"]], + ["숫자만 닮음", True, ["D", "B"]], # 뜻 낱말 안 겹치는 「후보」 = 끝 + ] + assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"] + assert got["groups"]["material"] == ["1위", "그 밖 후보"] + assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색 assert got["picked"] == [ {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None}, {"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1},