From 292568e68802081397a4e31070f392a9c166dbf7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 21:34:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=ED=8B=80=20+=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20A=20=EC=A7=88=EB=AC=B8=C2=B7=EB=8B=B5=20?= =?UTF-8?q?=EB=A7=88=EB=B2=95=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 왼쪽 패널에 「일위대가 로직 테스트」 컨테이너 추가 — 공종 10 개 고르기 + 방식 탭 A·B·C (B·C 는 준비 중) - 방식 A: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표 · 식 안 보임 · 저장 없음 - 대상 10 개는 복잡도로 재어 고름(scripts/로직_복잡도_재기.py) · 시험 test_m01_test_container.py Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_UI_Page.ts | 52 +++- M01_MasterData/M01_MasterData_UI_Side.ts | 18 +- M01_MasterData/M01_MasterData_UI_Test.ts | 98 +++++++ M01_MasterData/M01_MasterData_UI_Test_A.ts | 274 ++++++++++++++++++ .../M01_MasterData_UI_Test_Style.css | 105 +++++++ M01_MasterData/M01_MasterData_UI_Test_Text.ts | 53 ++++ .../master_data/scripts/로직_복잡도_재기.py | 53 ++++ resources/tester/test_m01_test_container.py | 50 ++++ ui_template/ui_template_locale_m1.ts | 1 + 9 files changed, 686 insertions(+), 18 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_Test.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Test_A.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Test_Style.css create mode 100644 M01_MasterData/M01_MasterData_UI_Test_Text.ts create mode 100644 resources/master_data/scripts/로직_복잡도_재기.py create mode 100644 resources/tester/test_m01_test_container.py diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 79e47e04..5ad2e830 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_Page.ts - * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직) + * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 아홉: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직·로직 테스트) * / 우측 요소 표 · 표 목록 · 로직 편집 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — @@ -45,6 +45,7 @@ function buildPage(): HTMLElement { let query = ""; let dispose = (): void => {}; let logicMounted = false; + let testMounted = false; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -60,6 +61,7 @@ function buildPage(): HTMLElement { children: [title, searchField.root, summary, drop, save], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); + const testHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [head, notice, body], @@ -77,14 +79,15 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (logic: boolean): void => { - logicHost.hidden = !logic; - elementView.hidden = logic; + const showMode = (mode: "elements" | "logic" | "test"): void => { + logicHost.hidden = mode !== "logic"; + testHost.hidden = mode !== "test"; + elementView.hidden = mode !== "elements"; }; const openFile = (pick: Pick): void => { dispose(); - showMode(false); + showMode("elements"); if (pick.view) { query = pick.view.q; search.value = query; @@ -162,21 +165,38 @@ function buildPage(): HTMLElement { return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again]; }; - /* --- 좌측: 컨테이너 여덟 --- */ - const side = buildSide(openFile, () => { - dispose(); - dispose = (): void => {}; - current = null; - showMode(true); - if (logicMounted) return; - logicMounted = true; - void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side)); - }); + /* --- 좌측: 컨테이너 아홉 --- */ + const side = buildSide( + openFile, + () => { + dispose(); + dispose = (): void => {}; + current = null; + showMode("logic"); + if (logicMounted) return; + logicMounted = true; + void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side)); + }, + () => { + dispose(); + dispose = (): void => {}; + current = null; + showMode("test"); + if (testMounted) return; + testMounted = true; + void import("./M01_MasterData_UI_Test").then((m) => m.mountM01Test(testHost)); + }, + ); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { className: "ui-workflow-layout__main", - children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })], + children: [ + el("div", { + className: "m01-master__panel", + children: [elementView, logicHost, testHost], + }), + ], }); const overlays = createWorkflowOverlays({ title: L("M01_Title"), diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index e9ec3b65..f3361854 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -104,8 +104,13 @@ const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; +const TEST_ID = "테스트|"; -export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { +export function buildSide( + onOpen: (pick: Pick) => void, + onLogic: () => void, + onTest: () => void, +): SideHandle { const lists = new Map(); const bodies = new Map(); const buttons = new Map(); @@ -375,7 +380,16 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si onLogic(); }); - const root = el("div", { className: "m01-side", children: [...groups, logic] }); + // 테스트 컨테이너 — 눌러 펼치는 목록이 없는 한 줄(환율·요율과 같은 모양) + const test = section(L("M01_GroupTest"), null); + test.classList.add("m01-side__leaf"); + buttons.set(TEST_ID, test); + test.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { + setActive(TEST_ID); + onTest(); + }); + + const root = el("div", { className: "m01-side", children: [...groups, logic, test] }); attachCollapsible(root); void Promise.all(GROUPS.map(refresh)).catch((error) => showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), diff --git a/M01_MasterData/M01_MasterData_UI_Test.ts b/M01_MasterData/M01_MasterData_UI_Test.ts new file mode 100644 index 00000000..999c9b41 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * M01_MasterData_UI_Test.ts + * 로직 테스트 컨테이너 틀 — 공종(로직) 10 개 고르기 + 방식 탭 A·B·C + * 읽기와 시험 계산만 — 저장 없음 · 정본 로직은 안 고침 · 계산은 로직 화면과 같은 `/calc` + * 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나 + * ========================================================================== */ + +import { createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { fetchLogics } from "./M01_MasterData_UI_Logic_Api"; +import { render as renderA } from "./M01_MasterData_UI_Test_A"; +import { tt } from "./M01_MasterData_UI_Test_Text"; +import "./M01_MasterData_UI_Test_Style.css"; + +/** 시험 대상 산림품셈 로직 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서. + * 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py` · 메쌓기·노체·트럭 운반은 끝까지 눌러 보는 세 개. */ +export const TEST_KEYS: readonly string[] = [ + "GF000181", // 12-16 맨홀 + "GF000177", // 12-12 날개벽 + "GF000173", // 12-11-1 VR관(소켓식) 부설 + "GF000174", // 12-11-2-1 흄관 부설 + "GF000160", // 12-4 합판거푸집 + "GF000182", // 12-17-1 펌프카 타설 + "GF000069", // 7-11 동력상하차기 집재 + "GF000219", // 13-4-1 돌쌓기 메쌓기(인력) + "GF000119", // 9-16-1 노체포설 + "GF000137", // 10-4 중기운반 트레일러 +]; + +type Render = (host: HTMLElement, logicKey: string) => void; + +/** 방식 파일이 오면 여기 한 줄씩 잇는다 — null 은 준비 중 */ +const METHODS: { id: string; label: () => string; render: Render | null }[] = [ + { id: "A", label: () => tt("Tab_A"), render: renderA }, + { id: "B", label: () => tt("Tab_B"), render: null }, + { id: "C", label: () => tt("Tab_C"), render: null }, +]; + +export function mountM01Test(host: HTMLElement): void { + let logicKey = TEST_KEYS[0]; + let method = METHODS[0].id; + const body = el("div", { className: "m01-test__body" }); + const tabs = el("div", { className: "m01-test__tabs" }); + const select = createSelectField({ + label: tt("Pick_Logic"), + options: TEST_KEYS.map((k) => ({ value: k, text: k })), + value: logicKey, + onChange: (v) => { + logicKey = v; + draw(); + }, + }); + + const draw = (): void => { + tabs.replaceChildren( + ...METHODS.map((m) => + el("button", { + className: `m01-test__tab${m.id === method ? " is-active" : ""}`, + text: m.label(), + attrs: { type: "button", "data-method": m.id }, + }), + ), + ); + tabs.querySelectorAll("button").forEach((b) => + b.addEventListener("click", () => { + method = b.dataset.method ?? "A"; + draw(); + }), + ); + const one = METHODS.find((m) => m.id === method); + if (one?.render) one.render(body, logicKey); + else body.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Soon") })); + }; + + host.replaceChildren( + el("div", { + className: "m01-test", + children: [ + el("div", { + className: "m01-test__bar", + children: [el("h2", { text: tt("Title") }), select.root, tabs], + }), + el("p", { className: "m01-test__muted", text: tt("Read_Only") }), + body, + ], + }), + ); + // 목록에서 이름을 받아 「번호 이름」 으로 바꿈 — 못 받아도 키로 쓸 수 있음 + fetchLogics() + .then((all) => { + const label = new Map(all.map((r) => [r.키, `${r.원문번호} ${r.이름}`])); + select.setOptions( + TEST_KEYS.map((k) => ({ value: k, text: label.get(k) ?? k })), + logicKey, + ); + }) + .catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error")); + draw(); +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_A.ts b/M01_MasterData/M01_MasterData_UI_Test_A.ts new file mode 100644 index 00000000..6d61d9c4 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_A.ts @@ -0,0 +1,274 @@ +/* ============================================================================= + * M01_MasterData_UI_Test_A.ts + * 방식 A — 질문·답 마법사: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표 + * 식은 안 보임 · 시험 계산은 로직 화면과 같은 `/calc` · 저장 없음 + * ========================================================================== */ + +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { + fetchLogic, + runCalc, + searchElements, + type CalcAnswer, + type ElementBrief, + type HoLine, + type LogicInput, + type LogicOne, +} from "./M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; +import { tt } from "./M01_MasterData_UI_Test_Text"; + +type Step = + { kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" }; + +const PICKABLE = ["재료", "기계"]; + +export function render(host: HTMLElement, logicKey: string): void { + host.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Loading") })); + fetchLogic(logicKey) + .then((one) => wizard(host, one)) + .catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error")); +} + +function wizard(host: HTMLElement, one: LogicOne): void { + const { logic } = one; + const values: Record = {}; + const swaps = new Map(); + const lines = (logic.호표 ?? []) + .map((line, i) => ({ line, i })) + .filter((x) => PICKABLE.includes(x.line.종류)); + const steps: Step[] = [ + { kind: "job" }, + ...(logic.입력 ?? []).map((spec): Step => ({ kind: "ask", spec })), + ...(lines.length ? [{ kind: "pick" } as Step] : []), + { kind: "result" }, + ]; + let at = 0; + + const go = (next: number): void => { + at = next; + draw(); + }; + + const frame = ( + title: string, + note: string, + content: HTMLElement[], + nextOk: boolean, + ): HTMLButtonElement => { + const last = steps[at].kind === "result"; + const back = createButton({ label: tt("Back"), variant: "ghost", onClick: () => go(at - 1) }); + back.disabled = at === 0; + const next = createButton({ label: tt("Next"), onClick: () => go(at + 1) }); + next.disabled = !nextOk; + const again = createButton({ label: tt("Again"), variant: "ghost", onClick: () => go(0) }); + host.replaceChildren( + el("div", { + className: "m01-test__card", + children: [ + el("p", { + className: "m01-test__step", + text: tt("Step", { n: at + 1, total: steps.length }), + }), + el("h3", { text: title }), + ...(note ? [el("p", { className: "m01-test__muted", text: note })] : []), + ...content, + el("div", { className: "m01-test__nav", children: last ? [back, again] : [back, next] }), + ], + }), + ); + return next; + }; + + const draw = (): void => { + const step = steps[at]; + if (step.kind === "job") { + frame( + `${logic.원문번호} ${logic.이름}`, + tt("Job_Note"), + [el("p", { text: `${tt("Job_Title")} · ${logic.결과단위}` })], + true, + ); + } else if (step.kind === "ask") drawAsk(step.spec); + else if (step.kind === "pick") drawPick(); + else void drawResult(); + }; + + const drawAsk = (spec: LogicInput): void => { + const name = spec.이름; + const ready = (): boolean => (values[name] ?? "").trim() !== ""; + let next: HTMLButtonElement | null = null; + const changed = (v: string): void => { + values[name] = v; + if (next) next.disabled = !ready(); + }; + let control: HTMLElement; + if (spec.고르기?.length) { + control = createSelectField({ + options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })), + value: values[name] ?? "", + onChange: changed, + }).root; + } else { + const box = el("input", { + className: "m01-test__input", + attrs: { type: "text", inputmode: "decimal" }, + }); + box.value = values[name] ?? ""; + box.addEventListener("input", () => changed(box.value)); + control = box; + } + const question = spec.고르기?.length + ? tt("Ask_Pick", { name }) + : tt("Ask_Number", { name, unit: spec.단위 ? ` (${spec.단위})` : "" }); + const hint = spec.범위 ? tt("Ask_Range", { min: spec.범위[0], max: spec.범위[1] }) : ""; + next = frame(question, hint, [control], ready()); + }; + + const drawPick = (): void => { + const rows = lines.map(({ line, i }) => pickRow(line, i)); + frame( + tt("Mat_Title"), + tt("Mat_Note"), + rows.length ? rows : [el("p", { text: tt("Mat_None") })], + true, + ); + }; + + const pickRow = (line: HoLine, i: number): HTMLElement => { + const price = one.prices[line.요소]?.값; + const found = new Map(); + const select = createSelectField({ + options: [{ value: "", text: tt("Mat_Default") }], + value: "", + onChange: (ref) => { + const item = found.get(ref); + if (item) swaps.set(i, item); + else swaps.delete(i); + }, + }); + void searchElements(line.종류, line.이름 ?? "") + .then((r) => { + r.items.forEach((it) => found.set(it.ref, it)); + select.setOptions( + [ + { value: "", text: tt("Mat_Default") }, + ...r.items.map((it) => ({ + value: it.ref, + text: [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), formatNumber(it.값)] + .filter(Boolean) + .join(" · "), + })), + ], + swaps.get(i)?.ref ?? "", + ); + }) + .catch(() => undefined); + const spec = line.규격 ? ` ${line.규격}` : ""; + const unit = line.단위 ?? ""; + const cost = + price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : ""; + return el("div", { + className: "m01-test__pick", + children: [ + el("strong", { text: `${line.종류} · ${line.이름 ?? line.요소}${spec}` }), + el("span", { className: "m01-test__muted", text: `${unit}${cost}` }), + select.root, + ], + }); + }; + + const drawResult = async (): Promise => { + const inputs: Record = {}; + for (const spec of logic.입력 ?? []) { + const raw = (values[spec.이름] ?? "").trim(); + if (raw === "") continue; + const option = spec.고르기?.find((o) => String(o) === raw); + inputs[spec.이름] = + option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw); + } + // 바꿔 본 재료·기계가 있으면 복사본으로만 셈 — 정본 로직은 그대로 + const row = swaps.size + ? { + ...logic, + 호표: (logic.호표 ?? []).map((l, i) => { + const s = swaps.get(i); + return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l; + }), + } + : undefined; + let answer: CalcAnswer; + try { + answer = await runCalc({ key: logic.키, inputs, ...(row ? { row, file: one.file } : {}) }); + } catch (error) { + showToast(error instanceof Error ? error.message : tt("Failed"), "error"); + return; + } + frame(tt("Result_Title"), "", resultView(answer), true); + }; + + draw(); +} + +function resultView(answer: CalcAnswer): HTMLElement[] { + if (!answer.ok) { + return [ + el("div", { + className: "m01-test__reasons", + children: [ + el("strong", { text: tt("Result_Stopped") }), + el("div", { text: answer.reason }), + ], + }), + ]; + } + const out: HTMLElement[] = []; + if (answer.lines) { + out.push( + el("table", { + className: "m01-test__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tt("Col_Name"), tt("Col_Qty"), tt("Col_Amount")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { + children: answer.lines.map((l) => + el("tr", { + children: [ + el("td", { text: l.출처 ? `${l.이름} (${l.출처})` : l.이름 }), + el("td", { text: `${formatNumber(l.수량)} ${l.단위}` }), + el("td", { className: "m01-test__money", text: formatNumber(l.금액) }), + ], + }), + ), + }), + ], + }), + ); + } + if (answer.sums) { + out.push( + el("p", { + className: "m01-test__total", + attrs: { "data-total": String(answer.sums["계"] ?? 0) }, + text: `${tt("Sum_Total")} ${formatNumber(answer.sums["계"] ?? 0)}`, + }), + ); + } + if (answer.result !== undefined) { + out.push( + el("p", { + className: "m01-test__total", + attrs: { "data-total": String(answer.result) }, + text: `${tt("Sum_Result")} ${formatNumber(answer.result)}`, + }), + ); + } + return out; +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Style.css b/M01_MasterData/M01_MasterData_UI_Test_Style.css new file mode 100644 index 00000000..fa9f7842 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_Style.css @@ -0,0 +1,105 @@ +/* M01 로직 테스트 컨테이너 — 틀 + 방식 A (방식 B·C 는 각자 파일에서 같은 접두 m01-test__ 를 씀) */ +.m01-test { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + padding: var(--spacing-12); + box-sizing: border-box; + overflow: auto; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-test__bar, +.m01-test__tabs, +.m01-test__nav { + display: flex; + align-items: center; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +.m01-test__bar h2 { + margin: 0 auto 0 0; + font-size: 18px; + color: var(--color-text); +} + +.m01-test__tab { + padding: var(--spacing-4) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: transparent; + color: inherit; + cursor: pointer; +} + +.m01-test__tab.is-active { + background: var(--color-surface); + font-weight: 600; +} + +.m01-test__muted { + margin: 0; + color: var(--color-text-muted); +} + +.m01-test__card { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + max-width: 640px; + padding: var(--spacing-12); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + +.m01-test__card h3, +.m01-test__step { + margin: 0; +} + +.m01-test__pick { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-test__input { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: transparent; + color: inherit; +} + +.m01-test__grid { + width: 100%; + border-collapse: collapse; +} + +.m01-test__grid th, +.m01-test__grid td { + padding: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + text-align: left; +} + +.m01-test__money { + text-align: right !important; + font-variant-numeric: tabular-nums; +} + +.m01-test__total { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.m01-test__reasons { + padding: var(--spacing-8); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: var(--color-danger, #c0392b); +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Text.ts b/M01_MasterData/M01_MasterData_UI_Test_Text.ts new file mode 100644 index 00000000..29b87d46 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_Text.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * M01_MasterData_UI_Test_Text.ts + * 로직 테스트 컨테이너 글자 — [한국어, 영어] · 틀과 방식 파일(A·B·C)이 같이 씀 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +const TEXT = { + Title: ["일위대가 로직 테스트", "Logic test"], + Pick_Logic: ["공종 고르기", "Pick a job"], + Tab_A: ["A 질문·답", "A Wizard"], + Tab_B: ["B 호표 + 설명", "B Table + notes"], + Tab_C: ["C 흐름 그림", "C Flow"], + Soon: ["준비 중", "Coming soon"], + Loading: ["불러오는 중", "Loading"], + Failed: ["불러오지 못함", "Load failed"], + Read_Only: ["시험 계산만 — 저장하지 않음", "Trial only — nothing is saved"], + Step: ["{n} / {total}", "{n} / {total}"], + Back: ["이전", "Back"], + Next: ["다음", "Next"], + Again: ["처음부터", "Start over"], + Job_Title: ["이 공종의 단가를 셈합니다", "Price this job"], + Job_Note: [ + "몇 가지만 물어봅니다 — 답을 마치면 결과 표가 나옵니다", + "A few questions, then the result table", + ], + Ask_Pick: ["「{name}」 — 어느 것으로 할까요?", "「{name}」 — which one?"], + Ask_Number: ["「{name}」 값을 넣어 주세요{unit}", "Enter 「{name}」{unit}"], + Ask_Range: ["범위 {min} ∼ {max}", "Range {min} ∼ {max}"], + Mat_Title: ["쓰이는 재료·기계", "Materials and machines"], + Mat_Note: [ + "기본은 원문 그대로 — 바꿔 보면 결과가 달라짐(저장 안 함)", + "Default is the source as is — swapping only changes this trial", + ], + Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"], + Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"], + Mat_Price: ["단가", "Unit price"], + Result_Title: ["결과", "Result"], + Result_Stopped: ["계산이 멈춤", "Calculation stopped"], + Col_Name: ["이름", "Name"], + Col_Qty: ["수량", "Qty"], + Col_Amount: ["금액", "Amount"], + Sum_Total: ["합계", "Total"], + Sum_Result: ["결과 값", "Result"], +} as const satisfies Record; + +export type TestTextKey = keyof typeof TEXT; + +export function tt(key: TestTextKey, fill: Record = {}): string { + const entry = TEXT[key]; + const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0]; + return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); +} diff --git a/resources/master_data/scripts/로직_복잡도_재기.py b/resources/master_data/scripts/로직_복잡도_재기.py new file mode 100644 index 00000000..662f6e9f --- /dev/null +++ b/resources/master_data/scripts/로직_복잡도_재기.py @@ -0,0 +1,53 @@ +"""산림품셈 로직 복잡도 재기 — 입력 수 · 표 찾기 수 · 로직 부르기 · 덧줄 · 재료 가짓수. + +사용: python resources/master_data/scripts/로직_복잡도_재기.py [상위 N] +점수 = 입력 + 찾기 + 로직부르기*2 + 덧줄 + 재료 (읽기 전용 · 아무것도 안 씀) +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIND = re.compile(r"찾기\(") +CALL = re.compile(r"로직\(") + + +def measure(row: dict) -> dict: + ho = row.get("호표") or [] + text = " ".join(str(h.get("수량", "")) + str(h.get("요소", "")) for h in ho) + text += " ".join(str(m.get("식", "")) for m in row.get("중간") or []) + text += " ".join(str(m.get("식", "")) for m in row.get("덧줄") or []) + m = { + "입력": len(row.get("입력") or []), + "찾기": len(FIND.findall(text)), + "로직": len(CALL.findall(text)), + "덧줄": len(row.get("덧줄") or []), + "재료": sum(1 for h in ho if h.get("종류") == "재료"), + } + m["점수"] = m["입력"] + m["찾기"] + m["로직"] * 2 + m["덧줄"] + m["재료"] + return m + + +def main() -> None: + top = int(sys.argv[1]) if len(sys.argv) > 1 else 25 + out = [] + for f in sorted(ROOT.glob("로직_산림품셈_*.json")): + for row in json.loads(f.read_text(encoding="utf-8"))["줄"]: + out.append( + {"키": row["키"], "번호": row["원문번호"], "이름": row["이름"], **measure(row)} + ) + out.sort(key=lambda r: -r["점수"]) + for r in out[:top]: + print( + r["키"], + r["번호"], + r["이름"], + {k: r[k] for k in ("입력", "찾기", "로직", "덧줄", "재료", "점수")}, + ) + print("총", len(out)) + + +if __name__ == "__main__": + main() diff --git a/resources/tester/test_m01_test_container.py b/resources/tester/test_m01_test_container.py new file mode 100644 index 00000000..845cca8b --- /dev/null +++ b/resources/tester/test_m01_test_container.py @@ -0,0 +1,50 @@ +"""M01 로직 테스트 컨테이너 — 대상 10 개(틀 파일 TEST_KEYS)가 실제 산림품셈 로직이고 막히지 않았는지. + +방식 A·B·C 는 같은 `/calc` 를 씀 — 여기서는 10 개가 모두 첫 선택값으로 셈이 끝까지 가는지만 봄. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as store + +FRAME = Path(store.__file__).with_name("M01_MasterData_UI_Test.ts") +KEYS = re.findall(r'^\s+"(GF\d{6})",', FRAME.read_text(encoding="utf-8"), re.M) + + +def _first_inputs(logic: dict) -> dict: + """고르기 = 첫 값 · 범위 = 아래 끝 · 나머지 = 10 (마법사가 흔히 넣는 값).""" + out = {} + for spec in logic.get("입력") or []: + if spec.get("고르기"): + out[spec["이름"]] = spec["고르기"][0] + elif spec.get("범위"): + out[spec["이름"]] = spec["범위"][0] + else: + out[spec["이름"]] = 10 + return out + + +def test_ten_keys(): + assert len(KEYS) == 10 and len(set(KEYS)) == 10 + + +def test_keys_are_forest_logics_and_not_blocked(): + for key in KEYS: + one = store.logic(key) + assert one["file"].startswith("로직_산림품셈_"), key + assert not one["blocked"], (key, one["reasons"]) + + +def test_required_three_present(): + # 메쌓기 13-4-1 · 노체 9-16-1 · 트럭 운반 10-4 — 끝까지 눌러 보는 셋 + assert {"GF000219", "GF000119", "GF000137"} <= set(KEYS) + + +def test_calc_answers_for_each(): + for key in KEYS: + one = store.logic(key) + answer = store.calc(key, _first_inputs(one["logic"]), None, None) + assert isinstance(answer, dict) and "ok" in answer, key diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 33c82a98..98907892 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -67,4 +67,5 @@ export const ui_locales_m1 = { M01_GroupExchange: ["환율", "Exchange rates"], M01_GroupRate: ["요율", "Rates"], M01_GroupLogic: ["일위대가 로직", "Unit-cost logic"], + M01_GroupTest: ["일위대가 로직 테스트", "Logic test"], } as const;