Files
Aislo/M01_MasterData/M01_MasterData_UI_Test_A.ts
T
eomsangdonandClaude Sonnet 5 292568e688 feat(M01): 일위대가 로직 테스트 컨테이너 틀 + 방식 A 질문·답 마법사
- 왼쪽 패널에 「일위대가 로직 테스트」 컨테이너 추가 — 공종 10 개 고르기 + 방식 탭 A·B·C (B·C 는 준비 중)
- 방식 A: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표 · 식 안 보임 · 저장 없음
- 대상 10 개는 복잡도로 재어 고름(scripts/로직_복잡도_재기.py) · 시험 test_m01_test_container.py

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
2026-09-20 21:34:39 +09:00

275 lines
8.9 KiB
TypeScript

/* =============================================================================
* M01_MasterData_UI_Test_A.ts
* 방식 A — 질문·답 마법사: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표
* 식은 안 보임 · 시험 계산은 로직 화면과 같은 `/calc` · 저장 없음
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
searchElements,
type CalcAnswer,
type ElementBrief,
type HoLine,
type LogicInput,
type LogicOne,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tt } from "./M01_MasterData_UI_Test_Text";
type Step =
{ kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" };
const PICKABLE = ["재료", "기계"];
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 lines = (logic.호표 ?? [])
.map((line, i) => ({ line, i }))
.filter((x) => PICKABLE.includes(x.line.종류));
const steps: Step[] = [
{ kind: "job" },
...(logic.입력 ?? []).map((spec): Step => ({ kind: "ask", spec })),
...(lines.length ? [{ kind: "pick" } as Step] : []),
{ kind: "result" },
];
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.결과단위}` })],
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" },
});
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 => {
const rows = lines.map(({ line, i }) => pickRow(line, i));
frame(
tt("Mat_Title"),
tt("Mat_Note"),
rows.length ? rows : [el("p", { text: tt("Mat_None") })],
true,
);
};
const pickRow = (line: HoLine, i: number): HTMLElement => {
const price = one.prices[line.요소]?.;
const found = new Map<string, ElementBrief>();
const select = createSelectField({
options: [{ value: "", text: tt("Mat_Default") }],
value: "",
onChange: (ref) => {
const item = found.get(ref);
if (item) swaps.set(i, item);
else swaps.delete(i);
},
});
void searchElements(line.종류, line.이름 ?? "")
.then((r) => {
r.items.forEach((it) => found.set(it.ref, it));
select.setOptions(
[
{ value: "", text: tt("Mat_Default") },
...r.items.map((it) => ({
value: it.ref,
text: [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), formatNumber(it.)]
.filter(Boolean)
.join(" · "),
})),
],
swaps.get(i)?.ref ?? "",
);
})
.catch(() => undefined);
const spec = line.규격 ? ` ${line.규격}` : "";
const unit = line.단위 ?? "";
const cost =
price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : "";
return el("div", {
className: "m01-test__pick",
children: [
el("strong", { text: `${line.종류} · ${line.이름 ?? line.요소}${spec}` }),
el("span", { className: "m01-test__muted", text: `${unit}${cost}` }),
select.root,
],
});
};
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 = swaps.size
? {
...logic,
호표: (logic.호표 ?? []).map((l, i) => {
const s = swaps.get(i);
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l;
}),
}
: undefined;
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: 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;
}