feat(m01-easycalc): 쉬운 단가산출 Model 속 채움 · 시험
- toDoc · toRow: 호표 인력 · 재료 · 기계경비 로직(GC000267~276) · 그 밖 로직 카드 · 숫자 아닌 수량은 잠금 + 쉬운 말 까닭 - 덧줄 「바탕 * 숫자 / 100」(노무비 · 재료비 · 경비 · 사람 줄 노임 합) = 덧붙임 카드 · 그 밖 = 잠금 - 안 바뀐 줄은 원래 글자 그대로 — 견본 셋 · 마스터 로직 1,469개 왕복 같음 - addCard · addPercent · withInputs · updateCard · removeCard · moveCard(같은 묶음 안) · checkCard · checkName · EasyHistory - 시험 resources/tester/test_m01_easycalc_model.py 14개 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user