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