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
This commit is contained in:
2026-09-20 21:34:39 +09:00
co-authored by Claude Sonnet 5
parent 565833bcb8
commit 292568e688
9 changed files with 686 additions and 18 deletions
+36 -16
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직)
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 아홉: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직·로직 테스트)
* / 우측 요소 표 · 표 목록 · 로직 편집
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
@@ -45,6 +45,7 @@ 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") });
@@ -60,6 +61,7 @@ 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],
@@ -77,14 +79,15 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (logic: boolean): void => {
logicHost.hidden = !logic;
elementView.hidden = logic;
const showMode = (mode: "elements" | "logic" | "test"): void => {
logicHost.hidden = mode !== "logic";
testHost.hidden = mode !== "test";
elementView.hidden = mode !== "elements";
};
const openFile = (pick: Pick): void => {
dispose();
showMode(false);
showMode("elements");
if (pick.view) {
query = pick.view.q;
search.value = query;
@@ -162,21 +165,38 @@ function buildPage(): HTMLElement {
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
};
/* --- 좌측: 컨테이너 여덟 --- */
const side = buildSide(openFile, () => {
dispose();
dispose = (): void => {};
current = null;
showMode(true);
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
});
/* --- 좌측: 컨테이너 아홉 --- */
const side = buildSide(
openFile,
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("logic");
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
},
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("test");
if (testMounted) return;
testMounted = true;
void import("./M01_MasterData_UI_Test").then((m) => m.mountM01Test(testHost));
},
);
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })],
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost, testHost],
}),
],
});
const overlays = createWorkflowOverlays({
title: L("M01_Title"),
+16 -2
View File
@@ -104,8 +104,13 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const TEST_ID = "테스트|";
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
onTest: () => void,
): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -375,7 +380,16 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
onLogic();
});
const root = el("div", { className: "m01-side", children: [...groups, logic] });
// 테스트 컨테이너 — 눌러 펼치는 목록이 없는 한 줄(환율·요율과 같은 모양)
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] });
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* M01_MasterData_UI_Test.ts
* 로직 테스트 컨테이너 틀 — 공종(로직) 10 개 고르기 + 방식 탭 A·B·C
* 읽기와 시험 계산만 — 저장 없음 · 정본 로직은 안 고침 · 계산은 로직 화면과 같은 `/calc`
* 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { fetchLogics } from "./M01_MasterData_UI_Logic_Api";
import { render as renderA } from "./M01_MasterData_UI_Test_A";
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: null },
{ id: "C", label: () => tt("Tab_C"), render: null },
];
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],
}),
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();
}
+274
View File
@@ -0,0 +1,274 @@
/* =============================================================================
* 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;
}
@@ -0,0 +1,105 @@
/* 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__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.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);
}
@@ -0,0 +1,53 @@
/* =============================================================================
* 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"],
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",
],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Mat_Price: ["단가", "Unit price"],
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 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));
}
@@ -0,0 +1,53 @@
"""산림품셈 로직 복잡도 재기 — 입력 수 · 표 찾기 수 · 로직 부르기 · 덧줄 · 재료 가짓수.
사용: python resources/master_data/scripts/로직_복잡도_재기.py [상위 N]
점수 = 입력 + 찾기 + 로직부르기*2 + 덧줄 + 재료 (읽기 전용 · 아무것도 안 씀)
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIND = re.compile(r"찾기\(")
CALL = re.compile(r"로직\(")
def measure(row: dict) -> dict:
ho = row.get("호표") or []
text = " ".join(str(h.get("수량", "")) + str(h.get("요소", "")) for h in ho)
text += " ".join(str(m.get("", "")) for m in row.get("중간") or [])
text += " ".join(str(m.get("", "")) for m in row.get("덧줄") or [])
m = {
"입력": len(row.get("입력") or []),
"찾기": len(FIND.findall(text)),
"로직": len(CALL.findall(text)),
"덧줄": len(row.get("덧줄") or []),
"재료": sum(1 for h in ho if h.get("종류") == "재료"),
}
m["점수"] = m["입력"] + m["찾기"] + m["로직"] * 2 + m["덧줄"] + m["재료"]
return m
def main() -> None:
top = int(sys.argv[1]) if len(sys.argv) > 1 else 25
out = []
for f in sorted(ROOT.glob("로직_산림품셈_*.json")):
for row in json.loads(f.read_text(encoding="utf-8"))[""]:
out.append(
{"": row[""], "번호": row["원문번호"], "이름": row["이름"], **measure(row)}
)
out.sort(key=lambda r: -r["점수"])
for r in out[:top]:
print(
r[""],
r["번호"],
r["이름"],
{k: r[k] for k in ("입력", "찾기", "로직", "덧줄", "재료", "점수")},
)
print("", len(out))
if __name__ == "__main__":
main()
@@ -0,0 +1,50 @@
"""M01 로직 테스트 컨테이너 — 대상 10 개(틀 파일 TEST_KEYS)가 실제 산림품셈 로직이고 막히지 않았는지.
방식 A·B·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)
def _first_inputs(logic: dict) -> dict:
"""고르기 = 첫 값 · 범위 = 아래 끝 · 나머지 = 10 (마법사가 흔히 넣는 값)."""
out = {}
for spec in logic.get("입력") or []:
if spec.get("고르기"):
out[spec["이름"]] = spec["고르기"][0]
elif spec.get("범위"):
out[spec["이름"]] = spec["범위"][0]
else:
out[spec["이름"]] = 10
return out
def test_ten_keys():
assert len(KEYS) == 10 and len(set(KEYS)) == 10
def test_keys_are_forest_logics_and_not_blocked():
for key in KEYS:
one = store.logic(key)
assert one["file"].startswith("로직_산림품셈_"), key
assert not one["blocked"], (key, one["reasons"])
def test_required_three_present():
# 메쌓기 13-4-1 · 노체 9-16-1 · 트럭 운반 10-4 — 끝까지 눌러 보는 셋
assert {"GF000219", "GF000119", "GF000137"} <= set(KEYS)
def test_calc_answers_for_each():
for key in KEYS:
one = store.logic(key)
answer = store.calc(key, _first_inputs(one["logic"]), None, None)
assert isinstance(answer, dict) and "ok" in answer, key
+1
View File
@@ -67,4 +67,5 @@ export const ui_locales_m1 = {
M01_GroupExchange: ["환율", "Exchange rates"],
M01_GroupRate: ["요율", "Rates"],
M01_GroupLogic: ["일위대가 로직", "Unit-cost logic"],
M01_GroupTest: ["일위대가 로직 테스트", "Logic test"],
} as const;