feat(M01): 로직 화면에 「새로 만들기」 단추 · 저장 뒤 방식 C 로 열기 · 테스트 컨테이너와 방식 A·B 화면 걷음

- 로직 화면 바의 새로 만들기 단추가 모달을 엶 · 저장·본떠 저장 뒤 새 자체 로직을 흐름 그림으로 바로 엶
- UI_Test* 다섯 파일 삭제 · 왼쪽 「테스트」 줄 제거 · 모달의 까닭 글은 UI_Logic_Text 것을 씀
- 견본 10개 키 목록은 시험(test_m01_test_container)으로 옮김 · 흐름 그림 시험의 옛 파일 이름을 바로잡음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-21 09:57:17 +09:00
co-authored by Claude Sonnet 5
parent e2d3eb5737
commit f458da7f20
12 changed files with 34 additions and 893 deletions
+9 -35
View File
@@ -35,6 +35,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import type { SideHandle } from "./M01_MasterData_UI_Side";
import { tx } from "./M01_MasterData_UI_Logic_Text";
@@ -286,41 +287,14 @@ export async function mountM01Logic(
persist();
};
const onNew = (): void => {
const f = files.find((x) => x.file === opened?.file) ?? files[0];
if (!f) return;
const id = `new:${Date.now()}`;
// 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌
const row: LogicRow = {
: "",
: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
: "",
: "원/",
: "",
: "공용",
: [],
: [],
: [],
: [],
끝수: null,
};
const next: Opened = {
id,
sub: subOf(f),
file: f.file,
version: f.version,
origKey: null,
row,
original: null,
prices: {},
reasons: [],
lines: null,
values: {},
};
drafts[id] = pick(next, clone(row));
persist();
show(next);
};
/** 「새로 만들기」 모달 — 저장·본떠 저장이 끝나면 그 새 자체 로직을 흐름 그림으로 바로 엶 */
const onNew = (): void =>
openLogicWizard({
onSaved: (key) =>
void reload()
.then(() => open(logicId("자체", key), "자체", key))
.catch(failed),
});
/** 「본떠 만들기」 — 정본을 자체 파일로 복제하고(계약 5장) 그 새 로직을 바로 엶 */
const onCopy = async (from: Opened): Promise<void> => {
@@ -23,7 +23,7 @@ import {
} from "./M01_MasterData_UI_Logic_Wizard_Model";
import { STEPS, type Step } from "./M01_MasterData_UI_Logic_Wizard_Steps";
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
import { plainReason } from "./M01_MasterData_UI_Test_Text";
import { plainReason } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Wizard_Style.css";
export interface WizardOptions {
+5 -20
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 아홉: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직·로직 테스트)
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직·로직 테스트)
* / 우측 요소 표 · 표 목록 · 로직 편집
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
@@ -54,7 +54,6 @@ function buildPage(): HTMLElement {
let query = "";
let dispose = (): void => {};
let logicMounted = false;
let testMounted = false;
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
@@ -70,7 +69,6 @@ function buildPage(): HTMLElement {
children: [title, searchField.root, summary, drop, save],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const testHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [head, notice, body],
@@ -88,9 +86,8 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (mode: "elements" | "logic" | "test"): void => {
const showMode = (mode: "elements" | "logic"): void => {
logicHost.hidden = mode !== "logic";
testHost.hidden = mode !== "test";
elementView.hidden = mode !== "elements";
};
@@ -196,20 +193,8 @@ function buildPage(): HTMLElement {
});
};
/* --- 좌측: 컨테이너 아홉 --- */
const side = buildSide(
openFile,
() => openLogicTab(),
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("test");
if (testMounted) return;
testMounted = true;
void import("./M01_MasterData_UI_Test").then((m) => m.mountM01Test(testHost));
},
);
/* --- 좌측: 컨테이너 --- */
const side = buildSide(openFile, () => openLogicTab());
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
@@ -217,7 +202,7 @@ function buildPage(): HTMLElement {
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost, testHost],
children: [elementView, logicHost],
}),
],
});
+2 -16
View File
@@ -127,13 +127,8 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const TEST_ID = "테스트|";
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
onTest: () => void,
): SideHandle {
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -408,16 +403,7 @@ export function buildSide(
onLogic();
});
// 테스트 컨테이너 — 눌러 펼치는 목록이 없는 한 줄(환율·요율과 같은 모양)
const test = section(L("M01_GroupTest"), null);
test.classList.add("m01-side__leaf");
buttons.set(TEST_ID, test);
test.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
setActive(TEST_ID);
onTest();
});
const root = el("div", { className: "m01-side", children: [...groups, logic, test] });
const root = el("div", { className: "m01-side", children: [...groups, logic] });
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
-106
View File
@@ -1,106 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Test.ts
* 로직 테스트 컨테이너 틀 — 공종(로직) 10 개 고르기 + 방식 탭 A·B·C
* 읽기와 시험 계산만 — 저장 없음 · 정본 로직은 안 고침 · 계산은 로직 화면과 같은 `/calc`
* 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { fetchLogics } from "./M01_MasterData_UI_Logic_Api";
import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard";
import { render as renderA } from "./M01_MasterData_UI_Test_A";
import { render as renderB } from "./M01_MasterData_UI_Test_B";
import { render as renderC } from "./M01_MasterData_UI_Logic_Flow";
import { tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_Style.css";
/** 시험 대상 산림품셈 로직 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서.
* 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py` · 메쌓기·노체·트럭 운반은 끝까지 눌러 보는 세 개. */
export const TEST_KEYS: readonly string[] = [
"GF000181", // 12-16 맨홀
"GF000177", // 12-12 날개벽
"GF000173", // 12-11-1 VR관(소켓식) 부설
"GF000174", // 12-11-2-1 흄관 부설
"GF000160", // 12-4 합판거푸집
"GF000182", // 12-17-1 펌프카 타설
"GF000069", // 7-11 동력상하차기 집재
"GF000219", // 13-4-1 돌쌓기 메쌓기(인력)
"GF000119", // 9-16-1 노체포설
"GF000137", // 10-4 중기운반 트레일러
];
type Render = (host: HTMLElement, logicKey: string) => void;
/** 방식 파일이 오면 여기 한 줄씩 잇는다 — null 은 준비 중 */
const METHODS: { id: string; label: () => string; render: Render | null }[] = [
{ id: "A", label: () => tt("Tab_A"), render: renderA },
{ id: "B", label: () => tt("Tab_B"), render: renderB },
{ id: "C", label: () => tt("Tab_C"), render: renderC },
];
export function mountM01Test(host: HTMLElement): void {
let logicKey = TEST_KEYS[0];
let method = METHODS[0].id;
const body = el("div", { className: "m01-test__body" });
const tabs = el("div", { className: "m01-test__tabs" });
const select = createSelectField({
label: tt("Pick_Logic"),
options: TEST_KEYS.map((k) => ({ value: k, text: k })),
value: logicKey,
onChange: (v) => {
logicKey = v;
draw();
},
});
const draw = (): void => {
tabs.replaceChildren(
...METHODS.map((m) =>
el("button", {
className: `m01-test__tab${m.id === method ? " is-active" : ""}`,
text: m.label(),
attrs: { type: "button", "data-method": m.id },
}),
),
);
tabs.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => {
method = b.dataset.method ?? "A";
draw();
}),
);
const one = METHODS.find((m) => m.id === method);
if (one?.render) one.render(body, logicKey);
else body.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Soon") }));
};
host.replaceChildren(
el("div", {
className: "m01-test",
children: [
el("div", {
className: "m01-test__bar",
children: [
el("h2", { text: tt("Title") }),
select.root,
tabs,
createButton({ label: tt("New_Logic"), onClick: () => openLogicWizard() }),
],
}),
el("p", { className: "m01-test__muted", text: tt("Read_Only") }),
body,
],
}),
);
// 목록에서 이름을 받아 「번호 이름」 으로 바꿈 — 못 받아도 키로 쓸 수 있음
fetchLogics()
.then((all) => {
const label = new Map(all.map((r) => [r., `${r.} ${r.}`]));
select.setOptions(
TEST_KEYS.map((k) => ({ value: k, text: label.get(k) ?? k })),
logicKey,
);
})
.catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error"));
draw();
}
-214
View File
@@ -1,214 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Test_A.ts
* 방식 A — 질문·답 마법사: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표
* 식은 안 보임 · 시험 계산은 로직 화면과 같은 `/calc` · 저장 없음
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type ElementBrief,
type LogicInput,
type LogicOne,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
type Step =
{ kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" };
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Loading") }));
fetchLogic(logicKey)
.then((one) => wizard(host, one))
.catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error"));
}
function wizard(host: HTMLElement, one: LogicOne): void {
const { logic } = one;
const values: Record<string, string> = {};
const swaps = new Map<number, ElementBrief>();
const steps: Step[] = [
{ kind: "job" },
...(logic. ?? []).map((spec): Step => ({ kind: "ask", spec })),
...(hasPickable(logic) ? [{ kind: "pick" } as Step] : []),
{ kind: "result" },
];
const picker = pickPanel(one, swaps, () => undefined);
let at = 0;
const go = (next: number): void => {
at = next;
draw();
};
const frame = (
title: string,
note: string,
content: HTMLElement[],
nextOk: boolean,
): HTMLButtonElement => {
const last = steps[at].kind === "result";
const back = createButton({ label: tt("Back"), variant: "ghost", onClick: () => go(at - 1) });
back.disabled = at === 0;
const next = createButton({ label: tt("Next"), onClick: () => go(at + 1) });
next.disabled = !nextOk;
const again = createButton({ label: tt("Again"), variant: "ghost", onClick: () => go(0) });
host.replaceChildren(
el("div", {
className: "m01-test__card",
children: [
el("p", {
className: "m01-test__step",
text: tt("Step", { n: at + 1, total: steps.length }),
}),
el("h3", { text: title }),
...(note ? [el("p", { className: "m01-test__muted", text: note })] : []),
...content,
el("div", { className: "m01-test__nav", children: last ? [back, again] : [back, next] }),
],
}),
);
return next;
};
const draw = (): void => {
const step = steps[at];
if (step.kind === "job") {
frame(
`${logic.} ${logic.}`,
tt("Job_Note"),
[
el("p", { text: `${tt("Job_Title")} · ${logic.}` }),
el("ul", { children: guideLines("A").map((t) => el("li", { text: t })) }),
],
true,
);
} else if (step.kind === "ask") drawAsk(step.spec);
else if (step.kind === "pick") drawPick();
else void drawResult();
};
const drawAsk = (spec: LogicInput): void => {
const name = spec.;
const ready = (): boolean => (values[name] ?? "").trim() !== "";
let next: HTMLButtonElement | null = null;
const changed = (v: string): void => {
values[name] = v;
if (next) next.disabled = !ready();
};
let control: HTMLElement;
if (spec.?.length) {
control = createSelectField({
options: ["", ...spec..map(String)].map((o) => ({ value: o, text: o })),
value: values[name] ?? "",
onChange: changed,
}).root;
} else {
const box = el("input", {
className: "m01-test__input",
attrs: { type: "text", inputmode: "decimal", placeholder: tt("Enter_Value") },
});
box.value = values[name] ?? "";
box.addEventListener("input", () => changed(box.value));
control = box;
}
const question = spec.?.length
? tt("Ask_Pick", { name })
: tt("Ask_Number", { name, unit: spec.단위 ? ` (${spec.})` : "" });
const hint = spec. ? tt("Ask_Range", { min: spec.범위[0], max: spec.범위[1] }) : "";
next = frame(question, hint, [control], ready());
};
const drawPick = (): void => {
frame(tt("Mat_Title"), tt("Mat_Note"), [picker], true);
};
const drawResult = async (): Promise<void> => {
const inputs: Record<string, unknown> = {};
for (const spec of logic. ?? []) {
const raw = (values[spec.] ?? "").trim();
if (raw === "") continue;
const option = spec.?.find((o) => String(o) === raw);
inputs[spec.] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
const row = swappedRow(logic, swaps); // 바꿔 본 것이 있으면 복사본으로만 셈
let answer: CalcAnswer;
try {
answer = await runCalc({ key: logic.키, inputs, ...(row ? { row, file: one.file } : {}) });
} catch (error) {
showToast(error instanceof Error ? error.message : tt("Failed"), "error");
return;
}
frame(tt("Result_Title"), "", resultView(answer), true);
};
draw();
}
function resultView(answer: CalcAnswer): HTMLElement[] {
if (!answer.ok) {
return [
el("div", {
className: "m01-test__reasons",
children: [
el("strong", { text: tt("Result_Stopped") }),
el("div", { text: plainReason(answer.reason) }),
],
}),
];
}
const out: HTMLElement[] = [];
if (answer.lines) {
out.push(
el("table", {
className: "m01-test__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tt("Col_Name"), tt("Col_Qty"), tt("Col_Amount")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", {
children: answer.lines.map((l) =>
el("tr", {
children: [
el("td", { text: l.출처 ? `${l.} (${l.})` : l. }),
el("td", { text: `${formatNumber(l.)} ${l.}` }),
el("td", { className: "m01-test__money", text: formatNumber(l.) }),
],
}),
),
}),
],
}),
);
}
if (answer.sums) {
out.push(
el("p", {
className: "m01-test__total",
attrs: { "data-total": String(answer.sums["계"] ?? 0) },
text: `${tt("Sum_Total")} ${formatNumber(answer.sums["계"] ?? 0)}`,
}),
);
}
if (answer.result !== undefined) {
out.push(
el("p", {
className: "m01-test__total",
attrs: { "data-total": String(answer.result) },
text: `${tt("Sum_Result")} ${formatNumber(answer.result)}`,
}),
);
}
return out;
}
-291
View File
@@ -1,291 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Test_B.ts
* 로직 사용성 테스트 컨테이너 — 방식 B: 호표 + 옆 설명 카드 (PLAN.md 1-2)
*
* 접속 계약: export function render(host, logicKey) 하나.
* 읽기 + 시험 계산만 — 저장 없음 · 정본 로직 안 고침. 로직 읽기·시험 계산은 지금
* 로직 화면이 쓰는 Store·API(fetchLogic · runCalc · elements · table)를 그대로
* 씀 — 새 엔진·새 계산 없음(만약·찾기 식 자체는 서버가 풂 · 여기선 찾기(...) 가
* 가리키는 원문 표를 다시 읽어 「걸린 줄」만 강조).
*
* 줄을 누르면 옆 카드 — 원문 표 미리보기(걸린 줄 강조) · 적용된 할증 · 쉬운 말
* 풀이(식을 문장으로, 안 되면 원문 식 그대로) · 출처. 입력을 바꾸면 호표와
* 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음).
* ========================================================================== */
import { createSelectField, el } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type CalcLine,
type HoLine,
type LogicOne,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { buildNote, relatedFinds, type EvalCtx } from "./M01_MasterData_UI_Logic_Note";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Logic_Pick";
import { guideLines, plainReason, tt } from "./M01_MasterData_UI_Test_Text";
/* ── 화면 상태 ────────────────────────────────────────────────────────── */
interface State {
one: LogicOne;
values: Record<string, string>;
answer: CalcAnswer | null;
selected: number;
calcSeq: number;
swaps: Swaps;
picker: HTMLElement;
}
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" }));
void fetchLogic(logicKey)
.then((one) => {
const swaps: Swaps = new Map();
const state: State = {
one,
// 고르기 칸은 첫 값으로 시작 — 방식 C 와 같게(바로 계산이 돎)
values: Object.fromEntries(
(one.logic. ?? [])
.filter((spec) => spec.?.length)
.map((spec) => [spec., String(spec.?.[0])]),
),
answer: null,
selected: 0,
calcSeq: 0,
swaps,
picker: pickPanel(one, swaps, () => void recalc(host, state)),
};
paint(host, state);
void recalc(host, state);
})
.catch((error) => {
host.replaceChildren(
el("p", { className: "m01-logic__reasons", text: String(error?.message ?? error) }),
);
});
}
async function recalc(host: HTMLElement, state: State): Promise<void> {
const seq = ++state.calcSeq;
const inputs: Record<string, unknown> = {};
for (const spec of state.one.logic. ?? []) {
const raw = (state.values[spec.] ?? "").trim();
if (raw === "") continue;
const option = spec.?.find((o) => String(o) === raw);
inputs[spec.] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
try {
const answer = await runCalc({
key: state.one.logic.키,
inputs,
row: swappedRow(state.one.logic, state.swaps) ?? state.one.logic,
file: state.one.file,
});
if (seq !== state.calcSeq) return; // 최신 응답만 채택
state.answer = answer;
} catch (error) {
if (seq !== state.calcSeq) return;
state.answer = { ok: false, reason: error instanceof Error ? error.message : String(error) };
}
paint(host, state);
}
/* ── 그리기 ───────────────────────────────────────────────────────────── */
function paint(host: HTMLElement, state: State): void {
const row = state.one.logic;
const inputWrap = el("div", { className: "m01-logic__fields" });
inputWrap.style.cssText = "display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px;";
for (const spec of row. ?? []) {
let control: HTMLElement;
if (spec.?.length) {
const pick = createSelectField({
options: ["", ...spec..map(String)].map((o) => ({ value: o, text: o || "(선택)" })),
value: state.values[spec.] ?? "",
compact: true,
onChange: (v) => {
state.values[spec.] = v;
void recalc(host, state);
},
});
control = pick.root;
} else {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal", placeholder: tt("Enter_Value") },
});
box.value = state.values[spec.] ?? "";
let timer: ReturnType<typeof setTimeout> | undefined;
box.addEventListener("input", () => {
state.values[spec.] = box.value;
clearTimeout(timer);
timer = setTimeout(() => void recalc(host, state), 300);
});
control = box;
}
const label = spec. ? `${spec.} (${spec.})` : spec.;
inputWrap.append(
el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
}),
);
}
const main = el("div", {});
main.style.cssText = "flex:1 1 60%;min-width:0;";
main.append(
el("h3", { text: `${row.} ${row.}` }),
el("ul", {
className: "m01-logic__muted",
children: guideLines("B").map((t) => el("li", { text: t })),
}),
inputWrap,
...(hasPickable(state.one.logic)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tt("Mat_Title") }), state.picker],
}),
]
: []),
buildHoTable(host, state),
);
if (state.answer && !state.answer.ok) {
main.append(
el("div", {
className: "m01-logic__reasons",
children: [
el("strong", { text: "멈춤" }),
el("div", { text: plainReason(state.answer.reason) }),
],
}),
);
}
const side = el("div", { className: "m01-logic__side" });
side.style.cssText =
"flex:1 1 36%;min-width:280px;border:1px solid var(--ui-border,#ddd);border-radius:8px;padding:12px;";
buildCard(side, state);
const layout = el("div", {});
layout.style.cssText = "display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;";
layout.append(main, side);
host.replaceChildren(layout);
}
function buildHoTable(host: HTMLElement, state: State): HTMLElement {
const lines: CalcLine[] | null = state.answer?.ok ? (state.answer.lines ?? null) : null;
const rows = (state.one.logic. ?? []).map((ho, i) => {
const calc = lines?.[i];
const tr = el("tr", {
className: i === state.selected ? "m01-logic__row is-selected" : "m01-logic__row",
children: [
el("td", { text: ho.이름 ?? ho. }),
el("td", { text: ho.규격 ?? "" }),
el("td", { text: calc ? `${formatNumber(calc.)} ${calc.}` : (ho. ?? "") }),
el("td", { className: "m01-logic__money", text: calc ? formatNumber(calc.) : "" }),
],
});
tr.style.cssText =
"cursor:pointer;" + (i === state.selected ? "background:var(--ui-accent-bg,#eef4ff);" : "");
tr.addEventListener("click", () => {
state.selected = i;
paint(host, state);
});
return tr;
});
return el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [
el("tr", {
children: ["이름", "규격", "수량", "금액"].map((h) => el("th", { text: h })),
}),
],
}),
el("tbody", {
children: [
...rows,
// 하위 로직이 여러 줄로 풀린 경우 — 호표 줄 수보다 많은 줄도 빠짐없이 보임
...(lines ?? []).slice((state.one.logic. ?? []).length).map((calc) =>
el("tr", {
children: [
el("td", { text: calc.이름 }),
el("td", { text: "" }),
el("td", { text: `${formatNumber(calc.)} ${calc.}` }),
el("td", { className: "m01-logic__money", text: formatNumber(calc.) }),
],
}),
),
...(state.answer?.ok && state.answer.sums
? [
el("tr", {
attrs: { "data-total": String(state.answer.sums["계"] ?? 0) },
children: [
el("td", { text: tt("Sum_Total") }),
el("td", { text: "" }),
el("td", { text: "" }),
el("td", { text: formatNumber(state.answer.sums["계"] ?? 0) }),
],
}),
]
: []),
],
}),
],
});
}
function buildCard(side: HTMLElement, state: State): void {
const ho: HoLine | undefined = (state.one.logic. ?? [])[state.selected];
if (!ho) {
side.replaceChildren(el("p", { className: "m01-logic__muted", text: "호표 줄이 없음" }));
return;
}
const lines = state.answer?.ok ? (state.answer.lines ?? []) : [];
const calc = lines[state.selected];
const middle = state.answer?.ok ? (state.answer.middle ?? {}) : {};
const ctx: EvalCtx = { values: state.values, middle };
const related = relatedFinds(ho., state.one.logic. ?? []);
side.replaceChildren(
el("h4", { text: `이 수량은 어디서 왔나 — ${ho. ?? ho.}` }),
el("dl", {
className: "m01-logic__sums",
children: [
el("dt", { text: "지금 값" }),
el("dd", {
text: calc
? `${formatNumber(calc.)} ${calc.} · ${formatNumber(calc.)}`
: "값을 넣어야 계산됨",
}),
el("dt", { text: "출처" }),
el("dd", { text: calc?.출처 ?? ho. ?? "-" }),
],
}),
);
const surcharge = related.filter((r) => r.source !== "이 줄" && r.source.includes("할증"));
if (surcharge.length) {
side.append(
el("h4", { text: "적용된 할증" }),
...surcharge.map((r) =>
el("p", {
text: `${r.source} = ${middle[r.source] !== undefined ? formatNumber(middle[r.source]) : "(입력 필요)"}`,
}),
),
);
}
// 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로) — 흐름 그림과 같은 카드
const note = el("div");
side.append(note);
buildNote(note, { expr: ho.수량, middles: state.one.logic.중간 ?? [], ctx });
}
@@ -1,99 +0,0 @@
/* M01 로직 테스트 컨테이너 — 틀 + 방식 A (방식 B·C 는 각자 파일에서 같은 접두 m01-test__ 를 씀) */
.m01-test {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
padding: var(--spacing-12);
box-sizing: border-box;
overflow: auto;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-test__bar,
.m01-test__tabs,
.m01-test__nav {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01-test__bar h2 {
margin: 0 auto 0 0;
font-size: 18px;
color: var(--color-text);
}
.m01-test__tab {
padding: var(--spacing-4) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
cursor: pointer;
}
.m01-test__tab.is-active {
background: var(--color-surface);
font-weight: 600;
}
.m01-test__muted {
margin: 0;
color: var(--color-text-muted);
}
.m01-test__card {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 640px;
padding: var(--spacing-12);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-test__card h3,
.m01-test__step {
margin: 0;
}
.m01-test__input {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
}
.m01-test__grid {
width: 100%;
border-collapse: collapse;
}
.m01-test__grid th,
.m01-test__grid td {
padding: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.m01-test__money {
text-align: right !important;
font-variant-numeric: tabular-nums;
}
.m01-test__total {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.m01-test__reasons {
padding: var(--spacing-8);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-danger, #c0392b);
}
@@ -1,102 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_Test_Text.ts
* 로직 테스트 컨테이너 글자 — [한국어, 영어] · 틀과 방식 파일(A·B·C)이 같이 씀
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 로직 테스트", "Logic test"],
Pick_Logic: ["공종 고르기", "Pick a job"],
Tab_A: ["A 질문·답", "A Wizard"],
Tab_B: ["B 호표 + 설명", "B Table + notes"],
Tab_C: ["C 흐름 그림", "C Flow"],
Soon: ["준비 중", "Coming soon"],
New_Logic: ["새로 만들기", "Create new"],
Loading: ["불러오는 중", "Loading"],
Failed: ["불러오지 못함", "Load failed"],
Read_Only: ["시험 계산만 — 저장하지 않음", "Trial only — nothing is saved"],
Step: ["{n} / {total}", "{n} / {total}"],
Back: ["이전", "Back"],
Next: ["다음", "Next"],
Again: ["처음부터", "Start over"],
Job_Title: ["이 공종의 단가를 셈합니다", "Price this job"],
Job_Note: [
"몇 가지만 물어봅니다 — 답을 마치면 결과 표가 나옵니다",
"A few questions, then the result table",
],
Ask_Pick: ["「{name}」 — 어느 것으로 할까요?", "「{name}」 — which one?"],
Ask_Number: ["「{name}」 값을 넣어 주세요{unit}", "Enter 「{name}」{unit}"],
Ask_Range: ["범위 {min} {max}", "Range {min} {max}"],
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
Mat_Note: [
"기본은 원문 그대로 — 바꿔 보면 결과가 달라짐(저장 안 함) · 값이 여럿이면 그중 낮은 값으로 셈",
"Default is the source as is — swapping only changes this trial; the lowest listed price is used",
],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Pick_All: ["(전체)", "(All)"],
Use_Of: ["이 표의 용도", "Used for"],
Mat_Price: ["단가", "Unit price"],
Mat_NoPrice: ["단가 없음", "No price"],
Stop_Missing: [
"「{name}」 값을 아직 안 넣었습니다 — 값을 넣으면 계산됩니다",
"「{name}」 is empty — enter it to calculate",
],
Stop_NoRow: [
"넣은 값에 맞는 표 줄이 없습니다 — 값을 바꿔 보세요",
"No table row fits these values — try other values",
],
Stop_Spec: [
"「{name}」 규격이 여럿이라 정하지 못함 — 위 「쓰이는 재료·기계」에서 하나를 골라 주세요",
"「{name}」 has several specs — pick one in the materials list above",
],
Stop_NoPrice: [
"「{name}」 단가가 자료에 비어 있어 셈하지 못함 — 자료를 채워야 함",
"「{name}」 has no price in the data yet",
],
Guide_A: [
"한 번에 하나씩 묻는 대로 답하면 끝에 결과 표가 나옴|값을 잘못 넣었으면 「이전」으로 돌아가 고침|재료·기계를 바꿔 보는 칸은 뒤쪽 단계에 있음",
"Answer one question at a time; the result table comes last|Use Back to fix a value|Swap materials in a later step",
],
Guide_B: [
"위쪽 칸에 값을 넣으면 아래 호표(재료·인력·기계 줄)의 수량과 금액이 바로 바뀜|줄을 누르면 오른쪽에 그 수량이 어디서 왔는지 나옴|맨 아래 「합계」가 최종 금액",
"Type values above and the table below updates at once|Click a row to see where its quantity comes from|The last row is the total",
],
Guide_C: [
"왼쪽 「설계 값」을 넣으면 오른쪽으로 수량 → 단가 → 합계 순으로 흐름이 이어짐|상자를 누르면 값과 출처가 펼쳐짐|빨간 글이 뜨면 그 까닭 그대로 값을 채우거나 바꿈",
"Enter design values on the left; quantity, price and total follow to the right|Click a box for details|Red text says what is missing",
],
Enter_Value: ["값을 넣어 주세요", "Enter a value"],
Result_Title: ["결과", "Result"],
Result_Stopped: ["계산이 멈춤", "Calculation stopped"],
Col_Name: ["이름", "Name"],
Col_Qty: ["수량", "Qty"],
Col_Amount: ["금액", "Amount"],
Sum_Total: ["합계", "Total"],
Sum_Result: ["결과 값", "Result"],
} as const satisfies Record<string, readonly [string, string]>;
export type TestTextKey = keyof typeof TEXT;
/** 엔진이 멈춘 까닭을 설계자 말로 — 모르는 글은 그대로 */
export function plainReason(reason: string): string {
const missing = /입력 「([^」]+)」 없음/.exec(reason);
if (missing) return tt("Stop_Missing", { name: missing[1] });
if (/맞는 줄 0개/.test(reason)) return tt("Stop_NoRow");
const spec = /「([^」]+)」 후보 여럿/.exec(reason);
if (spec) return tt("Stop_Spec", { name: spec[1] });
const noPrice = /「([^」]+)」 값 없음/.exec(reason);
if (noPrice) return tt("Stop_NoPrice", { name: noPrice[1] });
return reason;
}
/** 방식 첫 화면 「이 방식은 이렇게 씀」 — 줄 목록 */
export const guideLines = (id: "A" | "B" | "C"): string[] =>
tt(`Guide_${id}` as TestTextKey).split("|");
export function tt(key: TestTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
+1 -1
View File
@@ -6,7 +6,7 @@ const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Test_C_Model.ts"),
path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Logic_Flow_Model.ts"),
"utf8",
);
const js = ts.transpileModule(source, {
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Test_C_Model.ts`.
"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Logic_Flow_Model.ts`.
프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_m01_flow_c.cjs)가 TS를 트랜스파일해
돌리고, pytest 는 그 결과(JSON)를 본다. 뼈대만 봄 — 금액은 엔진이 준 답을 그대로 옮기는지.
+15 -7
View File
@@ -1,17 +1,25 @@
"""M01 로직 테스트 컨테이너 — 대상 10 개(틀 파일 TEST_KEYS)가 실제 산림품셈 로직이고 막히지 않았는지.
"""M01 로직 테스트 컨테이너 — 견본 10 개가 실제 산림품셈 로직이고 막히지 않았는지.
방식 A·B·C 는 같은 `/calc` 를 씀 — 여기서는 10 개가 모두 첫 선택값으로 셈이 끝까지 가는지만 봄.
흐름 그림(방식 C)과 고급 화면은 같은 `/calc` 를 씀 — 여기서는 10 개가 모두 첫 선택값으로 셈이 끝까지 가는지만 봄.
"""
from __future__ import annotations
import re
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
FRAME = Path(store.__file__).with_name("M01_MasterData_UI_Test.ts")
KEYS = re.findall(r'^\s+"(GF\d{6})",', FRAME.read_text(encoding="utf-8"), re.M)
# 견본 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서 · 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py`
KEYS = [
"GF000181", # 12-16 맨홀
"GF000177", # 12-12 날개벽
"GF000173", # 12-11-1 VR관(소켓식) 부설
"GF000174", # 12-11-2-1 흄관 부설
"GF000160", # 12-4 합판거푸집
"GF000182", # 12-17-1 펌프카 타설
"GF000069", # 7-11 동력상하차기 집재
"GF000219", # 13-4-1 돌쌓기 메쌓기(인력)
"GF000119", # 9-16-1 노체포설
"GF000137", # 10-4 중기운반 트레일러
]
def _first_inputs(logic: dict) -> dict: