From b0d908c68bd3f25644054c57e24367a54cd58449 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:01:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=C2=B7=20=EC=8B=9C=ED=97=98=ED=8C=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=C2=B7=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=C2=B7=20=EA=B3=A0=EC=B9=98=EB=8A=94=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80(1=EA=B1=B8=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 4 + M02_MasterTemplete/CostFlow/M02_CF_Entry.css | 155 +++++++++++ M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 260 +++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Entry.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Entry.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 18f02794c..1daa1b757 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -45,6 +45,8 @@ import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry"; import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry"; // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 import { createSheetLogicButton } from "./SheetLogic/M01_SL_Entry"; +// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 +import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -410,6 +412,8 @@ export async function mountM01LogicLab( createLogicBuilderButton(), // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 createSheetLogicButton(), + // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createCostFlowButton(), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css new file mode 100644 index 000000000..a967d85f7 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -0,0 +1,155 @@ +/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */ + +.m02-cf { + position: fixed; + inset: 0; + z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */ + overflow-x: hidden; + overflow-y: auto; + background: var(--color-canvas); + color: var(--color-text); + font-size: var(--text-body); +} + +.m02-cf:focus { + outline: none; +} + +.m02-cf__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +/* 위 띠 — 밀어도 위에 붙어 있음 */ +.m02-cf__head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding: var(--spacing-12) var(--spacing-24); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.m02-cf__title { + margin: 0; + font-size: var(--text-subheading); +} + +.m02-cf__trial { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-cf__strip { + display: flex; + flex: 1 1 480px; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); +} + +.m02-cf__name { + flex: 1 1 260px; +} + +.m02-cf__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +.m02-cf__body { + padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); +} + +.m02-cf__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +/* 시작 화면 */ +.m02-cf__start { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--spacing-24); +} + +.m02-cf__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m02-cf__start-col h3 { + margin: var(--spacing-8) 0 0; +} + +/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */ +.m02-cf__layout { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + +.m02-cf__part { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.m02-cf__part h3 { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m02-cf__part-body { + padding: var(--spacing-12); +} + +.m02-cf__diagram .m02-cf__part-body { + min-height: 60vh; +} + +.m02-cf__lower { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); + gap: var(--spacing-16); + align-items: start; +} + +.m02-cf__box .m02-cf__part-body { + min-height: 50vh; +} + +.m02-cf__box-slots { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--spacing-12); +} + +.m02-cf__slot { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m02-cf__slot h4 { + margin: 0; +} + +@media (max-width: 1100px) { + .m02-cf__lower, + .m02-cf__start, + .m02-cf__box-slots { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts new file mode 100644 index 000000000..c1e8bad94 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * M02_CF_Entry.ts + * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀 + * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 + * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. + * 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음. + * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { CfLogic } from "./M02_CF_Model"; +import "./M02_CF_Entry.css"; + +const TEXT = { + Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], + Title: ["단가산출 다이어그램", "Cost diagram"], + Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], + Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + Start_New: ["새로 만들기", "New"], + Start_Mine: ["내 로직", "My logics"], + Start_MineNone: ["아직 없음", "None yet"], + Start_Samples: ["견본", "Samples"], + Name: ["로직 이름", "Logic name"], + NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], + Unit: ["결과 단위", "Result unit"], + Undo: ["실행 취소", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Unsaved: ["저장 안 함", "Not saved"], + Diagram: ["다이어그램", "Diagram"], + Box: ["고른 상자", "Chosen box"], + Panel_Input: ["입력", "Input"], + Panel_Function: ["함수", "Function"], + Panel_Output: ["출력", "Output"], + Summary: ["원가", "Cost"], + NotYet: ["준비 중", "Coming soon"], + Leave_Ask: [ + "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", + "There are unsaved changes — close without saving?", + ], + Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], +} as const; + +type TextKey = keyof typeof TEXT; + +const te = (key: TextKey): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; + +const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text }); + +/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ +const blankLogic = (name: string, unit: string): Promise => + import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); + +/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ +async function unitChoices(): Promise { + const count = new Map(); + try { + for (const x of await fetchLogics()) { + if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); + } + } catch { + /* 못 받으면 기본 하나 */ + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; +} + +/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ +export function createCostFlowButton(): HTMLButtonElement { + return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow }); +} + +function openCostFlow(): void { + /** 고치는 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean } | null = null; + let units: string[] | null = null; + + const mayLeave = async (): Promise => + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + + const body = el("main", { className: "m02-cf__body" }); + const strip = el("div", { className: "m02-cf__strip" }); + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) shut(); + })(), + }); + const page = el("div", { + className: "m02-cf", + attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, + children: [ + el("header", { + className: "m02-cf__head", + children: [ + el("h2", { className: "m02-cf__title", text: te("Title") }), + el("span", { className: "m02-cf__trial", text: te("Trial") }), + strip, + close, + ], + }), + body, + ], + }); + const prevOverflow = document.body.style.overflow; + const shut = (): void => { + page.remove(); + document.body.style.overflow = prevOverflow; + }; + page.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape" || ev.defaultPrevented) return; + void (async () => { + if (await mayLeave()) shut(); + })(); + }); + + /* ---------- 시작 화면 ---------- */ + + const showStart = (): void => { + paper = null; + strip.replaceChildren(); + body.replaceChildren( + el("div", { + className: "m02-cf__start", + children: [ + el("section", { + className: "m02-cf__start-col", + children: [ + bigButton(te("Start_New"), () => void openNew()), + el("h3", { text: te("Start_Mine") }), + muted(te("Start_MineNone")), + ], + }), + el("section", { + className: "m02-cf__start-col", + children: [el("h3", { text: te("Start_Samples") }), muted(te("NotYet"))], + }), + ], + }), + ); + }; + + /* ---------- 고치는 화면 ---------- */ + + const openNew = async (): Promise => { + units ??= await unitChoices(); + const lg = await blankLogic("", units[0]); + showPaper(lg, units); + }; + + const showPaper = (lg: CfLogic | null, choices: string[]): void => { + let dirty = false; + paper = { dirty: () => dirty }; + const unsaved = createTag(te("Unsaved"), "warning"); + unsaved.hidden = true; + const touched = (): void => { + dirty = true; + unsaved.hidden = false; + }; + const name = createInputField({ + label: te("Name"), + placeholder: te("NamePlaceholder"), + value: lg?.name ?? "", + onInput: touched, + }); + name.root.classList.add("m02-cf__name"); + const unit = createSelectField({ + label: te("Unit"), + options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: lg?.resultUnit ?? choices[0], + onChange: touched, + }); + const notYet = (): void => showToast(te("NotYet"), "info"); + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); + undo.disabled = true; + strip.replaceChildren( + name.root, + unit.root, + unsaved, + el("div", { + className: "m02-cf__actions", + children: [ + undo, + createButton({ label: te("Save"), onClick: notYet }), + createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + ], + }), + ); + body.replaceChildren( + el("div", { + className: "m02-cf__layout", + children: [ + part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]), + el("div", { + className: "m02-cf__lower", + children: [ + part(te("Box"), "m02-cf__box", [ + el("div", { + className: "m02-cf__box-slots", + children: [ + slot(te("Panel_Input")), + slot(te("Panel_Function")), + slot(te("Panel_Output")), + ], + }), + ]), + part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]), + ], + }), + ], + }), + ); + name.input.focus(); + }; + + showStart(); + document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 + document.body.append(page); + page.tabIndex = -1; + page.focus(); +} + +/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ +function part(title: string, className: string, children: HTMLElement[]): HTMLElement { + return el("section", { + className: `m02-cf__part ${className}`, + children: [el("h3", { text: title }), el("div", { className: "m02-cf__part-body", children })], + }); +} + +/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */ +function slot(title: string): HTMLElement { + return el("div", { + className: "m02-cf__slot", + children: [el("h4", { text: title }), muted(te("NotYet"))], + }); +} + +function bigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m02-cf__big"); + return button; +} From a68482039483d93d143fc5d60700382c20798681 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:02:18 +0900 Subject: [PATCH 2/4] auto: 2026-10-01 16:02 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 +++++++++++++++++++ docs/raw/M01_쉬운단가/원가다이어그램_정함.md | 40 +++++++ 2 files changed, 150 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md create mode 100644 docs/raw/M01_쉬운단가/원가다이어그램_정함.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. diff --git a/docs/raw/M01_쉬운단가/원가다이어그램_정함.md b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md new file mode 100644 index 000000000..bc9680deb --- /dev/null +++ b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md @@ -0,0 +1,40 @@ +# 단가산출 다이어그램 시험판 — 정함 (PLAN 35-7 · 브레인 · 10-01 오후) + +사용자 기준(10-01 14시 무렵 · 요약): 다이어그램은 요소 종류로 칸을 나누지 않고 자유 배치 · 단가산출 로직 = 최종 원가를 내는 로직 · 상자는 입력 · 함수 · 출력 · 입력 상자 = M01 저장 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율) · 입력 상자를 누르면 그 정보 — M01 그룹 내용 · 요소 고르기 · M01 표 「추가」 화면을 다시 써서 추가 / 삭제 · 그룹 밖 정보(면적 · 길이 · 부피)는 변수 + 입력 정의 · 함수 상자도 변수 + 입력 정의 · 함수 상자는 받은 입력을 엑셀로 셈해 결과를 다음으로 · 함수 상자 하나 = 함수 하나(연속 연산 가능 · 출력은 하나) · 함수 상자 직렬 · 병렬 · 모든 상자 오른쪽 위에 단위(무차원 · 글 · 어려우면 「-」) · 출력 상자는 입력 · 함수 상자에 이어지고 어떤 비용인지(노무비 · 재료비 · 경비 …) 정함 · 품셈 분석보다 사용자와 브레인이 같이 쓸 프로그램이 먼저 · 상자 색 지정 · 엑셀 · 다이어그램 영역 더 높게 · 「상자와 선」 이름은 「다이어그램」 · 별도 단추로 시험판 페이지 · M02 폴더 · 기존 시험판과 다른 폴더 · 화면에 보일 임시 파일도 같은 폴더. + +## 기준 점검 — 빠진 곳 · 두 뜻인 곳과 브레인 정함(사용자가 바꿀 수 있음) + +1. **요율이 입력 그룹에 없음** — M01 에는 요율(19줄 · 공구손료 등 비율)이 있음 → 입력 그룹에 넣음. +2. **유가/전력 · 품셈재료** — M01 에서는 재료 그룹 안의 파일 → 입력 그룹에서 유가/전력은 따로 · 품셈재료는 재료 안. +3. **소요량 · 계수는 조건 표** — 요소 하나가 아니라 「조건 칸 → 값 칸」 표 → 입력 상자는 표(또는 고른 줄 하나)를 내보내고, 조건에 맞는 줄 찾기는 함수 상자의 엑셀 수식(XLOOKUP · INDEX/MATCH)이 함. +4. **함수 상자 「함수 하나 · 연속 연산 · 출력 하나」** — 상자 안에서는 여러 칸으로 이어 셈해도 되고, 밖으로 나가는 출력은 한 칸만. +5. **비용이 아닌 출력** — 시간당 작업량처럼 다른 로직이 받을 값도 있음 → 출력 종류에 「값(비용 아님)」 더함. +6. **최종 원가 합계** — 출력 상자들을 비목별로 모아 원가(노무비 · 재료비 · 경비 · 계 · 기준단위당)를 보이는 칸을 둠. +7. **「추가 / 삭제」 뜻** — 입력 상자에 넣을 요소를 M01 그룹 화면에서 골라 넣고 빼는 것. 그룹에 없는 새 요소는 M01 표 「추가」 화면 꼴로 만들되 마스터에 쓰지 않고 시험 폴더에 「사용자 요소」로 저장. +8. **단위** — 상자 오른쪽 위 = 그 상자 출력의 단위 · 입력 상자는 요소 단위를 따름(요소가 여럿이면 알약마다 · 상자 표시는 「-」) · 함수 · 출력 상자는 사용자가 정함(기본 「-」). +9. **색** — 종류별 기본 색(입력 · 함수 · 출력이 서로 다름) + 상자마다 색 바꾸기. +10. **들어가는 곳** — 별도 단추 「단가산출 다이어그램(시험)」 = M01 단가산출 막대의 다른 시험판 단추 옆 · 누르면 화면 전체를 덮는 시험판 페이지(닫기로 돌아감) — 길잡이(router)는 안 고침. +11. **폴더** — `M02_MasterTemplete/CostFlow/` · 저장 · 임시 파일 = 그 안 `_data/`(git 밖) · 화면에 처음 보일 견본 로직 = 그 안 `_sample/` · 폴더째 지우면 끝 · SheetLogic · LogicBuilder 폴더에 기대지 않음(필요한 코드는 새 폴더로 옮겨 고쳐 씀 · 엔진 · ui_template · M01 Api 는 가져다 씀). +12. **다이어그램** — 상자는 끌어 자유 배치(자리 저장) · 판 끌기 · 확대 / 축소 · 선은 굽은 선 · 고른 상자와 이어진 선만 진하게 · 「다이어그램」 이름 · 높이 크게(화면 위쪽 큰 영역) · 아래 고른 상자 판도 크게(안쪽 스크롤 없이). +13. **우선** — 품셈 1,469 로직 옮기기 · 분석은 안 함 · 사용자와 브레인이 같이 쓰는 프로그램 먼저. + +## 상자 · 선 약속 + +- 입력 상자: 갈래 = 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율 · 요율) 또는 변수 · 그룹이면 고른 요소마다 출력 알약(이름 · 값 · 단위) · 소요량 / 계수면 표(또는 한 줄) 알약 · 변수면 변수마다 알약(이름 · 갈래 숫자 / 선택 / 글 · 단위 · 범위 · 예시값). +- 함수 상자: 시트 하나 · 입력 알약(받는 칸) 여럿 · 변수(입력 정의) · **출력 알약 하나** · 단위. +- 출력 상자: 받는 자리 하나 · 비용 종류(노무비 · 재료비 · 경비 · 값) · 단위. +- 선: 입력 → 함수 · 입력 → 출력 · 함수 → 함수(직렬) · 여러 함수 → 한 함수(병렬 모음) · 함수 → 출력. 출력에서 나가는 선 없음 · 순환 막음. +- 계산: 우리 스프레드시트 엔진 한 벌 · 화면은 브라우저 · 저장은 서버가 같은 엔진 Node 로 다시 셈 · 입력 값(노임 · 단가 · 표)은 열 때 · 저장 때 마스터에서. + +## 파일 · 창 (`M02_MasterTemplete/CostFlow/` · 700줄 안) + +| 파일 | 창 | +| --- | --- | +| `M02_CF_Model.ts` · `M02_CF_Eval.ts` | sub7 — 타입 먼저 | +| `M02_CF_Router.py` · `M02_CF_Api.ts` · `.gitignore` | sub4 | +| `M02_CF_Diagram.ts` + css | sub8 | +| `M02_CF_FuncPanel.ts` + css | sub2 | +| `M02_CF_InputPanel.ts` + css | sub3 | +| `M02_CF_OutputPanel.ts` · `M02_CF_Summary.ts` + css | sub5 | +| `_sample/*.json` · `M02_CF_Sample.ts` | sub6 | +| `M02_CF_Entry.ts` + css | sub1 — 단추 · 시험판 페이지 · 잇기 · 끝 검증 | From e837e5965e3718aa729446023c71c6859647038e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:03:23 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(m02-cf):=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=82=B4=20=EB=A1=9C=EC=A7=81=20=C2=B7=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20cfList=20?= =?UTF-8?q?=EB=A1=9C(=EC=86=8D=EC=9D=B4=20=EC=98=A4=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EC=97=94=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/CostFlow/M02_CF_Entry.css | 36 ++++++++++++++++++++ M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 32 +++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css index a967d85f7..98c3faa23 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -153,3 +153,39 @@ grid-template-columns: minmax(0, 1fr); } } + +.m02-cf__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-cf__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-cf__item:hover { + border-color: var(--color-accent); +} + +.m02-cf__item-name { + flex: 1; + min-width: 0; +} + +.m02-cf__item-unit { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + white-space: nowrap; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts index c1e8bad94..84e7a2f91 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -18,6 +18,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { CfListItem } from "./M02_CF_Api"; import type { CfLogic } from "./M02_CF_Model"; import "./M02_CF_Entry.css"; @@ -62,6 +63,8 @@ const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted /* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ const blankLogic = (name: string, unit: string): Promise => import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); +const loadList = (): Promise => + import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []); /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ async function unitChoices(): Promise { @@ -133,9 +136,34 @@ function openCostFlow(): void { /* ---------- 시작 화면 ---------- */ + const listRow = (item: CfListItem): HTMLElement => { + const row = el("button", { + className: "m02-cf__item", + attrs: { type: "button", title: item.saved }, + children: [ + el("span", { className: "m02-cf__item-name", text: item.name }), + el("span", { className: "m02-cf__item-unit", text: item.resultUnit }), + ], + }); + row.addEventListener("click", () => showToast(te("NotYet"), "info")); + return row; + }; + const showStart = (): void => { paper = null; strip.replaceChildren(); + const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); + const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); + void loadList().then((all) => { + const mine = all.filter((x) => !x.sample); + const samples = all.filter((x) => x.sample); + mineHost.replaceChildren( + ...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]), + ); + sampleHost.replaceChildren( + ...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]), + ); + }); body.replaceChildren( el("div", { className: "m02-cf__start", @@ -145,12 +173,12 @@ function openCostFlow(): void { children: [ bigButton(te("Start_New"), () => void openNew()), el("h3", { text: te("Start_Mine") }), - muted(te("Start_MineNone")), + mineHost, ], }), el("section", { className: "m02-cf__start-col", - children: [el("h3", { text: te("Start_Samples") }), muted(te("NotYet"))], + children: [el("h3", { text: te("Start_Samples") }), sampleHost], }), ], }), From 6ba9d766057f0464b158c88406b67bf3f86fc8da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:04:05 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20Mod?= =?UTF-8?q?el=20=C2=B7=20Eval=20=EC=86=8D=20=EC=B1=84=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상자 더하기(종류별 기본 색 · 단위 - · 함수 = 빈 시트 하나 · 출력 = 노무비) · 지우기(선도) · 자리 · 색 · 단위(함수 · 출력 안쪽 단위도) · updateBox - 알약 이름 규칙(브레인 정함) — 입력 = element.name · 표 · variable.name / 함수 = inputs[].port · output.name / 출력 = 값 - 선 규칙 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 · 입력으로 들어감 · 같은 상자 · 순환 · 같은 받는 자리 두 번 · 없는 알약은 막고 까닭 - 계산 — 함수 상자를 차례대로(직렬 · 병렬) 받는 칸 · 변수 칸에 값을 넣고 우리 스프레드시트 엔진으로 셈 · 출력 상자 비용 종류별 원가 합(노무비 · 재료비 · 경비 · 값들 · 계) - 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류 칸 - 시험 resources/tester/test_m02_cf_model.py 11개(엔진 그대로 Node) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/CostFlow/M02_CF_Eval.ts | 182 ++++++++++++- M02_MasterTemplete/CostFlow/M02_CF_Model.ts | 197 +++++++++++--- resources/tester/test_m02_cf_model.py | 274 ++++++++++++++++++++ 3 files changed, 609 insertions(+), 44 deletions(-) create mode 100644 resources/tester/test_m02_cf_model.py diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts index 1cd3f877b..4d0679995 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts @@ -2,9 +2,14 @@ * M02_CF_Eval.ts * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 + * 엔진을 정적으로 가져옴 — 화면(Entry)은 CostFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 * ========================================================================== */ -import type { CfLogic } from "./M02_CF_Model"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_CF_Model"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ export type CfValue = number | string | boolean | null | (number | string | null)[][]; @@ -12,33 +17,188 @@ export type CfValue = number | string | boolean | null | (number | string | null /** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ export type CfValues = Record>; +type Scalar = number | string | boolean | null; + export interface CfEvalResult { /** 상자 id → 내보내는 알약 이름 → 값 */ ports: Record>; /** 출력 상자 id → 값 */ - outputs: Record; + outputs: Record; cost: { 노무비: number; 재료비: number; 경비: number; /** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */ - 값들: Record; + 값들: Record; 계: number; }; /** 쉬운 말 */ errors: string[]; } -const notReady = (): never => { - throw new Error("준비 중"); +const TEXT = { + /** {box} {cell} {err} */ + CellError: [ + "「{box}」 {cell} 칸 계산이 막힘 — {err}", + "「{box}」 cell {cell} can't be worked out — {err}", + ], + Cycle: ["돌고 도는 참조", "circular reference"], + /** {box} */ + Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"], + NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"], + /** {box} {pill} */ + EmptyIn: [ + "「{box}」 의 받는 알약 「{pill}」 에 선이 없음", + "Pill 「{pill}」 of 「{box}」 has no line", + ], + NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], + NoOutput: ["출력 상자가 없음", "There is no output box"], + NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], +} as const; + +const tl = (key: keyof typeof TEXT, fill: Record = {}): string => + Object.entries(fill).reduce( + (s, [k, v]) => s.replace(`{${k}}`, v), + TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, + ); + +/* ── 차례 ───────────────────────────────────────────────────────────────── */ + +/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */ +function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } { + const funcs = lg.boxes.filter((b) => b.kind === "function"); + const done: CfBox[] = []; + const left = new Set(funcs.map((f) => f.id)); + for (let moved = true; moved && left.size;) { + moved = false; + for (const f of funcs) { + if (!left.has(f.id)) continue; + const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box)); + if (waits) continue; + done.push(f); + left.delete(f.id); + moved = true; + } + } + return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) }; +} + +/* ── 칸 ─────────────────────────────────────────────────────────────────── */ + +function putValue(칸: Record, cell: string, value: Scalar): void { + const style = 칸[cell]?.서식; + if (value === null) { + if (style) 칸[cell] = { 서식: style }; + else delete 칸[cell]; + } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; +} + +/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */ +function put(칸: Record, cell: string, value: CfValue): void { + const at = parseA1(cell); + if (!at) return; + if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value); + value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x))); +} + +const fromCalc = (v: CalcValue | undefined): Scalar => { + if (!v) return null; + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; }; -/** 화면 계산 */ -export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult { - return notReady(); +/* ── 계산 ───────────────────────────────────────────────────────────────── */ + +/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ +function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record { + const given = values[box.id] ?? {}; + const pills = portsOut(box); + const out: Record = {}; + const inp = box.input; + if (!inp) return out; + // portsOut 차례 = 요소 · 표 · 변수 + const sources: CfValue[] = [ + ...(inp.mode === "group" + ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) + : []), + ...(inp.mode === "group" && inp.table ? [null] : []), + ...inp.variables.map((v) => v.example), + ]; + pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null))); + return out; } -/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */ -export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] { - return notReady(); +/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */ +function runFunc( + box: CfBox, + incoming: Record, + given: Record, + errors: string[], +): Scalar { + const func = box.func!; + const sheet: Sheet = structuredClone(func.sheet); + for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null); + for (const v of func.variables) + if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); + const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "CF", 서식: [{}], 시트: [sheet] }); + for (const [cell, v] of Object.entries(values[sheet.id] ?? {})) + if ("오류" in v) { + const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; + errors.push(tl("CellError", { box: box.name, cell, err })); + } + const at = func.output.cell.toUpperCase(); + const c = sheet.칸[at]; + if (!c) return null; + return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); +} + +/** 화면 계산 */ +export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult { + const errors: string[] = []; + const ports: CfEvalResult["ports"] = {}; + for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values); + const into = (box: string): Record => + Object.fromEntries( + lg.links + .filter((l) => l.to.box === box) + .map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]), + ); + const { funcs, loop } = order(lg); + for (const f of loop) errors.push(tl("Loop", { box: f.name })); + for (const f of funcs) + ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) }; + const outputs: CfEvalResult["outputs"] = {}; + const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + for (const b of lg.boxes.filter((x) => x.kind === "output")) { + const v = into(b.id)[OUTPUT_PORT] ?? null; + const scalar: Scalar = Array.isArray(v) ? null : v; + outputs[b.id] = scalar; + const kind = b.output?.cost ?? "값"; + if (kind === "값") cost.값들[b.name] = scalar; + else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; + else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); + } + cost.계 = cost.노무비 + cost.재료비 + cost.경비; + return { ports, outputs, cost, errors }; +} + +/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ +export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { + const out: string[] = []; + for (const b of lg.boxes) { + for (const p of portsIn(b)) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) + out.push(tl("EmptyIn", { box: b.name, pill: p.name })); + if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) + out.push(tl("NoCell", { box: b.name })); + } + if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); + const ev = evalLogic(lg, values); + for (const b of lg.boxes.filter((x) => x.kind === "input")) + for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {})) + if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill)) + out.push(tl("NoValue", { box: b.name, pill })); + return [...out, ...ev.errors]; } diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts index bc61a1ae1..949ad705a 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -4,6 +4,7 @@ * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로 * ========================================================================== */ +import { currentLanguageIndex } from "@ui/ui_template_locale"; import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; export type CfKind = "input" | "function" | "output"; @@ -105,60 +106,190 @@ export interface CfPill { unit: string; } -const notReady = (): never => { - throw new Error("준비 중"); +const TEXT = { + Input: ["입력", "Input"], + Func: ["함수", "Function"], + Output: ["출력", "Output"], + Result: ["결과", "Result"], + Value: ["값", "Value"], + NoPill: ["알약이 없음", "No such pill"], + FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"], + ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"], + Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], + Taken: ["이미 선이 들어온 자리", "This spot already has a line"], + Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 종류별 기본 색 */ +export const BOX_COLOR: Record = { + input: "#2f80ed", + function: "#7b4fd6", + output: "#27ae60", }; -export function newLogic(_name?: string, _resultUnit?: string): CfLogic { - return notReady(); +/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */ +export const OUTPUT_PORT = "값"; +export const TABLE_PORT = "표"; + +const clone = (v: T): T => structuredClone(v); + +/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */ +function nextId(prefix: string, ids: string[]): string { + const nums = ids + .filter((x) => x.startsWith(prefix)) + .map((x) => Number(x.slice(prefix.length))) + .filter((n) => Number.isInteger(n)); + return `${prefix}${Math.max(0, ...nums) + 1}`; } -/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나 · 출력은 노무비 */ +/** 같은 이름이면 「 2」 「 3」 */ +function uniqueName(used: string[], base: string): string { + if (!used.includes(base)) return base; + for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`; +} + +const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); + +/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */ +export function updateBox( + lg: CfLogic, + id: string, + patch: Partial>, +): CfLogic { + return { + ...lg, + boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)), + }; +} + +/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ + +export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic { + return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] }; +} + +/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */ export function addBox( - _lg: CfLogic, - _kind: CfKind, - _x: number, - _y: number, + lg: CfLogic, + kind: CfKind, + x: number, + y: number, ): { lg: CfLogic; box: CfBox } { - return notReady(); + const id = nextId( + "b", + lg.boxes.map((b) => b.id), + ); + const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output"); + const name = uniqueName( + lg.boxes.map((b) => b.name), + base, + ); + const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; + if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; + if (kind === "function") + box.func = { + sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } }, + inputs: [], + variables: [], + output: { cell: "B1", name: tl("Result"), unit: "-" }, + }; + if (kind === "output") box.output = { cost: "노무비", unit: "-" }; + return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; } /** 상자 지우기 — 이어진 선도 지움 */ -export function removeBox(_lg: CfLogic, _id: string): CfLogic { - return notReady(); +export function removeBox(lg: CfLogic, id: string): CfLogic { + return { + ...lg, + boxes: lg.boxes.filter((b) => b.id !== id), + links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), + }; } -export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic { - return notReady(); +export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { + return updateBox(lg, id, { x, y }); } -export function setColor(_lg: CfLogic, _id: string, _color: string): CfLogic { - return notReady(); +export function setColor(lg: CfLogic, id: string, color: string): CfLogic { + return updateBox(lg, id, { color }); } -export function setUnit(_lg: CfLogic, _id: string, _unit: string): CfLogic { - return notReady(); +/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */ +export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { + const box = boxOf(lg, id); + if (!box) return lg; + const u = unit.trim() || "-"; + const patch: Partial = { unit: u }; + if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } }; + if (box.output) patch.output = { ...box.output, unit: u }; + return updateBox(lg, id, patch); } -/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */ -export function portsOut(_box: CfBox): CfPill[] { - return notReady(); +/* ── 알약 ───────────────────────────────────────────────────────────────── */ + +/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name + * 함수 = func.output.name 하나 · 출력 = 없음 */ +export function portsOut(box: CfBox): CfPill[] { + if (box.kind === "function" && box.func) + return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; + if (box.kind !== "input" || !box.input) return []; + const inp = box.input; + return [ + ...(inp.mode === "group" + ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) + : []), + ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), + ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), + ]; } -/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */ -export function portsIn(_box: CfBox): CfPill[] { - return notReady(); +/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */ +export function portsIn(box: CfBox): CfPill[] { + if (box.kind === "function" && box.func) + return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); + if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; + return []; } -/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */ -export function link( - _lg: CfLogic, - _from: CfPort, - _to: CfPort, -): { lg: CfLogic; why: string | null } { - return notReady(); +/* ── 선 ─────────────────────────────────────────────────────────────────── */ + +/** from 상자에서 선을 따라 to 상자에 닿나 */ +export function reaches(lg: CfLogic, from: string, to: string): boolean { + const seen = new Set(); + const walk = (id: string): boolean => { + if (id === to) return true; + if (seen.has(id)) return false; + seen.add(id); + return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box)); + }; + return walk(from); } -export function unlink(_lg: CfLogic, _id: string): CfLogic { - return notReady(); +/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */ +export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { + const a = boxOf(lg, from.box); + const b = boxOf(lg, to.box); + if (!a || !b) return { lg, why: tl("NoPill") }; + if (a.kind === "output") return { lg, why: tl("FromOutput") }; + if (b.kind === "input") return { lg, why: tl("ToInput") }; + if (a.id === b.id) return { lg, why: tl("Self") }; + if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name)) + return { lg, why: tl("NoPill") }; + if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; + if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) + return { lg, why: tl("Taken") }; + const id = nextId( + "l", + lg.links.map((l) => l.id), + ); + return { + lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] }, + why: null, + }; +} + +export function unlink(lg: CfLogic, id: string): CfLogic { + return { ...lg, links: lg.links.filter((l) => l.id !== id) }; } diff --git a/resources/tester/test_m02_cf_model.py b/resources/tester/test_m02_cf_model.py new file mode 100644 index 000000000..8072c83af --- /dev/null +++ b/resources/tester/test_m02_cf_model.py @@ -0,0 +1,274 @@ +"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_CF_Model.ts` · `M02_CF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. + +· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 +· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사 +· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길) +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CF = ROOT / "M02_MasterTemplete" / "CostFlow" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +LOADER = r""" +const fs = require("fs"); +const path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const mod = { exports: {} }; + cache.set(full, mod); + const req = (r) => { + if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; + if (r.endsWith(".css")) return {}; + if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); + if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`)); + return require(r); + }; + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Model.ts")); +const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Eval.ts")); +const out = (x) => console.log(JSON.stringify(x)); +const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y); +const join = (lg, a, ap, b, bp) => { + const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }); + if (r.why) throw new Error(r.why); + return r.lg; +}; +/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */ +const func = (lg, box, cells, inputs, output, variables = []) => + M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables, + output: { ...box.func.output, ...output } } }); +""" + + +def _run(body: str) -> object: + result = subprocess.run( + ["node", "-e", LOADER + body, str(ROOT)], + capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비 +ONE = r""" +let lg = M.newLogic("견본 하나", "원/㎡"); +let r = add(lg, "input"); lg = r.lg; let man = r.box; +lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력", + elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } }); +r = add(lg, "input"); lg = r.lg; let area = r.box; +lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [], + variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } }); +r = add(lg, "function"); lg = r.lg; let f = r.box; +lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } }, + [{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" }, + [{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]); +r = add(lg, "output"); lg = r.lg; let o = r.box; +lg = join(lg, man, "석공", f, "노임"); +lg = join(lg, area, "면적", f, "면적"); +lg = join(lg, f, "노무비", o, "값"); +const V = { [man.id]: { 석공: 268908 } }; +""" + +# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬) +TWO = r""" +let lg = M.newLogic("견본 둘"); +let r = add(lg, "input"); lg = r.lg; const inp = r.box; +lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력", + elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }], + variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } }); +const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box; + lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; }; +const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]); +const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]); +const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]); +const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]); +lg = join(lg, inp, "면적", f1, "가"); +lg = join(lg, inp, "보통인부", f2, "가"); +lg = join(lg, f1, "결과", f3, "가"); +lg = join(lg, f2, "결과", f3, "나"); +lg = join(lg, f3, "결과", f4, "가"); +r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } }); +r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } }); +r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } }); +lg = join(lg, f4, "결과", oExp, "값"); +lg = join(lg, f1, "결과", oVal, "값"); +lg = join(lg, inp, "면적", oMat, "값"); +const V = { [inp.id]: { 보통인부: 172068 } }; +""" + + +def test_견본하나_입력_변수_함수_출력_노무비() -> None: + got = _run( + ONE + + "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);" + ) + ev, check, boxes = got + assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908 + assert ev["outputs"] == {"b4": 537816} + assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816} + assert ev["errors"] == [] and check == [] + assert boxes == [ + ["b1", "input", "입력", "#2f80ed", "-"], + ["b2", "input", "입력 2", "#2f80ed", "-"], + ["b3", "function", "함수", "#7b4fd6", "-"], + ["b4", "output", "출력", "#27ae60", "-"], + ] + + +def test_변수값_바꾸면_값이_바뀜() -> None: + got = _run( + ONE + + "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);" + ) + assert got == 0.1 * 20 * 268908 + + +def test_견본둘_직렬_병렬_원가_합() -> None: + got = _run( + TWO + + "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);" + ) + ports, cost, errors, check = got + f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5")) + assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938) + assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943} + assert errors == [] and check == [] + + +def test_선_규칙() -> None: + got = _run( + TWO + + "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n" + "out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적')," + " t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값')," + " M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);" + ) + assert got == [ + "이으면 돌고 도는 선이 됨", + "이으면 돌고 도는 선이 됨", + "출력 상자에서는 선이 나가지 않음", + "입력 상자로는 선이 들어가지 않음", + "같은 상자끼리는 못 이음", + "이미 선이 들어온 자리", + "알약이 없음", + "이미 선이 들어온 자리", + True, + ] + + +def test_알약_출력은_하나() -> None: + got = _run( + TWO + + "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n" + "out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3])," + " M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);" + ) + in_out, in_in, f_out, f_in, o_out, o_in = got + assert in_out == [ + {"name": "보통인부", "unit": "인"}, + {"name": "표", "unit": "-"}, + {"name": "면적", "unit": "㎡"}, + ] + assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}] + assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}] + assert o_out == [] and o_in == [{"name": "값", "unit": "원"}] + + +def test_선_끊기_상자_지우기_검사() -> None: + got = _run( + TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n" + "const a = M.unlink(lg, cut);\n" + "const b = M.removeBox(lg, f3.id);\n" + "const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n" + "out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length," + " E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);" + ) + f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got + assert f3_after == {"결과": 10} + assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"] + assert (n_b, n_all) == (5, 8) + assert check_b == [ + "「함수 4」 의 받는 알약 「가」 에 선이 없음", + ] + assert check_c == ["출력 상자가 없음"] + assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"] + + +def test_오류칸_순환칸_숫자아닌_출력() -> None: + got = _run( + ONE + + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," + " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n" + "const a = E.evalLogic(lg, V);\n" + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," + " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n" + "const b = E.evalLogic(lg, V);\n" + "out([a.errors, a.cost.계, b.errors]);" + ) + a_err, total, b_err = got + assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"] + assert total == 0 + assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err + + +def test_자리_색_단위() -> None: + got = _run( + ONE + + "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n" + "g = M.setUnit(g, o.id, ' ');\n" + "const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n" + "out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);" + ) + assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0] + + +def test_사용자_요소_값() -> None: + got = _run( + ONE + + "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n" + "lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n" + "out(E.evalLogic(lg, {}).cost.노무비);" + ) + assert got == 0.2 * 10 * 300000 + + +def test_화면_글에_금지_낱말_없음() -> None: + for name in ("M02_CF_Model.ts", "M02_CF_Eval.ts"): + src = (CF / name).read_text(encoding="utf-8") + block = src[src.index("const TEXT = {") : src.index("} as const;")] + korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] + assert korean + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): + assert bad not in text, (name, bad, text) + + +def test_알약_이름_규칙() -> None: + got = _run( + "let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n" + "lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료'," + " elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 }," + " variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n" + "out(M.portsOut(lg.boxes[0]).map(p => p.name));" + ) + assert got == ["시멘트", "표", "길이"]