From 5d7291b89a98e1a43e1193cc70ed0a2919140091 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:28:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01):=20=ED=9D=90=EB=A6=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=99=BC=EC=AA=BD=20=E3=80=8C=EC=A7=80=EA=B8=88=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=E3=80=8D=20=E2=80=94=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=C2=B7=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=8B=9D(buildFlowView)=20=C2=B7?= =?UTF-8?q?=20Detail=20=ED=9D=90=EB=A6=84=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowRows(순수): 출력 상자를 비용 종류별 줄 · 소계 · 계(합계 끝수 1원 미만 버림) · 함수 상자 결과 - FlowView: 화면 · sub2 식 보기가 있으면 식 두 줄 - Detail: DetailContext.flowHost 가 있으면 호표 · 읽는 식 대신 이 칸 - 시험 test_m01_logic_flowview.py Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../M01_MasterData_UI_LogicLab_Detail.ts | 6 +- .../M01_MasterData_UI_LogicLab_FlowRows.ts | 122 +++++++++++ .../M01_MasterData_UI_LogicLab_FlowView.css | 30 +++ .../M01_MasterData_UI_LogicLab_FlowView.ts | 207 ++++++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Round.ts | 5 + resources/tester/test_m01_logic_flowview.py | 184 ++++++++++++++++ 6 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts create mode 100644 resources/tester/test_m01_logic_flowview.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 5842ec45e..2e599bb42 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -72,6 +72,9 @@ export interface DetailContext { onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */ calcHost: HTMLElement; + /** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 · + * 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */ + flowHost?: HTMLElement; } type Group = "labor" | "material" | "cost" | "other"; @@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { : null; host.replaceChildren( infoBox(ctx), - hoBox(ctx), - section(tl("Formula_Title"), [formula]), + ...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]), section(tl("Calc_Title"), [ctx.calcHost]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts new file mode 100644 index 000000000..a9652b42b --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowRows.ts + * 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어 + * 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py + * ========================================================================== */ + +import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; + +export type FlowGroupKey = "labor" | "material" | "cost" | "plain"; + +/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */ +export const GROUP_OF: Record = { + 노무비: "labor", + 재료비: "material", + 경비: "cost", + 값: "plain", +}; +export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"]; +const COST_OF: Record, "노무비" | "재료비" | "경비"> = { + labor: "노무비", + material: "재료비", + cost: "경비", +}; + +export interface FlowLine { + box: string; + name: string; + unit: string; + value: number | string | boolean | null; + /** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */ + text: string; +} + +export interface FlowGroup { + key: FlowGroupKey; + lines: FlowLine[]; + /** 비용 묶음만 — 합계 끝수를 적용한 소계 */ + sub: number | null; + subText: string; +} + +export interface FlowFunc { + box: string; + name: string; + unit: string; + value: unknown; + text: string; +} + +export interface FlowRows { + groups: FlowGroup[]; + /** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */ + total: number; + totalText: string; + funcs: FlowFunc[]; + errors: string[]; +} + +const plain = (v: unknown): string => + v === null || v === undefined || Array.isArray(v) + ? "—" + : typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : String(v); + +/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */ +export function flowRows( + lg: CfLogic, + ev: CfEvalResult, + round: { item: CfRound; total: CfRound }, +): FlowRows { + const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); + const groups = GROUP_ORDER.map((key): FlowGroup => { + const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key); + const lines = mine.map((b): FlowLine => { + const v = ev.outputs[b.id] ?? null; + const r = b.output?.round ?? (key === "plain" ? undefined : round.item); + return { + box: b.id, + name: b.name, + unit: b.output?.unit ?? b.unit, + value: v, + text: typeof v === "number" ? showRounded(v, r) : plain(v), + }; + }); + const raw = key === "plain" ? null : ev.cost[COST_OF[key]]; + const sub = raw === null ? null : roundBy(round.total, raw); + return { + key, + lines, + sub, + subText: sub === null ? "" : showRounded(sub, round.total), + }; + }).filter((g) => g.lines.length); + const total = roundBy(round.total, ev.cost.계); + const funcs = lg.boxes + .filter((b) => b.kind === "function" && b.func) + .map((b): FlowFunc => { + const out = b.func!.output; + const v = ev.ports[b.id]?.[out.name]; + return { + box: b.id, + name: b.name, + unit: out.unit || b.unit, + value: v, + text: + typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : plain(v), + }; + }); + return { + groups, + total, + totalText: showRounded(total, round.total), + funcs, + errors: ev.errors, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css new file mode 100644 index 000000000..4a4f282e8 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css @@ -0,0 +1,30 @@ +.m01flow__group { + margin: 0 0 var(--spacing-12); +} + +.m01flow__block { + margin: 0 0 var(--spacing-8); +} + +.m01flow__func { + margin: 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; +} + +.m01flow__branch { + margin: 0 0 0 var(--spacing-16); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01flow__branch--now { + color: var(--color-text-body); + font-weight: 600; +} + +.m01flow__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts new file mode 100644 index 000000000..87b9d9de2 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowView.ts + * 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다 + * 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠. + * 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름. + * 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { + evalLogic, + type CfEvalOptions, + type CfEvalResult, + type CfValues, +} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; +import { + flowRows, + type FlowFunc, + type FlowGroup, + type FlowGroupKey, +} from "./M01_MasterData_UI_LogicLab_FlowRows"; +import "./M01_MasterData_UI_LogicLab_FlowView.css"; + +const TEXT = { + Detail: ["단가산출 상세", "Unit-price detail"], + Formula: ["텍스트 수식", "Text formulas"], + Labor: ["인력 · 노무비", "Labor · labor cost"], + Material: ["재료 · 재료비", "Material · material cost"], + Cost: ["경비", "Expense"], + Plain: ["값(비용 아님)", "Values (not costs)"], + HName: ["이름", "Name"], + HUnit: ["단위", "Unit"], + HValue: ["값", "Value"], + Sub: ["소계", "Subtotal"], + Total: ["계", "Total"], + TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"], + None: ["출력 상자가 없음", "No output box"], + NoFunc: ["함수 상자가 없음", "No function box"], +} as const; + +type TextKey = keyof typeof TEXT; +const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; +const GROUP_TITLE: Record = { + labor: "Labor", + material: "Material", + cost: "Cost", + plain: "Plain", +}; + +/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */ +interface FormulaLine { + name: string; + named: string; + valued: string; + branches?: { when: string; then: string; now: boolean }[]; +} +type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[]; +const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>( + "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", +); +const loadFormula = async (): Promise => { + const load = Object.values(FORMULA)[0]; + return load ? ((await load()).formulaLines ?? null) : null; +}; + +const section = (title: string, body: HTMLElement[]): HTMLElement => + el("section", { + className: "m01-logic__section m01lab__box", + children: [ + el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }), + ...body, + ], + }); + +const groupTable = (g: FlowGroup): HTMLElement => { + const sub = + g.sub === null + ? [] + : [ + el("tr", { + className: "m01lab__sub", + children: [ + el("td", { text: tf("Sub"), attrs: { colspan: "2" } }), + el("td", { className: "m01-logic__money", text: g.subText }), + ], + }), + ]; + return el("div", { + className: "m01flow__group", + children: [ + el("h4", { text: tf(GROUP_TITLE[g.key]) }), + el("div", { + className: "m01-logic__scroll", + children: [ + el("table", { + className: "m01-logic__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { + children: [ + ...g.lines.map((l) => + el("tr", { + children: [ + el("td", { text: l.name }), + el("td", { text: l.unit }), + el("td", { className: "m01-logic__money", text: l.text }), + ], + }), + ), + ...sub, + ], + }), + ], + }), + ], + }), + ], + }); +}; + +/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */ +function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement { + const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : ""; + const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` }); + if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] }); + return el("div", { + className: "m01flow__block", + children: [ + head, + ...lines.flatMap((l) => [ + el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }), + el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }), + ...(l.branches ?? []).map((b) => + el("p", { + className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`, + text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`, + }), + ), + ]), + ], + }); +} + +/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */ +export function buildFlowView( + host: HTMLElement, + lg: CfLogic, + values: CfValues, + opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {}, +): void { + let ev: CfEvalResult; + try { + ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics }); + } catch (e) { + host.replaceChildren( + section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]), + ); + return; + } + const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() }); + const detail = section(tf("Detail"), [ + ...(rows.groups.length + ? rows.groups.map(groupTable) + : [el("p", { className: "m01-logic__muted", text: tf("None") })]), + el("p", { + className: "m01lab__total", + attrs: { "data-sum": String(rows.total) }, + text: `${tf("Total")} ${rows.totalText}`, + }), + el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), + ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), + ]); + const bodies = rows.funcs.map((f) => funcBlock(f, null)); + const text = section( + tf("Formula"), + bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })], + ); + host.replaceChildren(detail, text); + // sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함 + void loadFormula().then((fn) => { + if (!fn || !bodies.length) return; + const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func); + const merged = { ...ev.ports, ...ev.arrived } as CfValues; + text.replaceChildren( + text.firstElementChild!, + ...rows.funcs.map((f, i) => { + try { + return funcBlock(f, fn(boxes[i], merged)); + } catch { + return bodies[i]; + } + }), + ); + }); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index 5341b5a83..5101ad946 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string { maximumFractionDigits: Math.max(0, r.places), }); } + +/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */ +let totalNow: CfRound = { places: 0, method: "버림" }; +void defaultTotalRound().then((r) => (totalNow = r)); +export const totalRoundNow = (): CfRound => totalNow; diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py new file mode 100644 index 000000000..67a4d35ea --- /dev/null +++ b/resources/tester/test_m01_logic_flowview.py @@ -0,0 +1,184 @@ +"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 · +화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) }); +const cache = {}; +function load(f) { + if (cache[f]) return cache[f].exports; + const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[f] = { exports: {} }); + const req = (id) => { + if (id.startsWith(".")) { + const p = path.resolve(path.dirname(f), id); + return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; + } + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const m = load(file); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round)); +process.stdout.write(JSON.stringify(out)); +setTimeout(() => process.exit(0), 50); +""" + +ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}} + + +def box(id_, kind, name, unit="원", **extra): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#000", + "unit": unit, + "x": 0, + "y": 0, + **extra, + } + + +def out_box(id_, name, cost, unit="원", round_=None): + output = {"cost": cost, "unit": unit} + if round_: + output["round"] = round_ + return box(id_, "output", name, unit, output=output) + + +def func_box(id_, name, out_name, unit="시간"): + func = { + "sheet": {"id": "s-" + id_, "이름": name, "칸": {}}, + "inputs": [], + "variables": [], + "output": {"cell": "B1", "name": out_name, "unit": unit}, + } + return box(id_, "function", name, unit, func=func) + + +def logic(boxes): + return { + "key": None, + "name": "시험", + "resultUnit": "원/㎡", + "boxes": boxes, + "links": [], + "userElements": [], + } + + +def result(outputs, cost, ports=None, errors=None): + return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []} + + +def run(jobs: list[dict]) -> list: + node = "node" + done = subprocess.run( + [node, "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@pytest.fixture(scope="module") +def sample(): + lg = logic( + [ + out_box("o1", "인건비", "노무비"), + out_box("o2", "재료값", "재료비"), + out_box("o3", "시간당 작업량", "값", "㎥/시간"), + out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}), + func_box("f1", "운반 시간", "시간", "시간"), + ] + ) + ev = result( + {"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239}, + { + "노무비": 3216.69, + "재료비": 100.04, + "경비": 1.239, + "값들": {"시간당 작업량": 12.5}, + "계": 3317.969, + }, + ports={"f1": {"시간": 6.024}}, + ) + return run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + + +def group(rows, key): + return next(g for g in rows["groups"] if g["key"] == key) + + +def test_group_order_and_costs_only(sample): + assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"] + + +def test_line_uses_output_round(sample): + # 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음) + assert group(sample, "labor")["lines"][0]["text"] == "3,216.6" + assert group(sample, "material")["lines"][0]["text"] == "100" + # 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림) + assert group(sample, "cost")["lines"][0]["text"] == "1.24" + + +def test_plain_value_is_not_rounded_and_has_no_subtotal(sample): + plain = group(sample, "plain") + assert plain["lines"][0]["text"] == "12.5" + assert plain["lines"][0]["unit"] == "㎥/시간" + assert plain["sub"] is None + + +def test_subtotal_and_total_floor_to_won(sample): + assert group(sample, "labor")["sub"] == 3216 + assert group(sample, "labor")["subText"] == "3,216" + assert sample["total"] == 3317 + assert sample["totalText"] == "3,317" + + +def test_function_rows(sample): + assert sample["funcs"] == [ + {"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"} + ] + + +def test_empty_group_is_hidden(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10}) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert [g["key"] for g in rows["groups"]] == ["labor"] + assert rows["total"] == 10 + + +def test_missing_value_shows_dash(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result( + {"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"] + ) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert group(rows, "labor")["lines"][0]["text"] == "—" + assert rows["errors"] == ["끊김"]