Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-01 09:37:53 +09:00
2 changed files with 894 additions and 25 deletions
+492 -25
View File
@@ -1,10 +1,16 @@
/* =============================================================================
* M01_EasyCalc_Model.ts
* 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로
* DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로
*
* 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic
* 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말)
* 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked
* 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림
* 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤
* ========================================================================== */
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api";
export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
export type CostKind = "노무비" | "재료비" | "경비";
@@ -66,55 +72,516 @@ export interface PickResult {
needInputs?: LogicInput[];
}
const notReady = (): never => {
throw new Error("준비 중");
/* ── 글 ─────────────────────────────────────────────────────────────────── */
const TEXT = {
WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"],
WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"],
WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"],
WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"],
WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"],
NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"],
Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"],
Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"],
NoName: ["이름이 비어 있음", "The name is empty"],
SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"],
SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"],
/** {per} {qty} {unit} 을 채움 */
TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"],
} as const;
let lang = 0;
/** 화면 말 고르기 — 0 한국어 · 1 영어 */
export function setEasyModelLanguage(index: number): void {
lang = index === 1 ? 1 : 0;
}
const tx = (key: keyof typeof TEXT): string => TEXT[key][lang];
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
const COSTS: readonly string[] = ["노무비", "재료비", "경비"];
const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v);
/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */
const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/;
const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/;
const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/;
let seq = 0;
const newId = (): string => `c${++seq}`;
const clone = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
const isObj = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const norm = (s: string): string => s.replace(/\s+/g, "");
/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */
function numText(n: number): string {
const s = String(Number(n.toPrecision(12)));
return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s;
}
/** 바깥을 통째 감싼 괄호 하나 벗김 */
function unwrap(s: string): string {
const t = s.trim();
if (!t.startsWith("(") || !t.endsWith(")")) return t;
let depth = 0;
for (let i = 0; i < t.length; i++) {
if (t[i] === "(") depth++;
else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t;
}
return t.slice(1, -1).trim();
}
/** 괄호 밖의 + 로 나눔 */
function splitPlus(s: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of s) {
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "+" && depth === 0) {
out.push(cur.trim());
cur = "";
} else cur += ch;
}
out.push(cur.trim());
return out;
}
const hasName = (formula: string, name: string): boolean => {
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula);
};
/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */
function lockedWhy(formula: string, row: LogicRow): string {
const seen = new Set<string>();
const flags = { table: false, cond: false, input: false };
const walk = (f: string, depth: number): void => {
if (/찾기\s*\(/.test(f)) flags.table = true;
if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true;
for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true;
if (depth > 6) return;
for (const m of row.중간 ?? []) {
if (seen.has(m.이름) || !hasName(f, m.이름)) continue;
seen.add(m.이름);
walk(m.식, depth + 1);
}
};
walk(formula, 0);
if (flags.table) return tx("WhyTable");
if (flags.cond) return tx("WhyCond");
if (flags.input) return tx("WhyInput");
return tx("WhyCalc");
}
/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */
/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */
function personTerm(qtyText: string, element: unknown): string {
const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`;
return `${q} * ${String(element)}`;
}
/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */
function parsePercent(formula: string): { base: string; rate: number } | null {
const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim());
if (!m || !m[1].trim()) return null;
return { base: unwrap(m[1]), rate: Number(m[2]) };
}
/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */
function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean {
if (persons.length === 0) return false;
const terms = splitPlus(base);
if (terms.length !== persons.length) return false;
return terms.every((t, i) => {
const p = persons[i];
const want = personTerm(p.qtyText, p.element);
return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`);
});
}
function baseOf(
base: string,
persons: { qtyText: string; element: unknown }[],
): EasyCard["percentBase"] {
if (isCost(base)) return base;
return isPersonSum(base, persons) ? "사람줄합" : null;
}
/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */
const qtyTextOf = (line: HoLine): string => String(line.수량 ?? "");
function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard {
const kind: CardKind =
line.종류 === "인력"
? "person"
: line.종류 === "재료"
? "material"
: line.종류 === "로직"
? MACHINE_CALL.test(String(line.요소))
? "machine"
: "logic"
: "locked";
const qtyText = qtyTextOf(line);
const numeric = NUMBER_TEXT.test(qtyText);
return {
id: newId(),
kind,
name: line.이름 ?? "",
spec: line.규격 ?? "",
unit: line.단위 ?? "",
qty: numeric ? Number(qtyText) : null,
qtyFormula: numeric ? null : qtyText,
cost: isCost(line.비목) ? line.비목 : null,
percentBase: null,
element: clone(line.요소),
lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row),
source: { part: "호표", index },
raw: clone(line),
};
}
function addCardOf(
line: NamedFormula,
index: number,
row: LogicRow,
persons: { qtyText: string; element: unknown }[],
): EasyCard {
const p = parsePercent(line.식);
const base = p ? baseOf(p.base, persons) : null;
const percent = p !== null && base !== null;
return {
id: newId(),
kind: percent ? "percent" : "locked",
name: line.이름 ?? "",
spec: "",
unit: percent ? "%" : "",
qty: percent ? p.rate : null,
qtyFormula: percent ? null : line.식,
cost: isCost(line.비목) ? line.비목 : null,
percentBase: percent ? base : null,
element: null,
lockedWhy: percent ? null : lockedWhy(line.식, row),
source: { part: "덧줄", index },
raw: clone(line),
};
}
export function toDoc(
_row: LogicRow,
_meta: { key: string | null; version: string | null; isBase: boolean },
row: LogicRow,
meta: { key: string | null; version: string | null; isBase: boolean },
): EasyDoc {
return notReady();
const ho = row.호표 ?? [];
const persons = ho
.filter((h) => h.종류 === "인력")
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
return {
key: meta.key,
version: meta.version,
isBase: meta.isBase,
name: row.이름 ?? "",
spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "",
resultUnit: row.결과단위 ?? "",
inputs: clone(row.입력 ?? []),
cards: [
...ho.map((h, i) => hoCard(h, i, row)),
...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)),
],
row: clone(row),
};
}
export function toRow(_doc: EasyDoc): LogicRow {
return notReady();
/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
export function emptyRow(name = "", per = "㎡"): LogicRow {
return {
키: "",
원문번호: "",
이름: name,
결과단위: `원/${per}`,
출처: "자체",
입력: [],
중간: [],
호표: [],
덧줄: [],
끝수: null,
};
}
export function addCard(_doc: EasyDoc, _pick: PickResult): EasyDoc {
return notReady();
/** 빈 종이로 새로 */
export function newDoc(name = "", per = "㎡"): EasyDoc {
return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false });
}
export function updateCard(_doc: EasyDoc, _id: string, _patch: Partial<EasyCard>): EasyDoc {
return notReady();
/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */
const partOf = (c: EasyCard): "호표" | "덧줄" =>
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
const TYPE_OF: Record<string, string> = {
person: "인력",
material: "재료",
machine: "로직",
logic: "로직",
};
/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */
function qtyTextFor(c: EasyCard, rawText: unknown): string {
if (c.qtyFormula !== null) return c.qtyFormula;
if (c.qty === null || !Number.isFinite(c.qty)) return "";
const keep =
typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty;
return keep ? (rawText as string) : numText(c.qty);
}
export function removeCard(_doc: EasyDoc, _id: string): EasyDoc {
return notReady();
/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */
function setField(o: Record<string, unknown>, k: string, v: string): void {
if (v !== "" || k in o) o[k] = v;
}
export function moveCard(_doc: EasyDoc, _id: string, _step: -1 | 1): EasyDoc {
return notReady();
function hoLineOf(c: EasyCard): HoLine {
const raw = isObj(c.raw) ? clone(c.raw) : {};
const line: Record<string, unknown> = raw;
if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind];
line.요소 = clone(c.element);
setField(line, "이름", c.name);
setField(line, "규격", c.spec);
setField(line, "단위", c.unit);
line.수량 = qtyTextFor(c, raw.수량);
if (c.cost !== null) line.비목 = c.cost;
else if (isCost(raw.비목)) delete line.비목;
return line as unknown as HoLine;
}
export function checkCard(_card: EasyCard, _doc: EasyDoc): string | null {
return notReady();
function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string {
const rate = numText(c.qty ?? 0);
if (c.percentBase === "사람줄합") {
const terms = persons.map((p) => personTerm(p.qtyText, p.element));
return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`;
}
return `${c.percentBase ?? "노무비"} * ${rate} / 100`;
}
function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula {
const raw = isObj(c.raw) ? clone(c.raw) : {};
const line: Record<string, unknown> = raw;
line.이름 = c.name;
const rawText = typeof raw.식 === "string" ? raw.식 : "";
if (c.kind === "percent") {
const p = parsePercent(rawText);
const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase;
line.식 = same ? rawText : percentText(c, persons);
} else {
line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText);
}
if (c.cost !== null) line.비목 = c.cost;
else if (isCost(raw.비목)) delete line.비목;
return line as unknown as NamedFormula;
}
export function toRow(doc: EasyDoc): LogicRow {
const row = clone(doc.row) as LogicRow & Record<string, unknown>;
row.이름 = doc.name;
setField(row, "규격", doc.spec);
row.결과단위 = doc.resultUnit;
row.입력 = clone(doc.inputs);
const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf);
const persons = ho
.filter((h) => h.종류 === "인력")
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons));
if (ho.length || "호표" in row) row.호표 = ho;
if (add.length || "덧줄" in row) row.덧줄 = add;
return row;
}
/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */
const COST_OF: Record<PickResult["kind"], CostKind | null> = {
person: "노무비",
material: "재료비",
machine: null,
logic: null,
};
/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */
const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind);
/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */
function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] {
const last = (hit: (c: EasyCard) => boolean): number => {
let at = -1;
cards.forEach((c, i) => {
if (hit(c)) at = i;
});
return at;
};
let at = last((c) => groupOf(c) === groupOf(card));
if (at < 0) at = last((c) => partOf(c) === partOf(card));
if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1;
const out = cards.slice();
out.splice(at + 1, 0, card);
return out;
}
export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc {
const card: EasyCard = {
id: newId(),
kind: pick.kind,
name: pick.name,
spec: pick.spec,
unit: pick.unit,
qty: pick.qty,
qtyFormula: null,
cost: COST_OF[pick.kind],
percentBase: null,
element: clone(pick.element),
lockedWhy: null,
source: null,
raw: null,
};
return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []);
}
/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */
export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc {
const inputs = doc.inputs.slice();
for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need));
return inputs.length === doc.inputs.length ? doc : { ...doc, inputs };
}
/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */
export function addPercent(
doc: EasyDoc,
name: string,
base: CostKind | "사람줄합",
rate: number | null,
cost: CostKind | null,
): EasyDoc {
const card: EasyCard = {
id: newId(),
kind: "percent",
name,
spec: "",
unit: "%",
qty: rate,
qtyFormula: null,
cost,
percentBase: base,
element: null,
lockedWhy: null,
source: null,
raw: null,
};
return { ...doc, cards: insertAt(doc.cards, card) };
}
/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */
export function updateCard(doc: EasyDoc, id: string, patch: Partial<EasyCard>): EasyDoc {
return {
...doc,
cards: doc.cards.map((c) => {
if (c.id !== id) return c;
const next: EasyCard = { ...c, ...patch, id: c.id };
if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null;
return next;
}),
};
}
export function removeCard(doc: EasyDoc, id: string): EasyDoc {
return { ...doc, cards: doc.cards.filter((c) => c.id !== id) };
}
/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */
export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc {
const from = doc.cards.findIndex((c) => c.id === id);
if (from < 0) return doc;
const g = groupOf(doc.cards[from]);
let to = from + step;
while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step;
if (to < 0 || to >= doc.cards.length) return doc;
const cards = doc.cards.slice();
[cards[from], cards[to]] = [cards[to], cards[from]];
return { ...doc, cards };
}
/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */
/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */
const TOO_BIG: Record<string, number> = { 인: 10, 일: 10, 시간: 24, "%": 100 };
const TOO_BIG_ELSE = 10000;
export function checkCard(card: EasyCard, doc: EasyDoc): string | null {
if (!card.name.trim()) return tx("NoName");
if (card.qtyFormula !== null) return null;
if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber");
if (card.qty === 0) return tx("Zero");
if (card.qty < 0) return tx("Negative");
const same = doc.cards.some(
(c) =>
c.id !== card.id &&
c.kind === card.kind &&
c.name === card.name &&
c.spec === card.spec &&
JSON.stringify(c.element) === JSON.stringify(card.element) &&
(card.kind !== "percent" || c.percentBase === card.percentBase),
);
if (same) return tx("SameLine");
if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) {
const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`;
return tx("TooBig")
.replace("{per}", per)
.replace("{qty}", numText(card.qty))
.replace("{unit}", card.unit);
}
return null;
}
/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */
export function checkName(name: string, mine: string[]): string | null {
if (!name.trim()) return tx("NoName");
return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null;
}
/* ── 되돌리기 ───────────────────────────────────────────────────────────── */
const HISTORY_MAX = 200;
/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */
export class EasyHistory {
push(_doc: EasyDoc): void {
notReady();
private stack: string[] = [];
private saved: string | null = null;
push(doc: EasyDoc): void {
const s = JSON.stringify(doc);
if (this.stack[this.stack.length - 1] === s) return;
this.stack.push(s);
if (this.saved === null) this.saved = s;
if (this.stack.length > HISTORY_MAX) this.stack.shift();
}
undo(): EasyDoc | null {
return notReady();
if (this.stack.length < 2) return null;
this.stack.pop();
return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc;
}
get canUndo(): boolean {
return notReady();
return this.stack.length > 1;
}
/** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */
get dirty(): boolean {
return notReady();
return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved;
}
markSaved(): void {
notReady();
this.saved = this.stack[this.stack.length - 1] ?? null;
}
}
+402
View File
@@ -0,0 +1,402 @@
"""M01 쉬운 단가산출 Model 시험(34-3b) — Node 로 `M01_EasyCalc_Model.ts` 를 바로 돌림 · 화면 조작은 ORCA.
· 견본 로직 줄 셋(인력+재료 · 기계+% 덧줄 · 표 찾기 잠금)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 왕복(toDoc → toRow 가 원래 줄과 같음) · 카드 종류 · 잠금 까닭 · 더하기 · 지우기 · 차례 · 숫자로 바꾸기 · 되돌리기 · 검사
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MODEL = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Model.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
# ── 견본 줄 ───────────────────────────────────────────────────────────────
HEAD = {"원문번호": "", "구분": "자체", "상세구분": "시험", "출처": "자체", "소유": "현장"}
PERSON_MATERIAL = {
**HEAD,
"키": "GX900001",
"이름": "시험 찰쌓기",
"결과단위": "원/㎡",
"입력": [],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "0.072",
"비목": "노무비",
},
{
"종류": "재료",
"요소": {"구분": "시멘트", "검색어": "시멘트 40kg"},
"이름": "시멘트",
"규격": "40kg",
"단위": "포",
"수량": "0.50",
"비목": "재료비",
"출처": "산림품셈 12-1-1",
},
],
"덧줄": [],
"끝수": None,
"비고": "견본",
}
MACHINE_PERCENT = {
**HEAD,
"키": "GX900002",
"이름": "시험 깬돌 쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "LB000002",
"이름": "보통인부",
"단위": "인",
"수량": "0.036",
"비목": "노무비",
},
{
"종류": "로직",
"요소": "로직(GC000270, 기계='0201-0060', 집게='7206-0070', 지역=지역)",
"이름": "굴삭기+집게",
"규격": "0.6㎥",
"단위": "시간",
"수량": "0.27",
},
{
"종류": "로직",
"요소": "로직(GX000777)",
"이름": "모르터바르기",
"단위": "㎥",
"수량": "0.009",
},
],
"덧줄": [
{"이름": "공구손료", "식": "(0.036 * LB000002) * 2 / 100", "비목": "재료비"},
{"이름": "잡재료비", "식": "노무비 * 3 / 100", "비목": "재료비", "출처": "산림품셈 1-2-6"},
],
"끝수": {"대상": "계", "자리": 0, "방법": "버림"},
}
TABLE_LOCKED = {
**HEAD,
"키": "GX900003",
"이름": "시험 메쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "보정", "단위": "배"}],
"중간": [{"이름": "석공품", "식": "찾기(QF000041, 뒷길이=뒷길이).석공"}],
"호표": [
{
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "석공품",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000002",
"이름": "보통인부",
"단위": "인",
"수량": "만약(뒷길이 > 40, 0.4, 0.3)",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000003",
"이름": "특별인부",
"단위": "인",
"수량": "보정 * 0.1",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000001",
"이름": "작업반장",
"단위": "인",
"수량": "0.1 * 2",
"비목": "노무비",
},
],
"덧줄": [
{
"이름": "공구손료",
"식": "(석공품 * LB000005 + (만약(뒷길이 > 40, 0.4, 0.3)) * LB000002"
" + (보정 * 0.1) * LB000003 + (0.1 * 2) * LB000001) * 3 / 100",
"비목": "경비",
},
{
"이름": "잡재료",
"식": "주재료비 * 찾기(QC000589, 구분='잡재료비').값 / 100",
"비목": "재료비",
},
],
"끝수": None,
}
SAMPLES = [PERSON_MATERIAL, MACHINE_PERCENT, TABLE_LOCKED]
META = {"key": "GX900001", "version": "v1", "isBase": False}
def _run(body: str, *args: object) -> object:
"""견본을 받아 Model 을 돌리는 Node 글 — `M` = Model 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
script = (
f"const M = await import({json.dumps(MODEL.as_uri())});\n"
"const A = JSON.parse(process.argv[1]);\n"
"const out = (x: unknown) => console.log(JSON.stringify(x));\n" + body
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-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)
def _doc(row: dict) -> dict:
return _run("out(M.toDoc(A[0], A[1]));", row, META)
# ── 왕복 ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("row", SAMPLES, ids=["인력재료", "기계덧붙임", "표찾기잠금"])
def test_왕복_같음(row: dict) -> None:
back = _run("out(M.toRow(M.toDoc(A[0], A[1])));", row, META)
assert back == row
def test_새_로직_최소칸() -> None:
row = _run("out(M.toRow(M.newDoc('새 일', 'm')));")
assert row == {
"키": "",
"원문번호": "",
"이름": "새 일",
"결과단위": "원/m",
"출처": "자체",
"입력": [],
"중간": [],
"호표": [],
"덧줄": [],
"끝수": None,
}
# ── 카드 바꾸기 ─────────────────────────────────────────────────────────────
def test_카드_종류와_숫자() -> None:
d = _doc(MACHINE_PERCENT)
assert [c["kind"] for c in d["cards"]] == ["person", "machine", "logic", "percent", "percent"]
person, machine, logic, tool, misc = d["cards"]
assert person["qty"] == 0.036 and person["cost"] == "노무비" and person["qtyFormula"] is None
assert machine["cost"] is None and machine["spec"] == "0.6㎥"
assert logic["cost"] is None
assert (tool["percentBase"], tool["qty"], tool["unit"], tool["cost"]) == (
"사람줄합",
2,
"%",
"재료비",
)
assert misc["percentBase"] == "노무비" and misc["qty"] == 3
assert d["inputs"] == MACHINE_PERCENT["입력"] and d["resultUnit"] == "원/㎡"
def test_잠금_까닭_쉬운말() -> None:
d = _doc(TABLE_LOCKED)
kinds = [c["kind"] for c in d["cards"]]
assert kinds == ["person"] * 4 + ["percent", "locked"]
why = [c["lockedWhy"] for c in d["cards"]]
assert why[:4] == [
"품셈 표에서 찾는 값",
"조건에 따라 달라지는 값",
"설계 때 정하는 값으로 정해짐",
"계산으로 정해지는 값",
]
assert d["cards"][0]["qtyFormula"] == "석공품" and d["cards"][0]["qty"] is None
assert d["cards"][4]["percentBase"] == "사람줄합"
assert why[5] == "품셈 표에서 찾는 값"
for w in filter(None, why):
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in w
# ── 카드 조작 ───────────────────────────────────────────────────────────────
def test_더하기_묶음_뒤_기계는_설계값_더함() -> None:
pick_person = {
"kind": "person",
"name": "석공",
"spec": "",
"unit": "인",
"element": "LB000005",
"qty": 0.5,
}
pick_machine = {
"kind": "machine",
"name": "굴삭기",
"spec": "0.7㎥",
"unit": "시간",
"element": "로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업='해당 없음')",
"qty": 0.3,
"needInputs": [{"이름": "지역", "고르기": ["전국평균"]}],
}
got = _run(
"let d = M.toDoc(A[0], A[1]);\n"
"d = M.addCard(d, A[2]); d = M.addCard(d, A[3]); d = M.addCard(d, A[3]);\n"
"d = M.addPercent(d, '공구손료', '사람줄합', 2, '경비');\n"
"out(M.toRow(d));",
PERSON_MATERIAL,
META,
pick_person,
pick_machine,
)
ho = got["호표"]
assert [h["이름"] for h in ho] == ["석공", "석공", "시멘트", "굴삭기", "굴삭기"]
assert ho[1] == {
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "0.5",
"비목": "노무비",
}
assert ho[3]["종류"] == "로직" and "비목" not in ho[3] and ho[3]["규격"] == "0.7㎥"
assert got["입력"] == [{"이름": "지역", "고르기": ["전국평균"]}]
assert got["덧줄"] == [
{"이름": "공구손료", "식": "(0.072 * LB000005 + 0.5 * LB000005) * 2 / 100", "비목": "경비"}
]
def test_지우기와_사람줄합_다시짬() -> None:
got = _run(
"let d = M.toDoc(A[0], A[1]);\n"
"const extra = M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005', qty:0.1});\n"
"const removed = M.removeCard(extra, d.cards[0].id);\n"
"out([M.toRow(extra), M.toRow(removed), M.toRow(M.removeCard(d, d.cards[0].id))]);",
MACHINE_PERCENT,
META,
)
two, one, none = got
assert two["덧줄"][0]["식"] == "(0.036 * LB000002 + 0.1 * LB000005) * 2 / 100"
assert two["덧줄"][1] == MACHINE_PERCENT["덧줄"][1]
assert one["덧줄"][0]["식"] == "(0.1 * LB000005) * 2 / 100"
assert [h["이름"] for h in none["호표"]] == ["굴삭기+집게", "모르터바르기"]
assert none["덧줄"][0]["식"] == "(0) * 2 / 100"
def test_차례_같은_묶음_안에서만() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const ids = d.cards.map((c: any) => c.id);\n"
"const names = (x: any) => x.cards.map((c: any) => c.name);\n"
"out([names(M.moveCard(d, ids[1], 1)), names(M.moveCard(d, ids[0], -1)),"
" names(M.moveCard(d, ids[3], 1)), names(M.moveCard(d, ids[0], 1)) ]);",
TABLE_LOCKED,
META,
)
down, top, add_down, end = got
assert down == ["석공", "특별인부", "보통인부", "작업반장", "공구손료", "잡재료"]
assert top == ["석공", "보통인부", "특별인부", "작업반장", "공구손료", "잡재료"]
assert add_down == top # 사람 묶음 끝 → 덧붙임으로 안 넘어감
assert end == ["보통인부", "석공", "특별인부", "작업반장", "공구손료", "잡재료"]
def test_잠금을_숫자로_바꾸기() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const n = M.updateCard(d, d.cards[0].id, { qty: 0.5, qtyFormula: null });\n"
"out([n.cards[0], M.toRow(n), d.cards[0].qtyFormula]);",
TABLE_LOCKED,
META,
)
card, row, before = got
assert card["lockedWhy"] is None and card["qty"] == 0.5
assert row["호표"][0]["수량"] == "0.5"
assert before == "석공품" # 받은 문서는 안 바뀜
# 석공 수량이 바뀌어 사람 줄 합이 다시 짜임
assert row["덧줄"][0]["식"].startswith("(0.5 * LB000005 + ")
def test_되돌리기와_저장안함() -> None:
got = _run(
"const h = new M.EasyHistory();\n"
"const d0 = M.toDoc(A[0], A[1]);\n"
"const log: unknown[] = [];\n"
"h.push(d0); log.push([h.canUndo, h.dirty]);\n"
"const d1 = M.updateCard(d0, d0.cards[0].id, { qty: 0.08 });\n"
"h.push(d1); h.push(d1); log.push([h.canUndo, h.dirty]);\n"
"h.markSaved(); log.push([h.dirty]);\n"
"const back = h.undo(); log.push([back.cards[0].qty, h.canUndo, h.dirty, h.undo()]);\n"
"out(log);",
PERSON_MATERIAL,
META,
)
assert got == [[False, False], [True, True], [False], [0.072, False, True, None]]
# ── 검사 ───────────────────────────────────────────────────────────────────
def test_검사_쉬운말_한줄() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const c = d.cards[0];\n"
"const t = (p: any) => M.checkCard({ ...c, ...p }, d);\n"
"out([t({}), t({ qty: null }), t({ qty: Number.NaN }), t({ qty: 0 }), t({ qty: -1 }),"
" t({ name: ' ' }), t({ qty: 12 }), t({ qty: null, qtyFormula: '석공품' }),"
" M.checkCard(M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005',"
" qty:0.1}).cards[1], M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인',"
" element:'LB000005', qty:0.1})),"
" M.checkName('시험 찰쌓기', ['시험 찰쌓기']), M.checkName('', []), M.checkName('새것', ['헌것'])]);",
PERSON_MATERIAL,
META,
)
assert got == [
None,
"얼마인지 숫자로 넣어야 함",
"얼마인지 숫자로 넣어야 함",
"0 은 안 됨 — 빼려면 지움",
"0 보다 큰 수만 됨",
"이름이 비어 있음",
"1㎡ 에 12인 — 너무 큰 수 아닌지?",
None,
"같은 줄이 이미 있음 — 한 줄에 합쳐 넣음",
"내가 만든 것에 같은 이름이 있음",
"이름이 비어 있음",
None,
]
def test_영어_글() -> None:
got = _run(
"M.setEasyModelLanguage(1);\n"
"const d = M.toDoc(A[0], A[1]);\n"
"out([d.cards[0].lockedWhy, M.checkCard({ ...d.cards[1], qty: 0, qtyFormula: null }, d)]);",
TABLE_LOCKED,
META,
)
assert got == [
"Looked up from the standard table",
"0 is not allowed — remove the line instead",
]
def test_화면_글에_금지_낱말_없음() -> None:
src = MODEL.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) == 12
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
assert bad not in text, (bad, text)