feat(m01): 로직 테스트 방식 C — 흐름 그림

설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계 일곱 칸.
상자를 누르면 값·출처 · 로직이 부르는 로직은 상자 안에서 펼침 · 설계 값을 바꾸면
바로 다시 셈(로직 화면과 같은 `fetchLogic`·`runCalc` — 읽기·시험 계산만).
뼈대는 순수 모듈로 떼어 Node 헬퍼 + pytest 로 검증.

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