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:
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user