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
This commit is contained in:
2026-10-01 11:47:53 +09:00
co-authored by Claude Opus 5.5
parent 6be6a1bdfc
commit f62689dff1
4 changed files with 683 additions and 32 deletions
+24 -32
View File
@@ -11,26 +11,9 @@
* DOM 없음.
* ========================================================================== */
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
/* ── 맞출 모양(설계초안 7-1 의 쓰는 칸만) ─────────────────────────────── */
type Ref =
| { k: "input"; name: string }
| { k: "fixed"; value: number | string }
| { k: string; [x: string]: unknown };
export interface CheckLookup {
id: string;
table: string;
conds: { col: string; from: Ref }[];
col: string;
}
export interface CheckLogic {
inputs: LogicInput[];
lookups: CheckLookup[];
}
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { lbCalcLater, type LbTables } from "./M01_LB_Api";
import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks";
type Cell = string | number | boolean | null | [number | null, number | null];
export interface CheckTable {
@@ -109,7 +92,7 @@ export interface Gap {
}
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
function combos(lb: CheckLogic, lk: CheckLookup): Record<string, unknown>[] | null {
function combos(lb: LbLogic, lk: LbLookup): Record<string, unknown>[] | null {
const lists: [string, unknown[]][] = [];
for (const { col, from } of lk.conds) {
if (from.k === "fixed") lists.push([col, [from.value]]);
@@ -126,7 +109,7 @@ function combos(lb: CheckLogic, lk: CheckLookup): Record<string, unknown>[] | nu
}
/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */
export function gaps(lb: CheckLogic, tables: Record<string, unknown>): Gap[] {
export function gaps(lb: LbLogic, tables: Record<string, unknown>): Gap[] {
const out: Gap[] = [];
const seen = new Set<string>();
for (const lk of lb.lookups) {
@@ -136,7 +119,7 @@ export function gaps(lb: CheckLogic, tables: Record<string, unknown>): Gap[] {
for (const conds of sets) {
const rows = findRows(table, conds, lk.col);
if (rows.length === 1) continue;
const tag = `${lk.table}|${lk.col}|${JSON.stringify(conds)}`;
const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`;
if (seen.has(tag)) continue;
seen.add(tag);
out.push({
@@ -152,7 +135,7 @@ export function gaps(lb: CheckLogic, tables: Record<string, unknown>): Gap[] {
}
/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */
export function gapSkipped(lb: CheckLogic, tables: Record<string, unknown>): string[] {
export function gapSkipped(lb: LbLogic, tables: Record<string, unknown>): string[] {
return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id);
}
@@ -162,7 +145,7 @@ export function gapSkipped(lb: CheckLogic, tables: Record<string, unknown>): str
export type LbHit = number | null | "없음" | "중복";
export function lookupHits(
lb: CheckLogic,
lb: LbLogic,
tables: Record<string, unknown>,
values: Record<string, unknown>,
): Record<string, LbHit> {
@@ -173,8 +156,8 @@ export function lookupHits(
let known = !!table?.줄;
for (const { col, from } of lk.conds) {
if (from.k === "fixed") conds[col] = from.value;
else if (from.k === "input" && values[from.name as string] !== undefined)
conds[col] = values[from.name as string];
else if (from.k === "input" && values[from.name] !== undefined)
conds[col] = values[from.name];
else known = false;
}
if (!known || !table) {
@@ -226,16 +209,25 @@ export function sameAsExpect(got: number | undefined, expect: number): boolean {
type CalcFn = (
row: LogicRow,
inputs: Record<string, unknown>,
tables: Record<string, unknown>,
tables: LbTables,
) => Promise<CalcAnswer>;
/** 줄마다 시험 계산(차례로 · 하나씩) — `row` 는 블록을 글로 되돌린 로직 줄 · `calc` 는 LB Api 의 바로 계산 */
/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */
const calcOnce: CalcFn = (row, inputs, tables) =>
new Promise((resolve, reject) =>
lbCalcLater(row, inputs, tables, (ans, plain) =>
ans ? resolve(ans) : reject(new Error(plain ?? "")),
),
);
/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */
export async function runExpect(
row: LogicRow,
tables: Record<string, unknown>,
lb: LbLogic,
tables: LbTables,
rows: ExpectRow[],
calc: CalcFn,
calc: CalcFn = calcOnce,
): Promise<ExpectResult[]> {
const row = writeLogic(lb);
const out: ExpectResult[] = [];
for (const r of rows) {
try {
@@ -0,0 +1,196 @@
/* 로직 만들기 판 — 왼쪽 원문 */
.m01-lbsrc {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
font-size: var(--text-body-sm);
}
.m01-lbsrc p {
margin: 0;
}
.m01-lbsrc__muted {
color: var(--color-text-muted);
}
.m01-lbsrc__muted:empty {
display: none;
}
.m01-lbsrc__preview {
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas));
color: var(--color-text);
}
.m01-lbsrc__table {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
min-width: 0;
}
.m01-lbsrc__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
}
.m01-lbsrc__users {
margin-left: auto;
padding: 0;
border: 0;
background: none;
color: var(--color-accent);
font: inherit;
cursor: pointer;
text-decoration: underline;
}
.m01-lbsrc__warn {
color: var(--color-danger);
font-weight: 600;
}
/* 엑셀 같은 격자 */
.m01-lbsrc__grid {
max-height: 22rem;
overflow: auto;
border: 1px solid var(--color-border);
}
.m01-lbsrc__grid table {
border-collapse: collapse;
width: 100%;
}
.m01-lbsrc__grid th,
.m01-lbsrc__grid td {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
white-space: nowrap;
text-align: left;
}
.m01-lbsrc__grid thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-paper);
color: var(--color-text-secondary);
font-weight: 500;
vertical-align: top;
}
.m01-lbsrc__grid thead th span {
display: block;
}
.m01-lbsrc__grid .m01-lbsrc__no {
width: 2.5rem;
background: var(--color-paper);
color: var(--color-text-muted);
text-align: right;
}
.m01-lbsrc__grid td {
cursor: cell;
}
.m01-lbsrc__grid td.is-value {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m01-lbsrc__grid td.is-col {
font-weight: 700;
}
.m01-lbsrc__grid tr.is-hit td {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-lbsrc__grid td.is-edited {
box-shadow: inset 0 0 0 2px var(--color-accent);
}
.m01-lbsrc__grid td.is-bad {
box-shadow: inset 0 0 0 2px var(--color-danger);
}
.m01-lbsrc__input {
width: 100%;
min-width: 4rem;
padding: 0 var(--spacing-4);
border: 1px solid var(--color-accent);
font: inherit;
background: var(--color-canvas);
color: var(--color-text);
}
/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */
.m01-lbsrc__chip {
margin-top: var(--spacing-4);
padding: 0 var(--spacing-8);
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
}
.m01-lbsrc__chip.is-cond {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.m01-lbsrc__chip.is-fixed {
color: var(--color-text-secondary);
}
.m01-lbsrc__chip.is-result {
color: var(--color-success, #2f6b4f);
background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent);
}
.m01-lbsrc__chip.is-free {
color: var(--color-text-muted);
border-style: dashed;
}
.m01-lbsrc__notes summary {
cursor: pointer;
color: var(--color-text-secondary);
}
.m01-lbsrc__notes ol {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-lbsrc__notes li.is-used {
font-weight: 600;
}
.m01-lbsrc__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-4);
border-radius: 4px;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
font-size: var(--text-caption);
font-weight: 500;
}
@@ -0,0 +1,371 @@
/* =============================================================================
* 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),
),
],
}),
);
}
+92
View File
@@ -288,3 +288,95 @@ def test_기대값_시험표():
]
)
assert same == [True, False, True]
SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts"
RUNNER_UI = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "parse") return m.parseCell(job.text, job.before);
if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]);
if (job.op === "role") return m.roleOf(job.lookups, job.col);
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run_ui(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_원문_칸_고친_글_읽기():
out = run_ui(
[
{"op": "parse", "text": "0.3", "before": 0.26},
{"op": "parse", "text": "1,200", "before": 1000},
{"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음
{"op": "parse", "text": "3~4", "before": [2, 3]},
{"op": "parse", "text": "~5", "before": [None, 4]},
{"op": "parse", "text": "7~", "before": [6, None]},
{"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음
{"op": "parse", "text": "깬돌", "before": "깬잡석"},
{"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로
{"op": "parse", "text": "", "before": 1},
]
)
assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None]
def test_계산에_쓰인_주와_칸_역할():
[notes] = run_ui(
[
{
"op": "notes",
"row": {
"중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}],
"비고": "주① 참고",
},
}
]
)
assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]]
lookups = [
{
"id": "a",
"table": "T1",
"col": "석공",
"conds": [
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}},
],
}
]
out = run_ui(
[
{"op": "role", "lookups": lookups, "col": "석공"},
{"op": "role", "lookups": lookups, "col": "뒷길이"},
{"op": "role", "lookups": lookups, "col": "돌"},
{"op": "role", "lookups": lookups, "col": "쌓기"},
]
)
assert out == ["결과", "조건", "고정", None]