From 17838d00980272e3508597826db409aa6f00541c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 21:38:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01):=20=EB=A1=9C=EC=A7=81=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=A9=EC=8B=9D=20C=20=E2=80=94=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EA=B7=B8=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 일곱 칸. 상자를 누르면 값·출처 · 로직이 부르는 로직은 상자 안에서 펼침 · 설계 값을 바꾸면 바로 다시 셈(로직 화면과 같은 `fetchLogic`·`runCalc` — 읽기·시험 계산만). 뼈대는 순수 모듈로 떼어 Node 헬퍼 + pytest 로 검증. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- M01_MasterData/M01_MasterData_UI_Test_C.ts | 271 ++++++++++++++++++ .../M01_MasterData_UI_Test_C_Model.ts | 238 +++++++++++++++ .../M01_MasterData_UI_Test_C_Style.css | 182 ++++++++++++ resources/tester/helper_m01_flow_c.cjs | 141 +++++++++ resources/tester/test_m01_flow_c.py | 82 ++++++ 5 files changed, 914 insertions(+) create mode 100644 M01_MasterData/M01_MasterData_UI_Test_C.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Test_C_Model.ts create mode 100644 M01_MasterData/M01_MasterData_UI_Test_C_Style.css create mode 100644 resources/tester/helper_m01_flow_c.cjs create mode 100644 resources/tester/test_m01_flow_c.py diff --git a/M01_MasterData/M01_MasterData_UI_Test_C.ts b/M01_MasterData/M01_MasterData_UI_Test_C.ts new file mode 100644 index 00000000..11fef122 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_C.ts @@ -0,0 +1,271 @@ +/* ============================================================================= + * M01_MasterData_UI_Test_C.ts + * 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 + * + * 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길 + * (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는 + * 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜. + * 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Test_C_Model.ts`. + * ========================================================================== */ + +import { createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + fetchLogic, + runCalc, + type CalcAnswer, + type LogicOne, + type LogicRow, +} from "./M01_MasterData_UI_Logic_Api"; +import { + buildFlow, + type FlowBox, + type FlowColumn, + type FlowKind, +} from "./M01_MasterData_UI_Test_C_Model"; +import "./M01_MasterData_UI_Test_C_Style.css"; + +const TEXT = { + Loading: ["불러오는 중", "Loading"], + Failed: ["불러오지 못함", "Load failed"], + Stopped: ["멈춤", "Stopped"], + NoInputs: ["받을 값 없음", "No inputs"], + Other: ["그 밖", "Other"], + Sub: ["이 로직이 부르는 로직", "Logic called here"], + SubLines: ["호표", "Unit-cost lines"], + Col_입력: ["설계 값", "Design values"], + Col_중간: ["표 찾기", "Table lookup"], + Col_수량: ["수량", "Qty"], + Col_단가: ["× 단가", "× Unit price"], + Col_덧줄: ["할증·덧줄", "Extra lines"], + Col_비목: ["비목 합계", "Cost items"], + Col_계: ["계", "Total"], +} as const satisfies Record; + +const tc = (key: keyof typeof TEXT): string => + TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; + +const title = (kind: FlowKind): string => tc(`Col_${kind}` as keyof typeof TEXT); + +/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎) */ +export function render(host: HTMLElement, logicKey: string): void { + host.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") })); + void fetchLogic(logicKey) + .then((one) => mount(host, one)) + .catch((error: unknown) => { + host.replaceChildren( + el("p", { + className: "m01c__bad", + text: error instanceof Error ? error.message : tc("Failed"), + }), + ); + }); +} + +function mount(host: HTMLElement, one: LogicOne): void { + const row = one.logic; + const values: Record = {}; + // 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠) + for (const spec of row.입력 ?? []) { + if (spec.고르기?.length) values[spec.이름] = String(spec.고르기[0]); + } + const open = new Set(); + const subs = new Map(); + let answer: CalcAnswer | null = null; + let timer: number | undefined; + + const rest = el("div", { className: "m01c__rest" }); + const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } }); + + const boxView = (box: FlowBox): HTMLElement => { + const node = el("details", { + className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`, + children: [ + el("summary", { + children: [ + el("span", { className: "m01c__label", text: box.label }), + el("span", { className: "m01c__value", text: box.value }), + ...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []), + ], + }), + el("dl", { + className: "m01c__detail", + children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]), + }), + ], + }); + if (open.has(box.id)) node.open = true; + node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id))); + if (box.logic) node.append(subView(box.logic)); + return node; + }; + + /** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */ + const subView = (key: string): HTMLElement => { + const body = el("div", { className: "m01c__sub-body" }); + const node = el("details", { + className: "m01c__sub", + children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body], + }); + const fill = (sub: LogicRow): void => { + body.replaceChildren( + el("p", { className: "m01c__muted", text: `${sub.이름} (${sub.결과단위}) · ${sub.출처}` }), + el("p", { className: "m01c__muted", text: tc("SubLines") }), + el("ul", { + children: (sub.호표 ?? []).map((item) => + el("li", { text: `${item.이름 ?? item.요소} · ${item.수량}` }), + ), + }), + ); + }; + node.addEventListener("toggle", () => { + if (!node.open || body.childElementCount) return; + const had = subs.get(key); + if (had) { + fill(had); + return; + } + body.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") })); + void fetchLogic(key) + .then((deep) => { + subs.set(key, deep.logic); + fill(deep.logic); + }) + .catch(() => body.replaceChildren(el("p", { className: "m01c__bad", text: tc("Failed") }))); + }); + return node; + }; + + const columnView = (column: FlowColumn): HTMLElement => { + const head = el("h4", { className: "m01c__col-head", text: title(column.kind) }); + // 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」) + const costOf = (box: FlowBox): string => box.cost ?? tc("Other"); + const costs = [...new Set(column.boxes.map(costOf))]; + const body = + column.boxes.length > 6 && costs.length > 1 + ? costs.map((cost) => + el("details", { + className: "m01c__group", + attrs: { open: "" }, + children: [ + el("summary", { text: cost }), + ...column.boxes.filter((b) => costOf(b) === cost).map(boxView), + ], + }), + ) + : column.boxes.map(boxView); + return el("section", { className: "m01c__col", children: [head, ...body] }); + }; + + const redraw = (): void => { + const columns = buildFlow(row, answer, one.prices, values); + const nodes: HTMLElement[] = []; + for (const column of columns.slice(1)) { + nodes.push(el("span", { className: "m01c__arrow", text: "›" }), columnView(column)); + } + rest.replaceChildren(...nodes); + const reason = answer && !answer.ok ? answer.reason : ""; + stopped.textContent = reason ? `${tc("Stopped")} — ${reason}` : ""; + stopped.hidden = !reason; + }; + + const run = (): void => { + const inputs: Record = {}; + for (const spec of row.입력 ?? []) { + 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); + } + void runCalc({ key: row.키, inputs }) + .then((got) => { + answer = got; + redraw(); + }) + .catch((error: unknown) => { + showToast(error instanceof Error ? error.message : tc("Failed"), "error"); + }); + }; + + const later = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(run, 250); + }; + + host.replaceChildren( + el("div", { + className: "m01c", + children: [ + el("div", { + className: "m01c__head", + children: [ + el("h3", { text: `${row.원문번호} ${row.이름}` }), + el("span", { className: "m01c__muted", text: `${row.결과단위} · ${row.출처}` }), + ], + }), + ...(one.reasons.length + ? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })] + : []), + stopped, + el("div", { + className: "m01c__flow", + children: [inputColumn(row, values, later), rest], + }), + ], + }), + ); + redraw(); + run(); +} + +/** 설계 값 칸 — 한 번만 세움(다시 그려도 적던 값·글쇠 자리가 안 날아감) */ +function inputColumn( + row: LogicRow, + values: Record, + onChange: () => void, +): HTMLElement { + const fields = (row.입력 ?? []).map((spec) => { + let control: HTMLElement; + if (spec.고르기?.length) { + control = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value: values[spec.이름] ?? "", + compact: true, + onChange: (v) => { + values[spec.이름] = v; + onChange(); + }, + }).root; + } else { + const box = el("input", { + className: "m01c__input", + attrs: { type: "text", inputmode: "decimal" }, + }); + if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; + box.value = values[spec.이름] ?? ""; + box.addEventListener("input", () => { + values[spec.이름] = box.value; + onChange(); + }); + control = box; + } + return el("label", { + className: "m01c__field", + children: [ + el("span", { + className: "m01c__label", + text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, + }), + control, + ], + }); + }); + return el("section", { + className: "m01c__col m01c__col--input", + children: [ + el("h4", { className: "m01c__col-head", text: title("입력") }), + ...(fields.length ? fields : [el("p", { className: "m01c__muted", text: tc("NoInputs") })]), + ], + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_C_Model.ts b/M01_MasterData/M01_MasterData_UI_Test_C_Model.ts new file mode 100644 index 00000000..a7a8a656 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_C_Model.ts @@ -0,0 +1,238 @@ +/* ============================================================================= + * M01_MasterData_UI_Test_C_Model.ts + * 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자. + * 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 + * + * 순수 — DOM·서버를 안 씀(시험이 이 파일만 떼어 Node 로 돌림). + * 값은 모두 시험 계산 답(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음. + * ========================================================================== */ + +import type { + CalcAnswer, + CalcLine, + ElementBrief, + HoLine, + LogicRow, + NamedFormula, +} from "./M01_MasterData_UI_Logic_Api"; + +export type FlowKind = "입력" | "중간" | "수량" | "단가" | "덧줄" | "비목" | "계"; + +/** 흐름 상자 하나 — 누르면 `detail`(값과 출처) */ +export interface FlowBox { + /** 다시 그려도 같은 id — 펼친 상자를 그대로 둠 */ + id: string; + kind: FlowKind; + label: string; + value: string; + note?: string; + detail: [string, string][]; + /** 로직이 로직을 부르는 줄 — 그 로직 키(펼치기) */ + logic?: string; + /** 비목 — 상자가 많은 로직에서 묶기·접기 */ + cost?: string; + /** 값이 없어 막힌 상자 */ + bad?: boolean; +} + +export interface FlowColumn { + kind: FlowKind; + boxes: FlowBox[]; +} + +export const COSTS = ["노무비", "재료비", "경비"]; + +export function fmt(value: unknown): string { + if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); + if (value === null || value === undefined) return ""; + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + +/** 로직 줄의 결과가 돈인지 — 돈이면 호표·비목·계, 아니면 결과 식 하나 */ +export const isMoney = (row: LogicRow): boolean => + !("결과" in row) && (row.결과단위 ?? "").startsWith("원"); + +/** `로직(GC000268, 기계='…')` 에서 부르는 로직 키 */ +export function logicRef(element: string): string | undefined { + return /로직\(\s*([A-Z]{1,2}\w+)/.exec(element)?.[1]; +} + +const keep = (pairs: [string, unknown][]): [string, string][] => + pairs.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => [k, fmt(v)]); + +/** 파일에 있으나 화면 틀에 없는 칸(입력·호표의 출처·비고) */ +const extra = (item: object, name: string): string | undefined => + (item as Record)[name]; + +export function buildFlow( + row: LogicRow, + answer: CalcAnswer | null, + prices: Record, + values: Record, +): FlowColumn[] { + const ok = answer?.ok ? answer : null; + const lines = ok?.lines ?? []; + const middle = ok?.middle ?? {}; + const columns: FlowColumn[] = [inputColumn(row, values)]; + const mid = middleColumn(row, middle); + if (mid.boxes.length) columns.push(mid); + if (!isMoney(row)) { + columns.push(resultColumn(row, ok?.result)); + return columns; + } + const ho = row.호표 ?? []; + columns.push(qtyColumn(ho, lines), priceColumn(ho, lines, prices)); + const plus = extraColumn(row.덧줄 ?? [], lines, ho.length); + if (plus.boxes.length) columns.push(plus); + const sums = ok?.sums ?? null; + columns.push(costColumn(sums, lines), totalColumn(row, sums)); + return columns; +} + +function inputColumn(row: LogicRow, values: Record): FlowColumn { + const boxes = (row.입력 ?? []).map((spec, i) => ({ + id: `입력:${i}`, + kind: "입력" as const, + label: spec.이름, + value: values[spec.이름] ?? "", + note: spec.단위, + detail: keep([ + ["단위", spec.단위], + ["고르기", (spec.고르기 ?? []).join(" · ")], + ["범위", spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : ""], + ["출처", extra(spec, "출처")], + ]), + })); + return { kind: "입력", boxes }; +} + +function middleColumn(row: LogicRow, middle: Record): FlowColumn { + const boxes = (row.중간 ?? []).map((step, i) => ({ + id: `중간:${i}`, + kind: "중간" as const, + label: step.이름, + value: fmt(middle[step.이름]), + note: step.식.startsWith("찾기(") ? "표 찾기" : "식", + detail: keep([ + ["식", step.식], + ["출처", step.출처], + ]), + })); + return { kind: "중간", boxes }; +} + +function qtyColumn(ho: HoLine[], lines: CalcLine[]): FlowColumn { + const boxes = ho.map((item, i) => ({ + id: `수량:${i}`, + kind: "수량" as const, + label: item.이름 || item.요소, + value: lines[i] ? fmt(lines[i].수량) : "", + note: item.단위, + cost: item.비목, + detail: keep([ + ["식", item.수량], + ["종류", item.종류], + ["단위", item.단위], + ["비고", extra(item, "비고")], + ]), + })); + return { kind: "수량", boxes }; +} + +function priceColumn( + ho: HoLine[], + lines: CalcLine[], + prices: Record, +): FlowColumn { + const boxes = ho.map((item, i) => { + const line = lines[i]; + const brief = prices[item.요소]; + const price = line ? line.단가 : brief?.값; + return { + id: `단가:${i}`, + kind: "단가" as const, + label: item.이름 || item.요소, + value: line ? fmt(line.금액) : "", + note: price === undefined || price === null ? "단가 —" : `× ${fmt(price)}`, + cost: item.비목, + logic: logicRef(item.요소), + bad: !line && brief === null, + detail: keep([ + ["요소", item.요소], + ["단가", price], + ["금액", line?.금액], + ["비목", item.비목 ?? Object.keys(line?.비목 ?? {}).join(" · ")], + ["출처", line?.출처 ?? [brief?.이름, brief?.규격].filter(Boolean).join(" ")], + ]), + }; + }); + return { kind: "단가", boxes }; +} + +function extraColumn(list: NamedFormula[], lines: CalcLine[], from: number): FlowColumn { + const boxes = list.map((item, i) => ({ + id: `덧줄:${i}`, + kind: "덧줄" as const, + label: item.이름, + value: fmt(lines[from + i]?.금액), + note: item.비목, + cost: item.비목, + detail: keep([ + ["식", item.식], + ["비목", item.비목], + ["출처", item.출처], + ]), + })); + return { kind: "덧줄", boxes }; +} + +function costColumn(sums: Record | null, lines: CalcLine[]): FlowColumn { + const boxes = COSTS.map((cost) => ({ + id: `비목:${cost}`, + kind: "비목" as const, + label: cost, + value: fmt(sums?.[cost]), + cost, + detail: lines + .filter((line) => line.비목?.[cost] !== undefined) + .map((line) => [line.이름, fmt(line.비목[cost])] as [string, string]), + })); + return { kind: "비목", boxes }; +} + +function totalColumn(row: LogicRow, sums: Record | null): FlowColumn { + return { + kind: "계", + boxes: [ + { + id: "계", + kind: "계", + label: row.결과단위 || "계", + value: fmt(sums?.계), + detail: keep([ + ...COSTS.map((c) => [c, sums?.[c]] as [string, unknown]), + ["끝수", row.끝수], + ["출처", row.출처], + ]), + }, + ], + }; +} + +function resultColumn(row: LogicRow, result: unknown): FlowColumn { + return { + kind: "계", + boxes: [ + { + id: "계", + kind: "계", + label: row.결과단위 || "결과", + value: fmt(result), + detail: keep([ + ["식", row.결과?.식], + ["출처", row.출처], + ]), + }, + ], + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Test_C_Style.css b/M01_MasterData/M01_MasterData_UI_Test_C_Style.css new file mode 100644 index 00000000..434e52b1 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Test_C_Style.css @@ -0,0 +1,182 @@ +/* M01 로직 테스트 — 방식 C(흐름 그림). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */ +.m01c [hidden] { + display: none !important; +} + +.m01c { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + box-sizing: border-box; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01c__head { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +.m01c__head h3 { + margin: 0; + font-size: 16px; + color: var(--color-text); +} + +.m01c__flow { + display: flex; + align-items: flex-start; + gap: var(--spacing-4); + overflow-x: auto; + padding-bottom: var(--spacing-8); +} + +/* 값 칸은 다시 그려도 설계 값 칸은 그대로 — 그래서 한 겹 더 있음 */ +.m01c__rest { + display: contents; +} + +.m01c__col { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + flex: 0 0 auto; + width: 170px; + min-width: 0; +} + +.m01c__col--input { + width: 200px; +} + +.m01c__col-head { + margin: 0; + padding-bottom: 2px; + border-bottom: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: var(--text-caption); + font-weight: 600; +} + +.m01c__arrow { + align-self: center; + padding-top: 28px; + color: var(--color-text-muted); + font-size: 18px; +} + +.m01c__box, +.m01c__group { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.m01c__group { + border-style: dashed; + background: none; +} + +.m01c__group > summary { + padding: 2px 6px; + color: var(--color-text-muted); + font-size: var(--text-caption); + cursor: pointer; +} + +.m01c__group > .m01c__box { + margin: 2px; +} + +.m01c__box > summary { + display: flex; + flex-direction: column; + gap: 1px; + padding: 4px 6px; + cursor: pointer; +} + +.m01c__box--bad { + border-color: var(--color-danger); +} + +.m01c__label { + color: var(--color-text); + overflow-wrap: anywhere; +} + +.m01c__value { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.m01c__note, +.m01c__muted { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01c__bad { + margin: 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01c__detail { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 1px var(--spacing-4); + margin: 0; + padding: 4px 6px; + border-top: 1px solid var(--color-border); + font-size: var(--text-caption); +} + +.m01c__detail dt { + color: var(--color-text-muted); +} + +.m01c__detail dd { + margin: 0; + overflow-wrap: anywhere; +} + +.m01c__sub { + padding: 0 6px 4px; + font-size: var(--text-caption); +} + +.m01c__sub > summary { + color: var(--color-text-muted); + cursor: pointer; +} + +.m01c__sub-body ul { + margin: 2px 0; + padding-left: 14px; +} + +.m01c__field { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.m01c__input { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--text-body-sm); +} diff --git a/resources/tester/helper_m01_flow_c.cjs b/resources/tester/helper_m01_flow_c.cjs new file mode 100644 index 00000000..b3ceec7f --- /dev/null +++ b/resources/tester/helper_m01_flow_c.cjs @@ -0,0 +1,141 @@ +/* M01 방식 C(흐름 그림) 뼈대 검증 헬퍼 — TS를 그 자리에서 트랜스파일해 Node로 돌린다. + * (프론트에 JS 테스트 러너가 없어 pytest가 이 스크립트를 부른다 — test_m01_flow_c.py) + * 확장자가 `.cjs` 인 까닭은 helper_b05_patch_skirt.cjs 머리말 참고(루트가 ESM). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const source = fs.readFileSync( + path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Test_C_Model.ts"), + "utf8", +); +const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const moduleBox = { exports: {} }; +new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require); +const { buildFlow, logicRef } = moduleBox.exports; + +/** 산림품셈 13-4-1 메쌓기 모양 — 표 찾기 하나 · 인력 두 줄 · 덧줄 하나(할증) */ +const 메쌓기 = { + 키: "GF000219", + 원문번호: "13-4-1", + 이름: "돌쌓기 메쌓기(인력)", + 결과단위: "원/㎡", + 출처: "산림품셈 13-4-1", + 입력: [ + { 이름: "뒷길이", 단위: "㎝", 고르기: [25, 30] }, + { 이름: "높이", 단위: "m" }, + ], + 중간: [{ 이름: "증가율", 식: "찾기(QF000422, 높이=높이).증가율", 출처: "산림품셈 13-4-1 주③" }], + 호표: [ + { + 종류: "인력", + 요소: "LB000033", + 이름: "석공", + 단위: "인", + 수량: "찾기(QF000421, 뒷길이=뒷길이).석공", + 비목: "노무비", + }, + { + 종류: "로직", + 요소: "로직(GC000268, 기계='2702-0020')", + 이름: "굴착기", + 단위: "시간", + 수량: "1 / N", + 비목: "경비", + }, + ], + 덧줄: [{ 이름: "잡재료", 식: "노무비 * 0.03", 비목: "재료비", 출처: "산림품셈 13-4-1 주①" }], + 끝수: null, +}; + +const 답 = { + ok: true, + lines: [ + { 이름: "석공", 단위: "인", 수량: 1.2, 단가: 300000, 금액: 360000, 비목: { 노무비: 360000 } }, + { 이름: "굴착기", 단위: "시간", 수량: 0.5, 단가: 80000, 금액: 40000, 비목: { 경비: 40000 } }, + { 이름: "잡재료", 단위: "식", 수량: 1, 단가: 10800, 금액: 10800, 비목: { 재료비: 10800 } }, + ], + sums: { 노무비: 360000, 재료비: 10800, 경비: 40000, 계: 410800 }, + middle: { 증가율: 15 }, +}; + +/** 돈이 아닌 로직 — 결과 식 하나 */ +const 작업량 = { + 키: "GF000118", + 원문번호: "9-16-1", + 이름: "노체포설 시간당 작업량", + 결과단위: "㎥/hr", + 출처: "산림품셈 9-16-1", + 입력: [{ 이름: "토질", 고르기: ["보통토", "암괴"] }], + 중간: [{ 이름: "E", 식: "찾기(QF000300, 토질=토질).E" }], + 결과: { 식: "3600 * q * E / Cm" }, +}; + +const kinds = (columns) => columns.map((c) => c.kind); +const box = (columns, kind, i) => columns.find((c) => c.kind === kind).boxes[i]; + +const out = { + logicRef: [logicRef("로직(GC000268, 기계='2702-0020')"), logicRef("LB000033") ?? null], + 칸: kinds(buildFlow(메쌓기, 답, {}, { 뒷길이: "25" })), + 칸_계산전: kinds(buildFlow(메쌓기, null, {}, {})), + 칸_돈아님: kinds(buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {})), +}; + +{ + const columns = buildFlow(메쌓기, 답, {}, { 뒷길이: "25", 높이: "4" }); + const 입력 = box(columns, "입력", 0); + const 중간 = box(columns, "중간", 0); + const 수량 = box(columns, "수량", 0); + const 단가1 = box(columns, "단가", 0); + const 단가2 = box(columns, "단가", 1); + const 덧줄 = box(columns, "덧줄", 0); + const 계 = box(columns, "계", 0); + const 비목 = columns.find((c) => c.kind === "비목").boxes; + out.값 = { + 입력값: 입력.value, + 입력출처: 입력.detail, + 중간값: 중간.value, + 중간식: 중간.detail[0], + 수량값: 수량.value, + 수량식: 수량.detail[0], + 단가노트: 단가1.note, + 단가값: 단가1.value, + 로직: 단가2.logic ?? null, + 로직아님: 단가1.logic ?? null, + 덧줄값: 덧줄.value, + 덧줄식: 덧줄.detail[0], + 비목: 비목.map((b) => [b.label, b.value]), + 비목상세: 비목.map((b) => b.detail), + 계값: 계.value, + 묶음: columns.find((c) => c.kind === "수량").boxes.map((b) => b.cost), + id: [수량.id, 단가1.id, 덧줄.id, 계.id], + }; +} + +{ + const columns = buildFlow(메쌓기, null, { LB000033: null }, {}); + out.계산전 = { + 수량값: box(columns, "수량", 0).value, + 수량식: box(columns, "수량", 0).detail[0], + 단가막힘: box(columns, "단가", 0).bad === true, + 계값: box(columns, "계", 0).value, + }; +} + +{ + const columns = buildFlow(메쌓기, { ok: false, reason: "입력 없음" }, {}, {}); + out.멈춤 = { 칸: kinds(columns), 계값: box(columns, "계", 0).value }; +} + +{ + const columns = buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {}); + out.돈아님 = { + 중간값: box(columns, "중간", 0).value, + 결과값: box(columns, "계", 0).value, + 결과식: box(columns, "계", 0).detail[0], + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m01_flow_c.py b/resources/tester/test_m01_flow_c.py new file mode 100644 index 00000000..ea1106be --- /dev/null +++ b/resources/tester/test_m01_flow_c.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Test_C_Model.ts`. + +프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_m01_flow_c.cjs)가 TS를 트랜스파일해 +돌리고, pytest 는 그 결과(JSON)를 본다. 뼈대만 봄 — 금액은 엔진이 준 답을 그대로 옮기는지. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def flow(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m01_flow_c.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_칸_차례(flow): + """설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계.""" + assert flow["칸"] == ["입력", "중간", "수량", "단가", "덧줄", "비목", "계"] + # 계산 전에도 같은 뼈대 — 값만 비어 있음 + assert flow["칸_계산전"] == flow["칸"] + # 돈이 아닌 로직은 호표가 없음 + assert flow["칸_돈아님"] == ["입력", "중간", "계"] + + +def test_값은_엔진_답_그대로(flow): + v = flow["값"] + assert v["중간값"] == "15" + assert v["수량값"] == "1.2" + assert (v["단가노트"], v["단가값"]) == ("× 300,000", "360,000") + assert v["덧줄값"] == "10,800" + assert v["비목"] == [["노무비", "360,000"], ["재료비", "10,800"], ["경비", "40,000"]] + assert v["계값"] == "410,800" + + +def test_상자를_누르면_값과_출처(flow): + v = flow["값"] + assert v["중간식"] == ["식", "찾기(QF000422, 높이=높이).증가율"] + assert v["수량식"] == ["식", "찾기(QF000421, 뒷길이=뒷길이).석공"] + assert v["덧줄식"] == ["식", "노무비 * 0.03"] + assert ["단위", "㎝"] in v["입력출처"] + # 비목 상자 = 그 비목에 들어온 줄 + assert v["비목상세"] == [[["석공", "360,000"]], [["잡재료", "10,800"]], [["굴착기", "40,000"]]] + + +def test_로직이_로직을_부르는_줄(flow): + assert flow["logicRef"] == ["GC000268", None] + assert flow["값"]["로직"] == "GC000268" # 펼칠 상자 + assert flow["값"]["로직아님"] is None + + +def test_id_는_다시_그려도_같음(flow): + assert flow["값"]["id"] == ["수량:0", "단가:0", "덧줄:0", "계"] + assert flow["값"]["묶음"] == ["노무비", "경비"] # 비목으로 묶어 접기 + + +def test_계산_전과_멈춤(flow): + # 값이 없어도 식은 보임 · 값 없는 요소는 막힘 표시 + assert flow["계산전"]["수량값"] == "" + assert flow["계산전"]["수량식"][1].startswith("찾기(") + assert flow["계산전"]["단가막힘"] is True + assert flow["계산전"]["계값"] == "" + assert flow["멈춤"]["칸"] == flow["칸"] and flow["멈춤"]["계값"] == "" + + +def test_돈_아닌_로직(flow): + assert flow["돈아님"]["중간값"] == "0.8" + assert flow["돈아님"]["결과값"] == "42.5" + assert flow["돈아님"]["결과식"] == ["식", "3600 * q * E / Cm"]