diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts index 6768e03d2..ba2f0782e 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts @@ -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 = (v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T)); +const isObj = (v: unknown): v is Record => + 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(); + 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): EasyDoc { - return notReady(); +/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */ + +const partOf = (c: EasyCard): "호표" | "덧줄" => + c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; + +const TYPE_OF: Record = { + 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, 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 = 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 = 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; + 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 = { + 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): 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 = { 인: 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; } } diff --git a/resources/tester/test_m01_easycalc_model.py b/resources/tester/test_m01_easycalc_model.py new file mode 100644 index 000000000..b6f6ac8ee --- /dev/null +++ b/resources/tester/test_m01_easycalc_model.py @@ -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)