feat(m01-lb): 로직 만들기 판 Blocks 속 채움 · 시험

- readLogic: 식 자리(중간 · 수량 · 요소 · 비율 · 결과)를 엔진 문법으로 읽어 ① 입력값 ② 표 조회(Table readFinds) ③ 계산(값 · 곱나눔 · 할증 · 더하기 · 조건목록 · 최소최대 · 올림 · 단계 · Cond readCond) ④ 항목(Model EasyDoc) ⑤ 결과값
- writeLogic = Model toRow · setCond · setLookupTable · setCalc 는 그 자리 글만 갈아끼운 뒤 다시 읽음 · newLogic
- 못 읽는 꼴은 blocked 에 쉬운 말(표 조회 조건에 계산 · 그리고/또는 · 넘길 값에 계산)
- 마스터 1,469 왕복 다른 것 0 · 블록으로 다 읽힘 1,396(95%)
- 시험 resources/tester/test_m01_lb_blocks.py 14개(견본 여섯 · Node 고리로 EasyCalc 그대로 부름)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-01 11:46:28 +09:00
co-authored by Claude Opus 5.5
parent 07f063adcf
commit c22eaeb16c
2 changed files with 879 additions and 25 deletions
+570 -25
View File
@@ -2,11 +2,18 @@
* M01_LB_Blocks.ts
* 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로
*
* 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나
* 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어
* ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서
* 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import type { CondRead } from "../EasyCalc/M01_EasyCalc_Cond";
import type { EasyDoc } from "../EasyCalc/M01_EasyCalc_Model";
import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond";
import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model";
import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table";
/** 값이 오는 곳 */
export type LbRef =
@@ -47,7 +54,7 @@ export interface LbCalc {
/** 곱나눔 · 더하기 · 할증(바탕 · 율) */
parts: (LbRef | LbExpr)[];
ops?: ("*" | "/" | "+" | "-")[];
/** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 */
/** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */
cond?: CondRead;
at: { part: Exclude<LbPart, "요소">; index: number };
/** 원래 글(안 고치면 그대로 씀) */
@@ -77,44 +84,582 @@ export interface TableEdit {
value: number | string | [number | null, number | null];
}
const notReady = (): never => {
throw new Error("준비 중");
const TEXT = {
BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"],
CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"],
AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"],
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
Unknown: ["모르는 이름이 있음", "Has an unknown name"],
Result: ["결과값", "Result value"],
Qty: ["수량", "quantity"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */
type Node = {
t:
"num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin";
v?: string;
f?: string;
op?: string;
a?: Node;
b?: Node;
items?: Node[];
head?: string;
args?: [string, Node][];
src: string;
};
const CMPS = ["<=", ">=", "==", "!=", "<", ">"];
const ROUND = ["올림", "버림", "반올림"];
function parse(s: string): Node | null {
let i = 0;
const fail = (): never => {
throw new Error("parse");
};
const ws = (): void => {
while (i < s.length && /\s/.test(s[i])) i++;
};
const take = (w: string): boolean => {
ws();
if (!s.startsWith(w, i)) return false;
i += w.length;
return true;
};
const need = (w: string): void => void (take(w) || fail());
const stick = (re: RegExp): string | null => {
re.lastIndex = i;
const m = re.exec(s);
if (m) i = re.lastIndex;
return m ? m[0] : null;
};
const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail());
const quoted = (): string => {
const e = s.indexOf("'", i);
if (e < 0) fail();
const v = s.slice(i, e);
i = e + 1;
return v;
};
/** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */
const ref = (head: boolean): string => {
const st = i;
for (let d = 0; i < s.length; i++) {
const c = s[i];
if ("({".includes(c)) d++;
else if (")}".includes(c)) {
if (!d) break;
d--;
} else if (!d && c === ",") break;
else if (head) continue;
else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break;
else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break;
}
return s.slice(st, i).trim();
};
const mk = (st: number, o: Omit<Node, "src">): Node => ({ ...o, src: s.slice(st, i).trim() });
const fields = (st: number, n: Node): Node => {
while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() });
return n;
};
const args = (): [string, Node][] => {
const out: [string, Node][] = [];
while (take(",")) {
const k = take("'") ? quoted() : word();
need("=");
out.push([k, expr()]);
}
need(")");
return out;
};
const primary = (): Node => {
ws();
const st = i;
if (take("(")) {
const a = expr();
need(")");
return fields(st, mk(st, { t: "paren", a }));
}
if (take("'")) return mk(st, { t: "str", v: quoted() });
const n = stick(/\d+(?:\.\d+)?/y);
if (n) return mk(st, { t: "num", v: n });
const w = word();
if (w === "찾기" || w === "로직") {
need("(");
ws();
const head = ref(true);
return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() }));
}
if (["만약", "최소", "최대", ...ROUND].includes(w)) {
need("(");
const items = [expr()];
while (take(",")) items.push(expr());
need(")");
return fields(st, mk(st, { t: "call", f: w, items }));
}
if (s[i] === ":") {
i++;
ref(false);
return fields(st, mk(st, { t: "eref" }));
}
return fields(st, mk(st, { t: "name", v: w }));
};
const unary = (): Node => {
ws();
const st = i;
return take("-") ? mk(st, { t: "neg", a: unary() }) : primary();
};
const chain = (next: () => Node, ops: string[], once = false): Node => {
ws();
const st = i;
let n = next();
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
n = mk(st, { t: "bin", op, a: n, b: next() });
return n;
};
const mul = (): Node => chain(unary, ["*", "/"]);
const add = (): Node => chain(mul, ["+", "-"]);
const cmp = (): Node => chain(add, CMPS, true);
const both = (): Node => chain(cmp, ["그리고"]);
const expr = (): Node => chain(both, ["또는"]);
try {
const node = expr();
ws();
return i === s.length ? node : null;
} catch {
return null;
}
}
const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n);
const flat = (s: string): string => s.replace(/\s+/g, "");
const KEY = /^[A-Z]{2}\d{6}$/;
/* ── 읽기 ───────────────────────────────────────────────────────────────── */
interface Ctx {
row: LogicRow;
inputs: Set<string>;
mids: Set<string>;
/** 찾기 글(빈칸 뺌) → 표 조회 번호 */
finds: Map<string, string>;
/** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */
personSum: string | null;
bad: Set<string>;
}
/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */
const SRC = new WeakMap<object, { text: string; snap: string }>();
const keep = <T extends object>(ref: T, text: string): T => {
SRC.set(ref, { text, snap: JSON.stringify(ref) });
return ref;
};
const isSimple = (n: Node, c: Ctx): boolean => {
const u = un(n);
if (u.t === "num" || u.t === "str") return true;
if (u.t === "neg") return un(u.a!).t === "num";
return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!));
};
/** 식 나무를 훑어 못 읽는 까닭을 모음 */
function check(n: Node, c: Ctx): void {
const u = un(n);
if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr"));
if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup"));
if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc"));
if (u.t === "find" || u.t === "logic") return;
if (u.t === "name") {
const v = u.v!;
const known =
c.inputs.has(v) ||
c.mids.has(v) ||
KEY.test(v) ||
["노무비", "재료비", "경비", "계", "줄"].includes(v);
const item = (c.row.호표 ?? []).some((h) => h.이름 === v);
if (!known && !item) c.bad.add(tl("Unknown"));
}
for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c);
}
/** 잎 하나 → 값이 오는 곳 */
function refOf(node: Node, c: Ctx): LbRef | LbExpr {
const u = un(node);
const text = node.src;
if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text);
if (u.t === "neg" && un(u.a!).t === "num")
return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text);
if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text);
if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text);
if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text);
if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!))
return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text);
if (c.personSum !== null && flat(text) === c.personSum)
return keep({ k: "cost", name: "사람줄합" }, text);
if (u.t === "field" && u.a!.t === "find") {
const id = c.finds.get(flat(u.src));
if (id) return keep({ k: "lookup", id }, text);
}
if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) {
const call = u.t === "logic" ? u : u.a!;
return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text);
}
return { k: "expr", text };
}
const isLeaf = (n: Node): boolean => {
const u = un(n);
return (
["num", "str", "name", "field", "logic", "eref"].includes(u.t) ||
(u.t === "neg" && un(u.a!).t === "num")
);
};
/** 같은 연산 사슬 — a op b op c … */
function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } {
const u = un(n);
if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] };
const left = chainOf(u.a!, ops);
return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] };
}
function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc {
const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] };
const node = parse(text);
if (!node) {
c.bad.add(tl("BadText"));
return { ...base, kind: "단계" };
}
check(node, c);
const u = un(node);
const inner = (): CondRead | undefined => {
const r = readCond(text, c.row);
return r && r.via === null ? r : undefined;
};
if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] };
if (u.t === "call") {
const kind: LbCalcKind =
u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대";
const cond = inner();
const whole =
cond &&
cond.blocks.length === 1 &&
cond.blocks[0].p[0] === 0 &&
cond.blocks[0].p[1] >= cond.text.length;
return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond };
}
if (u.t === "bin" && u.op === "*") {
const r = un(u.b!);
if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") {
const rate = un(r.b!);
if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100")
return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() };
}
}
for (const [kind, ops] of [
["곱나눔", ["*", "/"]],
["더하기", ["+", "-"]],
] as const) {
const ch = chainOf(u === node ? node : u, [...ops]);
if (ch.items.length > 1)
return {
...base,
kind,
parts: ch.items.map((x) => refOf(x, c)),
ops: ch.ops as LbCalc["ops"],
cond: inner(),
};
}
return { ...base, kind: "단계", cond: inner() };
}
/** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */
function personSumText(row: LogicRow): string | null {
const terms = (row.호표 ?? [])
.filter((h) => h.종류 === "인력")
.map((h) => {
const q = String(h.수량 ?? "").trim();
return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`;
});
return terms.length ? `(${terms.join(" + ")})` : null;
}
/** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */
function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] {
const ho = row.호표 ?? [];
return [
...(row.중간 ?? []).map((m, i) => ({
part: "중간" as const,
index: i,
text: m.식,
label: m.이름,
})),
...ho.map((h, i) => ({
part: "수량" as const,
index: i,
text: String(h.수량 ?? ""),
label: `${h.이름 ?? ""} ${tl("Qty")}`,
})),
...ho
.map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" }))
.filter(
(p): p is { part: "요소"; index: number; text: string; label: string } =>
typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text),
),
...(row.덧줄 ?? []).map((d, i) => ({
part: "비율" as const,
index: i,
text: d.식,
label: d.이름,
})),
...(row.결과
? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }]
: []),
];
}
/** 로직 줄 → 블록 */
export function readLogic(
_row: LogicRow,
_meta: { key: string | null; version: string | null; isBase: boolean },
row: LogicRow,
meta: { key: string | null; version: string | null; isBase: boolean },
): LbLogic {
return notReady();
const doc = toDoc(row, meta);
const ps = personSumText(row);
const c: Ctx = {
row,
inputs: new Set((row.입력 ?? []).map((x) => x.이름)),
mids: new Set((row.중간 ?? []).map((x) => x.이름)),
finds: new Map(),
personSum: ps === null ? null : flat(ps),
bad: new Set(),
};
const lookups: LbLookup[] = [];
const blocked: string[] = [];
const places = placesOf(row);
for (const p of places) {
const calls = readFinds(p.text);
if (!calls) {
blocked.push(`${p.label} — ${tl("BadText")}`);
continue;
}
for (const call of calls) {
const key = flat(p.text.slice(call.start, call.end));
let id = c.finds.get(key);
if (!id) {
id = `L${lookups.length + 1}`;
c.finds.set(key, id);
const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] };
look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) }));
ARGS.set(look, call.args);
lookups.push(look);
}
lookups
.find((x) => x.id === id)!
.at.push({ part: p.part, index: p.index, start: call.start, end: call.end });
}
}
const calcs: LbCalc[] = [];
for (const p of places) {
if (p.part === "요소") {
const n = parse(p.text);
c.bad.clear();
if (n) check(n, c);
else c.bad.add(tl("BadText"));
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
continue;
}
c.bad.clear();
const name = p.part === "중간" ? p.label : null;
calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c));
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
}
const resultCalc = calcs.find((x) => x.at.part === "결과");
return {
doc,
inputs: row.입력 ?? [],
lookups,
calcs,
result: resultCalc
? { kind: "값", unit: row.결과단위, calc: resultCalc }
: { kind: "금액", unit: row.결과단위 },
blocked: [...new Set(blocked)],
};
}
/** 블록 → 로직 줄 — 고친 조각 자리만 글 갈아끼우기 */
export function writeLogic(_lb: LbLogic): LogicRow {
return notReady();
/** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */
const ARGS = new WeakMap<LbLookup, FindArg[]>();
function argRef(a: FindArg, c: Ctx): LbRef | LbExpr {
const n = parse(a.rhs);
return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs };
}
/* ── 쓰기 ───────────────────────────────────────────────────────────────── */
/** 블록 → 로직 줄 */
export function writeLogic(lb: LbLogic): LogicRow {
return toRow(lb.doc);
}
/** 단계 안내가 채울 빈 로직 */
export function newLogic(_name?: string, _per?: string): LbLogic {
return notReady();
export function newLogic(name = "", per = "㎡"): LbLogic {
return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false });
}
const metaOf = (lb: LbLogic) => ({
key: lb.doc.key,
version: lb.doc.version,
isBase: lb.doc.isBase,
});
/** 한 자리 글 읽기 · 쓰기 */
function textAt(row: LogicRow, part: LbPart, index: number): string {
if (part === "중간") return row.중간[index].식;
if (part === "비율") return row.덧줄![index].식;
if (part === "결과") return row.결과!.식;
const h = row.호표![index];
return String(part === "수량" ? h.수량 : h.요소);
}
function putText(row: LogicRow, part: LbPart, index: number, text: string): void {
if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text };
else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text };
else if (part === "결과") row.결과 = { ...row.결과!, 식: text };
else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text };
else row.호표![index] = { ...row.호표![index], 요소: text };
}
/** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */
function splice(
lb: LbLogic,
edits: { part: LbPart; index: number; start: number; end: number; text: string }[],
): LbLogic {
const row = writeLogic(lb);
const groups = new Map<string, typeof edits>();
for (const e of edits)
groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]);
for (const list of groups.values()) {
const { part, index } = list[0];
let text = textAt(row, part, index);
for (const e of [...list].sort((x, y) => y.start - x.start))
text = text.slice(0, e.start) + e.text + text.slice(e.end);
putText(row, part, index, text);
}
return readLogic(row, metaOf(lb));
}
const numText = (n: number): string => {
const t = String(Number(n.toPrecision(12)));
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
};
/** 조각 → 글 — 안 바뀐 조각은 원래 글 */
function refText(ref: LbRef | LbExpr, lb: LbLogic): string {
const src = SRC.get(ref);
if (src && src.snap === JSON.stringify(ref)) return src.text;
switch (ref.k) {
case "expr":
return ref.text;
case "input":
case "calc":
return ref.name;
case "fixed":
return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`;
case "cost":
return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name;
case "logic":
return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`;
case "lookup": {
const look = lb.lookups.find((x) => x.id === ref.id);
return look ? findText(look, look.conds, look.table) : "0";
}
}
}
/** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */
function wrap(text: string, strong: boolean): string {
const n = parse(text);
const low = n && n.t === "bin" && ["+", "-"].includes(n.op!);
const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!);
return low || (strong && mid) ? `(${text})` : text;
}
function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string {
const raw = ARGS.get(look) ?? [];
const args: FindArg[] = conds.map((cd) => {
const old = raw.find((a) => a.key === cd.col);
const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from;
return {
key: cd.col,
rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic),
};
});
return writeFind(table, args, look.col);
}
/** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */
export function setCond(
_lb: LbLogic,
_lookupId: string,
_col: string,
_from: LbRef | LbExpr | null,
lb: LbLogic,
lookupId: string,
col: string,
from: LbRef | LbExpr | null,
): LbLogic {
return notReady();
}
/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬 */
export function setCalc(_lb: LbLogic, _calc: LbCalc, _patch: Partial<LbCalc>): LbLogic {
return notReady();
const look = lb.lookups.find((x) => x.id === lookupId);
if (!look) return lb;
const has = look.conds.some((x) => x.col === col);
const conds =
from === null
? look.conds.filter((x) => x.col !== col)
: has
? look.conds.map((x) => (x.col === col ? { col, from } : x))
: [...look.conds, { col, from }];
const text = findText(look, conds, look.table);
return splice(
lb,
look.at.map((a) => ({ ...a, text })),
);
}
/** 표 조회가 보는 표 바꾸기(현장 사본 등) */
export function setLookupTable(_lb: LbLogic, _lookupId: string, _table: string): LbLogic {
return notReady();
export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic {
const look = lb.lookups.find((x) => x.id === lookupId);
if (!look) return lb;
const text = findText(look, look.conds, table);
return splice(
lb,
look.at.map((a) => ({ ...a, text })),
);
}
/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */
export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial<LbCalc>): LbLogic {
const next: LbCalc = { ...calc, ...patch };
const old = textAt(writeLogic(lb), calc.at.part, calc.at.index);
const text = patch.text !== undefined ? patch.text : buildText(next, lb, old);
return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]);
}
function buildText(c: LbCalc, lb: LbLogic, old: string): string {
const parts = c.parts.map((p) => refText(p, lb));
switch (c.kind) {
case "값":
return parts[0] ?? old;
case "할증":
return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`;
case "곱나눔":
case "더하기": {
const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+"));
const strong = c.kind === "곱나눔";
return parts.reduce(
(acc, p, i) =>
i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false),
"",
);
}
default:
return c.cond ? writeRead(c.cond) : old;
}
}
+309
View File
@@ -0,0 +1,309 @@
"""M01 로직 만들기 판 Blocks 시험(35-2a) — Node 로 `M01_LB_Blocks.ts` 를 바로 돌림 · 화면 조작은 ORCA.
· 견본 로직 줄 여섯(정함 10)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 왕복(readLogic → writeLogic 이 원래 줄과 같음) · 블록 내용 · 조건 바꾸기 · 표 바꾸기 · 계산 바꾸기 · 빈 로직 · 못 읽는 꼴
· EasyCalc Model · Table · Cond 를 그대로 부름 — 화면 모듈(@ui · 서버 길)은 시험 고리가 빈 것으로 갈음
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
BLOCKS = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Blocks.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ────────────
HOOKS = r"""
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripTypeScriptTypes } from "node:module";
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks)(\.ts)?$/;
export async function resolve(spec, ctx, next) {
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
const u = new URL(spec + ".ts", ctx.parentURL);
if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true };
}
return next(spec, ctx);
}
export async function load(url, ctx, next) {
if (!url.endsWith(".ts")) return next(url, ctx);
let src = readFileSync(fileURLToPath(url), "utf8");
src = src.replace(/import\s+"[^"]+\.css";/g, "");
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})");
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => {
if (from.startsWith(".") && ALLOW.test(from)) return m;
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
});
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
}
"""
STUB = (
"globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : "
"k === 'API_BASE_URL' ? '/api' : () => undefined });\n"
)
def _run(body: str, *args: object) -> object:
"""`B` = Blocks 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
script = (
"import { register } from 'node:module';\n"
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
+ STUB
+ f"const B = await import({json.dumps(BLOCKS.as_uri())});\n"
"const A = JSON.parse(process.argv[1]);\n"
"const out = (x) => console.log(JSON.stringify(x));\n"
"const META = { key: 'GX900001', version: 'v1', isBase: false };\n" + body
)
result = subprocess.run(
["node", "--no-warnings", "--input-type=module", "-e", script, json.dumps(list(args))],
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
# ── 견본 줄 여섯(정함 10) ──────────────────────────────────────────────────
H = {"원문번호": "", "출처": "자체", "끝수": None}
GF000208 = {
**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
"덧줄": [],
} # fmt: skip
GF000219 = {
**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35]}, {"이름": "돌", "고르기": ["깬잡석", "깬돌"]},
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "단위": "m"},
{"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
"호표": [
{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인",
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비"},
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비"},
],
"덧줄": [],
} # fmt: skip
GF000025 = {
**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
"중간": [],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)",
"비목": "노무비"}],
"덧줄": [],
} # fmt: skip
GF000249 = {
**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
"단위": "시간", "수량": "1 / 작업량"}],
"덧줄": [],
} # fmt: skip
GF000133 = {
**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f", "단위": ""}, {"이름": "E", "단위": ""}, {"이름": "Cm", "단위": "분"}],
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)", "출처": "건설품셈 공통 8-1-4 주①"},
"끝수": "소수 3자리 사사오입(건설품셈 공통 8-1-4 주①)",
} # fmt: skip
GC000051 = {
**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
"덧줄": [{"이름": "공구손료 및 경장비",
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100",
"비목": "경비", "출처": "건설품셈 공통 2-9-3"}],
} # fmt: skip
SIX = [GF000208, GF000219, GF000025, GF000249, GF000133, GC000051]
def _lb(row: dict) -> dict:
return _run("const lb = B.readLogic(A[0], META); out({ ...lb, doc: undefined });", row)
# ── 왕복 ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("row", SIX, ids=[r["키"] for r in SIX])
def test_왕복_같음_다_읽힘(row: dict) -> None:
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
assert got == [row, []]
# ── 블록 내용 ───────────────────────────────────────────────────────────────
def test_메쌓기_표조회_조건목록_할증() -> None:
lb = _lb(GF000219)
assert [(x["id"], x["table"], x["col"]) for x in lb["lookups"]] == [
("L1", "QF000422", "증가율"),
("L2", "QF000421", "석공"),
("L3", "QF000421", "보통인부"),
]
assert lb["lookups"][1]["conds"] == [
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "input", "name": "돌"}},
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
]
assert lb["lookups"][1]["at"] == [{"part": "수량", "index": 0, "start": 0, "end": 36}]
mid, stone, worker = lb["calcs"]
assert (mid["name"], mid["kind"], mid["at"]) == (
"증가율",
"조건목록",
{"part": "중간", "index": 0},
)
assert mid["cond"]["blocks"][0]["shape"]["type"] == "if"
assert (stone["kind"], stone["parts"]) == (
"할증",
[{"k": "lookup", "id": "L2"}, {"k": "calc", "name": "증가율"}],
)
assert worker["parts"][0] == {"k": "lookup", "id": "L3"}
assert lb["result"] == {"kind": "금액", "unit": "원/㎡"}
assert [x["이름"] for x in lb["inputs"]] == ["뒷길이", "돌", "쌓기", "높이", "초과증가율"]
def test_떼붙임_다짐_덤프_안전통로() -> None:
turf = _lb(GF000025)
assert turf["lookups"][0]["conds"] == [
{"col": "구분", "from": {"k": "fixed", "value": "비탈구배 해당 시 품 할증"}}
]
c = turf["calcs"][0]
assert c["kind"] == "할증" and c["parts"][0] == {"k": "input", "name": "품"}
assert c["parts"][1]["k"] == "expr" and c["cond"]["blocks"][0]["shape"]["type"] == "if"
tamp = _lb(GF000249)
work, qty = tamp["calcs"]
assert work["kind"] == "값" and work["parts"] == [
{"k": "logic", "key": "GF000114", "field": None}
]
assert (qty["kind"], qty["ops"]) == ("곱나눔", ["/"])
assert qty["parts"] == [{"k": "fixed", "value": 1}, {"k": "calc", "name": "작업량"}]
dump = _lb(GF000133)
assert dump["result"]["kind"] == "값" and dump["result"]["unit"] == "㎥/시간"
assert dump["result"]["calc"]["kind"] == "올림" and dump["calcs"] == [dump["result"]["calc"]]
walk = _lb(GC000051)
rate = walk["calcs"][-1]
assert rate["at"] == {"part": "비율", "index": 0} and rate["ops"] == ["*", "/"]
assert rate["parts"] == [
{"k": "cost", "name": "사람줄합"},
{"k": "lookup", "id": "L2"},
{"k": "fixed", "value": 100},
]
fill = _lb(GF000208)
assert fill["lookups"] == [] and fill["calcs"][0]["parts"] == [{"k": "fixed", "value": 1}]
# ── 고치기 ─────────────────────────────────────────────────────────────────
def _ho(row: dict) -> list[str]:
return [h["수량"] for h in row["호표"]]
def test_조건_바꾸기_빼기_표_바꾸기() -> None:
got = _run(
"let lb = B.readLogic(A[0], META);\n"
"const a = B.writeLogic(B.setCond(lb, 'L2', '돌', { k: 'fixed', value: '깬돌' }));\n"
"const b = B.writeLogic(B.setCond(lb, 'L2', '쌓기', null));\n"
"const c = B.writeLogic(B.setCond(lb, 'L1', '높이', { k: 'fixed', value: 5 }));\n"
"const d = B.writeLogic(B.setLookupTable(lb, 'L3', 'QX000001'));\n"
"out([a, b, c, d]);",
GF000219,
)
a, b, c, d = got
assert _ho(a) == [
"찾기(QF000421, 뒷길이=뒷길이, 돌='깬돌', 쌓기=쌓기).석공 * (1 + 증가율 / 100)",
GF000219["호표"][1]["수량"],
]
assert _ho(b)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)"
assert (
c["중간"][0]["식"]
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=5).증가율, 초과증가율))"
)
assert (
_ho(d)[1] == "찾기(QX000001, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
)
assert _ho(d)[0] == GF000219["호표"][0]["수량"]
def test_계산_바꾸기() -> None:
got = _run(
"const lb = B.readLogic(A[0], META);\n"
"const [mid, stone] = lb.calcs;\n"
"const r1 = B.writeLogic(B.setCalc(lb, stone, { parts: [stone.parts[0], { k: 'fixed', value: 10 }] }));\n"
"const cond = structuredClone(mid.cond); cond.blocks[0].shape.otherwise = { kind: 'num', value: 90 };\n"
"const r2 = B.writeLogic(B.setCalc(lb, mid, { cond }));\n"
"const r3 = B.writeLogic(B.setCalc(lb, stone, { kind: '값', parts: [{ k: 'fixed', value: 0.3 }] }));\n"
"const r4 = B.writeLogic(B.setCalc(lb, stone, { text: '0.25 * 2' }));\n"
"const after = B.readLogic(r1, META);\n"
"out([r1, r2, r3, r4, after.calcs[1].parts, after.lookups.map((x) => x.id)]);",
GF000219,
)
r1, r2, r3, r4, parts, ids = got
assert _ho(r1)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 10 / 100)"
assert _ho(r1)[1] == GF000219["호표"][1]["수량"]
assert (
r2["중간"][0]["식"]
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 90))"
)
assert _ho(r3)[0] == "0.3" and _ho(r4)[0] == "0.25 * 2"
assert parts == [{"k": "lookup", "id": "L2"}, {"k": "fixed", "value": 10}]
assert ids == ["L1", "L2", "L3"]
def test_곱나눔_더하기_괄호() -> None:
got = _run(
"const lb = B.readLogic(A[0], META); const q = lb.calcs[1];\n"
"const r1 = B.writeLogic(B.setCalc(lb, q, { parts: [{ k: 'fixed', value: 2 }, q.parts[1]] }));\n"
"const r2 = B.writeLogic(B.setCalc(lb, q, { parts: [q.parts[0], { k: 'expr', text: '작업량 + 1' }] }));\n"
"const r3 = B.writeLogic(B.setCalc(lb, q, { kind: '더하기', ops: ['-'], parts: [{ k: 'fixed', value: 3 },"
" { k: 'expr', text: '작업량 - 1' }] }));\n"
"out([r1.호표[0].수량, r2.호표[0].수량, r3.호표[0].수량, r1.중간]);",
GF000249,
)
assert got == ["2 / 작업량", "1 / (작업량 + 1)", "3 - (작업량 - 1)", GF000249["중간"]]
# ── 빈 로직 · 못 읽는 꼴 ────────────────────────────────────────────────────
def test_빈_로직() -> None:
got = _run(
"const lb = B.newLogic('새 일', 'm'); out([B.writeLogic(lb), lb.lookups, lb.calcs, lb.result, lb.blocked]);"
)
row, looks, calcs, result, blocked = got
assert row["이름"] == "새 일" and row["결과단위"] == "원/m" and row["출처"] == "자체"
assert (looks, calcs, result, blocked) == ([], [], {"kind": "금액", "unit": "원/m"}, [])
def test_못_읽는_꼴은_쉬운말_왕복은_같음() -> None:
row = {
**GF000219,
"중간": [
{
"이름": "증가율",
"식": "만약(높이 > 3 그리고 높이 <= 7.5, 찾기(QF000422, 높이=최소(높이, 7.5)).증가율, 0)",
}
],
}
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
assert got[0] == row
assert got[1] == [
"증가율 — 조건에 「그리고 · 또는」이 섞임",
"증가율 — 표 조회 조건에 계산이 들어 있음",
]
def test_화면_글에_금지_낱말_없음() -> None:
src = BLOCKS.read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert len(korean) >= 7
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체", "잠금"):
assert bad not in text, (bad, text)