diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index 01b7d9b9..852e03b6 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -35,6 +35,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; +import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -286,41 +287,14 @@ export async function mountM01Logic( persist(); }; - const onNew = (): void => { - const f = files.find((x) => x.file === opened?.file) ?? files[0]; - if (!f) return; - const id = `new:${Date.now()}`; - // 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌 - const row: LogicRow = { - 키: "", - 원문번호: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`, - 이름: "", - 결과단위: "원/", - 출처: "", - 소유: "공용", - 입력: [], - 중간: [], - 호표: [], - 덧줄: [], - 끝수: null, - }; - const next: Opened = { - id, - sub: subOf(f), - file: f.file, - version: f.version, - origKey: null, - row, - original: null, - prices: {}, - reasons: [], - lines: null, - values: {}, - }; - drafts[id] = pick(next, clone(row)); - persist(); - show(next); - }; + /** 「새로 만들기」 모달 — 저장·본떠 저장이 끝나면 그 새 자체 로직을 흐름 그림으로 바로 엶 */ + const onNew = (): void => + openLogicWizard({ + onSaved: (key) => + void reload() + .then(() => open(logicId("자체", key), "자체", key)) + .catch(failed), + }); /** 「본떠 만들기」 — 정본을 자체 파일로 복제하고(계약 5장) 그 새 로직을 바로 엶 */ const onCopy = async (from: Opened): Promise => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts index d3b6f611..2fadf47e 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts @@ -23,7 +23,7 @@ import { } from "./M01_MasterData_UI_Logic_Wizard_Model"; import { STEPS, type Step } from "./M01_MasterData_UI_Logic_Wizard_Steps"; import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text"; -import { plainReason } from "./M01_MasterData_UI_Test_Text"; +import { plainReason } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Logic_Wizard_Style.css"; export interface WizardOptions { diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 2dab4fc7..16091ea6 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)에 쌓이고 [저장] 한 번에 서버로 — @@ -54,7 +54,6 @@ 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") }); @@ -70,7 +69,6 @@ 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], @@ -88,9 +86,8 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (mode: "elements" | "logic" | "test"): void => { + const showMode = (mode: "elements" | "logic"): void => { logicHost.hidden = mode !== "logic"; - testHost.hidden = mode !== "test"; elementView.hidden = mode !== "elements"; }; @@ -196,20 +193,8 @@ function buildPage(): HTMLElement { }); }; - /* --- 좌측: 컨테이너 아홉 --- */ - const side = buildSide( - openFile, - () => openLogicTab(), - () => { - dispose(); - dispose = (): void => {}; - current = null; - showMode("test"); - if (testMounted) return; - testMounted = true; - void import("./M01_MasterData_UI_Test").then((m) => m.mountM01Test(testHost)); - }, - ); + /* --- 좌측: 컨테이너 --- */ + const side = buildSide(openFile, () => openLogicTab()); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { @@ -217,7 +202,7 @@ function buildPage(): HTMLElement { children: [ el("div", { className: "m01-master__panel", - children: [elementView, logicHost, testHost], + children: [elementView, logicHost], }), ], }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 6b7146bf..6c1a4311 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -127,13 +127,8 @@ const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; -const TEST_ID = "테스트|"; -export function buildSide( - onOpen: (pick: Pick) => void, - onLogic: () => void, - onTest: () => void, -): SideHandle { +export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { const lists = new Map(); const bodies = new Map(); const buttons = new Map(); @@ -408,16 +403,7 @@ export function buildSide( onLogic(); }); - // 테스트 컨테이너 — 눌러 펼치는 목록이 없는 한 줄(환율·요율과 같은 모양) - 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] }); + const root = el("div", { className: "m01-side", children: [...groups, logic] }); 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 deleted file mode 100644 index c345e550..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Test.ts - * 로직 테스트 컨테이너 틀 — 공종(로직) 10 개 고르기 + 방식 탭 A·B·C - * 읽기와 시험 계산만 — 저장 없음 · 정본 로직은 안 고침 · 계산은 로직 화면과 같은 `/calc` - * 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나 - * ========================================================================== */ - -import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; -import { fetchLogics } from "./M01_MasterData_UI_Logic_Api"; -import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard"; -import { render as renderA } from "./M01_MasterData_UI_Test_A"; -import { render as renderB } from "./M01_MasterData_UI_Test_B"; -import { render as renderC } from "./M01_MasterData_UI_Logic_Flow"; -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: renderB }, - { id: "C", label: () => tt("Tab_C"), render: renderC }, -]; - -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, - createButton({ label: tt("New_Logic"), onClick: () => openLogicWizard() }), - ], - }), - 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 deleted file mode 100644 index 6717ff80..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test_A.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Test_A.ts - * 방식 A — 질문·답 마법사: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표 - * 식은 안 보임 · 시험 계산은 로직 화면과 같은 `/calc` · 저장 없음 - * ========================================================================== */ - -import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; -import { - fetchLogic, - runCalc, - type CalcAnswer, - type ElementBrief, - type LogicInput, - type LogicOne, -} from "./M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Logic_Pick"; -import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text"; - -type Step = - { kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" }; - -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 steps: Step[] = [ - { kind: "job" }, - ...(logic.입력 ?? []).map((spec): Step => ({ kind: "ask", spec })), - ...(hasPickable(logic) ? [{ kind: "pick" } as Step] : []), - { kind: "result" }, - ]; - const picker = pickPanel(one, swaps, () => undefined); - 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.결과단위}` }), - el("ul", { children: guideLines("A").map((t) => el("li", { text: t })) }), - ], - 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", placeholder: tt("Enter_Value") }, - }); - 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 => { - frame(tt("Mat_Title"), tt("Mat_Note"), [picker], true); - }; - - 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 = swappedRow(logic, swaps); // 바꿔 본 것이 있으면 복사본으로만 셈 - 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: plainReason(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_B.ts b/M01_MasterData/M01_MasterData_UI_Test_B.ts deleted file mode 100644 index e582fc41..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test_B.ts +++ /dev/null @@ -1,291 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_Test_B.ts - * 로직 사용성 테스트 컨테이너 — 방식 B: 호표 + 옆 설명 카드 (PLAN.md 1-2) - * - * 접속 계약: export function render(host, logicKey) 하나. - * 읽기 + 시험 계산만 — 저장 없음 · 정본 로직 안 고침. 로직 읽기·시험 계산은 지금 - * 로직 화면이 쓰는 Store·API(fetchLogic · runCalc · elements · table)를 그대로 - * 씀 — 새 엔진·새 계산 없음(만약·찾기 식 자체는 서버가 풂 · 여기선 찾기(...) 가 - * 가리키는 원문 표를 다시 읽어 「걸린 줄」만 강조). - * - * 줄을 누르면 옆 카드 — 원문 표 미리보기(걸린 줄 강조) · 적용된 할증 · 쉬운 말 - * 풀이(식을 문장으로, 안 되면 원문 식 그대로) · 출처. 입력을 바꾸면 호표와 - * 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음). - * ========================================================================== */ - -import { createSelectField, el } from "@ui/ui_template_elements"; -import { - fetchLogic, - runCalc, - type CalcAnswer, - type CalcLine, - type HoLine, - type LogicOne, -} from "./M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { buildNote, relatedFinds, type EvalCtx } from "./M01_MasterData_UI_Logic_Note"; -import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick"; -import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text"; - -/* ── 화면 상태 ────────────────────────────────────────────────────────── */ - -interface State { - one: LogicOne; - values: Record; - answer: CalcAnswer | null; - selected: number; - calcSeq: number; - swaps: Swaps; - picker: HTMLElement; -} - -export function render(host: HTMLElement, logicKey: string): void { - host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" })); - void fetchLogic(logicKey) - .then((one) => { - const swaps: Swaps = new Map(); - const state: State = { - one, - // 고르기 칸은 첫 값으로 시작 — 방식 C 와 같게(바로 계산이 돎) - values: Object.fromEntries( - (one.logic.입력 ?? []) - .filter((spec) => spec.고르기?.length) - .map((spec) => [spec.이름, String(spec.고르기?.[0])]), - ), - answer: null, - selected: 0, - calcSeq: 0, - swaps, - picker: pickPanel(one, swaps, () => void recalc(host, state)), - }; - paint(host, state); - void recalc(host, state); - }) - .catch((error) => { - host.replaceChildren( - el("p", { className: "m01-logic__reasons", text: String(error?.message ?? error) }), - ); - }); -} - -async function recalc(host: HTMLElement, state: State): Promise { - const seq = ++state.calcSeq; - const inputs: Record = {}; - for (const spec of state.one.logic.입력 ?? []) { - const raw = (state.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); - } - try { - const answer = await runCalc({ - key: state.one.logic.키, - inputs, - row: swappedRow(state.one.logic, state.swaps) ?? state.one.logic, - file: state.one.file, - }); - if (seq !== state.calcSeq) return; // 최신 응답만 채택 - state.answer = answer; - } catch (error) { - if (seq !== state.calcSeq) return; - state.answer = { ok: false, reason: error instanceof Error ? error.message : String(error) }; - } - paint(host, state); -} - -/* ── 그리기 ───────────────────────────────────────────────────────────── */ - -function paint(host: HTMLElement, state: State): void { - const row = state.one.logic; - - const inputWrap = el("div", { className: "m01-logic__fields" }); - inputWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px;"; - for (const spec of row.입력 ?? []) { - let control: HTMLElement; - if (spec.고르기?.length) { - const pick = createSelectField({ - options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o || "(선택)" })), - value: state.values[spec.이름] ?? "", - compact: true, - onChange: (v) => { - state.values[spec.이름] = v; - void recalc(host, state); - }, - }); - control = pick.root; - } else { - const box = el("input", { - className: "m01-logic__input", - attrs: { type: "text", inputmode: "decimal", placeholder: tt("Enter_Value") }, - }); - box.value = state.values[spec.이름] ?? ""; - let timer: ReturnType | undefined; - box.addEventListener("input", () => { - state.values[spec.이름] = box.value; - clearTimeout(timer); - timer = setTimeout(() => void recalc(host, state), 300); - }); - control = box; - } - const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; - inputWrap.append( - el("label", { - className: "m01-logic__field", - children: [el("span", { text: label }), control], - }), - ); - } - - const main = el("div", {}); - main.style.cssText = "flex:1 1 60%;min-width:0;"; - main.append( - el("h3", { text: `${row.원문번호} ${row.이름}` }), - el("ul", { - className: "m01-logic__muted", - children: guideLines("B").map((t) => el("li", { text: t })), - }), - inputWrap, - ...(hasPickable(state.one.logic) - ? [ - el("details", { - attrs: { open: "" }, - children: [el("summary", { text: tt("Mat_Title") }), state.picker], - }), - ] - : []), - buildHoTable(host, state), - ); - if (state.answer && !state.answer.ok) { - main.append( - el("div", { - className: "m01-logic__reasons", - children: [ - el("strong", { text: "멈춤" }), - el("div", { text: plainReason(state.answer.reason) }), - ], - }), - ); - } - - const side = el("div", { className: "m01-logic__side" }); - side.style.cssText = - "flex:1 1 36%;min-width:280px;border:1px solid var(--ui-border,#ddd);border-radius:8px;padding:12px;"; - buildCard(side, state); - - const layout = el("div", {}); - layout.style.cssText = "display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;"; - layout.append(main, side); - host.replaceChildren(layout); -} - -function buildHoTable(host: HTMLElement, state: State): HTMLElement { - const lines: CalcLine[] | null = state.answer?.ok ? (state.answer.lines ?? null) : null; - const rows = (state.one.logic.호표 ?? []).map((ho, i) => { - const calc = lines?.[i]; - const tr = el("tr", { - className: i === state.selected ? "m01-logic__row is-selected" : "m01-logic__row", - children: [ - el("td", { text: ho.이름 ?? ho.요소 }), - el("td", { text: ho.규격 ?? "" }), - el("td", { text: calc ? `${formatNumber(calc.수량)} ${calc.단위}` : (ho.단위 ?? "") }), - el("td", { className: "m01-logic__money", text: calc ? formatNumber(calc.금액) : "" }), - ], - }); - tr.style.cssText = - "cursor:pointer;" + (i === state.selected ? "background:var(--ui-accent-bg,#eef4ff);" : ""); - tr.addEventListener("click", () => { - state.selected = i; - paint(host, state); - }); - return tr; - }); - return el("table", { - className: "m01-logic__grid", - children: [ - el("thead", { - children: [ - el("tr", { - children: ["이름", "규격", "수량", "금액"].map((h) => el("th", { text: h })), - }), - ], - }), - el("tbody", { - children: [ - ...rows, - // 하위 로직이 여러 줄로 풀린 경우 — 호표 줄 수보다 많은 줄도 빠짐없이 보임 - ...(lines ?? []).slice((state.one.logic.호표 ?? []).length).map((calc) => - el("tr", { - children: [ - el("td", { text: calc.이름 }), - el("td", { text: "" }), - el("td", { text: `${formatNumber(calc.수량)} ${calc.단위}` }), - el("td", { className: "m01-logic__money", text: formatNumber(calc.금액) }), - ], - }), - ), - ...(state.answer?.ok && state.answer.sums - ? [ - el("tr", { - attrs: { "data-total": String(state.answer.sums["계"] ?? 0) }, - children: [ - el("td", { text: tt("Sum_Total") }), - el("td", { text: "" }), - el("td", { text: "" }), - el("td", { text: formatNumber(state.answer.sums["계"] ?? 0) }), - ], - }), - ] - : []), - ], - }), - ], - }); -} - -function buildCard(side: HTMLElement, state: State): void { - const ho: HoLine | undefined = (state.one.logic.호표 ?? [])[state.selected]; - if (!ho) { - side.replaceChildren(el("p", { className: "m01-logic__muted", text: "호표 줄이 없음" })); - return; - } - const lines = state.answer?.ok ? (state.answer.lines ?? []) : []; - const calc = lines[state.selected]; - const middle = state.answer?.ok ? (state.answer.middle ?? {}) : {}; - const ctx: EvalCtx = { values: state.values, middle }; - const related = relatedFinds(ho.수량, state.one.logic.중간 ?? []); - - side.replaceChildren( - el("h4", { text: `이 수량은 어디서 왔나 — ${ho.이름 ?? ho.요소}` }), - el("dl", { - className: "m01-logic__sums", - children: [ - el("dt", { text: "지금 값" }), - el("dd", { - text: calc - ? `${formatNumber(calc.수량)} ${calc.단위} · ${formatNumber(calc.금액)}원` - : "값을 넣어야 계산됨", - }), - el("dt", { text: "출처" }), - el("dd", { text: calc?.출처 ?? ho.비목 ?? "-" }), - ], - }), - ); - - const surcharge = related.filter((r) => r.source !== "이 줄" && r.source.includes("할증")); - if (surcharge.length) { - side.append( - el("h4", { text: "적용된 할증" }), - ...surcharge.map((r) => - el("p", { - text: `${r.source} = ${middle[r.source] !== undefined ? formatNumber(middle[r.source]) : "(입력 필요)"}`, - }), - ), - ); - } - - // 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로) — 흐름 그림과 같은 카드 - const note = el("div"); - side.append(note); - buildNote(note, { expr: ho.수량, middles: state.one.logic.중간 ?? [], ctx }); -} diff --git a/M01_MasterData/M01_MasterData_UI_Test_Style.css b/M01_MasterData/M01_MasterData_UI_Test_Style.css deleted file mode 100644 index 3fbb2c66..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test_Style.css +++ /dev/null @@ -1,99 +0,0 @@ -/* 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__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 deleted file mode 100644 index b2252f5d..00000000 --- a/M01_MasterData/M01_MasterData_UI_Test_Text.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* ============================================================================= - * 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"], - New_Logic: ["새로 만들기", "Create new"], - 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; the lowest listed price is used", - ], - Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"], - Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"], - Pick_All: ["(전체)", "(All)"], - Use_Of: ["이 표의 용도", "Used for"], - Mat_Price: ["단가", "Unit price"], - Mat_NoPrice: ["단가 없음", "No price"], - Stop_Missing: [ - "「{name}」 값을 아직 안 넣었습니다 — 값을 넣으면 계산됩니다", - "「{name}」 is empty — enter it to calculate", - ], - Stop_NoRow: [ - "넣은 값에 맞는 표 줄이 없습니다 — 값을 바꿔 보세요", - "No table row fits these values — try other values", - ], - Stop_Spec: [ - "「{name}」 규격이 여럿이라 정하지 못함 — 위 「쓰이는 재료·기계」에서 하나를 골라 주세요", - "「{name}」 has several specs — pick one in the materials list above", - ], - Stop_NoPrice: [ - "「{name}」 단가가 자료에 비어 있어 셈하지 못함 — 자료를 채워야 함", - "「{name}」 has no price in the data yet", - ], - Guide_A: [ - "한 번에 하나씩 묻는 대로 답하면 끝에 결과 표가 나옴|값을 잘못 넣었으면 「이전」으로 돌아가 고침|재료·기계를 바꿔 보는 칸은 뒤쪽 단계에 있음", - "Answer one question at a time; the result table comes last|Use Back to fix a value|Swap materials in a later step", - ], - Guide_B: [ - "위쪽 칸에 값을 넣으면 아래 호표(재료·인력·기계 줄)의 수량과 금액이 바로 바뀜|줄을 누르면 오른쪽에 그 수량이 어디서 왔는지 나옴|맨 아래 「합계」가 최종 금액", - "Type values above and the table below updates at once|Click a row to see where its quantity comes from|The last row is the total", - ], - Guide_C: [ - "왼쪽 「설계 값」을 넣으면 오른쪽으로 수량 → 단가 → 합계 순으로 흐름이 이어짐|상자를 누르면 값과 출처가 펼쳐짐|빨간 글이 뜨면 그 까닭 그대로 값을 채우거나 바꿈", - "Enter design values on the left; quantity, price and total follow to the right|Click a box for details|Red text says what is missing", - ], - Enter_Value: ["값을 넣어 주세요", "Enter a value"], - 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 plainReason(reason: string): string { - const missing = /입력 「([^」]+)」 없음/.exec(reason); - if (missing) return tt("Stop_Missing", { name: missing[1] }); - if (/맞는 줄 0개/.test(reason)) return tt("Stop_NoRow"); - const spec = /「([^」]+)」 후보 여럿/.exec(reason); - if (spec) return tt("Stop_Spec", { name: spec[1] }); - const noPrice = /「([^」]+)」 값 없음/.exec(reason); - if (noPrice) return tt("Stop_NoPrice", { name: noPrice[1] }); - return reason; -} - -/** 방식 첫 화면 「이 방식은 이렇게 씀」 — 줄 목록 */ -export const guideLines = (id: "A" | "B" | "C"): string[] => - tt(`Guide_${id}` as TestTextKey).split("|"); - -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/tester/helper_m01_flow_c.cjs b/resources/tester/helper_m01_flow_c.cjs index b3ceec7f..9c4e1249 100644 --- a/resources/tester/helper_m01_flow_c.cjs +++ b/resources/tester/helper_m01_flow_c.cjs @@ -6,7 +6,7 @@ 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"), + path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Logic_Flow_Model.ts"), "utf8", ); const js = ts.transpileModule(source, { diff --git a/resources/tester/test_m01_flow_c.py b/resources/tester/test_m01_flow_c.py index ea1106be..9f5ed37c 100644 --- a/resources/tester/test_m01_flow_c.py +++ b/resources/tester/test_m01_flow_c.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Test_C_Model.ts`. +"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Logic_Flow_Model.ts`. 프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_m01_flow_c.cjs)가 TS를 트랜스파일해 돌리고, pytest 는 그 결과(JSON)를 본다. 뼈대만 봄 — 금액은 엔진이 준 답을 그대로 옮기는지. diff --git a/resources/tester/test_m01_test_container.py b/resources/tester/test_m01_test_container.py index 845cca8b..e27904eb 100644 --- a/resources/tester/test_m01_test_container.py +++ b/resources/tester/test_m01_test_container.py @@ -1,17 +1,25 @@ -"""M01 로직 테스트 컨테이너 — 대상 10 개(틀 파일 TEST_KEYS)가 실제 산림품셈 로직이고 막히지 않았는지. +"""M01 로직 테스트 컨테이너 — 견본 10 개가 실제 산림품셈 로직이고 막히지 않았는지. -방식 A·B·C 는 같은 `/calc` 를 씀 — 여기서는 10 개가 모두 첫 선택값으로 셈이 끝까지 가는지만 봄. +흐름 그림(방식 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) +# 견본 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서 · 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py` +KEYS = [ + "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 중기운반 트레일러 +] def _first_inputs(logic: dict) -> dict: