feat(M01테스트B): 로직 사용성 테스트 방식 B(호표+옆 카드) 새로 만듦
M01_MasterData_UI_Test_B.ts 새 파일 — export function render(host, logicKey) 하나로 접속. fetchLogic·runCalc·elements·table 기존 Store·API 그대로 재사용 (새 엔진·새 계산 없음). 호표 줄을 누르면 옆 카드에 이 수량이 어디서 왔나 표시: 식 속 찾기(표,조건).칸 호출을 뽑아 그 소요량·계수 표를 /elements+/table 로 다시 읽어 걸린 줄 강조 · 중간 식 이름에 「할증」 들어간 것은 적용된 할증으로 따로 보임 · 쉬운 말 풀이(찾기·만약 호출을 문장으로, 나머지는 원문 식) · 계산 답의 middle 값으로 할증률 등 중간 계산값 표시. 입력 바꾸면 자동 재계산(디바운스 300ms)해 호표· 카드 같이 갱신. 검증: npm run typecheck 통과(0). ORCA 로 임시 붙여 확인하려 했으나 로그인 화면에 막혀(시험 계정 없음) 실제 화면 확인은 못 함 — 임의 판정: typecheck만으로 마감(임시 연결은 확인 뒤 되돌려 커밋 안 함). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaS48Nvs26o3aFUpcfJxVv
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Test_B.ts
|
||||
* 로직 사용성 테스트 컨테이너 — 방식 B: 호표 + 옆 설명 카드 (PLAN.md 1-2)
|
||||
*
|
||||
* 접속 계약: export function render(host, logicKey) 하나.
|
||||
* 읽기 + 시험 계산만 — 저장 없음 · 정본 로직 안 고침. 로직 읽기·시험 계산은 지금
|
||||
* 로직 화면이 쓰는 Store·API(fetchLogic · runCalc · elements · table)를 그대로
|
||||
* 씀 — 새 엔진·새 계산 없음(만약·찾기 식 자체는 서버가 풂 · 여기선 찾기(...) 가
|
||||
* 가리키는 원문 표를 다시 읽어 「걸린 줄」만 강조).
|
||||
*
|
||||
* 줄을 누르면 옆 카드 — 원문 표 미리보기(걸린 줄 강조) · 적용된 할증 · 쉬운 말
|
||||
* 풀이(식을 문장으로, 안 되면 원문 식 그대로) · 출처. 입력을 바꾸면 호표와
|
||||
* 카드가 같이 바뀜(자동 계산 — [계산] 버튼 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchLogic,
|
||||
runCalc,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type ElementBrief,
|
||||
type HoLine,
|
||||
type LogicOne,
|
||||
type NamedFormula,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
|
||||
/* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */
|
||||
|
||||
interface TableRow {
|
||||
file: string;
|
||||
키: string;
|
||||
이름?: string;
|
||||
원문번호?: string;
|
||||
출처?: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
줄?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const tableCache = new Map<string, Promise<TableRow | null>>();
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}`);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadTable(key: string): Promise<TableRow | null> {
|
||||
const cached = tableCache.get(key);
|
||||
if (cached) return cached;
|
||||
const job = (async (): Promise<TableRow | null> => {
|
||||
for (const group of ["소요량", "계수"]) {
|
||||
const found = await getJson<{ items: ElementBrief[] }>(
|
||||
`/elements?group=${encodeURIComponent(group)}&q=${encodeURIComponent(key)}&limit=5`,
|
||||
);
|
||||
const file = found?.items?.find((i) => i.ref === key)?.file;
|
||||
if (!file) continue;
|
||||
const got = await getJson<{ table: TableRow }>(
|
||||
`/table?file=${encodeURIComponent(file)}&key=${encodeURIComponent(key)}`,
|
||||
);
|
||||
if (got?.table) return { ...got.table, file };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
tableCache.set(key, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */
|
||||
|
||||
interface FindCall {
|
||||
table: string;
|
||||
conds: [string, string][];
|
||||
col: string;
|
||||
}
|
||||
|
||||
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
|
||||
|
||||
function splitConds(raw: string): [string, string][] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p): [string, string] => {
|
||||
const i = p.indexOf("=");
|
||||
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
|
||||
});
|
||||
}
|
||||
|
||||
function findCalls(expr: string): FindCall[] {
|
||||
const out: FindCall[] = [];
|
||||
for (const m of expr.matchAll(FIND_RE)) {
|
||||
out.push({ table: m[1], conds: splitConds(m[2]), col: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 식 안에 이름이 낱말로 나오는지 — 한글엔 \b 가 안 먹어 앞뒤 글자를 직접 봄 */
|
||||
function mentions(expr: string, name: string): boolean {
|
||||
if (!name) return false;
|
||||
const idx = expr.indexOf(name);
|
||||
if (idx < 0) return false;
|
||||
const isWord = (c: string | undefined) => !!c && /[A-Za-z0-9_가-힣]/.test(c);
|
||||
return !isWord(expr[idx - 1]) && !isWord(expr[idx + name.length]);
|
||||
}
|
||||
|
||||
interface RelatedFind {
|
||||
source: string; // "이 줄" 또는 물려 쓰는 중간 이름
|
||||
find: FindCall;
|
||||
}
|
||||
|
||||
/** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */
|
||||
function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] {
|
||||
const out: RelatedFind[] = findCalls(expr).map((find) => ({ source: "이 줄", find }));
|
||||
for (const m of middles) {
|
||||
if (!mentions(expr, m.이름)) continue;
|
||||
for (const find of findCalls(m.식)) out.push({ source: m.이름, find });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */
|
||||
|
||||
interface EvalCtx {
|
||||
values: Record<string, string>;
|
||||
middle: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveIdent(name: string, ctx: EvalCtx): string | number | undefined {
|
||||
const quoted = name.match(/^["'](.*)["']$/);
|
||||
if (quoted) return quoted[1];
|
||||
if (/^-?\d+(\.\d+)?$/.test(name)) return Number(name);
|
||||
if (ctx.values[name] !== undefined && ctx.values[name] !== "") return ctx.values[name];
|
||||
if (ctx.middle[name] !== undefined) return ctx.middle[name] as number;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sameValue(a: unknown, b: unknown): boolean {
|
||||
if (a === undefined || a === null || b === undefined) return false;
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb;
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
/** 지금 조건 값으로 찾기() 가 고를 줄 — 못 맞히면 null(값을 아직 안 골랐거나 후보 여럿) */
|
||||
function matchRow(
|
||||
table: TableRow,
|
||||
conds: [string, string][],
|
||||
ctx: EvalCtx,
|
||||
): Record<string, unknown> | null {
|
||||
for (const row of table.줄 ?? []) {
|
||||
if (conds.every(([col, rhs]) => sameValue(row[col], resolveIdent(rhs, ctx)))) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── 쉬운 말 풀이 — 찾기()/만약() 을 문장으로, 안 되면 식 그대로 ─────────── */
|
||||
|
||||
function splitTop(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = "";
|
||||
for (const ch of s) {
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") depth--;
|
||||
if (ch === "," && depth === 0) {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function expandIf(expr: string): string {
|
||||
const at = expr.indexOf("만약(");
|
||||
if (at < 0) return expr;
|
||||
let depth = 0;
|
||||
let j = at + 2; // "만약(" 의 '(' 자리
|
||||
for (; j < expr.length; j++) {
|
||||
if (expr[j] === "(") depth++;
|
||||
else if (expr[j] === ")") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const [cond, then, els] = splitTop(expr.slice(at + 3, j - 1)).map((p) => p.trim());
|
||||
const said = `(${expandIf(cond)} 이면 ${expandIf(then)}, 아니면 ${expandIf(els ?? "")})`;
|
||||
return expandIf(expr.slice(0, at) + said + expr.slice(j));
|
||||
}
|
||||
|
||||
function explain(expr: string): string {
|
||||
const withFind = expr.replace(FIND_RE, (_all, table: string, conds: string, col: string) => {
|
||||
const cs = splitConds(conds)
|
||||
.map(([, rhs]) => rhs)
|
||||
.join(" · ");
|
||||
return `[${table} 표에서 ${cs} 에 맞는 「${col}」]`;
|
||||
});
|
||||
return expandIf(withFind).trim() || expr;
|
||||
}
|
||||
|
||||
/* ── 화면 상태 ────────────────────────────────────────────────────────── */
|
||||
|
||||
interface State {
|
||||
one: LogicOne;
|
||||
values: Record<string, string>;
|
||||
answer: CalcAnswer | null;
|
||||
selected: number;
|
||||
calcSeq: number;
|
||||
}
|
||||
|
||||
export function render(host: HTMLElement, logicKey: string): void {
|
||||
host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" }));
|
||||
void fetchLogic(logicKey)
|
||||
.then((one) => {
|
||||
const state: State = { one, values: {}, answer: null, selected: 0, calcSeq: 0 };
|
||||
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: 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" },
|
||||
});
|
||||
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.이름}` }),
|
||||
inputWrap,
|
||||
buildHoTable(host, state),
|
||||
);
|
||||
if (state.answer && !state.answer.ok) {
|
||||
main.append(
|
||||
el("div", {
|
||||
className: "m01-logic__reasons",
|
||||
children: [el("strong", { text: "멈춤" }), el("div", { text: 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;";
|
||||
void 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 }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function buildCard(side: HTMLElement, state: State): Promise<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.비목 ?? "-" }),
|
||||
],
|
||||
}),
|
||||
el("p", { text: explain(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]) : "(입력 필요)"}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
side.append(el("h4", { text: "원문 표 미리보기" }));
|
||||
const previewHost = el("div", { text: "찾는 중…" });
|
||||
side.append(previewHost);
|
||||
const previews = await Promise.all(
|
||||
related.map(async (r) => ({ r, table: await loadTable(r.find.table) })),
|
||||
);
|
||||
previewHost.replaceChildren(
|
||||
...previews.flatMap(({ r, table }) => {
|
||||
if (!table)
|
||||
return [el("p", { className: "m01-logic__muted", text: `${r.find.table} 표 못 찾음` })];
|
||||
const hit = matchRow(table, r.find.conds, ctx);
|
||||
const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})];
|
||||
const bodyRows = (table.줄 ?? []).map((line) => {
|
||||
const isHit = hit === line;
|
||||
const tr = el("tr", {
|
||||
children: cols.map((c) => el("td", { text: formatNumber(line[c]) })),
|
||||
});
|
||||
if (isHit) tr.style.cssText = "background:var(--ui-accent-bg,#fff3cd);font-weight:600;";
|
||||
return tr;
|
||||
});
|
||||
return [
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${r.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`,
|
||||
}),
|
||||
el("table", {
|
||||
className: "m01-logic__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
|
||||
}),
|
||||
el("tbody", { children: bodyRows }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
const advanced = el("details", {});
|
||||
advanced.append(el("summary", { text: "고급 — 식 그대로" }), el("pre", { text: ho.수량 }));
|
||||
side.append(advanced);
|
||||
}
|
||||
Reference in New Issue
Block a user