Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Source.ts
T
eomsangdonandClaude Opus 5.5 f62689dff1 feat(M01): 로직 만들기 판 왼쪽 원문 Source · Check 를 LB 타입에 맞춤(PLAN 35-2)
- renderSource: 표 이름 · 원문번호 · 기준 · 격자 · 역할 칩 · 걸린 줄 · 해당 없음/중복 · 칸 고침(on.edit) · 주 · 사용처
- Check: runExpect(lb, tables, rows) · 같은 조건 두 칸 조회는 빈틈 한 번
- 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 11:47:53 +09:00

372 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M01_LB_Source.ts
* 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절)
* 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과)
* · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처.
* 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { tableUsers, type LbTables } from "./M01_LB_Api";
import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks";
import type { LbHit } from "./M01_LB_Check";
import "./M01_LB_Source.css";
const TEXT = {
Preview: [
"표 고침은 미리 보기 — 저장은 결정 뒤",
"Table edits are a preview — saving comes later",
],
Users: ["사용처", "Used by"],
UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"],
UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"],
UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"],
Loading: ["불러오는 중", "Loading"],
Missing: ["표를 불러오지 못함", "Couldn't load the table"],
NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"],
DupRow: [
"중복 — 예시값에 맞는 줄이 둘 이상",
"Duplicate — more than one row fits the sample values",
],
Notes: ["주", "Notes"],
NoteUsed: ["계산에 쓰임", "Used in the calculation"],
Cond: ["조건", "Condition"],
Fixed: ["고정", "Fixed"],
Result: ["결과", "Result"],
Free: ["무관", "Any"],
RoleHint: ["눌러 역할 바꿈", "Tap to change the role"],
EditHint: [
"칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소",
"Tap a cell to edit · Enter to finish · Esc to cancel",
],
Edited: ["고친 칸", "Edited cell"],
} as const;
const ts = (key: keyof typeof TEXT, n?: number): string =>
TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? ""));
export type LbRole = "조건" | "고정" | "결과";
type Cell = string | number | boolean | null | [number | null, number | null] | undefined;
interface SourceTable {
키?: string;
이름?: string;
원문번호?: string;
기준?: string;
범위규칙?: string;
조건?: Record<string, string>;
값칸?: Record<string, string>;
주?: string[];
용도?: { 로직키?: string[] };
줄: Record<string, Cell>[];
}
export interface SourceOn {
edit(e: TableEdit): void;
role(table: string, col: string, role: LbRole): void;
}
/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */
const isRange = (v: unknown): v is [number | null, number | null] =>
Array.isArray(v) && v.length === 2;
/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */
export function showCell(v: Cell): string {
if (v === null || v === undefined) return "";
if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
return String(v);
}
/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */
export function parseCell(text: string, before: Cell): TableEdit["value"] | null {
const t = text.trim();
const num = (s: string): number | null | undefined => {
const c = s.replace(/,/g, "").trim();
if (!c) return null;
return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined;
};
if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) {
const parts = t.split(/[~~]/);
if (parts.length !== 2) return null;
const [a, b] = parts.map(num);
if (a === undefined || b === undefined || (a === null && b === null)) return null;
if (a !== null && b !== null && a > b) return null;
return [a, b];
}
if (!t) return null;
const n = num(t);
if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) {
return typeof n === "number" ? n : null;
}
return t;
}
/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */
/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */
export function roleOf(lookups: LbLookup[], col: string): LbRole | null {
for (const lk of lookups) {
if (lk.col === col) return "결과";
const c = lk.conds.find((x) => x.col === col);
if (c) return c.from.k === "fixed" ? "고정" : "조건";
}
return null;
}
const NEXT: Record<LbRole, LbRole> = { 조건: "고정", 고정: "결과", 결과: "조건" };
/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */
export function usedNotes(row: unknown): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
const add = (where: string, mark: string): void => {
const set = out.get(where) ?? new Set<string>();
set.add(mark);
out.set(where, set);
};
const walk = (v: unknown): void => {
if (typeof v === "string") {
for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]);
} else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === "object") Object.values(v).forEach(walk);
};
walk(row);
return out;
}
/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */
function hitsOf(
key: string,
lookups: LbLookup[],
hits: Record<string, LbHit | undefined>,
): { rows: Set<number>; none: boolean; dup: boolean } {
const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null);
return {
rows: new Set(got.filter((h): h is number => typeof h === "number")),
none: got.includes("없음"),
dup: got.includes("중복"),
};
}
/* ── 그리기 ─────────────────────────────────────────────────────── */
/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */
const originals = new Map<string, string[][]>();
function snapshot(key: string, table: SourceTable, cols: string[]): string[][] {
const had = originals.get(key);
if (had && had.length === table.줄.length) return had;
const shot = table.줄.map((r) => cols.map((c) => showCell(r[c])));
originals.set(key, shot);
return shot;
}
function roleChip(
key: string,
col: string,
role: LbRole | null,
isCond: boolean,
on: SourceOn,
): HTMLElement {
const shown = role ?? (isCond ? null : undefined);
if (shown === undefined) return el("span");
const label = shown
? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result")
: ts("Free");
const chip = el("button", {
className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`,
text: label,
attrs: { type: "button", title: ts("RoleHint") },
});
chip.addEventListener("click", (ev) => {
ev.stopPropagation();
on.role(key, col, shown ? NEXT[shown] : "조건");
});
return chip;
}
function editCell(
td: HTMLTableCellElement,
before: Cell,
done: (v: TableEdit["value"]) => void,
): void {
if (td.querySelector("input")) return;
const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } });
input.value = showCell(before);
const text = td.textContent ?? "";
td.replaceChildren(input);
input.focus();
input.select();
let closed = false;
const close = (save: boolean): void => {
if (closed) return;
closed = true;
const value = save ? parseCell(input.value, before) : null;
const same = value !== null && showCell(value) === showCell(before);
td.textContent = value === null || same ? text : showCell(value);
td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text);
if (value !== null && !same) done(value);
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") close(true);
if (ev.key === "Escape") close(false);
ev.stopPropagation();
});
input.addEventListener("blur", () => close(true));
}
function renderTable(
key: string,
table: SourceTable | undefined,
lb: LbLogic,
hits: Record<string, LbHit | undefined>,
notes: Map<string, Set<string>>,
on: SourceOn,
): HTMLElement {
const lookups = lb.lookups.filter((lk) => lk.table === key);
if (!table?.줄) {
return el("section", {
className: "m01-lbsrc__table",
children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })],
});
}
const conds = Object.keys(table.조건 ?? {});
const vals = Object.keys(table.값칸 ?? {});
const cols = [...conds, ...vals];
const orig = snapshot(key, table, cols);
const hit = hitsOf(key, lookups, hits);
const users = (table.용도?.로직키 ?? []).length;
const userBtn = el("button", {
className: "m01-lbsrc__users",
text: users ? ts("UsersN", users) : ts("UsersNone"),
attrs: { type: "button" },
});
const userList = el("p", { className: "m01-lbsrc__muted" });
userBtn.addEventListener("click", () => {
userList.textContent = ts("Loading");
tableUsers(key)
.then((list) => {
userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone");
userList.textContent = list.map((u) => u.이름).join(" · ");
})
.catch(() => (userList.textContent = ts("UsersFail")));
});
const head = el("tr", {
children: [
el("th", { className: "m01-lbsrc__no", text: "" }),
...cols.map((c) => {
const unit = table.값칸?.[c];
return el("th", {
className: vals.includes(c) ? "is-value" : "",
children: [
el("span", { text: unit ? `${c} (${unit})` : c }),
roleChip(key, c, roleOf(lookups, c), conds.includes(c), on),
],
});
}),
],
});
const body = table.줄.map((row, i) => {
const tr = el("tr", {
className: hit.rows.has(i) ? "is-hit" : "",
children: [
el("td", { className: "m01-lbsrc__no", text: String(i + 1) }),
...cols.map((c, j) => {
const td = el("td", {
className: [
vals.includes(c) ? "is-value" : "",
lookups.some((lk) => lk.col === c) ? "is-col" : "",
showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "",
].join(" "),
text: showCell(row[c]),
});
td.addEventListener("click", () =>
editCell(td, row[c], (value) => {
td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]);
on.edit({ table: key, row: i, col: c, value });
}),
);
return td;
}),
],
});
return tr;
});
const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]);
const noteList = (table.주 ?? []).map((n) => {
const used = marks.has(n.trim().charAt(0));
return el("li", {
className: used ? "is-used" : "",
children: [
el("span", { text: n }),
...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []),
],
});
});
return el("section", {
className: "m01-lbsrc__table",
children: [
el("div", {
className: "m01-lbsrc__head",
children: [
el("strong", { text: table.이름 ?? "" }),
el("span", {
className: "m01-lbsrc__muted",
text: [table.원문번호, table.기준].filter(Boolean).join(" · "),
}),
userBtn,
],
}),
userList,
...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []),
...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []),
el("div", {
className: "m01-lbsrc__grid",
children: [
el("table", {
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
}),
],
}),
...(noteList.length
? [
el("details", {
className: "m01-lbsrc__notes",
attrs: marks.size ? { open: "" } : {},
children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })],
}),
]
: []),
],
});
}
/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */
export function renderSource(
host: HTMLElement,
lb: LbLogic,
tables: LbTables,
hits: Record<string, LbHit | undefined>,
on: SourceOn,
): void {
const keys = [...new Set(lb.lookups.map((lk) => lk.table))];
const notes = usedNotes(lb.doc?.row);
host.replaceChildren(
el("div", {
className: "m01-lbsrc",
children: [
el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }),
el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }),
...keys.map((k) =>
renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on),
),
],
}),
);
}