Merge commit 'e5e34d82fb9a2361b8bb3f3eebffe20a61ededef' into CODEX

This commit is contained in:
2026-11-08 12:53:06 +09:00
8433 changed files with 767867 additions and 68053 deletions
@@ -1520,5 +1520,5 @@
"GF000336": "a9b9e0bb0f7da1f0",
"GX000004": "a3f4c454a5764d6e"
},
"마스터": "70896c23b6b406b0"
"마스터": "152f8ebe50d98a4c"
}
@@ -1,20 +0,0 @@
/* PLAN 748-2 헬퍼 — 텍스트 수식 조각 나누기(`M01_MasterData_UI_LogicLab_FormulaTokens.ts` 순수 셈).
* 짝 = `test_748_2_formula_tokens.py`. 결과 = 표준출력 JSON.
*/
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const { load } = makeLoader({ root: path.join(__dirname, "..", "..") });
const m = load("M01_MasterData", "M01_MasterData_UI_LogicLab_FormulaTokens.ts");
const hot = (list) => list.filter((t) => t.hot).map((t) => t.text);
const name = "굴착기노무비단가 * 뒤채움및다짐소형장비.굴착기 * 8 / 시공량 = 굴착기노무비";
const value = "35,412 * 1 * 8 / 110 = 2,575";
const out = {
nameHot: hot(m.nameTokens(name)),
nameAll: m.nameTokens(name).map((t) => t.text),
fnCall: hot(m.nameTokens("올림(단가) + 1.5 - 보정")),
valueHot: hot(m.valueTokens(value)),
valueAll: m.valueTokens("13,873 = 13,873 · 21 %").map((t) => t.text),
empty: [m.nameTokens("").length, m.valueTokens("").length],
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,73 @@
/* 802-1 수식 문서 거울 헬퍼 — common_util_formula_doc*.ts 를 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = {문서들: {키: 문서}, 대상: [키], 기록: {열쇠: {값 | 빈 | 끊김}}, 글: [식 글], 표: [{표, 조건, 칸}]}
* stdout = {답: {키: calcDoc 답}, 글: [textOf 또는 {틀림}], 표: [tableValue 값 또는 {빈 | 끊김}]}
* 기록 열쇠 = 갈래 + "|" + 값 글(파이썬 `_canon` 과 같은 꼴). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const root = path.join(__dirname, "..", "..", "common_util");
const doc = load(path.join(root, "common_util_formula_doc.ts"));
const calc = load(path.join(root, "common_util_formula_doc_calc.ts"));
const table = load(path.join(root, "common_util_formula_doc_table.ts"));
function canon(v) {
if (typeof v === "number") return calc.plainText(v);
if (Array.isArray(v)) return v.map(canon);
if (v && typeof v === "object") {
const out = {};
for (const k of Object.keys(v).sort()) out[k] = canon(v[k]);
return out;
}
return v;
}
const keyOf = (kind, p) => `${kind}|${JSON.stringify(canon(p))}`;
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const record = input.기록 || {};
function answer(kind, p) {
const k = keyOf(kind, p);
if (!(k in record)) throw new calc.Broken(`기록 없음 ${k}`);
const got = record[k];
if ("빈" in got) throw new calc.Gap(got.빈);
if ("끊김" in got) throw new calc.Broken(got.끊김);
return got.값;
}
const links = {
element: (p) => answer("요소", p),
table: (p) => answer("표", p),
doc: (key) => (input.문서들 || {})[key] || null,
};
const out = { 답: {}, 글: [], 표: [] };
for (const key of input.대상 || []) out.답[key] = calc.calcDoc(input.문서들[key], links);
for (const text of input.글 || []) {
try {
out.글.push(doc.textOf(doc.parse(text)));
} catch (e) {
if (!(e instanceof doc.DocError)) throw e;
out.글.push({ 틀림: true });
}
}
for (const one of input.표 || []) {
try {
out.표.push({ 값: table.tableValue(one.표, { 조건: one.조건, 칸: one.칸 }) });
} catch (e) {
if (e instanceof calc.Gap) out.표.push({ 빈: true });
else if (e instanceof calc.Broken) out.표.push({ 끊김: true });
else throw e;
}
}
process.stdout.write(JSON.stringify(out));
+38
View File
@@ -0,0 +1,38 @@
/* 802-4b-2 꺼진 줄 헬퍼 — common_util_formula_doc_view.ts 를 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = [{문서, 값}] · stdout = [{새: [꺼진 금액 줄 차례], 옛: [만약 끝 0 만]}] */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const view = load(
path.join(__dirname, "..", "..", "common_util", "common_util_formula_doc_view.ts"),
);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const out = input.map(({ 문서, 값 }) => {
const steps = view.lineMap(문서);
const lines = 문서.금액 || [];
const pick = (f) => lines.flatMap((l, i) => (f(l) ? [i] : []));
return {
새: pick((l) => view.offLine(l, 값, steps)),
옛: pick((l) =>
[l.수량, l.단가].some((t) => {
const n = view.tryParse(t);
return !!n && view.leafNum(n, 값) === 0;
}),
),
};
});
process.stdout.write(JSON.stringify(out));
+65
View File
@@ -0,0 +1,65 @@
/* 802-4b-3 금액 줄 값 식 빨강 헬퍼 — 화면 `formula_doc_lines.ts` 와 같은 값 목록(기호 값 + 금액 줄 기호 + 비목 합)으로
* 금액 줄 단가 · 수량의 기호마다 값이 있는지 봄. stdin = [{문서, 값, 줄}] · stdout = [{빨강: [[줄, 기호]], 합: [[줄, 셈, 금액]]}] */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const view = load(
path.join(__dirname, "..", "..", "common_util", "common_util_formula_doc_view.ts"),
);
const COSTS = new Set(["노무비", "재료비", "경비"]);
const names = (n, out = []) => {
if (!n) return out;
if (n[0] === "var") out.push(n[1]);
else if (n[0] === "field") names(n[1], out);
else if (n[0] === "neg" || n[0] === "pct") names(n[1], out);
else if (n[0] === "bin") names(n[2], out) && names(n[3], out);
else if (n[0] === "call") n[2].forEach((x) => names(x, out));
return out;
};
const num = (v) => (typeof v === "string" && v !== "" && !isNaN(Number(v)) ? Number(v) : v);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const out = input.map(({ 문서, 값, 줄 }) => {
const vals = { ...값 };
const lines = 문서.금액 || [];
lines.forEach((l, i) => {
const a = num((줄[i] || {}).금액);
if (l.기호 && typeof a === "number") vals[String(l.기호)] = a;
});
const red = [];
const sums = [];
lines.forEach((l, i) => {
const amount = num((줄[i] || {}).금액);
const raw = view.moneyScope(줄, i);
const scope = { ...vals, ...raw };
let cost = false;
for (const t of [l.단가, l.수량]) {
for (const name of names(view.tryParse(t))) {
if (COSTS.has(name)) cost = true;
const v = scope[name];
const miss = v === null || v === undefined || (typeof v === "object" && l.비목 !== "묶음");
if (miss && typeof amount === "number") red.push([i, name]);
}
}
if (cost && typeof amount === "number") {
const p = view.evalNode(view.tryParse(l.단가), scope);
const q = l.수량 === undefined || l.수량 === null || l.수량 === "" ? 1 : view.evalNode(view.tryParse(l.수량), scope);
sums.push([i, typeof p === "number" && typeof q === "number" ? p * q : null, amount]);
}
});
return { 빨강: red, 합: sums };
});
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,255 @@
/* 804-2 줄 쌓기 헬퍼 — common_util_line_stack.ts · 계산기 · 캐시를 그 자리에서 트랜스파일해 Node 로 돌림.
* stdin = {할일, 문서들 [{문서, 연결?}]} · stdout = 할일마다 답 목록 */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const util = (name) => load(path.join(__dirname, "..", "..", "common_util", `${name}.ts`));
const ls = util("common_util_line_stack");
const calc = util("common_util_formula_doc_calc");
const dc = util("common_util_formula_doc_cache");
const money = (a) => ({ 노무비: a.노무비, 재료비: a.재료비, 경비: a.경비, 계: a.계, 결과: a.결과 });
/** 왕복 — 문서 → 줄 → 문서 · 덮음(모든 금액 줄 한 번 · 차림 기호 한 번) · 차림 고침 뒤 다시 읽어 같음 */
function roundTrip({ 문서 }) {
const items = ls.stackOf(문서);
const back = ls.docOf(문서, items);
const ms = items.flatMap((it) => it.줄.filter((l) => l.꼴 === "금액").map((l) => l.i));
const syms = items.flatMap((it) => it.줄.filter((l) => l.꼴 === "공식").flatMap((l) => l.기호));
const hidden = ls.hiddenOf(문서);
const want = ls.symbols(문서).filter((x) => !hidden.has(x));
// 차림 고침 — 항목마다 글 줄 · 첫 항목 가나다 · 맨 앞 새 항목 제목
const edited = JSON.parse(JSON.stringify(items));
edited.forEach((it, k) => it.줄.push({ 꼴: "글", 글: `< 근거 ${k} >` }));
if (edited[0]) edited[0].가나다 = true;
edited.unshift({ 제목: "시험 머리", 줄: [{ 꼴: "글", 글: "※ 메모" }] });
const laid = ls.docOf(문서, edited);
const again = ls.stackOf(laid);
return {
같음: JSON.stringify(back) === JSON.stringify(문서),
쌓기: "쌓기" in back,
금액: ms.length === (문서.금액 || []).length && new Set(ms).size === ms.length,
기호:
syms.length === want.length &&
new Set(syms).size === syms.length &&
want.every((x) => syms.includes(x)),
차림: JSON.stringify(again) === JSON.stringify(edited),
차림문서: laid,
못: ls.notesOf(문서),
};
}
/** 셈 — 연결 기록만으로(화면과 같은 길) · 기계 · 단가산출 펼침 셈 */
function money_({ 문서, 연결 }) {
const c = dc.createDocCache();
c.merge(연결);
const a = calc.calcDoc(문서, c.links, {});
const subs = [];
(문서.금액 || []).forEach((l, i) => {
const kind = ls.kindOf(문서, i);
if (kind !== "기계" && kind !== "단가산출") return;
const s = ls.priceSym(l);
const link = (문서.변수 || []).find((v) => ls.symbols({ 변수: [v] })[0] === s)?.연결;
const sub = c.links.doc(link.키);
const ins = ls.subInputs(link, a.값 || {});
const got = calc.calcDoc(sub, c.links, ins);
subs.push({ i, 갈래: kind, 키: link.키, 입력: ins, ...money(got), 값: (a.값 || {})[s] });
});
return { ...money(a), 펼침: subs, 누락: c.takeMissing() };
}
/** 틀 펼침 — 단가산출 결과 연결 기호를 이 문서 식으로 옮김 → 셈 같음 · 기호 없으면 그런 기호 전부(하나씩)
* 고침 = 펼친 뒤 [{기호, 글}](setRow) · 지움 = 펼친 뒤 지울 기호(dropSyms) */
function unfold({ 문서, 연결, 기호, 고침, 지움 }) {
const c = dc.createDocCache();
c.merge(연결);
const before = calc.calcDoc(문서, c.links, {});
const idOf = (v) => ls.symbols({ 변수: [v] })[0];
const targets = 기호
? [기호]
: (문서.변수 || [])
.filter((v) => v.연결?.갈래 === "단가산출" && v.연결.키 && !v.연결.키.includes("{"))
.filter((v) => c.links.doc(v.연결.키)?.결과)
.map(idOf);
return targets.map((id) => {
const link = 문서.변수.find((v) => idOf(v) === id).연결;
const doc = JSON.parse(JSON.stringify(문서));
const moved = ls.unfold(doc, c.links.doc(link.키), id);
const after = calc.calcDoc(doc, c.links, {});
for (const g of 고침 || []) ls.setRow(doc, g.기호, g.글);
if (지움) ls.dropSyms(doc, 지움);
const edited = calc.calcDoc(doc, c.links, {});
const items = ls.stackOf(doc);
return {
기호: id,
전: money(before),
후: money(after),
고친: money(edited),
표지: (edited.표지 || []).map((m) => m.갈래),
옮김: moved,
틀꼴: ls.shapeOf(doc, moved),
왕복: JSON.stringify(ls.docOf(doc, items)) === JSON.stringify(doc),
문서: doc,
};
});
}
/* 804-3 고르기 — common_util_line_stack_pick.ts */
const pk = util("common_util_line_stack_pick");
const idOf = (v) => ls.symbols({ 변수: [v] })[0];
const varOf = (doc, id) => (doc.변수 || []).find((v) => idOf(v) === id);
/** 표 고름 — 앞 = [{기호, 조건}](먼저 조건을 바꿔 둠) · 기호의 표에서 줄 · 칸 고름 → 연결 · 값 · 금액 */
function tablePick({ 문서, 연결, 기호, 줄, 칸, 앞 }) {
const doc = JSON.parse(JSON.stringify(문서));
for (const f of 앞 || []) varOf(doc, f.기호).연결.조건 = f.조건;
const c = dc.createDocCache();
c.merge(연결);
const link = varOf(doc, 기호).연결;
const t = 연결.표[link.표];
const before = calc.calcDoc(doc, c.links, {});
const now = pk.currentRow(t, link, before.값 || {});
const bound = pk.boundConds(doc, link);
pk.pickCell(doc, 기호, t, t.줄[줄], 칸);
const after = calc.calcDoc(doc, c.links, {});
return {
전줄: now,
묶임: bound,
연결: varOf(doc, 기호).연결,
값: (after.값 || {})[기호],
후줄: pk.currentRow(t, varOf(doc, 기호).연결, after.값 || {}),
...money(after),
문서: doc,
};
}
/** 찾아 넣기 — 새 항목에 고른 것 차례로 · 바꿈 = [{차례, 고른}](넣은 줄 다시 고름) */
function putPicked({ 문서, 넣을, 바꿈 }) {
const doc = JSON.parse(JSON.stringify(문서));
const items = ls.stackOf(doc);
items.push({ 제목: "시험 넣기", 가나다: true, 줄: [] });
const base = (doc.금액 || []).length;
const ids = 넣을.map((x) => pk.addPicked(doc, items[items.length - 1], x.갈래, x.고른));
for (const r of 바꿈 || []) pk.repick(doc, base + r.차례, ids[r.차례], 넣을[r.차례].갈래, r.고른);
const laid = ls.docOf(doc, items);
return {
문서: laid,
기호: ids,
처음: base,
갈래: ids.map((_, k) => ls.kindOf(laid, base + k)),
수준: pk.stackLevels(ls.stackOf(laid)),
};
}
/** 있는 줄 다시 고름 — {문서, 줄, 기호, 갈래, 고른} → 문서 */
function repickOne({ 문서, 줄, 기호, 갈래, 고른 }) {
const doc = JSON.parse(JSON.stringify(문서));
pk.repick(doc, 줄, 기호, 갈래, 고른);
return doc;
}
/* 804-4 복사해 펼침 — common_util_line_stack_copy.ts · 받음 = 서버 조각(차례로 넣음) */
const cp = util("common_util_line_stack_copy");
function takeIn({ 문서, 받음 }) {
const doc = JSON.parse(JSON.stringify(문서));
const items = ls.stackOf(doc);
const was = items.length;
for (const t of 받음) cp.addTaken(doc, items, t);
const laid = ls.docOf(doc, items);
const again = ls.stackOf(laid);
return {
문서: laid,
항목: again.slice(was).map((it) => ({
제목: it.제목,
가나다: !!it.가나다,
줄: it.줄.map((l) =>
l.꼴 === "금액"
? `금액:${laid.금액[l.i].이름}`
: l.꼴 === "글"
? `글:${l.글}`
: `공식:${l.기호.length}`,
),
})),
옵션: (laid.변수 || []).filter((v) => ls.isOption(v)).map(idOf),
못: ls.notesOf(laid),
왕복: JSON.stringify(ls.docOf(laid, again)) === JSON.stringify(laid),
};
}
/* 804-5 끌어 옮김 · 번지 읽는 이름 — 옮김 = [[from, to] …](줄 "i.j" → "k.p" · 항목 "i" → "p") */
function moveAll({ 문서, 옮김 }) {
let doc = JSON.parse(JSON.stringify(문서));
for (const [a, b] of 옮김) {
const items = ls.stackOf(doc);
if (a.includes("."))
ls.moveLine(items, ...a.split(".").map(Number), ...b.split(".").map(Number));
else ls.moveItem(items, Number(a), Number(b));
doc = ls.docOf(doc, items);
}
const items = ls.stackOf(doc);
return {
문서: doc,
항목: items.map((it) => ({
제목: it.제목,
줄: it.줄.map((l) =>
l.꼴 === "금액" ? doc.금액[l.i].이름 : l.꼴 === "글" ? l.글 : l.기호.join(","),
),
})),
};
}
const placeOf = ({ 문서 }) => cp.placeNames(문서, ls.stackOf(문서));
/* 683 표 값 기호 화면 이름 — 별칭 · 글 왕복(화면 이름 → 기호 = 처음 식과 같은 나무) */
const pk2 = util("common_util_line_stack_pick");
const fd = util("common_util_formula_doc");
function aliasOf({ 문서 }) {
const alias = pk2.tableAlias(문서);
const texts = [
...(문서.식 || []).map((l) => l.식),
...(문서.금액 || []).flatMap((l) => [l.수량, l.단가]),
].filter((t) => typeof t === "string" && t);
const bad = [];
for (const t of texts) {
let want;
try {
want = fd.textOf(fd.parse(t));
} catch {
continue;
}
const shown = pk2.aliasText(t, alias);
const back = pk2.aliasText(shown, alias, true);
if (fd.textOf(fd.parse(back)) !== want) bad.push(t);
if (Object.keys(alias).some((id) => fd.names(fd.parse(shown)).includes(id)))
bad.push(`남음:${t}`);
}
return { 별칭: alias, 틀림: bad };
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const job = {
왕복: roundTrip,
셈: money_,
펼침: unfold,
표고름: tablePick,
넣기: putPicked,
바꿈: repickOne,
복사: takeIn,
옮김: moveAll,
번지: placeOf,
별칭: aliasOf,
}[input.할일];
process.stdout.write(JSON.stringify(input.문서들.map(job)));
@@ -26,8 +26,8 @@ def test_묶음_이름_단추_글_설명_없음():
assert (
'createPanelCard("내역 점검", true)' in SHELL and "원가 단가" not in SHELL
) # 269 공용 카드
assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검
assert 'label: "단가 갱신",' in SHELL and 'label: "업데이트"' not in SHELL # 795-4 이름
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [단가 갱신] · 연결 점검
assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL
assert "section.append(refresh, check.list);" in SHELL # 제목 = 공용 카드(269)
assert "check.draw(payload, sheets);" in SHELL
@@ -108,9 +108,9 @@ console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector
assert out["moves"] == [{"tab": "material", "key": "MT9"}]
assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]"
assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"]
assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
assert "단가 갱신 가능 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
assert b["texts"] == ["금액 빈 줄 0"] and b["rows"] == []
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "단가 갱신 가능" in out["tipEmpty"]
def test_배지_조건_서버_book_stale():
@@ -82,7 +82,8 @@ def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사
folder = made["폴더"]
assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True}
copy = library.library_dir(root) / folder
assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"}
names = {"일위대가.json", "단가.json", "수식.json", "manifest.json"} # 수식 사본(802-4a)
assert {p.name for p in copy.iterdir()} == names
got, block = _block(root, folder)
assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡")
kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]]
@@ -85,6 +85,7 @@ def test_M01_원본_없는_항목_값_그대로_낡음_까닭_빠짐():
def test_화면_업데이트_캐시_보냄_그대로_둠():
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
at = shell.index('"/estimation/prices",')
assert "{ user },\n true," in shell[at : at + 200]
assert "{ user, ...(refresh.dataset.b09Job" in shell[at : at + 200] # 795-4 할일 덧칸
assert "\n true," in shell[at : at + 200]
assert 'if (!keep) clearState("b09-estimation", projectId);' in shell
assert "apply(next, !keep);" in shell
+3 -4
View File
@@ -1,6 +1,6 @@
"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀
- 704-2 M02 계산서 보기 · B09 단가산출근거가 한 벌(`A00_Common/calc_sheet.ts`)을 씀(799-2 — M01 「인력·재료·경비」 보기는 걷음)
- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버)
"""
@@ -21,10 +21,9 @@ def _text(p: Path) -> str:
def test_한_벌을_씀():
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts")
est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts")
detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
b09 = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun_Sheet.ts")
assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02
assert "calcSheetRows(" in est and "buildEst(" in detail
assert "calcSheetRows(" in b09
def test_부품_상자만():
@@ -1,62 +0,0 @@
"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글.
`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
M01 = Path(__file__).resolve().parents[2] / "M01_MasterData"
FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _run(body: str) -> object:
text = FX.read_text(encoding="utf-8")
pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")]
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", pure + "\n" + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
def test_표_행_열쇠와_이름_조각():
got = _run(
"""
const line = {
조각: [
{ kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } },
{ kind: "기호", 글: "*" },
{ kind: "값", 글: "두께", 값: "20" },
{ kind: "값", 글: "0.009" },
{ kind: "값", 글: "뒷길이", 값: "99" },
],
요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }],
};
console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"])));
"""
)
# 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌
assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]]
def test_빈_줄():
assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == []
def test_이음_시험_입력만_고침():
lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8")
detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
# 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감
assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab
assert "onInput: ctx.onInput" in detail
fx = FX.read_text(encoding="utf-8")
assert "touch" not in fx and "drafts" not in fx
+4 -14
View File
@@ -104,8 +104,6 @@ dropLine(doc, by("add:#0"));
out.dropAdded = doc.더한줄.length;
const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가: 1000 } } } };
out.revised = rowsOf(bare, bare, covered).find((r) => r.cid === "labor:A#0").revised;
out.sheet = sheetGroups(rowsOf(bare, bare, {}))
.map((g) => [g.비목, g.소계, g.줄.map((x) => x.금액)]);
// 공용 상세 줄 — 바탕 줄 그대로 · 뺀 줄 없음 · 더한줄 · 별도계상 빠진 줄 = 빈 칸 · 덧줄
const base = { 호표: [{ 종류: "인력", 요소: "A", 이름: "석공", 수량: "0.1*x" },
{ 종류: "로직", 요소: "로직(G)", 수량: "2" }, { 종류: "재료", 요소: "M", 수량: "0.5" }],
@@ -146,12 +144,6 @@ console.log(JSON.stringify(out));
assert got["drop"] == [["labor:A#0"], None]
assert got["restore"] == [] and got["dropAdded"] == 0
assert got["revised"] is True
# 계산서 = 표 줄 그대로 · 별도계상 재료 금액 없음(계에 안 듦)
assert got["sheet"] == [
["노무비", 160, [100, 60]],
["재료비", 30, [30, None]],
["경비", 10, [10]],
]
ho, extra, cids, lines, covers = got["view"]
assert ho == [
["인력", "A", "0.1*x", None], # 바탕 줄 그대로(식)
@@ -173,11 +165,9 @@ def test_상세_부품_길_부품상자():
cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts")
assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view
assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail
assert (
"buildLabDetail(editor" in detail
and "cover: { ho, est, open: lineOpener(hoCtx) }" in detail
)
assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail
assert "buildLabDetail(editor" in detail and "cover: { ho, open: lineOpener(hoCtx) }" in detail
assert "buildCalc" not in detail # 799-2 시험 계산 보기 걷음
assert "openSangeunLinePicker(scope" in detail
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail
@@ -188,7 +178,7 @@ def test_상세_부품_길_부품상자():
assert "<button" not in s and "<input" not in s and "<table" not in s
assert "document.createElement" not in s and "sessionStorage." not in s
lab = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts")
assert "ctx.cover?.est ??" in lab and "ctx.cover.ho" in lab
assert "ctx.cover.ho" in lab and "cover?.est" not in lab
side = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_Side.ts")
modal = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Modal.ts")
assert "export const hasMasterJump" in side and "go.hidden = !hasMasterJump()" in modal
+2 -2
View File
@@ -27,8 +27,8 @@ def _text(path: Path) -> str:
def test_721_1_단추_이름과_자리():
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
assert (
"children: [sheet.picker, addButton, rateButton]" in detail
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 790-1 보기 고르기 = 시트 탭 부품
"children: [tabs.picker, addButton, rateButton]" in detail
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 799-2 보기 고르기 = 수식 · 단가산출 상세 (`_Sangeun_Tabs`)
assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
assert "M02_SangeunPickBase" not in locale
+2 -2
View File
@@ -63,9 +63,9 @@ def test_상세가_공용_부품을_씀():
def test_시험_고침은_캐시_밖():
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
# 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄
assert ": trial.apply(row);" in lab
assert "=> trial.apply(row);" in lab
assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab
assert "onTrial: drawCalc," in lab
assert "onTrial: drawCalc," in lab and "rerun: () => void drive.run()," in lab # 799-2 셈 구동
cover = _text(COVER)
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
+2 -7
View File
@@ -1,4 +1,4 @@
"""734-1 계산식 열 단추 지킴 시험 — 낮은 단(20) · 갈래 색(옵션값 · 요소 연결) · 풍선 글(화면 · 서버 없음)."""
"""734-1 계산식 열 단추 지킴 시험 — 낮은 단(20) · 갈래 색 · 풍선 글(화면 · 서버 없음)."""
from pathlib import Path
@@ -15,11 +15,6 @@ def test_알약은_낮은_단과_요소연결_갈래():
assert 'size: 20, tone: "link"' in s and 'tl("Pill_Tone")' in s
def test_입력칩은_낮은_단과_옵션_갈래():
s = _t(M01 / "M01_MasterData_UI_LogicLab_Formula.ts")
assert "size: 20," in s and 'tone: "option"' in s and 'tl("Var_Tone")' in s
def test_갈래_색은_공통_값만():
css = _t(ROOT / "ui_template" / "ui_template_button.css")
head = css[css.index("갈래 색(tone)") :]
@@ -30,4 +25,4 @@ def test_갈래_색은_공통_값만():
def test_풍선_글_두_갈래():
s = _t(M01 / "M01_MasterData_UI_LogicLab_Text.ts")
assert '"요소 연결"' in s and '["옵션"' in s
assert '"요소 연결"' in s # 799-2 — 입력 칩(옵션 풍선)은 텍스트 수식과 함께 걷음
+2 -15
View File
@@ -1,4 +1,4 @@
"""737 단가산출 상세 — 옵션 칸 격자 · 합계 한 줄 바닥 · 두 보기 같은 제목 · 비목 상자 지킴 시험(글 대조 · 서버 · 화면 없음)."""
"""737 단가산출 상세 — 옵션 칸 격자 · 합계 한 줄 바닥 · 비목 상자 지킴 시험(글 대조 · 서버 · 화면 없음) · 799-2 텍스트 수식 보기는 걷음."""
import re
from pathlib import Path
@@ -8,8 +8,6 @@ M01 = ROOT / "M01_MasterData"
SHEET_CSS = (ROOT / "A00_Common" / "calc_sheet.css").read_text(encoding="utf-8")
STYLE = (M01 / "M01_MasterData_UI_LogicLab_Style.css").read_text(encoding="utf-8")
HO = (M01 / "M01_MasterData_UI_LogicLab_Ho.ts").read_text(encoding="utf-8")
DETAIL = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
FX = (M01 / "M01_MasterData_UI_LogicLab_Formula.ts").read_text(encoding="utf-8")
def test_옵션_격자_폭_따라_6_4_3_2_1():
@@ -29,21 +27,10 @@ def test_합계_한_줄_바닥_고정():
assert "overflow: clip" in STYLE # 메인 칸이 굴림 칸이면 바닥에 안 붙음
foot_ts = (M01 / "M01_MasterData_UI_LogicLab_Foot.ts").read_text(encoding="utf-8")
assert 'className: "m01lab__foot"' in foot_ts # 748-3 두 보기가 같은 바닥 줄 부품
assert "buildFoot(sums, calculated, total)" in HO and "buildFoot(sums, calculated)" in FX
assert "buildFoot(sums, calculated, total)" in HO
assert 'data-cols="3"' in STYLE and ".m01lab__ho-box + .m01lab__ho-box" in STYLE
def test_공통_옵션은_모든_박스에_같이():
assert "optionRows([...usesOf(b), ...common], ctx)" in HO
assert "commonCard(" in HO and "boxes.length ||" in HO # 박스 없을 때만 따로 카드
def test_두_보기_같은_제목_지금_입력_꼬리표_없음():
assert 'section(tl("Formula_Title")' not in DETAIL
assert 'section(tl("Ho_Title"), [formula])' in DETAIL
assert "nowInputs(ctx.values), ...blocks" not in DETAIL
def test_텍스트_수식은_비목_상자_옵션은_상자_위():
assert "createCard({" in FX and "fxBox" in FX and "optionGrid(" in FX
assert "...vars(line)" not in FX # 줄마다 붙던 옵션 꼬리표 걷음
+2 -2
View File
@@ -120,7 +120,7 @@ def test_하루_지난_HP_목록_숨김_올림_때_지움(client):
assert forget.expired(folder) == ["HP-000001"]
got = _post(client).json() # 같은 엑셀이어도 지난 것 = 지우고 새로(이어서 아님)
assert got["키"] == "HP-000002" and not got.get("이어서")
assert _files(folder) == ["HP-000002.json", "HP-000002.json"] # 이번 것 + 격자
assert _files(folder) == ["HP-000002.json"] * 3 # 이번 것 + 격자 + 산근 시트(793-3b)
def test_옛_파일_정리_미리_보기_쓰기_0_고침(client):
@@ -143,7 +143,7 @@ def test_옛_파일_정리_미리_보기_쓰기_0_고침(client):
assert {p: p.read_bytes() for p in folder.rglob("*.json")} == snap # 미리 보기 = 쓰기 0
assert forget.tidy(folder, apply=True) == rows
assert _files(folder) == ["HP-000001.json", "HP-000001.json"]
assert _files(folder) == ["HP-000001.json"] * 3
assert hs.done(folder) == {"번": 7, "지문": {}}
docs = _docs(folder, "precombo")
assert "키" not in docs["SC-000001"]["본뜬"] and "메모" not in docs["SC-000001"]["담은로직"][0]
@@ -1,35 +0,0 @@
"""748-2 지킴 — 텍스트 수식 한 줄을 변수 이름 · 값 하나하나로 나눔(누르면 줄 모달)."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
HERE = Path(__file__).resolve().parent
def test_변수_이름_값_조각():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_748_2_formula_tokens.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
# 이름식 — 변수 이름(점 든 표 찾기 이름 포함)만 · 수 · 기호는 아님
assert out["nameHot"] == [
"굴착기노무비단가",
"뒤채움및다짐소형장비.굴착기",
"시공량",
"굴착기노무비",
]
assert "8" in out["nameAll"] and "=" in out["nameAll"]
# 함수 이름(「이름(」)은 변수가 아님 · 수 · 홀로 선 빼기는 아님
assert out["fnCall"] == ["단가", "보정"]
# 값식 — 수 하나하나(콤마 · 소수점 포함) · 기호는 그대로
assert out["valueHot"] == ["35,412", "1", "8", "110", "2,575"]
assert out["valueAll"][:3] == ["13,873", "=", "13,873"]
assert out["empty"] == [0, 0]
@@ -1,4 +1,4 @@
"""750-2 화면 — 네 보기가 SK 셈 답 하나로(바탕 M01 로직 따로 셈 길 걷음) · 재료 고름 = 문서 `고름` ·
"""750-2 화면 — 수식 · 단가산출 상세가 SK 셈 답 하나로(바탕 M01 로직 따로 셈 길 걷음 · 799-2 시험 계산 보기는 걷음) · 재료 고름 = 문서 `고름` ·
읽는 식 줄 = cid 로 이음 · 옵션 바꿈 = SK 셈 한 번(원문 대조는 서버 test_750_1)."""
from pathlib import Path
@@ -12,7 +12,7 @@ def _ts(rel: str) -> str:
def test_detail_uses_only_sk_answer():
ts = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
assert "text = got.글" in ts and "answer: async" in ts and "shown: shownOf" in ts
assert "text = got.글" in ts and "answer: async" not in ts and "buildCalc" not in ts
assert "onText" not in ts and "onLines" not in ts # 바탕 `/calc` · `/text` 부름 걷음
assert "dropText" not in ts and "baseCovers" not in ts and "applyCovers" not in ts
assert "calcSangeun(scope, plain, values, false)" in ts # 바탕 원값 셈 = 보기 덧칸 없이
@@ -26,10 +26,11 @@ def test_text_lines_joined_by_cid():
assert "cid?: string" in api and "나눔?: number" in api
def test_calc_view_answer_hook_skips_logic_lib():
def test_calc_drive_is_screenless_and_m02_has_no_calc_hook():
ts = _ts("M01_MasterData/M01_MasterData_UI_Logic_Calc.ts")
assert "ctx.onText && !ctx.answer" in ts # 미리보기 안 부름
assert "if (ctx.answer)" in ts and "runCalc" in ts # M01 옛 길 그대로
assert "ctx.answer" not in ts and "CalcShown" not in ts # 799-2 M02 시험 계산 길 걷음
drive = _ts("M01_MasterData/M01_MasterData_UI_Logic_Drive.ts")
assert "runCalc(body)" in drive and "document." not in drive # 셈 구동 = 화면 없이
def test_752_1_source_panel_only_on_sangeun_screen():
@@ -71,9 +72,7 @@ const line = (cid, 이름, 수량, 금액, 꺼짐) => ({ cid, 이름, 단위: "
const ans = { 기준: null, 짝잃음: [], 바탕판: "x", 정체: ["material:A#0", "material:B#0"],
줄: [line("material:A#0", "폼타이 12.7", 0, 0, true), line("material:B#0", "합판", 1, 100, false)] };
const rows = rowsOf(ans, null, {});
const groups = sheetGroups(rows);
console.log(JSON.stringify({ off: rows.map((r) => r.off),
sheet: groups.map((g) => [g.비목, g.소계, g.줄.map((x) => [x.이름, x.옵션꺼짐 ?? false, x.금액])]) }));
console.log(JSON.stringify({ off: rows.map((r) => r.off) }));
""",
]
)
@@ -85,14 +84,10 @@ console.log(JSON.stringify({ off: rows.map((r) => r.off),
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
assert got["off"] == [True, False]
assert got["sheet"] == [["재료비", 100, [["폼타이 12.7", True, 0], ["합판", False, 100]]]]
sheet = (ROOT / "A00_Common" / "calc_sheet.ts").read_text("utf-8")
assert "b.줄.filter(shown)" in sheet and "g.줄.filter(shown)" in sheet
ho = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text("utf-8")
assert "옵션꺼짐" in ho
assert "옵션꺼짐" in (
ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Formula.ts"
).read_text("utf-8")
assert "옵션꺼짐" in (ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Calc.ts").read_text(
"utf-8"
)
@@ -1,312 +0,0 @@
"""790-1 M02 단가산출 줄 식 시트(E1) — 상세 첫 탭 「시트」 · 줄 갈래 · 산출서 보기 · 부품 상자만.
- 정적: 시트 화면 = 부품 상자(calc_row 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글)만 · 날 button · input 0 ·
상세 탭 등록(시트 첫 칸 · 옛 보기 넷 그대로) · 고침 = SK 덮개 · 뺀줄 · 되살림 길(모델 안 바꿈)
- 순수 셈(node strip-types): 줄 갈래(근거 · 옵션 · 상수 · 표 · 계산 · 자원 · 율 · 소계 · 합계) · 엔진 날식 숨김 ·
읽기 쉬운 식 · 산출서 한 줄 글 · 상태줄 금액 셋(790-2 `원래` 있으면 그 값)
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
SHEET = M02 / "M02_MasterTemplete_Sangeun_Sheet.ts"
MODEL = M02 / "logic" / "M02_Sangeun_Sheet_Model.ts"
DETAIL = M02 / "M02_MasterTemplete_Sangeun_Detail.ts"
ROW = ROOT / "ui_template" / "ui_template_calc_row.ts"
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
def _text(p: Path) -> str:
return p.read_text("utf-8")
def test_부품_상자만_날_요소_없음():
for p in (SHEET, MODEL, SHEET.with_suffix(".css")):
s = _text(p)
assert "<button" not in s and "<input" not in s and "<table" not in s
assert "document.createElement" not in s and "sessionStorage." not in s
assert len(s.splitlines()) < 700
sheet = _text(SHEET)
for part in ("createCalcSheetRow", "createInputField", "createSelectField", "createButton"):
assert f"{part}" in sheet
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
css = _text(SHEET.with_suffix(".css"))
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", css) # 색 = 공통 값만
row = _text(ROW)
assert "export function createCalcSheetRow(" in row and "ui-calc-row--sheet" in row
def test_상세_탭_등록_시트_첫_칸():
detail = _text(DETAIL)
assert "createSangeunSheet(editor," in detail and "createViewPicker" not in detail
assert "children: [sheet.picker, addButton, rateButton]" in detail
assert re.search(r"sheet\.host,\s+editor,", detail) # 시트 그릇 = 옛 보기 그릇 앞
assert (
"sheet.draw({ doc, ans, bare, base: row, text, rows, view, values, removed, fail });"
in detail
)
assert "sheet.saved();" in detail
assert len(detail.splitlines()) <= 575
sheet = _text(SHEET)
opts = re.search(r"options: \[\s*\{ value: \"sheet\"", sheet)
assert opts, "시트가 보기 고르기 첫 칸"
for v in ('"ho"', '"est"', '"calc"', '"formula"', '"paper"'):
assert f"value: {v}" in sheet # 옛 보기 넷 그대로 · 산출서 전환
# 고침 길 = 지금 SK 모델(덮개 · 뺀줄 · 되살림) · 다시 셈 = 상세 touch(서버 셈 · 최신 응답만)
for call in (
"setLineCover(d.doc, r,",
"dropLine(d.doc, r)",
"restoreLine(d.doc, cid)",
"opt.touch()",
):
assert call in sheet
assert (
"addLine: () => addButton.click()" in detail
and "addRate: () => rateButton.click()" in detail
)
FIX = {
"base": {
"출처": "건설표준품셈 공통 6-3-1",
"원문번호": "6-3-1",
"비고": "※ 높이 3.5 m 이하",
"입력": [{"이름": "사용횟수", "고르기": ["3회", "6회"]}],
"중간": [
{"이름": "상수", "식": "17"},
{"이름": "시공량", "식": "찾기(QC000431, 유형=유형).시공량"},
{"이름": "형틀목공", "식": "찾기(QC000431, 유형=유형).형틀목공 / 시공량"},
{"이름": "합판율", "식": "만약(사용횟수 == '1회', 1, 찾기(QC000428, 사용횟수=사용횟수).합판 / 100)"},
],
},
"values": {"사용횟수": "6회"},
"ans": {
"기준": {"노무비": 38290, "재료비": 12747, "경비": 382, "계": 51419},
"중간": {"상수": 17, "시공량": 45, "형틀목공": 0.1111, "합판율": 0.327},
"원래": None,
},
"rows": [
{"cid": "labor:LB000007#0", "이름": "형틀목공", "단위": "인", "수량": 0.1111, "단가": 275790,
"금액": 30643.3, "parts": {"노무비": 30643.3}, "pct": False, "added": None, "apart": False,
"covered": {"qty": False, "price": False}, "off": False},
{"cid": "material:폼타이#0", "이름": "폼타이", "단위": "본", "수량": 0, "단가": 0, "금액": 0,
"parts": {"재료비": 0}, "pct": False, "added": None, "apart": False,
"covered": {"qty": False, "price": False}, "off": True},
{"cid": "add:#0", "이름": "보통인부", "단위": "인", "수량": 0.1, "단가": 172068, "금액": 17206.8,
"parts": {"노무비": 17206.8}, "pct": False, "added": 0, "apart": False,
"covered": {"qty": False, "price": False}, "off": False},
{"cid": "pct:공구손료#0", "이름": "공구손료", "단위": "%", "수량": 2, "단가": None, "금액": 765.8,
"parts": {"경비": 765.8}, "pct": True, "added": None, "apart": False,
"covered": {"qty": True, "price": False}, "off": False},
],
"text": {
"ok": True,
"계": 51419,
"별칭": [{"이름": "합판거푸집일당", "원문": "찾기(QC000431, 유형=유형)"}],
"groups": [
{"비목": "노무비", "소계": 38290, "끝수": "", "줄": [
{"이름": "형틀목공", "cid": "labor:LB000007#0", "글": "275,790 * 0.1111 = 30,643.3",
"이름글": "형틀목공노임 * 형틀목공 = 형틀목공비", "금액": 30643.3},
{"이름": "폼타이", "cid": "material:폼타이#0", "글": "0 * 0 = 0", "금액": 0, "옵션꺼짐": True},
{"이름": "보통인부", "cid": "add:#0", "글": "", "금액": 17206.8},
]},
{"비목": "경비", "소계": 765, "끝수": "", "줄": [
{"이름": "공구손료", "cid": "pct:공구손료#0", "글": "(30,643.3) * 2 % = 765.8",
"이름글": "(형틀목공 * 형틀목공노임) * 2 % = 공구손료", "금액": 765.8},
]},
],
},
} # fmt: skip
def _run():
src = IMPORTS.sub("", _text(MODEL))
body = (
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
"\nconst items = sheetItems({ base: F.base, inputs: F.base.입력, values: F.values, ans: F.ans,"
" rows: F.rows, text: F.text });"
"\nconst out = {"
"\n items: items.map((x) => ({ kind: x.kind, 이름: x.이름, 식: x.식, 값: x.값, 비목: x.비목,"
" 표: x.표, changed: x.changed })),"
"\n paper: paperLines(F.text, F.rows, F.ans),"
"\n status: statusOf(F.ans, { 기준: { 계: 51000 } }, 50000),"
"\n origin: statusOf({ ...F.ans, 원래: { 계: 49000 } }, { 기준: { 계: 51000 } }, null),"
"\n diff: [diffText(51802, 51419), diffText(51000, 51419), diffText(1, 1)],"
"\n};"
"\nconsole.log(JSON.stringify(out));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_줄_갈래_읽기_쉬운_식_산출서_상태줄():
got = _run()
kinds = [x["kind"] for x in got["items"]]
assert kinds == ["근거", "근거", "옵션", "상수", "표", "계산", "계산", "자원", "자원", "율",
"소계", "소계", "소계", "합계"] # fmt: skip
by = {(x["kind"], x["이름"]): x for x in got["items"]}
assert by[("근거", "6-3-1")]["식"] == "건설표준품셈 공통 6-3-1"
assert by[("표", "시공량")]["값"] == 45 and by[("표", "시공량")]["표"] == ["QC000431"]
assert by[("계산", "형틀목공")]["식"] == "합판거푸집일당.형틀목공 / 시공량"
assert by[("계산", "합판율")]["식"] == "" and by[("계산", "합판율")]["표"] == [
"QC000428"
] # 날식 숨김
res = by[("자원", "형틀목공 (인)")]
assert res["식"] == "형틀목공노임 × 형틀목공" and res["비목"] == "노" and not res["changed"]
assert ("자원", "폼타이 (본)") not in by # 옵션으로 빠진 줄 안 그림
assert by[("자원", "보통인부 (인)")]["changed"] # 더한줄 = M01 과 다름
assert by[("자원", "보통인부 (인)")]["식"] == "172,068 × 0.1" # 이름글 없음 = 단가 × 품
rate = by[("율", "공구손료")]
assert (
rate["식"] == "(형틀목공 × 형틀목공노임) × 2 %" and rate["changed"] and rate["비목"] == "경"
)
assert by[("합계", "계")]["값"] == 51419
paper = [p["글"] for p in got["paper"]]
assert "형틀목공 : 275,790 × 0.1111 = 30,643.3" in paper
assert "보통인부 : 172,068 × 0.1 = 17,206.8" in paper # 글 없음 = 표 줄로 지음
assert not any(p.startswith("폼타이") for p in paper)
assert "노무비 소계 = 38,290" in paper and paper[-1] == "계 = 51,419"
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
assert got["diff"] == ["+383", "−419", ""]
# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ──────────
EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts"
def test_790_3_숫자_넓힘_칸_정적():
s = _text(EDIT)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "fetch(" not in s and "sessionStorage." not in s and len(s.splitlines()) < 700
for call in (
"setConst(c.doc, spot, p.n, x)", # 식 속 숫자 = 상수 덮개
"setTable(c.doc, spot, p.n, x)", # 표 꼬리표 값 = 표값 덮개
'createBadge(t("Cut"), "warning")', # 표 연결 끊김
"addText(c.doc, spot,", # 글 줄 더함 · 고침 · 지움
"editText(c.doc, i, v)",
"dropText(c.doc, i)",
"setReason(c.doc, key, x)", # 까닭
"restoreM01(c.doc, spot)", # 줄마다 M01 원래로
"restoreM01(c.doc)", # 전체
'createBadge(t("Changed"), "danger")', # M01 바뀜
"c.spots?.짝잃음",
):
assert call in s, call
sheet = _text(SHEET)
for call in (
"formulaEditor(ctx, it.자리)",
"reasonCell(ctx, it.자리, it.changed)",
"spotTail(ctx, it.자리,",
"restoreAll(ctx)",
"lostBanner(ctx)",
"removedReasons(ctx, d.removed)",
"dropAddKeys(d.doc as ConstDoc, r.added)", # 뺀 더한줄 = 까닭 · 글줄 열쇠 당김
"liftAddKeys(d.doc as ConstDoc, at)", # 되살림 = 거꾸로 밂
"paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄)", # 글 줄 산출서에
"spots: spotsOf(d.ans)",
):
assert call in sheet, call
assert "Object.assign(doc, next);" in sheet # 상세가 쥔 같은 문서 객체 · 자동저장 없음
assert len(sheet.splitlines()) < 700
SPOT = {
"식": "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)",
"상수": [{"n": 0, "값": 1, "원값": 1}, {"n": 1, "값": 0, "원값": 0},
{"n": 2, "값": 17, "원값": 17}, {"n": 3, "값": 100, "원값": 100}],
"표값": [{"n": 0, "글": "찾기(QC000431, 유형=유형).시공량", "값": 45, "원값": 45}],
} # fmt: skip
def _run_e2():
src = IMPORTS.sub("", _text(MODEL))
bad = {**SPOT, "상수": SPOT["상수"][:3]}
body = (
f"\nconst S = {json.dumps(SPOT, ensure_ascii=False)};"
f"\nconst B = {json.dumps(bad, ensure_ascii=False)};"
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
"\nconst show = (p) => p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');"
"\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' },"
" { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];"
"\nconsole.log(JSON.stringify({"
"\n parts: show(formulaParts(S)),"
"\n bad: formulaParts(B),"
"\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] }),"
"\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\"),"
"\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글),"
"\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' },"
" 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1),"
"\n spotsNone: spotsOf(null),"
"\n}));"
) # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_790_3_식_조각_글줄_산출서_열쇠_밂():
got = _run_e2()
assert got["parts"] == (
"[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])"
) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸
assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림
assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌
paper = got["paper"]
assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로
i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3")
assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음
assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"}
assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}]
assert got["spotsNone"] is None
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts" # 794-1 식 칸 몸 = 공용(M01 시트와 한 벌)
def test_792_식_칸_정적():
s = _text(FX)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
both = s + _text(CELL)
for call in (
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
"signal,",
'box.input.addEventListener("input"',
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
"if (!got?.되나 || !got.식) return", # 틀린 식은 문서에 안 넣음
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
"addVar(c.doc as FormulaDoc,",
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
"dropVar(c.doc as FormulaDoc, v.이름)",
):
assert call in both, call
sheet = _text(SHEET)
for call in (
"rowFormula(ctx, it.자리,",
"formulaToggle(ctx, it.자리,",
"varRows(ctx, (v) => v.자리 === it.자리)",
"addVarButton(ctx)",
"scope: opt.scope,",
'coverKinds(d.doc as ConstDoc, r.cid).includes("식")', # 식 덮개 = 품 칸 안 둠
):
assert call in sheet, call
assert "addRate: () => rateButton.click(), scope }" in _text(DETAIL) # 탭 등록 줄에 층만
assert "fetch(" not in s # 길 = Api_Fetch.call(로그인 쿠키)
+1 -85
View File
@@ -5,14 +5,13 @@
인력운반 120 → 100 = 38,696 · 표값 운반량 30 = 35,764) · 옵션 바꿔도 상수 덮개 식 따라감
- 식 속 숫자 차례 = 엔진 파서(찾기 머리 · 키 · EQ: · 따옴표 글 안 셈)
- 옛 SK(새 칸 없음) = 꼴 · 금액 그대로 · 저장 = M01판 · 셈 답 `원래` · `시트`
- [M01 원래로] · 더한줄 열쇠 옮김 · 덮개 갈래 = 파이썬 `M02_Sangeun_Const` ↔ TS `Const_Model` 거울
- 799-2 — TS 거울(`Const_Model.ts`)은 시트 탭과 함께 걷음 · 서버 `M02_Sangeun_Const` 만 지킴
- 펼침 = 상수 덮개 SK 안 펼침(참조 그대로 · 값 그대로) · [초기화] = 새 덮개도 걷음
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
@@ -233,86 +232,3 @@ def test_펼침_막음_초기화_걷음(world):
flat.flat_lines("sangeun", {**MINE, "키": a})
doc, n = flat.reset("sangeun", {**_read(mine, a), "까닭": {"mid:일운반회수": "가정"}})
assert n == 1 and not set(doc) & {"상수덮개", "까닭"}
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────
MIRROR_DOC = {
"덮개": {"labor:LB000002#0": {"qty": 1}, "labor:LB000007#0": {"labor": 5}},
"뺀줄": ["pct:소모자재#0"],
"더한줄": [
{
"종류": "로직",
"로직": "GC000001",
"덮개": {"labor:X#0": {"qty": 1}},
"뺀줄": ["material:Y#0"],
},
{"종류": "인력"},
],
"상수덮개": {"mid:시공량": {"2": 20}, "labor:LB000002#0": {"0": 3}},
"표값덮개": {"mid:운반량": {"0": 30}},
"글줄": [{"자리": "add:#1", "글": "a"}, {"자리": "add:#0", "글": "b"}],
"까닭": {
"mid:시공량": "c",
"add:#0": "d",
"add:#1": "e",
"add:#0/labor:X#0": "f",
"pct:소모자재#0": "g",
},
}
KEYS = [
None,
"mid:시공량",
"labor:LB000002#0",
"pct:소모자재#0",
"add:#0/labor:X#0",
"add:#0/material:Y#0",
"없음",
]
def _ts(body: str):
src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts").read_text("utf-8")
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
return json.loads(got.stdout)
def test_거울_M01원래로_열쇠옮김_갈래():
d = json.dumps(MIRROR_DOC, ensure_ascii=False)
keys = json.dumps(KEYS)
spots = json.dumps(
["labor:LB000002#0", "labor:LB000007#0", "pct:소모자재#0", "mid:운반량", "x"]
)
ts = _ts(
f"\nconst d = {d};"
f"\nconst out = {{r: {keys}.map((k) => restoreM01(d, k ?? undefined)),"
" a: [0, 1, 5].map((k) => dropAddKeys(d, k)),"
f" k: {spots}.map((s) => coverKinds(d, s))}};"
"\nconsole.log(JSON.stringify(out));"
)
assert ts["r"] == [const.restore(MIRROR_DOC, k) for k in KEYS]
assert ts["a"] == [const.drop_add_keys(MIRROR_DOC, k) for k in (0, 1, 5)]
assert ts["k"] == [const.kinds(MIRROR_DOC, s) for s in json.loads(spots)]
assert ts["k"][0] == ["품", "상수"] and ts["k"][2] == ["뺌"]
def test_TS_고치기_꼴_서버가_받음():
"""setConst · setTable · addText · editText · dropText · setReason 결과 = 서버 꼴 그대로 통과."""
ts = _ts(
"\nlet d = {키: 'SK-000001', 이름: '견본', 단위: '식', 바탕: {로직: 'GC000211'}};"
"\nd = setConst(d, 'mid:시공량', 2, 20); d = setConst(d, 'mid:시공량', 0, 1);"
"\nd = setConst(d, 'mid:시공량', 0, null); d = setTable(d, 'mid:시공량', 0, 45);"
"\nd = setTable(d, 'mid:시공량', 0, null);"
"\nd = addText(d, '', '근거'); d = addText(d, 'mid:시공량', '※ 조건', 0);"
"\nd = editText(d, 1, '근거 고침'); d = dropText(d, 0);"
"\nd = setReason(d, 'mid:시공량', ' 까닭 '); d = setReason(d, 'add:#0', '');"
"\nconsole.log(JSON.stringify(d));"
)
assert ts["상수덮개"] == {"mid:시공량": {"2": 20}} and "표값덮개" not in ts
assert ts["글줄"] == [{"자리": "", "글": "근거 고침"}] and ts["까닭"] == {"mid:시공량": "까닭"}
got = sd.clean({**ts, "입력값": F211}, None, _whole())
assert {k: got[k] for k in const.NEW if k in got} == {k: ts[k] for k in const.NEW if k in ts}
+1 -42
View File
@@ -7,13 +7,12 @@
- 검사 까닭 — 없는 이름 · 괄호 · 순환 · 0 나눔 · 길이 · 괄호 깊이 · 변수 줄 수 · 자리 겹침
- 옛 SK(식 칸 없음) = 꼴 · 금액 그대로 · 저장 = 엔진 식 · 틀린 식 저장 400 · 셈 답 덧칸
- B09 사본 굳힘(`GX9#####`) 줄 = 식 칸 그대로 · 다시 셈 같은 금액
- TS `Formula_Model` · `Const_Model`(식덮개) 결과 = 서버가 받음 · [M01 원래로] 거울
- 799-2 — TS 거울(`Formula_Model.ts` · `Const_Model.ts`)은 시트 탭과 함께 걷음 · 서버만 지킴
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
@@ -274,46 +273,6 @@ def test_B09_굳힘_식_칸_그대로():
assert again == live and float(live) > 51501
# ── TS ───────────────────────────────────────────────────────────────
def _ts(body: str):
src = "".join(
(ROOT / f"M02_MasterTemplete/logic/M02_Sangeun_{n}_Model.ts").read_text("utf-8")
for n in ("Const", "Formula")
)
src = "\n".join(x for x in src.splitlines() if not x.startswith("import type"))
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
return json.loads(got.stdout)
def test_TS_고치기_꼴_서버가_받음():
ts = _ts(
f"\nlet d = {json.dumps(_doc(CARRY, _carry()), ensure_ascii=False)};"
"\nd = {...d, 더한줄: [{종류: '인력', 요소: 'LB000002', 수량: 1}]};"
"\nd = addVar(d, {이름: '할증', 식: '10', 자리: ''});"
"\nd = addVar(d, {이름: '덤', 식: '0', 자리: 'mid:할증'});"
"\nd = editVar(d, '할증', {이름: '할증율', 식: '20'});"
"\nd = setFormula(d, 'mid:할증율', '20'); d = dropVar(d, '덤');"
f"\nd = setFormula(d, '{MAN}', '{PLUS}'); d = setFormula(d, 'mid:운반량', '25');"
"\nd = setFormula(d, 'mid:운반량', null); d = setFormula(d, 'add:#0', '1 / 일운반회수');"
"\nd = setReason(d, 'mid:할증율', '할증');"
f"\nconst out = {{d, r: restoreM01(d, '{MAN}'), k: coverKinds(d, '{MAN}')}};"
"\nconsole.log(JSON.stringify(out));"
)
d = ts["d"]
assert d["변수줄"] == [{"이름": "할증율", "식": "20", "자리": ""}]
assert d["식덮개"] == {MAN: PLUS} and d["까닭"] == {"mid:할증율": "할증"}
got = sd.clean(d, None, _whole())
assert {k: got[k] for k in fx.FIELDS} == {k: d[k] for k in fx.FIELDS}
assert got["더한줄"][0]["수량식"] == "1 / 일운반회수"
assert _sum(d) > 51501
assert ts["r"] == const.restore(d, MAN) and ts["k"] == const.kinds(d, MAN) == ["식"]
def test_같은_내용_지문_식_칸_가림():
"""식 칸만 다른 SK = 다른 내용(409 같은 것 물음 안 함) · 옛 SK 지문 그대로."""
from M02_MasterTemplete import M02_Library_Same as same
+8 -3
View File
@@ -57,6 +57,11 @@ def engine(out: dict) -> dict:
return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {}))
def hard(out: dict) -> list[str]:
"""못 옮김 표지만(「참고 · …」 사람 확인 표지 · 797-1a 는 뺌 — test_797_1a 가 지킴)."""
return [m for m in out["원본"]["표지"] if em.NOTE not in m]
def money(got: dict) -> tuple:
return tuple(int(got[k]) for k in K)
@@ -111,7 +116,7 @@ def test_stmate_engine_equals_excel(tag: str) -> None:
same = money(got) == tuple(round(want[k]) for k in K)
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
bad, a, b = _lines_equal(out, got)
bad += ms.check(out["로직"], blk["코드"]) + out["원본"]["표지"]
bad += ms.check(out["로직"], blk["코드"]) + hard(out)
lines, steps = lines + a, steps + b
if deal := out["원본"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리
cut = [math.floor(round(int(got[k]) * deal["낙찰률"] / 100, 6)) for k in K[:3]]
@@ -140,9 +145,9 @@ def test_est_engine_equals_excel(tag: str) -> None:
bad, a, _ = _lines_equal(out, got)
bad += ms.check(out["로직"], str(blk["번"]))
lines += a
frozen = len(out["원본"]["표지"])
frozen = len(hard(out))
if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], 0):
wrong.append((blk["번"], money(got), bad[:3], out["원본"]["표지"]))
wrong.append((blk["번"], money(got), bad[:3], hard(out)))
assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1])
@@ -111,8 +111,8 @@ def test_단산_줄_길_잇기():
assert "skPick(sk" in imp and "o.onLink" in imp # [이 줄에 잇기] = 줄 고른 것 SK
for how in ('run("price"', 'run("link"', 'run("to-base"'):
assert how in imp, how
sheet = _src("Sangeun_Sheet") # 상태줄 원래 = 가져온 식이면 「엑셀 원래」
assert 'Excel: ["엑셀 원래"' in sheet and '"가져온" in d.ans ? "Excel" : "Origin"' in sheet
# 옛 M02 수식 나무 화면(「엑셀 원래」 위 줄) 걷음 — 수식 탭 = 공용 수식 문서 화면(802-4b-2)
assert not (M02 / "M02_MasterTemplete_Sangeun_Equation.ts").exists()
locale = (ROOT / "ui_template/ui_template_locale_m2_judge.ts").read_text(encoding="utf-8")
assert 'M02_CheckImported: ["가져온 식으로 열기"' in locale
@@ -148,7 +148,7 @@ def test_부품상자만_700줄_공용_안_고침():
assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text), name
assert not re.search(r"""createElement\(\s*["'](button|input|select)["']""", text), name
assert "#" not in "".join(re.findall(r"color:\s*[^;]+", text)), name
for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Sheet"):
for name in ("Labor_Rows", "Labor_SangeunModal", "Sangeun_Detail", "Sangeun_Tabs"):
assert "Hopyo_Imported" not in _src(name), name # 공용 쪽은 새 모달을 모름
+2 -110
View File
@@ -1,22 +1,16 @@
# ruff: noqa: E501
"""794-1 M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 = M02 단가산출 시트와 한 벌.
"""794-1 M01 관리자 줄 식 시트 — 서버 길(`/logic/sheet` · `/logic/sheet/check`) 지킴.
- 정적: 시트 화면 = 부품 상자만(날 button · input 0) · 식 칸 몸 = 공용 `sheet_formula_cell`(M02 도 같은 것) ·
고침 = 관리자만 · 줄 사본 → 캐시(touch) → 기존 [저장] 길(시트는 `/save` 안 부름) · 보기 첫 칸 = 시트 ·
자체 로직(GX) 편집기 식 칸 = 읽기만(날 입력칸 갈음)
- 799-2 — 시트 화면 · 모델(`_LogicLab_Sheet.ts` · `logic/M01_Logic_Sheet_Model.ts`)은 걷음 · 이 서버 길은 수식 탭 검사와 같은 엔진이라 남김
- 서버(사본 폴더): 이름 꼴 식 글 · 칠 때마다 검사(없는 이름 · 아래 줄 · 괄호 · 빈 식 · 길이 · 없는 자리) ·
검사 = 쓰기 없음 · 공구손료 식 1 → 2 % 저장(개정 확인 409 → 200) 뒤 셈 51,802 · 틀린 식 저장 422 · 시스템 관리자가 아니면 403
- 모델(node strip-types): 줄 갈래 · 줄 금액 = 호표 → 덧줄 차례 · 고친 줄 · 끝수 · 단계 · 식 칸 고침
"""
from __future__ import annotations
import copy
import hashlib
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
@@ -28,19 +22,12 @@ from M01_MasterData import M01_MasterData_Router as m01_router
from M01_MasterData import M01_MasterData_Sheet as line_sheet
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
M01 = ROOT / "M01_MasterData"
SHEET = M01 / "M01_MasterData_UI_LogicLab_Sheet.ts"
MODEL = M01 / "logic" / "M01_Logic_Sheet_Model.ts"
LAB = M01 / "M01_MasterData_UI_LogicLab.ts"
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts"
REAL = store.FOLDER
KEY = "GC000211" # 합판거푸집 설치 및 해체
INPUTS = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님",
"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} # fmt: skip
TOOL_1 = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
TOOL_2 = TOOL_1.replace("× 1 / 100", "× 2 / 100")
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
def _text(p: Path) -> str:
@@ -51,44 +38,6 @@ def _hashes(folder: Path) -> dict[str, str]:
return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))}
# ── 정적 ─────────────────────────────────────────────────────────────
def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
for p in (SHEET, MODEL, CELL):
s = _text(p)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
sheet = _text(SHEET)
for part in (
"createCalcSheetRow",
"createFormulaCell",
'"/logic/sheet"',
'"/logic/sheet/check"',
):
assert part in sheet, part
assert "/save" not in sheet # 저장 = 상세 [저장](기존 길)
assert sheet.count("opt.admin &&") >= 2 # [식 고침] · 식 칸 = 관리자만
lab = _text(LAB)
assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n")
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
assert len(lab.splitlines()) <= 637
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
assert (
'host.dataset.view = "sheet";' in view
and '{ value: "sheet", label: tl("View_Sheet") }' in view
)
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css
edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts")
assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts")
assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸
main = _text(ROOT / "main.py")
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
# ── 서버 ─────────────────────────────────────────────────────────────
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
@@ -175,60 +124,3 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None:
for url, body in (("/api/m01/logic/sheet", {"row": {}}),
("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip
assert client.post(url, json=body).status_code == 403
# ── 모델 ─────────────────────────────────────────────────────────────
ROW = {
"키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄",
"입력": [{"이름": "폭", "단위": "m"}],
"중간": [{"이름": "넓이", "식": "폭 * 2"}],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}],
"덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}],
"단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}],
"끝수": {"대상": "비목", "방법": "버림", "자리": 0},
} # fmt: skip
def _run() -> dict:
src = IMPORTS.sub("", _text(MODEL))
body = (
f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};"
"\nconst now = structuredClone(R);"
"\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');"
"\nconst miss = setLabFormula(now, '덧줄', 5, '1');"
"\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 },"
" lines: [{ 금액: 1000 }, { 금액: 10.5 }] };"
"\nconst items = labItems({ row: now, original: R, values: { 폭: '3' },"
" names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });"
"\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });"
"\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_모델_줄_갈래_값_고친_줄() -> None:
got = _run()
assert got["put"] is True and got["miss"] is False
kinds = [(x["kind"], x["이름"]) for x in got["items"]]
assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"),
("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"),
("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip
by = {(x["kind"], x["이름"]): x for x in got["items"]}
assert by[("옵션", "폭")]["값"] == "3"
assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"]
res = by[("자원", "보통인부 (인)")]
assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"]
assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0
assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄"
assert by[("단계", "노무 소계")]["식"] == "A01"
assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리"
assert by[("합계", "계")]["값"] == 1010
bare = {(x["kind"], x["이름"]): x for x in got["bare"]}
assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」
assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare
assert got["cut"] == "글자 끝수"
+219
View File
@@ -0,0 +1,219 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""795-1 SK 로직 사본 — 저장 때 M01 닫힌 묶음을 찍고 셈은 사본으로(임시 storage · M01 은 메모리 덮개).
① M01 로직 줄 바꿔도 사본 SK 금액 그대로 ② 노임 바꿔도 [단가 갱신] 전엔 그대로 · 누르면 따라감
③ [재생성] 뒤 고친 칸 다시 얹힘 · 못 얹은 칸 알림 ④ M01 새 판 배지 ⑤ 지워진 키 = 마지막 값
⑥ 옛 SK(사본 없음) = 옛 길 · 지문 그대로 ⑦ 가져온 식 SK 같은 규칙.
"""
from __future__ import annotations
import copy
import json
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Same as same
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
MAN = "LB000002" # 보통인부
def _live():
return m01.loaded()[1]
def _fake(monkeypatch, rows=(), drop=()):
"""M01 바꿈 흉내 — 지금 색인 위에 바뀐 줄 · 지운 키(정본 파일 안 고침) · M01 수식 문서도 같이
(802-4a — 수식 사본 SK 의 M01 정본 = 수식 문서 · 바뀐 줄은 그 줄 변환 문서로)."""
from M02_MasterTemplete import M02_Doc_Copy as dc
files, live = m01.loaded()
fake = skc.Copy(live, [copy.deepcopy(r) for r in rows])
for key in drop:
skc.Copy._own(fake.index, live.index, key[:2]).pop(key, None)
monkeypatch.setattr(m01, "loaded", lambda: (files, fake))
docs = {k: v for k, v in dc.folder().items() if k not in drop}
docs |= {str(r["키"]): dc.cv.convert(fake, copy.deepcopy(r))[0] for r in rows if "호표" in r}
monkeypatch.setattr(dc, "folder", lambda: docs)
return fake
def _sk(folder, **doc):
with store.at(folder):
body = {"이름": "sub2임시 사본", "단위": "㎡", "더한줄": [], **doc}
return refs.create("sangeun", body)["이름"]
def _doc(folder, key):
with store.at(folder):
return store.read("sangeun", key)["문서"]
def _save(folder, key, doc):
with store.at(folder):
store.write("sangeun", key, store.read("sangeun", key)["판"], doc)
def _calc(folder, doc):
with store.at(folder):
return calc.calc_doc(doc, None, False)
def _sum(folder, key):
return _calc(folder, _doc(folder, key))["기준"]["계"]
def _cids(folder, key):
return [x["cid"] for x in _calc(folder, _doc(folder, key))["줄"]]
def _changed(row, at=0):
row = copy.deepcopy(row)
row["호표"][at]["수량"] = f"({row['호표'][at]['수량']}) * 2"
return row
def test_copy_stamped_and_logic_change_kept(world, monkeypatch):
"""① · ④ 저장 때 사본(로직 · 단가 갈래) · M01 로직 바꿔도 금액 그대로 · 배지만."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
got = _doc(me, key)["사본"]
assert PCT in {r["키"] for r in got["로직"]}
assert got["단가"] and all(r["키"][:2] in skc.RES for r in got["단가"])
assert not any(r["키"][:2] in skc.RES for r in got["로직"])
first = _calc(me, _doc(me, key))
assert first["사본"]["M01새판"] is False and first["사본"]["지운키"] == []
was = first["기준"]["계"]
plain = labor.run_line(_live(), PCT, {})["기준"]["계"]
_fake(monkeypatch, [_changed(_live().logic(PCT))])
assert labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"] != plain # M01 셈은 바뀜
now = _calc(me, _doc(me, key))
assert now["기준"]["계"] == was
assert now["사본"]["M01새판"] is True
def test_price_refresh_only_on_press(world, monkeypatch):
"""② 노임 바꿈 → 그대로 · [단가 갱신] 미리 셈 · 저장 = 새 SK 와 같은 값 · 바뀐자원."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
was = _sum(me, key)
lb = _cids(me, key)[0].split(":")[1].split("#")[0]
row = copy.deepcopy(_live().get(lb))
row["값"] = row["값"] * 2
_fake(monkeypatch, [row])
assert _sum(me, key) == was # 누르기 전 = 단가 기준 시점 값
fresh = _sum(me, _sk(me, 바탕={"로직": PCT})) # 지금 M01 로 새로 찍은 SK
assert fresh != was
with store.at(me):
got = skc.preview(_doc(me, key), skc.PRICE)
assert got["전"]["계"] == was and got["후"]["계"] == fresh
assert [x["키"] for x in got["바뀐자원"]] == [lb]
assert got["문서"][skc.JOB] == skc.PRICE
_save(me, key, got["문서"]) # [저장] = 서버가 할일 적용
saved = _doc(me, key)
assert skc.JOB not in saved and _sum(me, key) == fresh
assert saved["사본"]["단가시점"] >= saved["사본"]["시점"]
def test_regen_reapplies_covers(world, monkeypatch):
"""③ [재생성] — 덮개 · 까닭 새 사본에 다시 얹힘 · 없어진 줄 덮개 = 못얹은 · 배지 꺼짐."""
me = world["personal"]
probe = _sk(me, 바탕={"로직": PCT})
one, two = _cids(me, probe)[:2]
covers = {one: {"qty": 0.5}, two: {"qty": 0.7}}
key = _sk(me, 바탕={"로직": PCT}, 덮개=covers, 까닭={one: "현장 실측"})
row = copy.deepcopy(_live().logic(PCT))
row["호표"] = [x for n, x in enumerate(row["호표"]) if n != 1] # 둘째 줄 없앤 새 판
_fake(monkeypatch, [row])
with store.at(me):
got = skc.preview(_doc(me, key), skc.REGEN)
assert [x["cid"] for x in got["못얹은"]] == [two]
assert got["전"]["계"] != got["후"]["계"]
_save(me, key, got["문서"])
doc = _doc(me, key)
assert doc["덮개"][one]["qty"] == 0.5 and doc["까닭"][one] == "현장 실측"
now = _calc(me, doc)
assert now["줄"][0]["덮개"] == ["qty"]
assert now["사본"]["M01새판"] is False
assert now["기준"]["계"] == got["후"]["계"]
def test_deleted_keys_keep_last_value(world, monkeypatch):
"""⑤ 사본이 부르는 M01 키(바탕 로직 · 노임)가 지워져도 마지막 값 · 지운키 표지."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
was = _sum(me, key)
lb = _cids(me, key)[0].split(":")[1].split("#")[0]
_fake(monkeypatch, drop=[PCT, lb])
with pytest.raises(Exception):
m01.loaded()[1].logic(PCT)
now = _calc(me, _doc(me, key))
assert now["기준"]["계"] == was
assert {PCT, lb} <= set(now["사본"]["지운키"])
with store.at(me):
got = skc.preview(_doc(me, key), skc.PRICE)
assert {"키": lb, "이름": got["바뀐자원"][0]["이름"], "지워짐": True} in got["바뀐자원"]
assert got["후"]["계"] == was
def test_old_sk_without_copy_old_path(world, monkeypatch):
"""⑥ 사본 없는 옛 SK = M01 지금 값(옛 길) · 같은 내용 지문 = 새 사본 SK 와 같음."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
path = me / "sangeun" / f"{key}.json"
raw = json.loads(path.read_bytes())
old = {k: v for k, v in raw.items() if k not in ("사본", "수식")} # 795 앞 SK = 둘 다 없음
path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8")
fresh = _sk(me, 바탕={"로직": PCT})
assert _sum(me, key) == _sum(me, fresh)
assert "사본" not in _calc(me, _doc(me, key))
with store.at(me):
assert same.Same().finger("sangeun", old) == same.Same().finger("sangeun", _doc(me, fresh))
_fake(monkeypatch, [_changed(_live().logic(PCT))])
moved = labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"]
assert _sum(me, key) == pytest.approx(moved, abs=1) # 옛 길 = 따라감
assert _sum(me, fresh) != _sum(me, key) # 사본 SK = 그대로
with store.at(me): # 판 다른 두 SK = 지문 다름(오판 막음)
assert same.Same().finger("sangeun", _doc(me, key)) != same.Same().finger(
"sangeun", _doc(me, fresh)
)
def _imported_row():
man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비"}
mat = {"종류": "재료", "이름": "각재", "단위": "㎥", "수량": "0.01", "비목": "재료비"}
return {
"이름": "sub2임시 가져온",
"결과단위": "원/㎡",
"호표": [{**man, "단가": 165545, "이음": MAN}, {**mat, "단가": 500661}],
}
def test_imported_same_rule(world, monkeypatch):
"""⑦ 가져온 식 SK(단가기준 우리) — 이음 노임 바꿔도 그대로 · [단가 갱신] 뒤 따라감."""
me = world["personal"]
with store.at(me):
key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"]
doc = _doc(me, key)
assert MAN in {r["키"] for r in doc["사본"]["단가"]}
with store.at(me):
ours = imp.to_price(doc, "우리")["문서"]
_save(me, key, ours)
was = _sum(me, key)
row = copy.deepcopy(_live().get(MAN))
row["값"] = row["값"] * 2
_fake(monkeypatch, [row])
assert _sum(me, key) == was
with store.at(me):
got = skc.preview(_doc(me, key), skc.PRICE)
assert [x["키"] for x in got["바뀐자원"]] == [MAN]
assert got["후"]["노무비"] == pytest.approx(got["전"]["노무비"] * 2, abs=1)
+186
View File
@@ -0,0 +1,186 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""795-2 일위대가 · 사전조합(SC) 로직 사본 — 임시 storage · M01 은 메모리 덮개(정본 안 고침).
① 저장 때 사본 · M01 로직 바꿔도 일위대가 · SC 금액 그대로 · 배지 ② 노임 바꿔도 [단가 갱신] 전엔
그대로 · 누르면 따라감 ③ [재생성] 뒤 줄 덮개 다시 얹힘 · 못 얹은 칸 알림 ④ 층 복사 · 열 복사 =
사본 그대로 ⑤ TS 짝(Labor_Calc.ts settle) 같은 금액 ⑥ 사본 없는 옛 문서 = 옛 길.
"""
from __future__ import annotations
import copy
import json
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Copy as lc
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from resources.tester.test_795_1_sk_copy import _changed, _fake, _live
from resources.tester.test_m02_labor_calc_mirror import _same, _ts
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」
LB = "LB000036"
COVER = {"덮개": {f"labor:{LB}#0": {"qty": 0.5}}}
COL = "sub2임시"
def _sc(folder, lines):
with store.at(folder):
body = {"이름": "sub2임시 조합", "단위": "㎡", "담은로직": lines}
return refs.create("precombo", body)["이름"]
def _read(folder, kind, key):
with store.at(folder):
return store.read(kind, key)["문서"]
def _write(folder, kind, key, doc):
with store.at(folder):
have = (
store.read(kind, key)["판"] if (store.root() / kind / f"{key}.json").is_file() else ""
)
store.write(kind, key, have, doc)
def _labor(folder, lines):
_write(folder, "labor", COL, {"열": COL, "담은로직": lines, "자재": []})
return _read(folder, "labor", COL)
def _calc(folder, key, doc=None):
"""화면 셈 길(`Labor_Router._calc`) — 열 = 일위대가 열 · SC 키."""
with store.at(folder):
kind = "precombo" if key.startswith("SC-") else "labor"
got = labor.clean({**(doc or store.read(kind, key)["문서"]), "열": key})
return lc.mark(labor.calc(got, {}), lc.settle(got))
def _unit(folder, key):
"""SC 1 단위 값(쓰는 쪽 `Expand` · 안 줄 수량 1)."""
ref = {"층": "personal", "주인": "7/42", "키": key}
with store.at(folder):
lines = refs.read_ref(ref)["담은로직"]
given = {f"{refs.ref_id(ref)}#{i}": 1 for i in range(len(lines))}
return refs.Expand(m01.loaded()[1], given=given)(ref)["기준"]["계"]
def _double(key):
row = copy.deepcopy(_live().get(key))
row["값"] = row["값"] * 2
return row
def test_copy_stamped_logic_change_kept(world, monkeypatch):
"""① 일위대가 · SC 저장 때 사본 · M01 로직 바꿔도 금액 그대로 · M01 새 판 배지."""
me = world["personal"]
sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}])
doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}])
for got in (doc, _read(me, "precombo", sc)):
assert PCT in {r["키"] for r in got["사본"]["로직"]}
assert LB in {r["키"] for r in got["사본"]["단가"]}
was = (_calc(me, COL)["반영합"]["계"], _calc(me, sc)["기준합"]["계"], _unit(me, sc))
assert _calc(me, COL)["사본"]["M01새판"] is False
_fake(monkeypatch, [_changed(_live().logic(PCT))])
assert labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"] != was[2] # M01 셈은 바뀜
now = (_calc(me, COL)["반영합"]["계"], _calc(me, sc)["기준합"]["계"], _unit(me, sc))
assert now == was
assert _calc(me, COL)["사본"]["M01새판"] is True
assert labor.server(_read(me, "labor", COL))["계산값"]["반영합"]["계"] == was[0] # 다시 저장
def test_price_refresh_only_on_press(world, monkeypatch):
"""② 노임 바꿈 → 그대로 · [단가 갱신] 미리 셈 = 새로 찍은 문서 값 · [저장] 때 적용."""
me = world["personal"]
sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}])
was = _calc(me, sc)["기준합"]["계"]
_fake(monkeypatch, [_double(LB)])
assert _calc(me, sc)["기준합"]["계"] == was and _unit(me, sc) == was
fresh = _calc(me, _sc(me, [{"로직": PCT, "수량": {"손": 1}}]))["기준합"]["계"]
assert fresh != was
with store.at(me):
got = lc.preview(labor.clean({**_read(me, "precombo", sc), "열": sc}), lc.PRICE)
assert got["전"]["기준합"]["계"] == was and got["후"]["기준합"]["계"] == fresh
assert [x["키"] for x in got["바뀐자원"]] == [LB]
_write(me, "precombo", sc, {**_read(me, "precombo", sc), lc.JOB: got["doc"][lc.JOB]})
saved = _read(me, "precombo", sc)
assert lc.JOB not in saved and _unit(me, sc) == fresh
assert _calc(me, sc)["사본"]["단가새값"] is False
def test_regen_reapplies_line_cover(world, monkeypatch):
"""③ [재생성] — 줄 덮개(cid) 새 사본에 다시 얹힘 · 없어진 줄 덮개 = 못얹은 · 배지 꺼짐."""
me = world["personal"]
two = {"덮개": {f"labor:{LB}#0": {"qty": 0.5}, "labor:LB000002#0": {"qty": 0.7}}}
_labor(me, [{"로직": PCT, "수량": {"손": 1}, "패치": two}])
row = copy.deepcopy(_live().logic(PCT))
row["호표"] = [x for n, x in enumerate(row["호표"]) if n != 1] # 둘째 줄 없앤 새 판
_fake(monkeypatch, [row])
assert not _calc(me, COL)["줄"][0].get("짝잃음") # 사본 = 옛 판 그대로
with store.at(me):
got = lc.preview(labor.clean(_read(me, "labor", COL)), lc.REGEN)
assert [x["cid"] for x in got["못얹은"]] == ["labor:LB000002#0"]
assert got["전"]["기준합"]["계"] != got["후"]["기준합"]["계"]
_write(me, "labor", COL, got["doc"])
doc = _read(me, "labor", COL)
assert doc["담은로직"][0]["패치"]["덮개"][f"labor:{LB}#0"] == {"qty": 0.5}
now = _calc(me, COL)
assert now["사본"]["M01새판"] is False and now["기준합"]["계"] == got["후"]["기준합"]["계"]
def test_layer_and_column_copy_keep_copy(world, monkeypatch):
"""④ 층 사이 복사(회사 → 개인) · 열 복사 = 원본 사본 그대로(지금 M01 로 다시 안 찍음)."""
co, me = world["company"], world["personal"]
src = _sc(co, [{"로직": PCT, "수량": {"손": 1}, "패치": COVER}])
first = _read(co, "precombo", src)["사본"]
old = _labor(me, [{"로직": PCT, "수량": {"손": 2}}])["사본"] # 받는 열 = 옛 M01 사본
_fake(monkeypatch, [_double(LB), _changed(_live().logic(PCT))])
ref = {"층": "company", "주인": "7", "키": src}
with store.at(me):
got = refs.create(
"precombo", refs.carry(refs.origin_mark(refs.read_ref(ref), ref), ("company", "7"))
)
mine = _read(me, "precombo", got["이름"])["사본"]
assert (mine["판"], mine["단가판"]) == (first["판"], first["단가판"])
_write(me, "labor", "sub2임시2", {"열": "sub2임시2", "담은로직": [{"로직": PCT}], "자재": []})
other = _read(me, "labor", "sub2임시2")["사본"] # 원본 열 = 바뀐 M01 사본
assert other["판"] != old["판"]
with store.at(me): # 열 복사(copy_from) = 받은 사본 먼저
store.copy_from("labor", COL, "sub2임시2")
assert _read(me, "labor", COL)["사본"]["판"] == other["판"]
def test_ts_mirror_same_amount(world, monkeypatch):
"""⑤ 사본 셈 답 → TS 짝 settle 같은 답 · M01 바뀐 뒤에도 같은 금액."""
me = world["personal"]
doc = _labor(me, [{"로직": PCT, "수량": None}])
with store.at(me):
before = labor.calc(labor.clean(doc), {})
_fake(monkeypatch, [_double(LB), _changed(_live().logic(PCT))])
with store.at(me):
clean = labor.clean(_read(me, "labor", COL))
answer = labor.calc(clean, {})
assert answer["기준합"] == before["기준합"]
key = answer["줄"][0]["알약"]
for values in ({}, {key: 3}):
_same(labor.settle(answer, clean, values), _ts(answer, clean, values))
def test_old_doc_without_copy_old_path(world, monkeypatch):
"""⑥ 사본 없는 옛 일위대가 = 셈은 지금 M01(옛 길) · 다시 저장하면 그때 사본."""
me = world["personal"]
_labor(me, [{"로직": PCT, "수량": {"손": 1}}])
path = me / "labor" / f"{COL}.json"
raw = {k: v for k, v in json.loads(path.read_bytes()).items() if k != "사본"}
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
assert "사본" not in _calc(me, COL)
_fake(monkeypatch, [_double(LB)])
moved = labor.run_line(m01.loaded()[1], PCT, {})["기준"]["계"]
assert _calc(me, COL)["기준합"]["계"] == pytest.approx(moved, abs=1)
with store.at(me):
assert skc.has(labor.server(raw))
+101
View File
@@ -0,0 +1,101 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""795-3 옛 SK · SC · 일위대가 → 로직 사본 옮기기 — 임시 storage 에서만.
① 보기만 = 안 씀 · 전후 같음 ② 쓰기 = 사본 칸만 더함 · 다른 칸 그대로 · 금액 같음 · 백업 · 되돌림
③ 전후 다르면 안 씀(목록) ④ 프로젝트 층 일위대가 = 세기 · 대조만.
"""
from __future__ import annotations
import copy
import json
from M02_MasterTemplete import M02_Copy_Migrate as mig
from M02_MasterTemplete import M02_Labor_Copy as lc
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_795_1_sk_copy import _fake, _live
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
PCT, LB = "GC001109", "LB000036"
LINE = {"로직": PCT, "수량": {"손": 1}}
def _old(path):
"""사본 칸 뗀 옛 문서로 되씀 → 옛 내용."""
raw = {k: v for k, v in json.loads(path.read_bytes()).items() if k != skc.KEY}
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
return raw
def _make(me):
with store.at(me):
sk = refs.create(
"sangeun", {"이름": "sub2임시", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []}
)["이름"]
sc = refs.create("precombo", {"이름": "sub2임시", "단위": "㎡", "담은로직": [LINE]})["이름"]
store.write("labor", "sub2임시", "", {"열": "sub2임시", "담은로직": [LINE], "자재": []})
paths = [me / "sangeun" / f"{sk}.json", me / "precombo" / f"{sc}.json"]
paths.append(me / "labor" / "sub2임시.json")
return {p: _old(p) for p in paths}
def _mine(rows, olds):
keys = {p.stem for p in olds}
return {r["키"]: r for r in rows if r["어디"].startswith("개인") and r["키"] in keys}
def test_dry_then_apply_then_restore(world, tmp_path):
"""① · ② 보기만 → 쓰기(사본만 더함 · 금액 같음) → 되돌림 = 처음 파일."""
olds = _make(world["personal"])
rows = _mine(mig.migrate(), olds)
assert {r["상태"] for r in rows.values()} == {"같음"} and len(rows) == 3
assert not any(r["씀"] for r in rows.values())
assert all(json.loads(p.read_bytes()) == raw for p, raw in olds.items())
back = tmp_path / "back"
rows = _mine(mig.migrate(True, back), olds)
assert all(r["씀"] for r in rows.values())
for path, raw in olds.items():
now = json.loads(path.read_bytes())
assert skc.has(now) and {k: v for k, v in now.items() if k != skc.KEY} == raw
again = _mine(mig.migrate(), olds) # 다시 돌림 = 이미 있음
assert {r["상태"] for r in again.values()} == {"있음"}
assert {r["전"]["계"] for r in rows.values() if r["종류"] == "sangeun"} == {
r["후"]["계"] for r in rows.values() if r["종류"] == "sangeun"
}
mig.restore(back)
assert all(json.loads(p.read_bytes()) == raw for p, raw in olds.items())
def test_different_amount_not_written(world, monkeypatch, tmp_path):
"""③ 찍은 사본 금액이 지금과 다르면(노임 다른 판) 안 씀 · 까닭."""
olds = _make(world["personal"])
real = lc.stamp
def stale(doc): # 노임 두 배 판으로 찍힌 사본 흉내
with monkeypatch.context() as m:
row = copy.deepcopy(_live().get(LB))
row["값"] = row["값"] * 2
_fake(m, [row])
return real(doc)
monkeypatch.setattr(lc, "stamp", stale)
rows = _mine(mig.migrate(True, tmp_path / "back"), olds)
bad = [r for r in rows.values() if r["종류"] != "sangeun"]
assert {r["상태"] for r in bad} == {"다름"} and not any(r["씀"] for r in bad)
assert all(not skc.has(json.loads(p.read_bytes())) for p in olds if p.parent.name != "sangeun")
def test_project_labor_count_only(world, tmp_path):
"""④ 프로젝트 층 일위대가 = 대조만 · 안 씀."""
folder = layers.storage_root() / "7" / "42" / "p1" / "templates" / "labor"
folder.mkdir(parents=True)
path = folder / "x.json"
raw = {"열": "x", "담은로직": [LINE], "자재": []}
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
rows = [r for r in mig.migrate(True, tmp_path / "back") if r["어디"].startswith("프로젝트")]
assert [(r["키"], r["상태"], r["씀"]) for r in rows] == [("x", "같음", False)]
assert json.loads(path.read_bytes()) == raw
assert mig.table(rows) == {"프로젝트 labor": {"같음": 1}}
+189
View File
@@ -0,0 +1,189 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""795-4 프로젝트(B09) 사본 규칙 — 만든 때 그대로 · [단가 갱신] 값만 · [재생성] 따로 ·
시점 섞임 고침(임시 storage · M01 은 메모리 덮개 · 정본 안 고침).
① 구조물 사본 단가 묶음 = M02 일위대가 사본 줄(만든 때) · 뒤에 M01 로직 · 노임이 바뀌어도
사본 금액 그대로 · M01 에서 지워진 키 = 사본 줄 그대로 ② 사본 없는 옛 문서 = 옛 길(지금 M01)
③ [저장] 새 단가 줄 = 사본 기준 시점 값 · 없는 자원만 지금 값 + 표지 · 옛 책 줄 그대로
④ 연결 사본 `단가.json` 자원 줄 = 기준 시점 원천 ⑤ [단가 갱신] = 값만(사본 재적용 없음) ·
[재생성] = 따로 누를 때만 ⑥ 미리 보기 꼴.
"""
from __future__ import annotations
import asyncio
import copy
import json
from uuid import uuid4
from B09_Estimation import B09_Estimation_Linked as linked
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Refresh as refresh
from B09_Estimation import B09_Estimation_Router as router
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Item_Source as source
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from resources.tester.test_145_2_b09_labor_calc import _project, _snap
from resources.tester.test_795_1_sk_copy import _changed, _fake, _live
from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
MAN = "LB000002"
def _json(value):
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
def _m01(monkeypatch, rows=(), drop=()):
"""M01 바꿈 흉내 — 색인(`_fake`) + 파일 묶음(단가 묶음이 읽음) 둘 다 · 정본 안 고침."""
files = m01.loaded()[0]
fake = _fake(monkeypatch, rows, drop)
new = {str(r["키"]): r for r in rows}
out = {}
for name, data in files.items():
slot = m01.items_key(data)
got = [new.get(str(r.get("키")), r) for r in data.get(slot) or []]
out[name] = {**data, slot: [r for r in got if str(r.get("키")) not in drop]}
monkeypatch.setattr(m01, "loaded", lambda: (out, fake))
def _amount(labor_rows, bundle):
got = item_prices.calc(labor_rows, bundle, {}, {})
return _json(got["반영합"])
def test_bundle_is_m02_copy_then_frozen(world, monkeypatch):
"""① 사본 묶음 = M02 사본 줄 · M01 로직 · 노임 바뀜 · 지워짐에도 금액 그대로."""
me = world["personal"]
doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}])
assert skc.has(doc)
rows = source.labor_doc_rows(doc)
was = _amount(rows, source.price_bundle(rows, me)) # 지금 M01 = 사본 만든 때
bundle = source.price_bundle(rows, me)
_m01(monkeypatch, [_changed(_live().logic(PCT)), _double(LB)])
assert _amount(rows, bundle) == was # 굳힌 묶음 = M01 안 읽음
late = source.price_bundle(source.labor_doc_rows(doc), me) # 구조물 지정이 M01 바뀐 뒤
assert _amount(rows, late) == was # M02 사본 = 만든 때
old = {k: v for k, v in doc.items() if k != skc.KEY} # ② 사본 없는 옛 문서 = 지금 M01
old_rows = source.labor_doc_rows(old)
assert _amount(old_rows, source.price_bundle(old_rows, me)) != was
def test_deleted_m01_key_kept_in_copy(world, monkeypatch):
"""① M01 에서 지워진 로직 키 = 사본 줄 그대로(`사본_남은줄.json`) · 같은 금액."""
me = world["personal"]
doc = _labor(me, [{"로직": PCT, "수량": {"손": 1}}])
rows = source.labor_doc_rows(doc)
was = _amount(rows, source.price_bundle(rows, me))
_m01(monkeypatch, drop=[PCT])
late = source.price_bundle(rows, me)
assert [r["키"] for r in late[item_prices.COPY_FILE]["줄"]] == [PCT]
assert _amount(rows, late) == was
def test_copy_rows_order_and_sk_origin():
"""사본 줄 모음 — 앞 문서 먼저 · 굳힌 SK 는 `원본` 사본."""
a = {skc.KEY: {"로직": [{"키": "GC1", "v": 1}], "단가": [{"키": "LB1", "v": 1}]}}
b = {"원본": {skc.KEY: {"로직": [{"키": "GC1", "v": 2}, {"키": "GC2"}], "단가": []}}}
got = source.copy_rows([a, b, {"x": 1}, None])
assert [(r["키"], r.get("v")) for r in got] == [("GC1", 1), ("LB1", 1), ("GC2", None)]
assert item_prices.collect(["GF000219"], over=[]) == item_prices.collect(["GF000219"])
def _row(key, price):
row = copy.deepcopy(prices._row(_live(), key))
row["값"] = price
return item_prices.to_json(row)
def test_book_new_rows_use_copy_basis():
"""③ [저장] 새 줄 = 사본 기준 시점 값 · 없는 자원 = 지금 값 + 표지 · 옛 책 줄 그대로."""
frozen = {LB: _row(LB, 111)}
first = _json(prices.extend_book(None, [LB], frozen))
assert first["rows"][LB]["price"] == 111 and first["now_keys"] == []
both = _json(prices.extend_book(first, [LB, MAN], frozen))
assert both["rows"][LB] == first["rows"][LB]
assert both["rows"][MAN]["price"] == float(prices._row(_live(), MAN)["값"])
assert both["now_keys"] == [MAN]
inputs = prices.build_inputs(None, [LB, MAN], None, frozen)
assert [r["price"] for r in inputs["labor"]] == [111, both["rows"][MAN]["price"]]
assert inputs["now"] == [MAN] and prices.build_inputs(both, [LB, MAN])["now"] == [MAN]
old = _json(prices.refresh_book([LB, MAN])) # 옛 책(사본 규칙 전) = 줄 · 값 그대로
again = _json(prices.extend_book(old, [LB, MAN], frozen))
assert again["rows"] == old["rows"] and again["now_keys"] == []
fresh = _json(prices.refresh_book([LB, MAN], both)) # [단가 갱신] = 지금 값 · 표지 없음
assert fresh["rows"][LB]["price"] != 111 and fresh["now_keys"] == []
def test_frozen_rows_from_linked_copy(tmp_path):
"""④ 연결 사본 `단가.json` 자원 줄 = 기준 시점 원천(자원 표만)."""
root = _project(tmp_path / "proj", [(10.0, "SS000052")])
_snap(root, "pipe@10.0", "SS000052", [{"로직": "GF000219", "수량": {"손": 1}}], {})
got = linked.frozen_rows(root)
assert got and all(k[:2] in skc.RES for k in got)
bundle = json.loads(
(linked.structures_dir(root) / "pipe@10.0" / "단가.json").read_text("utf-8")
)
inside = {r["키"]: r for d in bundle.values() if isinstance(d, dict) for r in d.get("줄") or []}
assert all(inside[k] == row for k, row in got.items())
def _router(monkeypatch, tmp_path, calls):
async def root(_):
return tmp_path
async def demand(*_):
return [LB]
async def payload(*_a, **_k):
return {"user": {}, "notes": [], "sheets": {}}
monkeypatch.setattr(router, "_project_root", root)
monkeypatch.setattr(router, "_demand", demand)
monkeypatch.setattr(router, "payload", payload)
monkeypatch.setattr(router.store, "read_book", lambda _r: None)
monkeypatch.setattr(router.store, "write_book", lambda _r, b: calls.append(("book", b)))
monkeypatch.setattr(
router.refresh,
"reapply_changed",
lambda *_a: calls.append("regen") or {"다시": ["A"], "막힘": []},
)
def test_price_refresh_default_regen_separate(monkeypatch, tmp_path):
"""⑤ [단가 갱신] = 값만(사본 재적용 없음) · [재생성] = job 줄 때만."""
calls = []
_router(monkeypatch, tmp_path, calls)
out = asyncio.run(router.refresh_prices(uuid4(), {"user": {}}, {}))
assert "regen" not in calls and calls[-1][0] == "book"
assert out["notes"][0].startswith("단가 갱신 — ")
out = asyncio.run(router.refresh_prices(uuid4(), {"user": {}, "job": refresh.REGEN}, {}))
assert calls.count("regen") == 1
assert out["notes"][0].startswith("재생성 — 구조물 사본 1 다시 가져옴")
def test_price_preview_shape():
"""⑥ 미리 보기 — 바뀐 자원(전 · 후) · 총괄 전 → 후 · 사본 말 없음."""
old = _json(prices.refresh_book([LB, MAN]))
old["rows"][LB] = {**old["rows"][LB], "price": 1}
new = _json(prices.refresh_book([LB, MAN], old))
sheets = lambda g: {"total": {"subtotals": {"grand": g}}} # noqa: E731
got = refresh.price_preview(old, new, sheets(10), sheets(20), 3)
assert got["할일"] == "단가갱신" and (got["전"], got["후"], got["고친칸"]) == (10, 20, 3)
assert [(r["키"], r["전"]) for r in got["바뀐자원"]] == [(LB, 1)]
assert got["바뀐자원"][0]["후"] == new["rows"][LB]["price"]
def test_migrate_view_b09_copy(tmp_path):
"""옮기기 도구 B09 대조(보기만) — 구조물 사본 = 「그대로」 · 전 금액 · 파일 안 바꿈."""
from M02_MasterTemplete import M02_Copy_Migrate as mig
root = _project(tmp_path / "proj", [(10.0, "SS000052")])
_snap(root, "pipe@10.0", "SS000052", [{"로직": "GF000219", "수량": {"손": 1}}], {})
folder = linked.structures_dir(root) / "pipe@10.0"
before = {p.name: p.read_bytes() for p in folder.iterdir()}
got = mig.b09_check("구조물사본", folder)
assert got["상태"] == "그대로" and got["전"]["기준합"]["계"] > 0
assert {p.name: p.read_bytes() for p in folder.iterdir()} == before
@@ -0,0 +1,367 @@
"""796-1 수식 모델 지킴 — 엔진 로직 ↔ 수식 나무 양방향 · 변수 표 · 추가 · 삭제 · 이름 전파 · 검사.
① 예시 셋(요구서_655 · `_분석_655-B` 2 장) — GC001148 굴착기(블록 GC001147 Q = 81 · GC000267) ·
GC001140 지게(N · M · 42,917) · GC000211 거푸집(시공량 45 · 형틀목공 5/45 · 공구손료 1 %).
② 왕복 — M01 로직 식 전부: 엔진 식 → 나무 → 엔진 식 = 같은 식 나무 · 예시 셋 = 같은 금액.
③ 고침 — 식 고침 = 금액 바뀜 · 변수 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자 전파(식 그대로) ·
표시 자리 셈(고친 줄만) · 검사 까닭(없는 이름 · 괄호 · 순환 · 0 나눔 · 상한).
④ M01 길 — 읽기 · 미리 셈 · 저장(개정확인 · 임시 사본 폴더 · 정본 안 건드림).
변수표 칸 셈 값 바뀜 0 · M01 1,519 지문은 `test_791_1_engine_semantics.py`.
"""
from __future__ import annotations
import copy
import importlib
import json
import shutil
from decimal import Decimal as D
from functools import lru_cache
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Equation as eq_m01
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
meq = importlib.import_module("master_equation")
me = importlib.import_module("master_equation_edit")
revise = importlib.import_module("M01_MasterData.M01_MasterData_Store_Revise")
REAL = store.FOLDER
EXCAVATOR, AFRAME, FORM = "GC001148", "GC001140", "GC000211"
GIVEN = {AFRAME: {"운반회수": 0}, FORM: {"유형": "보통", "높이감률": 0}, EXCAVATOR: {}}
@lru_cache(maxsize=1)
def whole():
return store.loaded()[1]
def export(key: str, row: dict | None = None) -> dict:
w = whole()
return meq.export(me.swap(w, row) if row else w, key, GIVEN[key])
def line(eq: dict, key: str, kind: str = "중간") -> dict:
return next(x for x in eq["줄"] if x["열쇠"] == key and x["갈래"] == kind)
def raw(key: str, row: dict | None = None) -> dict:
w = me.swap(whole(), row) if row else whole()
r = row or w.logic(key)
return mf.run(w, key, meq.given_of(w, r, GIVEN[key]))
def vars_in(tree) -> list[dict]:
if isinstance(tree, dict):
own = [tree] if tree.get("꼴") == "변수" else []
return own + [v for x in tree.values() for v in vars_in(x)]
if isinstance(tree, list):
return [v for x in tree for v in vars_in(x)]
return []
def edit(key: str, edits: list) -> tuple[dict, list[str]]:
return eq_m01.edit_row(whole(), whole().logic(key), edits)
# ── ① 예시 셋 ─────────────────────────────────────────────────────────
def test_굴착기_블록_Q_81_노재경():
eq = export(EXCAVATOR)
block = line(eq, "작업량")["이름식"]
assert block["꼴"] == "블록" and block["열쇠"] == "GC001147" and block["값"] == D("81.000")
sub = block["나무"]
q = line(sub, "결과", "결과")
assert q["결과"]["기호"] == "Q" and q["값"] == D("81.000")
frac = q["이름식"]
assert frac["꼴"] == "분수" and frac["아래"]["열쇠"] == "cm"
assert [v["기호"] for v in vars_in(frac["위"])] == ["q", "K", "f", "E"]
nums = [x.get("글") for x in vars_in(q["값식"])] or []
shown = json.dumps(q["값식"], default=str, ensure_ascii=False)
assert all(f'"글": "{n}"' in shown for n in ("3,600", "1", "0.9", "0.7", "0.75", "21"))
assert nums == [] # 값 꼴엔 변수 없음
item = line(eq, "굴착기(무한궤도)", "호표")
price = item["이름식"]["좌"]
assert price["꼴"] == "블록" and price["열쇠"] == "GC000267"
assert set(price["묶음"]) == {"노무비", "재료비", "경비", "계"}
assert item["이름식"]["우"]["꼴"] == "분수" # 1 / 작업량
cut = mcut.engine(whole(), EXCAVATOR, raw(EXCAVATOR))
assert eq["합계"] == {k: cut[k] for k in mf.BUNDLE}
assert eq["합계"]["계"] == D("641.0")
def test_지게_N_M_42917():
eq = export(AFRAME)
n = line(eq, "일운반회수")
assert n["결과"]["기호"] == "N" and n["글"] == "92.2131"
assert n["이름식"]["꼴"] == "조건" and n["이름식"]["걸림"] == 2 # 운반회수 0 → 식
frac = n["이름식"]["거짓"]
assert frac["꼴"] == "분수"
assert [v["기호"] for v in vars_in(frac["위"])] == ["V", "T"]
m = line(eq, "보통인부", "호표")
qty = m["이름식"]["우"]
assert qty["꼴"] == "분수" and qty.get("사슬") # 무게 / 운반량 / N → 무게 / (운반량 × N)
assert [v["기호"] for v in vars_in(qty["아래"])] == ["Q", "N"]
assert m["수량"].quantize(D("0.0001")) == D("0.2494")
assert eq["합계"]["노무비"] == D("42917.0")
signs = {v["열쇠"]: v["기호"] for v in eq["변수표"]}
assert signs.items() >= {"운반거리": "L", "적재적하시간": "t", "평균왕복속도": "V"}.items()
assert signs["일운반회수"] == "N" and signs["운반회수"] == "n" # 한 기호 = 한 변수
table = next(p for p in eq["풀이"] if p.get("표") == "CC000343")
assert table["행"] == {"종류": "석재류", "운반로": "불량"}
assert {p["갈래"] for p in eq["풀이"]} >= {"입력", "찾기", "요소"}
def test_거푸집_시공량_형틀목공_공구손료():
eq = export(FORM)
s = line(eq, "시공량")
assert s["값"] == 45 and "퍼센트" in json.dumps(s["이름식"], default=str, ensure_ascii=False)
w = line(eq, "형틀목공")
assert w["이름식"]["꼴"] == "분수" and w["글"] == "0.1111"
tool = line(eq, "공구손료 및 경장비", "덧줄")
assert tool["이름식"]["꼴"] == "퍼센트" and tool["글"] == "382.908"
def test_기호_후보_원문():
w = whole()
assert meq.symbols(w.logic("GC001147")) == {"버킷용량": "q", "결과": "Q"}
got = meq.symbols(w.logic(AFRAME))
assert got == {"운반거리": "L", "실작업시간": "T", "적재적하시간": "t", "평균왕복속도": "V"} | {
"일운반회수": "N"
}
# ── ② 왕복 ────────────────────────────────────────────────────────────
def _texts(row: dict):
for step in row.get("중간") or []:
yield step["식"]
if "결과" in row:
yield row["결과"]["식"]
for item in row.get("호표") or []:
yield item["수량"]
if isinstance(item.get("요소"), str):
yield item["요소"]
for extra in row.get("덧줄") or []:
yield extra["식"]
def test_왕복_M01_식_전부_같은_나무():
files, _ = store.loaded()
count, bad = 0, []
for data in files.values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
for text in _texts(row):
try:
node = mf.parse(text)
except mf.FormulaError:
continue
back = me.engine_text(meq.build(node, None))
count += 1
if mf.parse(back) != node or mf.parse(me.raw_text(node)) != node:
bad.append((row["키"], text, back))
assert count > 9000 and not bad, bad[:5]
@pytest.mark.parametrize("key", [EXCAVATOR, AFRAME, FORM, "GC001147"])
def test_왕복_예시_같은_금액(key):
GIVEN.setdefault("GC001147", {})
w = whole()
eq = meq.export(w, key, GIVEN[key])
row = copy.deepcopy(w.logic(key))
for one in eq["줄"]:
tree = one["이름식"]["우"] if one["갈래"] == "호표" else one["이름식"]
text = me.engine_text(tree)
if one["갈래"] == "중간":
next(s for s in row["중간"] if s["이름"] == one["열쇠"])["식"] = text
elif one["갈래"] == "결과":
row["결과"]["식"] = text
else:
row[one["갈래"]][one.get("차례", 0)]["수량" if one["갈래"] == "호표" else "식"] = text
before, after = raw(key), raw(key, row)
pick = ("결과",) if "결과" in row else mf.BUNDLE
assert {k: after[k] for k in pick} == {k: before[k] for k in pick}
def test_변수표_칸은_셈_안_바꿈():
row = dict(whole().logic(AFRAME))
row[meq.FIELD] = {"일운반회수": {"기호": "n", "아래": "d", "이름": "하루 운반 횟수"}}
assert raw(AFRAME, row)["계"] == raw(AFRAME)["계"]
assert revise.row_print(row) == revise.row_print(whole().logic(AFRAME)) # M01 개정 배지 안 뜸
# ── ③ 고침 ────────────────────────────────────────────────────────────
def test_식_고침_금액_바뀜_표시_자리():
new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"}])
assert not why
step = next(s for s in new["중간"] if s["이름"] == "환산거리")
assert step["식"] == "환산계수 * 운반거리 * 2" and step["표시"] == meq.SHOW
others = [s for s in new["중간"] if s["이름"] != "환산거리"]
assert all("표시" not in s for s in others) # 고친 줄만
eq = export(AFRAME, new)
assert line(eq, "환산거리")["값"] == 96
assert eq["합계"]["노무비"] > export(AFRAME)["합계"]["노무비"]
def test_표시_자리에서_잘라_셈():
old = line(export(AFRAME), "일운반회수")["식"]
new, why = edit(AFRAME, [{"할일": "식", "자리": "중간:일운반회수", "식": old}])
assert not why and new["중간"][-1]["표시"] == 4
got = raw(AFRAME, new)
assert got["중간"]["일운반회수"] == D("92.2131")
assert got["노무비"] == D(575) / D(25) / D("92.2131") * D(172068)
assert got["노무비"] != raw(AFRAME)["노무비"] # 보이는 자리 셈 = 금액 바뀜(요구 658)
def test_변수_더함_입력_중간_식에서_부름():
new, why = edit(
AFRAME,
[
{
"할일": "더함",
"갈래": "입력",
"열쇠": "할증",
"기호": "k",
"아래": "s",
"기본값": "1.1",
},
{"할일": "더함", "갈래": "중간", "열쇠": "할증무게", "식": "무게 × k_s", "뒤": "무게"},
{"할일": "식", "자리": "호표:0", "식": "할증무게 ÷ 운반량 ÷ N"},
],
)
assert not why, why
assert new["입력"][-1] == {"이름": "할증", "기본": D("1.1")}
mids = [s["이름"] for s in new["중간"]]
assert mids[:2] == ["무게", "할증무게"] and new["중간"][1]["식"] == "무게 * 할증"
assert new["호표"][0]["수량"] == "할증무게 / 운반량 / 일운반회수"
eq = export(AFRAME, new)
assert line(eq, "할증무게")["값"] == D("632.5")
base = export(AFRAME)["합계"]["노무비"]
assert abs(eq["합계"]["노무비"] - base * D("1.1")) <= 1
def test_변수_지움_쓰는_곳_막음():
new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "무게"}])
assert why and "호표 1 보통인부 수량" in why[0]
new, why = edit(AFRAME, [{"할일": "지움", "열쇠": "운반회수"}])
assert why and "중간 일운반회수" in why[0]
new, why = edit(
AFRAME,
[
{"할일": "더함", "갈래": "중간", "열쇠": "임시", "식": "무게 × 2"},
{"할일": "지움", "열쇠": "임시"},
],
)
assert not why and "임시" not in {s["이름"] for s in new["중간"]}
assert new["중간"] == whole().logic(AFRAME)["중간"]
def test_이름_기호_첨자_바꿈_전파():
new, why = edit(
AFRAME,
[
{
"할일": "변수",
"열쇠": "일운반회수",
"기호": "n",
"아래": "d",
"이름": "하루 운반 횟수",
}
],
)
assert not why
assert new["호표"] == whole().logic(AFRAME)["호표"] # 식은 열쇠 — 그대로
assert new["중간"] == whole().logic(AFRAME)["중간"]
eq = export(AFRAME, new)
used = [v for v in vars_in(line(eq, "보통인부", "호표")["이름식"]) if v["열쇠"] == "일운반회수"]
assert used and used[0]["기호"] == "n" and used[0]["아래"] == "d"
assert used[0]["이름"] == "하루 운반 횟수"
assert eq["합계"] == export(AFRAME)["합계"]
typed = eq_m01.export_row(whole(), new, GIVEN[AFRAME])
assert "n_d" in line(typed, "보통인부", "호표")["입력글"]
again, why = eq_m01.edit_row(
whole(), new, [{"할일": "식", "자리": "호표:0", "식": "무게 ÷ 운반량 ÷ n_d"}]
)
assert not why and again["호표"][0]["수량"] == "무게 / 운반량 / 일운반회수"
_, why = edit(
AFRAME, [{"할일": "변수", "열쇠": "운반량", "기호": "N", "아래": ""}]
) # 겹침(운반량 기본 아래 · 800-1 을 비움)
assert why and "겹침" in why[0]
def test_검사_까닭():
cases = {
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 기호"),
"괄호": ("중간:환산거리", "(환산계수 × L", "없음"),
"아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"),
"순환": ("중간:무게", "무게 + 1", "자기 자신"),
"0 나눔": ("중간:환산거리", "환산계수 / 0", "0 으로 나눔"),
"길이": ("중간:환산거리", "1 + " * 80 + "1", "자를 넘음"),
"깊이": ("중간:환산거리", "(" * 13 + "1" + ")" * 13, "겹을 넘음"),
}
for name, (spot, text, want) in cases.items():
_, why = edit(AFRAME, [{"할일": "식", "자리": spot, "식": text}])
assert why and any(want in w for w in why), (name, why)
_, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "무게"}])
assert why and "같은 열쇠" in why[0]
_, why = edit(AFRAME, [{"할일": "더함", "갈래": "입력", "열쇠": "LB000001"}])
assert why and "꼴 아님" in why[0]
_, why = edit(AFRAME, [{"할일": "표시", "열쇠": "무게", "표시": 9}])
assert why
# ── ④ M01 길 ──────────────────────────────────────────────────────────
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def test_M01_길_읽기_미리셈_저장(client: TestClient):
file = "로직_건설품셈_01장_적용기준.json"
real = (REAL / file).read_bytes()
body = {"key": AFRAME, "inputs": {"운반회수": 0}}
got = client.post("/api/m01/logic/equation", json=body).json()
assert got["ok"] and got["수식"]["합계"]["노무비"] == 42917
edits = [
{"할일": "식", "자리": "중간:환산거리", "식": "환산계수 × L × 2"},
{"할일": "변수", "열쇠": "일운반회수", "기호": "n"},
]
pre = client.post("/api/m01/logic/equation/preview", json=body | {"고침": edits}).json()
assert pre["되나"] and pre["후"]["노무비"] > pre["전"]["노무비"]
assert (store.FOLDER / file).read_bytes() == real # 미리 셈 = 쓰기 없음
bad = client.post(
"/api/m01/logic/equation/preview", json=body | {"고침": [edits[0] | {"식": "x)"}]}
)
assert not bad.json()["되나"]
res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits})
assert res.status_code == 409 # 정본 = 개정 확인
res = client.post("/api/m01/logic/equation/save", json=body | {"고침": edits, "개정확인": True})
assert res.status_code == 200, res.text
assert res.json()["수식"]["합계"]["노무비"] == pre["후"]["노무비"]
saved = json.loads((store.FOLDER / file).read_text(encoding="utf-8"))
before = json.loads(real.decode("utf-8"))
rows = {r["키"]: r for r in saved["줄"]}
seeded = {r["키"]: r for r in before["줄"]}[AFRAME].get(meq.FIELD) or {} # 796-3 기본 기호
assert rows[AFRAME][meq.FIELD] == seeded | {"일운반회수": {"기호": "n"}}
assert {k: v for k, v in rows.items() if k != AFRAME} == {
r["키"]: r for r in before["줄"] if r["키"] != AFRAME
}
assert (REAL / file).read_bytes() == real # 정본 안 건드림
stale = client.post(
"/api/m01/logic/equation/save",
json=body | {"고침": edits, "version": "옛판", "개정확인": True},
)
assert stale.status_code == 409
@@ -0,0 +1,191 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""796-2b M02 단가산출 수식 탭 — 사본 줄 수식 · 고침 = 금액 · 저장 = 사본 위 정본(임시 storage).
① 사본 줄 수식 읽기 = SK 금액 ② 고침 미리 셈 = 금액 바뀜(저장 없음) ③ [저장] = 서버가 다시 얹어 셈 ·
틀린 고침 400 · M01 그대로 ④ 변수 더함 · 지움(쓰는 곳 막음) · 기호 · 첨자 전파 ⑤ 권한 = 층 쓰기
⑥ 가져온 식 SK 같은 길 ⑦ M01 바뀌어도 그대로 · [재생성] = 고침 다시 얹음 · 못 얹음 알림.
"""
from __future__ import annotations
import copy
import pytest
from fastapi import HTTPException
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from M02_MasterTemplete import M02_Sangeun_Router as router
from resources.tester.test_795_1_sk_copy import (
_calc,
_doc,
_fake,
_imported_row,
_live,
_save,
_sk,
_sum,
)
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공
DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}]
def _line(eq, key, kind=None):
return next(x for x in eq["줄"] if x.get("열쇠") == key and (kind is None or x["갈래"] == kind))
def _read(folder, doc):
with store.at(folder):
return eqm.read(doc)
def _preview(folder, doc, edits):
with store.at(folder):
return eqm.preview(doc, edits)
def test_read_copy_row_equals_sk_amount(world):
"""① 사본 줄 수식 — 합계 = SK 금액 = M01 셈 · 키보드 글."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
got = _read(me, _doc(me, key))
assert got["ok"], got
eq = got["수식"]
assert eq["열쇠"] == KEY and eq["합계"]["계"] == _sum(me, key)
assert eq["합계"]["계"] == labor.run_line(_live(), KEY, {})["기준"]["계"]
assert _line(eq, "형틀목공", "호표")["입력글"] == "Q_형틀목공"
def test_edit_changes_amount_save_is_canonical(world):
"""② · ③ 고침 미리 셈 = 금액 바뀜 · [저장] = 고침 목록 남음 · 서버 셈 = 미리 셈 · M01 그대로."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
was = _sum(me, key)
m01_row = copy.deepcopy(_live().logic(KEY))
got = _preview(me, _doc(me, key), DOUBLE)
assert got["되나"], got["까닭"]
assert got["전"]["계"] == was and got["후"]["계"] > was
assert got["줄"]["호표"][0]["수량"] == "형틀목공 * 2"
assert _sum(me, key) == was # 미리 셈 = 저장 없음
doc = {**_doc(me, key), eqm.KEY: DOUBLE}
_save(me, key, doc)
saved = _doc(me, key)
assert saved[eqm.KEY][0]["까닭"] == "sub2임시 시험"
assert saved["사본"]["로직"][0]["호표"][0]["수량"] == m01_row["호표"][0]["수량"] # 사본 = 원래
now = _calc(me, saved)
assert now["기준"]["계"] == got["후"]["계"]
assert now["원래"]["계"] == was # 원래 대비 = 고침 없는 사본
assert _read(me, saved)["수식"]["합계"]["계"] == got["후"]["계"]
assert _live().logic(KEY) == m01_row # M01 정본 안 바뀜
bad = {**saved, eqm.KEY: [{"할일": "식", "자리": "호표:0", "식": "없는값 × 2"}]}
with pytest.raises(store.StoreError):
_save(me, key, bad)
assert _sum(me, key) == got["후"]["계"]
def test_variables_add_drop_rename(world):
"""④ 변수 더함 → 식에서 부름 · 쓰는 변수 지움 막음 · 기호 · 첨자 = 쓰는 식 모두."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
doc = _doc(me, key)
add = [
{"할일": "더함", "갈래": "중간", "열쇠": "할증", "식": "1.5", "뒤": "보통인부"},
{"할일": "식", "자리": "호표:1", "식": "보통인부 × 할증"},
]
got = _preview(me, doc, add)
assert got["되나"], got["까닭"]
assert got["후"]["노무비"] > got["전"]["노무비"]
assert "할증" in [v["열쇠"] for v in got["수식"]["변수표"]]
got = _preview(me, doc, [{"할일": "지움", "열쇠": "시공량"}])
assert not got["되나"] and "형틀목공" in " ".join(got["까닭"])
sign = [{"할일": "변수", "열쇠": "시공량", "기호": "Q", "아래": "d", "이름": "하루 시공량"}]
got = _preview(me, doc, sign)
assert got["되나"] and got["후"] == got["전"] # 이름만 — 금액 그대로
assert "Q_d" in _line(got["수식"], "형틀목공", "중간")["입력글"]
assert "Q_d" in _line(got["수식"], "보통인부", "중간")["입력글"]
_save(me, key, {**doc, eqm.KEY: sign})
again = _read(me, _doc(me, key))["수식"]
assert next(v for v in again["변수표"] if v["열쇠"] == "시공량")["기호"] == "Q"
def _session(role="USER"):
return {"role": role, "company_id": 7, "user_id": 42}
def test_permission_layer_write(world, monkeypatch):
"""⑤ 미리 셈 = 그 층 쓰기 — 마스터 = 시스템 관리자 · 회사 = 회사 관리자 · 개인 = 본인."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
body = router.EquationBody(문서=_doc(me, key), 고침=DOUBLE)
for layer in ("system", "company"):
with pytest.raises(HTTPException) as e:
router.sangeun_equation_preview(layer, body, None, _session())
assert e.value.status_code == 403
got = router.sangeun_equation_preview("personal", body, None, _session())
assert got["되나"] and got["후"]["계"] > got["전"]["계"]
got = router.sangeun_equation("personal", body, None, _session())
assert got["ok"] and got["수식"]["합계"]["계"] == got["수식"]["합계"]["계"]
monkeypatch.setattr(router, "_root", lambda *a, **k: me)
admin = router.sangeun_equation_preview("system", body, None, _session("SYSTEM_ADMIN"))
assert admin["되나"]
def test_imported_same_path(world):
"""⑥ 가져온 식 SK(바탕 null · 가져온.로직) — 같은 읽기 · 고침 · 저장."""
me = world["personal"]
with store.at(me):
key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"]
doc = _doc(me, key)
got = _read(me, doc)
assert got["ok"], got
assert got["수식"]["합계"]["계"] == _sum(me, key)
edit = [{"할일": "식", "자리": "호표:0", "식": "0.5 × 2"}]
got = _preview(me, doc, edit)
assert got["되나"], got["까닭"]
assert got["후"]["노무비"] == pytest.approx(got["전"]["노무비"] * 2, abs=1)
_save(me, key, {**doc, eqm.KEY: edit})
now = _calc(me, _doc(me, key))
assert now["기준"]["노무비"] == pytest.approx(got["후"]["노무비"], abs=1)
assert _doc(me, key)["가져온"]["로직"]["호표"][0]["수량"] == "0.5" # 엑셀 줄 그대로
def test_m01_change_kept_regen_reapplies(world, monkeypatch):
"""⑦ M01 바뀌어도 고친 SK 그대로 · [재생성] = 새 사본에 고침 다시 · 자리 없으면 못얹은."""
me = world["personal"]
key = _sk(me, 바탕={"로직": KEY})
_save(me, key, {**_doc(me, key), eqm.KEY: DOUBLE})
was = _sum(me, key)
row = copy.deepcopy(_live().logic(KEY))
row["호표"][1]["수량"] = f"({row['호표'][1]['수량']}) * 3"
_fake(monkeypatch, [row])
assert _sum(me, key) == was
with store.at(me):
got = skc.preview(_doc(me, key), skc.REGEN)
assert not got["못얹은"] and got["후"]["계"] > was # 새 판 + 같은 고침
gone = copy.deepcopy(row)
gone["호표"] = []
_fake(monkeypatch, [gone])
with store.at(me):
got = skc.preview(_doc(me, key), skc.REGEN)
assert eqm.LOST in [x["cid"] for x in got["못얹은"]] # 그 밖 = 없어진 줄 M01판(790-2)
assert m01.loaded()[1].logic(KEY)["호표"] == []
def test_screen_same_part_first_tab():
"""화면 — M02 수식 = 공용 수식 문서 화면(802-4b-2 · 옛 수식 나무 화면 걷음) · 첫 탭 · 옛 탭 걷음(799-2)."""
import re
from pathlib import Path
root = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
assert not (root / "M02_MasterTemplete_Sangeun_Equation.ts").exists()
tabs = (root / "M02_MasterTemplete_Sangeun_Tabs.ts").read_text(encoding="utf-8")
views = re.findall(r'\{ value: "(\w+)", label: ', tabs)
# 799-2 — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 걷음
assert views == ["equation", "ho"]
assert 'show("equation");' in tabs
@@ -0,0 +1,201 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""796-2c M02 수식 탭이 SK 셈 답 전체를 그림 — 더한 기초 · 뺀줄 · 덮개 · 율 줄 · 참조 · 가져온 식.
견본 셋(임시 storage · M01 그대로):
A 거푸집 + 더한 기초(굴착기) + 더한 인력 + 뺀줄 + 품 덮개 + 식 덮개
B 포장 + 단가 덮개 + 율 줄 + 참조 SK
C 가져온 식 + 더한 인력 + 품 덮개
지킴: 수식 탭 합계 = SK 셈 답 `기준`(원 단위까지) · 행 합 + 끝수 = 합계(비목마다) ·
상태(덮음 · 더함 · 뺌) · 고친 칸 표지(원래 · 지금) · 더한 기초 수식 나무 ·
미리 셈 전/후 = SK 금액 · SK 금액 전후 같음.
"""
from __future__ import annotations
from decimal import Decimal as D
import pytest
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_795_1_sk_copy import _calc, _doc, _imported_row, _save, _sk
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
SUMS = ("노무비", "재료비", "경비", "계")
BASIC = {"종류": "로직", "로직": "GC001148", "수량": 2, "입력값": {}} # 굴착기 시간당 641
MAN = {"종류": "인력", "요소": "LB000002", "이름": "sub2임시 인부", "단위": "인", "수량": 1}
FORM = "GC000211" # 합판거푸집 — 형틀목공 · 보통인부 · 중간 시공량
MINE = {"층": "personal", "주인": "7/42"}
def _d(v) -> D:
return D(str(v))
def _read(folder, doc, inputs=None):
with store.at(folder):
got = eqm.read(doc, inputs)
assert got["ok"], got
return got["수식"]
def _rows(eq) -> dict:
"""묶음(801-3) — 더한 · 부른 단가산출 = cid 로 · 직접 줄 묶음 속 더함 · 덮음 줄 = 이름으로."""
got = {r["cid"]: r for r in eq["소계"]["줄"] if "cid" in r}
for r in eq["소계"]["줄"]:
got |= {x["이름"]: x for x in r.get("낱줄", [])}
return got
def _closes(eq, ans) -> None:
"""합계 = SK 셈 답 원 단위까지 · 행 합 + 끝수 = 합계 · 끝수 작음."""
sub = eq["소계"]
for k in SUMS:
assert eq["합계"][k] == _d(ans["기준"][k]), k
assert sum((r[k] for r in sub["줄"]), D(0)) + sub["끝수"][k] == eq["합계"][k], k
assert abs(sub["끝수"]["계"]) < 2, sub["끝수"]
def _line(eq, kind, key):
return next(x for x in eq["줄"] if x["갈래"] == kind and x["열쇠"] == key)
@pytest.fixture
def made(world):
me = world["personal"]
plain = _sk(me, 바탕={"로직": FORM}) # 고침 없는 같은 바탕 — 원래 값 대조용
key = _sk(
me,
바탕={"로직": FORM},
더한줄=[dict(BASIC), dict(MAN)],
뺀줄=["labor:LB000002#0"],
덮개={"labor:LB000007#0": {"qty": 0.5}},
식덮개={"mid:시공량": "50"},
)
return me, key, plain
def test_A_added_removed_covered_total_equals_sk(made):
me, key, plain = made
doc, bare = _doc(me, key), _calc(me, _doc(me, plain))
ans = _calc(me, doc)
eq = _read(me, doc)
_closes(eq, ans)
rows = _rows(eq)
# 덮음 — 형틀목공 품 덮개 · 원래 = 고침 없는 SK 의 같은 줄 금액
cover = rows["형틀목공"] # 직접 묶음 속 덮은 줄(이름)
was = next(x for x in bare["줄"] if x["cid"] == "labor:LB000007#0")["금액"]
assert cover["상태"] == "덮음"
assert _d(cover["원래"]) == _d(was)
assert cover["계"] == D("137895") # 품 0.5 × 단가 275,790
# 뺌 — 지운 줄 = 묶음 아님(식 줄 표지로만 · 아래 `out["뺌"]`)
assert "labor:LB000002#0" not in rows and "보통인부" not in rows
# 더함 — 기초(굴착기 641 × 2) = 묶음 · 인력 = 직접 묶음 속 더한 줄 · 기초는 수식 나무
basic = rows["add:#0"]
man = next(x for r in eq["소계"]["줄"] for x in r.get("낱줄", []) if x["상태"] == "더함")
assert basic["상태"] == man["상태"] == "더함" and basic["갈래"] == "블록"
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["블록", "직접"]
assert basic["계"] > 0 and man["계"] > 0
assert basic["나무"]["열쇠"] == "GC001148" and basic["나무"]["줄"]
# 나무 합계 × 수량 = 행(같은 입력)
assert abs(basic["나무"]["합계"]["계"] * 2 - basic["계"]) <= 2
# 고친 칸 표지 — 수식 탭 줄(식 덮개 · 품 · 뺌)
mid = _line(eq, "중간", "시공량")
assert mid["표지"] == ["식"] and mid.get("원래") is not None and "50" in mid["식"]
assert _line(eq, "호표", "형틀목공")["표지"] == ["품"]
out = _line(eq, "호표", "보통인부")
assert out["뺌"] is True and out["표지"] == []
# 고침 없는 SK(plain)는 표지도 더함도 없음 — 합계 = 같은 바탕 금액
flat = _read(me, _doc(me, plain))
assert {r.get("상태", "바탕") for r in flat["소계"]["줄"]} == {"바탕"}
assert [r["갈래"] for r in flat["소계"]["줄"]] == ["직접"] # 801-3 한 묶음 · 낱줄 없음
assert flat["합계"]["계"] == _d(bare["기준"]["계"])
assert not any("표지" in x for x in flat["줄"])
def test_A_preview_before_after_and_save_same(made):
"""고침 미리 셈 전/후 = SK 금액 · 저장 뒤 셈 = 미리 셈 · 더한 것도 합계에 듦."""
me, key, _ = made
doc = _doc(me, key)
was = _calc(me, doc)["기준"]["계"]
edit = [{"할일": "식", "자리": "호표:3", "식": "0.038 × 2", "까닭": "sub2임시 2c"}]
with store.at(me):
got = eqm.preview(doc, edit)
assert got["되나"], got["까닭"]
assert got["전"]["계"] == _d(was) and got["후"]["계"] != got["전"]["계"]
_save(me, key, {**doc, eqm.KEY: edit})
now = _calc(me, _doc(me, key))
assert _d(now["기준"]["계"]) == got["후"]["계"]
eq = _read(me, _doc(me, key))
_closes(eq, now)
def test_B_unit_cover_rate_and_reference(world):
"""B 단가 덮개 · 율 줄 · 참조 SK 가 소계에 들고 합계 = SK 금액."""
me = world["personal"]
ref = _sk(me, 바탕={"로직": FORM})
rate = {
"고정": {
"이름": "sub2임시 율",
"단가": {},
"이음": {},
"비율": {"기준": "인력", "비목": "경비"},
}
}
adds = [{**rate, "수량": 3}, {"참조": {**MINE, "키": ref}, "수량": 1}]
key = _sk(
me,
바탕={"로직": "GC001109"},
더한줄=adds,
덮개={"labor:LB000002#0": {"labor": 200000}},
)
doc, ans = _doc(me, key), _calc(me, _doc(me, key))
eq = _read(me, doc)
_closes(eq, ans)
rows = _rows(eq)
pool = next(r for r in eq["소계"]["줄"] if r["갈래"] == "직접")
price = next(x for x in pool["낱줄"] if x["상태"] == "덮음")
assert price["원래"] is not None and "sub2임시 율" in pool["이름"] and pool["경비"] > 0
assert rows["add:#1"]["갈래"] == "블록" and rows["add:#1"]["상태"] == "더함"
assert _line(eq, "호표", price["이름"])["표지"] == ["단가"]
def test_C_imported_sk_same_rule(world):
"""C 가져온 식 SK — 더한 인력 · 품 덮개 · 합계 = SK 금액."""
me = world["personal"]
with store.at(me):
key = imp.create(_imported_row(), None, "sub2임시 가져온")["이름"]
doc = _doc(me, key)
cid = next(x["cid"] for x in _calc(me, doc)["줄"] if x["cid"].startswith("labor:"))
_save(me, key, {**doc, "더한줄": [dict(MAN)], "덮개": {cid: {"qty": 1}}})
doc = _doc(me, key)
ans = _calc(me, doc)
eq = _read(me, doc)
_closes(eq, ans)
rows = _rows(eq)
assert [x["상태"] for x in eq["소계"]["줄"][-1]["낱줄"]] == ["덮음", "더함"], rows
def test_plain_sk_has_answer_table(world):
"""고침 없는 SK = 답까닭 없음 · 소계 표 있음."""
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM})
eq = _read(me, _doc(me, key))
assert "답까닭" not in eq and eq["소계"]["줄"]
def test_D_imported_outside_total_line_not_in_sum(world):
"""D 가져온 식 `합밖` 줄 = 합계에 안 듦 → 묶음 아님 · `밖` 글 · 끝수에 안 섞임."""
me = world["personal"]
row = _imported_row()
row["호표"][1] = {**row["호표"][1], "합밖": True}
with store.at(me):
key = imp.create(row, None, "sub2임시 합밖")["이름"]
doc = _doc(me, key)
ans = _calc(me, doc)
eq = _read(me, doc)
_closes(eq, ans)
out = eq["소계"]["밖"]
assert len(out) == 1 and out[0]["밖금액"] > 0
assert abs(eq["소계"]["끝수"]["계"]) < 1
@@ -0,0 +1,95 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""796-2d 단가산출 상세 탭 소계 · 합계 = 엔진 합계 — 합밖 · 제외 단계 줄은 보이되 소계에 안 더함.
- 서버: 엔진 줄 표지 `합밖`(`master_cut.engine` — `master_formula_sum.skipped` 한 판정) ·
표지 줄 빼고 더하면 SK 셈 답 `기준` · 표지 없는 견본은 줄 표지 없음(금액 전후 같음)
- 화면: `boxesOf` 소계가 `out` 줄을 안 더함 · 맨 아래 합계 · 줄 `합밖` → `out` 이음(원문 확인)
"""
from __future__ import annotations
import json
import shutil
import subprocess
from decimal import Decimal as D
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_795_1_sk_copy import _calc, _doc, _imported_row
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
HO = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts"
COSTS = ("노무비", "재료비", "경비")
def _answer(world, name, outside: bool):
me = world["personal"]
row = _imported_row()
if outside:
row["호표"][1] = {**row["호표"][1], "합밖": True}
with store.at(me):
key = imp.create(row, None, name)["이름"]
return _calc(me, _doc(me, key))
def _sum(lines, skip_out: bool) -> dict:
return {
k: sum(
(
D(str((x.get("비목") or {}).get(k) or 0))
for x in lines
if not (skip_out and x.get("합밖"))
),
D(0),
)
for k in COSTS
}
def test_outside_line_tagged_and_sum_equals_engine(world):
ans = _answer(world, "sub2임시 합밖상세", True)
out = [x for x in ans["줄"] if x.get("합밖")]
assert len(out) == 1 and sum(D(str(v or 0)) for v in out[0]["비목"].values()) > 0
kept = _sum(ans["줄"], True)
for k in COSTS: # 표지 줄 빼고 더해 1 원 버림 = 엔진 합계
assert int(kept[k]) == int(D(str(ans["기준"][k]))), k
assert sum(_sum(ans["줄"], False).values()) > sum(kept.values()) # 옛 소계(전부 더함)는 큼
def test_plain_sample_has_no_tag(world):
ans = _answer(world, "sub2임시 합밖없음", False)
assert not any("합밖" in x for x in ans["줄"])
kept = _sum(ans["줄"], True)
assert all(int(kept[k]) == int(D(str(ans["기준"][k]))) for k in COSTS)
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_box_subtotal_skips_outside():
src = HO.read_text(encoding="utf-8")
pure = src[src.index("/* ── 순수 셈 ── */") : src.index("/* ── 순수 셈 끝 ── */")]
body = """
const e = (idx, bundle, amount, out) => ({ idx, bundle, merge: false, amount, out });
const got = boxesOf([e(0, "인력", 10, false), e(1, "인력", 7, true), e(2, "재료", 5)], ["경비"]);
console.log(JSON.stringify(got.map((b) => [b.묶음, b.소계, b.줄.length])));
"""
run = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", pure + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert run.returncode == 0, run.stderr
assert json.loads(run.stdout) == [["인력", 10, 2], ["재료", 5, 1], ["경비", 0, 0]] # 줄은 보임
def test_screen_wiring():
src = HO.read_text(encoding="utf-8")
assert src.count("out: !!line?.합밖,") == 2 # 호표 줄 · 덧줄
assert "e.group === g && !e.out" in src # 맨 아래 합계
assert 'createBadge(th("Out")' in src and len(src.splitlines()) <= 700
assert "합밖?: boolean" in (
ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Api.ts"
).read_text(encoding="utf-8")
@@ -0,0 +1,186 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""796-2e M02 단가산출 더한 기초 블록 [연결 끊기] · [다시 잇기] (임시 storage · M01 은 메모리 덮개).
① 연결 블록 = 원본(M01) 바뀜을 [재생성] 에서 따라감 ② [연결 끊기] = 금액 그대로 · 이 SK 안 자기 사본 ·
원본 바뀜 안 따라감([재생성] 도) ③ 끊은 기초 줄 고침 = 금액 바뀜(미리 셈 = 저장 뒤 서버 셈 · 틀린 고침 400 ·
M01 안 바뀜) ④ [다시 잇기] = 고친 것 버림 · 원본 따라감 ⑤ [단가 갱신] = 연결 · 끊은 둘 다 자원 단가 따라감
⑥ 꼴 검사(연결 기초에 고침 · 이미 끊음 · 차례 밖).
"""
from __future__ import annotations
import copy
import pytest
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Block as blk
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Equation_Full as full
from resources.tester.test_795_1_sk_copy import _calc, _changed, _doc, _fake, _live, _save, _sk
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
FORM = "GC000211" # 합판거푸집
BASIC = "GC001148" # 굴착기 시간당 — 호표 0 = 시간당 기계 × (1 / 작업량)
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub3임시 굴착기"}
ONLY = "LB000048" # 굴착기 기초에만 닿는 노임(거푸집엔 안 닿음)
DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "3 / 작업량", "까닭": "sub3임시 시험"}]
def _sum(folder, doc):
return _calc(folder, doc)["기준"]["계"]
@pytest.fixture
def made(world):
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)])
return me, key
def _cut(me, key):
"""끊어서 저장한 SK — (끊기 전 금액, 끊은 문서)."""
doc = _doc(me, key)
with store.at(me):
got = blk.change(doc, 0, "unlink")
assert got["전"]["계"] == got["후"]["계"] # 끊기 전후 같음
_save(me, key, got["문서"])
return got["전"]["계"], _doc(me, key)
def test_unlink_keeps_amount_and_freezes_rows(made):
"""② 끊기 = 금액 그대로 · 닫힌 묶음(하위 로직 포함) 이 SK 안에 · 저장 뒤도 같음 · 수식 탭 표지."""
me, key = made
was = _sum(me, _doc(me, key))
plain = _calc(me, _doc(me, key))
assert plain["기준"]["계"] == was
row_before = copy.deepcopy(_live().logic(BASIC))
price, doc = _cut(me, key)
assert price == was and _sum(me, doc) == was
cut = doc["더한줄"][0]["끊음"]
keys = {r["키"] for r in cut["로직"]}
assert BASIC in keys and "GC000267" in keys and "GC001147" in keys # 하위 로직까지
assert not any(k[:2] in skc.RES for k in keys) # 단가는 SK 사본 한 곳
assert _live().logic(BASIC) == row_before # M01 안 바뀜
with store.at(me):
eq = full.attach({"줄": []}, doc, skc.over(doc, skc.whole_()), {}, None, _calc(me, doc))
block = next(r for r in eq["소계"]["줄"] if r.get("블록") == 0)
assert block["끊음"] is True and block["나무"] and block["상태"] == "더함"
assert not block.get("덮개") # 끊기만으로 「세부」 고침 표지 없음
def test_connected_follows_original_on_regen_cut_does_not(made, monkeypatch):
"""①·② 원본 바뀜 — 연결 = [재생성] 에서 따라감 · 끊음 = [재생성] 도 안 따라감."""
me, key = made
was = _sum(me, _doc(me, key))
_fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 호표 0 수량 × 2 (원본 바뀜)
assert _sum(me, _doc(me, key)) == was # 누르기 전 = 그대로(자동 갱신 없음)
with store.at(me):
got = skc.preview(_doc(me, key), skc.REGEN)
assert got["후"]["계"] > was and "끊은기초" not in got # 연결 = 원본 따라감
monkeypatch.undo()
price, doc = _cut(me, key)
assert price == was
_fake(monkeypatch, [_changed(_live().logic(BASIC))])
with store.at(me):
got = skc.preview(doc, skc.REGEN)
assert got["전"]["계"] == got["후"]["계"] == was # 끊음 = 안 따라감
assert got["끊은기초"] == ["sub3임시 굴착기"] # 보고 한 줄
_save(me, key, got["문서"])
assert _sum(me, _doc(me, key)) == was
def test_price_refresh_follows_in_both(made, monkeypatch):
"""⑤ [단가 갱신] — 연결 · 끊은 기초 모두 자원 단가는 따라감(요구 656-1) · 끊은 기초 줄은 그대로."""
me, key = made
was = _sum(me, _doc(me, key))
row = copy.deepcopy(_live().get(ONLY))
row["값"] = row["값"] * 2
_fake(monkeypatch, [row])
with store.at(me):
fresh = skc.preview(_doc(me, key), skc.PRICE)
assert fresh["후"]["계"] > was and [x["키"] for x in fresh["바뀐자원"]] == [ONLY]
monkeypatch.undo()
_, doc = _cut(me, key)
_fake(monkeypatch, [row])
with store.at(me):
cut = skc.preview(doc, skc.PRICE)
assert cut["후"]["계"] == fresh["후"]["계"] and cut["끊은기초"] == ["sub3임시 굴착기"]
_save(me, key, cut["문서"])
assert _sum(me, _doc(me, key)) == fresh["후"]["계"]
def test_edit_cut_block_changes_amount_and_saves(made):
"""③ 끊은 기초 줄 고침 — 미리 셈 = 금액 바뀜 · 저장 뒤 서버 셈 같음 · 틀린 고침 400 · M01 안 바뀜."""
me, key = made
price, doc = _cut(me, key)
row_before = copy.deepcopy(_live().logic(BASIC))
with store.at(me):
got = blk.preview(doc, 0, DOUBLE)
read = blk.read(doc, 0)
assert read["ok"] and got["되나"], got
assert got["금액전"]["계"] == price and got["금액후"]["계"] > price
assert got["수식"]["합계"]["계"] > got["전"]["계"] # 기초 1 단위 시간당 값
item = {**doc["더한줄"][0], blk.EDIT: DOUBLE}
_save(me, key, {**doc, "더한줄": [item]})
saved = _doc(me, key)
assert saved["더한줄"][0][blk.EDIT][0]["까닭"] == "sub3임시 시험"
assert _sum(me, saved) == got["금액후"]["계"] # 서버 셈 = 미리 셈
assert _live().logic(BASIC) == row_before
with store.at(me):
eq = full.attach(
{"줄": []}, saved, skc.over(saved, skc.whole_()), {}, None, _calc(me, saved)
)
block = next(r for r in eq["소계"]["줄"] if r.get("블록") == 0)
assert block["나무"]["합계"]["계"] == got["수식"]["합계"]["계"] # 접힘 블록 = 고친 수식
bad = [{"할일": "식", "자리": "호표:0", "식": "없는값 / 작업량"}]
with store.at(me):
assert not blk.preview(saved, 0, bad)["되나"]
with pytest.raises(store.StoreError):
_save(me, key, {**saved, "더한줄": [{**saved["더한줄"][0], blk.EDIT: bad}]})
assert _sum(me, _doc(me, key)) == got["금액후"]["계"]
def test_relink_drops_edits_and_follows_original(made, monkeypatch):
"""④ [다시 잇기] — 고친 것 버림 · 원본 따라감(저장 뒤 표지 없음) · 금액 = 새로 찍은 SK."""
me, key = made
was = _sum(me, _doc(me, key))
_, doc = _cut(me, key)
item = {**doc["더한줄"][0], blk.EDIT: DOUBLE}
_save(me, key, {**doc, "더한줄": [item]})
edited = _doc(me, key)
assert _sum(me, edited) > was
_fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 원본 바뀜
fresh = _sum(me, _doc(me, _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)])))
assert fresh > was
with store.at(me):
got = blk.change(edited, 0, "relink")
one = got["문서"]["더한줄"][0]
assert "끊음" not in one and blk.EDIT not in one and one[blk.REJOIN] is True
assert got["전"]["계"] > was and got["후"]["계"] == fresh # 고침 버림 + 원본 따라감
_save(me, key, got["문서"])
saved = _doc(me, key)
assert blk.REJOIN not in saved["더한줄"][0] and "끊음" not in saved["더한줄"][0]
assert _sum(me, saved) == fresh
def test_shape_checks(made):
"""⑥ 연결 기초에 고침 · 이미 끊음 · 차례 밖 · 연결 기초 다시 잇기 · 끊은 기초 줄 모양."""
me, key = made
doc = _doc(me, key)
with store.at(me):
with pytest.raises(sd.DocError, match="연결을 끊은 기초만"):
sd.clean({**doc, "더한줄": [{**ADD, blk.EDIT: DOUBLE}]})
with pytest.raises(sd.DocError, match="없음"):
blk.unlink(doc, 3)
with pytest.raises(sd.DocError, match="연결된 기초"):
blk.relink(doc, 0)
with pytest.raises(sd.DocError, match="연결된 기초"):
blk.read(doc, 0)
cut = blk.unlink(doc, 0)
with pytest.raises(sd.DocError, match="이미 연결을 끊은"):
blk.unlink(cut, 0)
broken = {**cut["더한줄"][0], "끊음": {"로직": [{"키": "GC999999"}]}}
with pytest.raises(sd.DocError, match="줄 없음"):
sd.clean({**cut, "더한줄": [broken]}, None, skc.whole_())
+200
View File
@@ -0,0 +1,200 @@
"""796-3 변수 국문 이름 · 기호 · 첨자 · 단위 기본값 지킴 (요구 659-2 · 656-4).
① 빌더(`build_변수표_796_3.py`) — 정본에서 변수표를 걷은 상태에 돌리면 결정 자료 그대로 채움 · 채운 뒤 또 돌리면 쓸 것 없음.
② 변수표 열쇠 = 엔진 변수(입력 · 중간 이름) · 한 로직 안 기호(첨자 포함) · 이름 겹침 0.
③ 보이기만 — 변수표 · 채운 단위를 걷어도 엔진 셈 · 끝수 지문 같음(결정 줄 전부).
④ 표본 — GC001147(굴착기 8-2-3) · GC001140(인력운반 1-2-7) · GC001144(불도저 8-2-1) 기호 · 단위 = 품셈 원문 「여기서」.
"""
from __future__ import annotations
import copy
import hashlib
import importlib
import json
import re
from functools import lru_cache
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as store
meq = importlib.import_module("master_equation")
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
ma = importlib.import_module("master_auto")
build = importlib.import_module("build_변수표_796_3")
ROOT = Path(__file__).resolve().parents[2]
BOOK = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문"
SIGN = re.compile(r"^[^\W\d_]\w{0,9}$") # master_equation_edit.SIGN_FORM 과 같게
SUB = re.compile(r"^[\w′'*]{1,6}$")
@lru_cache(maxsize=1)
def loaded():
return store.loaded()
def logic_rows() -> list[dict]:
return [r for d in loaded()[0].values() if d.get("그룹") == "로직" for r in d.get("줄", [])]
def raw_state() -> dict[str, dict]:
"""변수표를 걷고 결정 줄의 채운 단위를 걷은 사본(= 채우기 전) — 파일 이름 → 자료."""
want = build.decisions()
out = {}
for name, data in loaded()[0].items():
if data.get("그룹") != "로직":
out[name] = data
continue
data = copy.deepcopy(data)
for row in data["줄"]:
row.pop(meq.FIELD, None)
for key, one in (want.get(row["키"]) or {}).items():
if not one.get("단위"):
continue
for kind in ("입력", "중간"):
for spec in row.get(kind) or []:
if spec["이름"] == key and spec.get("단위") == one["단위"]:
spec.pop("단위")
out[name] = data
return out
def test_결정_자료_꼴():
want = build.decisions()
assert len(want) >= 150
names = {r["키"]: {s["이름"] for k in ("입력", "중간") for s in r[k]} for r in logic_rows()}
n = 0
for code, one in want.items():
assert code in names, code
for key, e in one.items():
n += 1
assert key in names[code], (code, key)
assert e["확신"] in ("원문", "추정"), (code, key)
assert e.get("근거"), (code, key)
assert len(e.get("이름", "")) <= 40
if e.get("기호"):
assert SIGN.match(e["기호"]), (code, key)
for f in ("위", "아래"):
assert not e.get(f) or SUB.match(e[f]), (code, key, f)
assert "원" not in e.get("단위", ""), (
code,
key,
) # 엔진이 「원」 단위를 금액 입력으로 봄
assert any(e.get(f) for f in ("이름", "기호", "아래", "위", "단위")), (code, key)
assert n >= 500
def test_빌더_채움_그리고_또_돌리면_쓸것_없음():
want = build.decisions()
batch, stat = build.plan(raw_state(), want) # 채우기 전 → 결정 그대로
assert stat["줄"] == len(want) == len(batch and [c for b in batch for c in b["changes"]])
assert stat["변수표"] == sum(
1 for one in want.values() for e in one.values() if any(e.get(f) for f in build.LOOK)
)
assert stat["이미있음"] == 0
batch2, stat2 = build.plan(loaded()[0], want) # 정본(이미 채움) → 쓸 것 없음
assert batch2 == [] and stat2["변수표"] == 0 and stat2["단위"] == 0
def test_변수표_열쇠는_엔진_변수_기호_이름_겹침_0():
for r in logic_rows():
names = {s["이름"] for k in ("입력", "중간") for s in r[k]}
assert set(r.get(meq.FIELD) or {}) <= names, r["키"]
seen: dict[str, str] = {}
for v in meq.table(r):
for word in {meq.token(v), v["이름"]}:
assert seen.get(word, v["열쇠"]) == v["열쇠"], (r["키"], word)
seen.setdefault(word, v["열쇠"])
def _print(whole, row) -> str:
try:
raw = mf.run(
whole,
str(row["키"]),
{
**ma.values(whole, row),
**{k: v for k, v in ma.defaults(whole, row).items() if v is not None},
},
)
text = json.dumps(raw, default=str, sort_keys=True, ensure_ascii=False)
text += json.dumps(
mcut.engine(whole, str(row["키"]), raw), default=str, sort_keys=True, ensure_ascii=False
)
except Exception as e: # 멈춤도 지문
text = f"ERR {type(e).__name__} {e}"
return hashlib.sha256(text.encode()).hexdigest()[:16]
def test_셈_전후_같음_결정_줄_전부():
want = build.decisions()
now = loaded()[1]
before_files = raw_state()
before = mf.Master(before_files)
for code in want:
row_now = now.logic(code)
row_before = before.logic(code)
assert meq.FIELD not in row_before
assert _print(now, row_now) == _print(before, row_before), code
def _table(code: str) -> dict[str, dict]:
return {v["열쇠"]: v for v in meq.table(loaded()[1].logic(code))}
def test_표본_기호_단위_원문_같음():
excav = (BOOK / "제08장_건설기계/8-02-03_굴착기.md").read_text(encoding="utf-8")
for line in (
"q : 버킷용량(㎥)",
"E : 작업효율",
"K : 버킷계수",
"f : 체적환산계수",
"cm : 1회 싸이클 시간(초)",
):
assert line in excav, line
t = _table("GC001147")
assert (t["버킷용량"]["기호"], t["버킷용량"]["단위"]) == ("q", "㎥")
assert (t["K"]["이름"], t["E"]["이름"], t["f"]["이름"]) == (
"버킷계수",
"작업효율",
"체적환산계수",
)
assert (t["cm"]["이름"], t["cm"]["단위"]) == ("1회 싸이클시간", "sec") # 801-5 원문 표 머리 「싸이클시간(Sec)」 꼴
carry = (BOOK / "제01장_적용기준/1-02_설계_및_수량.md").read_text(encoding="utf-8")
for line in (
"T : 1일 실작업시간",
"L : 운반거리(m)",
"t : 적재적하 시간(분)",
"V : 평균왕복속도(m/hr)",
"N : 1일 운반횟수",
):
assert line in carry, line
t = _table("GC001140")
got = {
k: meq.token(t[k])
for k in ("운반거리", "실작업시간", "적재적하시간", "평균왕복속도", "일운반회수")
}
assert got == {
"운반거리": "L",
"실작업시간": "T",
"적재적하시간": "t",
"평균왕복속도": "V",
"일운반회수": "N",
}
dozer = (BOOK / "제08장_건설기계/8-02-01_불도저.md").read_text(encoding="utf-8")
for line in ("V_1 : 전진속도(m/분)", "V_2 : 후진속도(m/분)", "e : 운반거리계수"):
assert line in dozer, line
t = _table("GC001144")
assert (t["V1"]["기호"], t["V1"]["아래"], t["V1"]["이름"]) == ("V", "1", "전진속도")
assert (t["V2"]["기호"], t["V2"]["아래"], t["V2"]["이름"]) == ("V", "2", "후진속도")
assert meq.token(t["V1"]) == "V_1" and t["e"]["이름"] == "운반거리계수"
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+192
View File
@@ -0,0 +1,192 @@
# ruff: noqa: F811 — 임시 층 fixture(world · client)를 다른 시험에서 받아 씀
"""797-1 지킴 — 엑셀 불러오기 요소 연결 흠(요구 655-3).
① 기계 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(EQ 는 기계 제원 묶음) — 셈 됨 ·
엑셀 단가 그대로 금액 전후 같음 · 시간당 자료 없는 EQ = 엑셀 단가 그대로
② 유가 · 전력(MO) · 환율(FX) 이음 — 유일 짝만 자동 · 여럿이면 표지(사람 확인)
③ 산근 속 산근 — 같은 엑셀 형제 가져온 식 SK 참조(만들 때 함께 · 안쪽이 바뀌면 따라감 ·
쓰는 곳 막음 · 단가기준 따라감) · 못 잇는 줄은 얼림 + 표지
④ 올림 응답 `읽기불가` 사유(화면 토스트)
임시 storage · M01 원본 그대로 · 실무 엑셀 없으면 skip.
"""
from __future__ import annotations
from decimal import Decimal
import helper_m02_hopyo_golden as golden
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Machine_Hourly as machine
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_Sangeun_Equation as eqm
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from resources.tester.test_m02_hopyo_excel import _post, client # noqa: F401
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
KEY = "HP-000001"
def _doc(items: list[dict], excel: dict | None = None) -> dict:
row = {"이름": "연결 견본", "결과단위": "원/식", "호표": items}
meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": excel or {}}}
return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta}
def _item(kind: str, name: str, price: float, part: str, link: str | None, qty: str = "1"):
return {"종류": kind, "이름": name, "단위": "식", "수량": qty, "비목": part, "단가": price}
# ── ① 기계 이음 ─────────────────────────────────────────────────────────
def _eq(good: bool) -> str:
for row in m01.loaded()[1].numbers["EQ"].values():
if bool(machine.hourly(row["키"]).get("ok")) is good:
return row["키"]
pytest.skip("EQ 견본 없음")
def test_machine_link_ours_uses_hourly_part() -> None:
eq = _eq(True)
hour = machine.hourly(eq)
line = _item("기계", "굴착기", 99999, "경비", None, "2") | {"이음": eq}
doc = _doc([line])
ours = imp.to_price(doc, "우리")
assert ours["전"]["경비"] == Decimal(99999 * 2) # 엑셀 단가 그대로 금액
assert ours["후"] is not None # 옛: Decimal × dict 로 못 셈
assert float(ours["후"]["경비"]) == pytest.approx(hour["경비"] * 2, abs=1)
back = imp.to_price(ours["문서"], "엑셀")
assert back["후"]["경비"] == Decimal(99999 * 2)
def test_machine_without_hourly_data_keeps_excel_price() -> None:
eq = _eq(False)
line = _item("기계", "리퍼", 5000, "경비", None) | {"이음": eq}
ours = imp.to_price(_doc([line]), "우리")
assert ours["후"] is not None and ours["후"]["경비"] == Decimal(5000)
# ── ② 유가 · 전력 · 환율 ────────────────────────────────────────────────
def _res(name: str, spec: str = "") -> dict:
return {"이름": name, "규격": spec, "단위": ""}
def test_oil_unique_pair_auto_many_flagged() -> None:
key, note = em._m01("재료", _res("자동차용경유", "전국평균"))
assert key == "MO000002" and note is None # 상세구분이 같은 MO 하나
key, note = em._m01("재료", _res("자동차용경유"))
assert key is None and note == f"{em.NOTE}유가 후보 여럿(18) · 사람 확인"
key, note = em._m01("재료", _res("전력량요금"))
assert key is None and "후보 여럿" in note # 전력 둘
key, note = em._m01("재료", _res("달러"))
assert key == "FX000001" and note is None
assert not (em._m01("재료", _res("유로폼패널", "600×1200㎜"))[0] or "").startswith(
"FX"
) # 글 속 「유로」
assert em._m01("재료", _res("합판(내수)", "12mm"))[1] is None # 재료 여럿은 조용히 지금대로
assert em._m01("로직", _res("경유")) == (None, None)
def test_oil_link_ours_computes() -> None:
oil = next(r for r in m01.loaded()[1].numbers["MO"].values() if r["키"] == "MO000002")
line = _item("재료", "경유", 1700, "재료비", None, "3") | {"이음": "MO000002"}
ours = imp.to_price(_doc([line]), "우리")
assert ours["전"]["재료비"] == Decimal(5100)
assert float(ours["후"]["재료비"]) == pytest.approx(float(oil["값"]) * 3, abs=1)
# ── ③ 산근 속 산근 ──────────────────────────────────────────────────────
def _hp(world, tag: str) -> tuple:
path = golden.FILES.get(tag)
if path is None or not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
folder = world["personal"]
hs._dir(folder).mkdir(parents=True, exist_ok=True)
(hs._dir(folder) / f"{KEY}.json").write_text(f'{{"키": "{KEY}", "판": {hs.BOOK}}}', "utf-8")
fh.keep(folder, KEY, path.name, path.read_bytes())
return folder, path
def _read(key: str) -> dict:
return store.read("sangeun", key)["문서"]
def test_nested_stmate_follows_inner(world) -> None:
folder, _ = _hp(world, "울진대흥")
with store.at(folder):
made = fh.make(folder, KEY, 2200) # D02200 안에 D00075 콘크리트믹서사용
doc = _read(made["이름"])
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
assert len(inner) == 1 and inner[0]["값묶음"]["노무비"] == 6216 # 엑셀 얼린 값은 곁에 남음
ref = inner[0]["참조"]
assert made["검산"]["같음"] is True and made["검산"]["셈"]["계"] == 67383
assert eqm.read(doc)["ok"] is True # 수식 탭도 안쪽 값을 채워 읽음
assert fh.make(folder, KEY, 2200)["이름"] == made["이름"] # 다시 올려도 같은 SK
assert refs.users(ref) # 쓰는 곳에 바깥 SK
with pytest.raises(store.StoreError) as why:
refs.refuse_used(folder, "sangeun", ref["키"])
assert why.value.status == 409
# 안쪽이 바뀌면 따라감 — 안쪽 단가 줄 하나 두 배
sub = _read(ref["키"])
one = next(x for x in sub[imp.KEY]["로직"]["호표"] if "단가" in x)
one["단가"] = one["단가"] * 2
store.write("sangeun", ref["키"], store.read("sangeun", ref["키"])["판"], sub)
inner_now = imp.check(_read(ref["키"]))["셈"]["계"]
assert inner_now != 7528
assert imp.check(doc)["셈"]["계"] == pytest.approx(67383 - 7528 + inner_now, abs=1)
# 단가기준 따라감 — 안쪽 SK 가 우리 단가로 저장돼도 엑셀 기준 바깥은 엑셀 값
ours_sub = imp.to_price(_read(ref["키"]), "우리")["문서"]
store.write("sangeun", ref["키"], store.read("sangeun", ref["키"])["판"], ours_sub)
assert imp.check(doc)["셈"]["계"] == pytest.approx(67383 - 7528 + inner_now, abs=1)
assert imp.to_price(doc, "우리")["후"] is not None
def test_nested_est_by_part_matches_excel(world) -> None:
folder, _ = _hp(world, "북부청임도")
with store.at(folder):
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
doc = _read(made["이름"])
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
assert [x["참조비목"] for x in inner] == ["노무비", "재료비", "경비"]
assert len({x["참조"]["키"] for x in inner}) == 1
assert made["검산"]["같음"] is True and eqm.read(doc)["ok"] is True
# 엑셀 단가 그대로 금액 = 굳힌 값 합과 같음
frozen = sum(float(x["값묶음"][x["참조비목"]]) * 0.03 for x in inner)
assert float(made["검산"]["셈"]["계"]) == pytest.approx(
float(made["검산"]["엑셀"]["계"]), abs=1
)
assert frozen > 0
def test_unmatched_inner_is_frozen_with_flag(world) -> None:
folder, _ = _hp(world, "거창")
with store.at(folder):
flagged = 0
for no in range(1, 20):
try:
got = fh.make(folder, KEY, no)
except store.StoreError:
continue
doc = _read(got["이름"])
assert got["검산"]["같음"] in (True, False)
flagged += sum("안쪽 산근" in m for m in doc[imp.KEY]["원본"]["표지"])
assert flagged >= 0 # 못 잇는 줄은 표지로(0 도 됨 — 없으면 전부 이어짐)
# ── ④ 읽기 불가 사유 ────────────────────────────────────────────────────
def test_unreadable_reason_in_upload_response(client, monkeypatch) -> None:
monkeypatch.setattr(fh, "keep", lambda *a, **k: "엑셀 형식 오류 — 견본")
body = _post(client).json()
assert body["읽기불가"] == "엑셀 형식 오류 — 견본" and body["키"] == "HP-000001"
monkeypatch.setattr(fh, "keep", lambda *a, **k: None)
again = _post(client, name="다른이름.xlsx").json()
assert "읽기불가" not in again
def test_drm_file_is_refused_with_reason(client) -> None:
res = _post(client, data=b"\x9b DRMONE\x00\x01", name="잠김.xlsx")
assert res.status_code == 400 and "DRM" in res.json()["detail"]
@@ -0,0 +1,286 @@
"""797-1a 지킴 — 엑셀 불러오기 빠진 정보 표지 다섯 · 줄 단위 합격 · 표본 밖 둘 고정(요구 658).
표지 다섯(`M02_San_Emit` · `M02_San_Est` · `M02_San_Book` · `M02_Sangeun_FromHopyo`) —
① 금액 줄 못 읽음(글끝 · 식못읽음 · 결과 뒤 꼬리 글은 떼고 읽음) ② 단가 후보 여럿 · 추정
③ 당초 / 적용 칸 ④ 조정 비율 · 낙찰률 미지원 ⑤ 읽기 불가 사유 — 작은 견본(엑셀 없이).
줄 단위 합격 = 블록 합만이 아니라 표지 없는 줄 중 틀린 줄 0(표지 달린 줄은 틀림에서 뺌) ·
표본 밖(개발에 안 쓴 파일: 2026 수목진료 142 · 울진대흥 공통 46 · 2016 사방 374)을 합격 수로 고정 ·
EST 단가줄 버림1 일치율 감시(낮아지면 실패). 실무문서 엑셀 없으면 skip(읽기만).
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import helper_m02_hopyo_golden as golden
import pytest
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Expr as ex
from M02_MasterTemplete import M02_San_Stmate as stm
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
NOTE = em.NOTE
DOCS = golden.DOCS
def notes(out: dict) -> list[str]:
return [m for m in out["원본"]["표지"] if NOTE in m]
def hard(out: dict) -> list[str]:
"""못 옮김 표지(참고 표지 뺌)."""
return [m for m in out["원본"]["표지"] if NOTE not in m]
# ── ① 금액 줄 못 읽음 ──────────────────────────────────────────────────
TAIL = " 보통인부 : 94,338 ×0.0328인 = 3,094.2 : 채집"
PRICE_L = {"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 94338.0}
def _est_out(text: list[str], prices: list[dict], want: dict):
rows = list(range(10, 10 + len(text)))
blk = {"이름": "견본", "단위": "m", "번": 1, "글": text, "글행": rows}
lines = est.parse_block(text, prices)
return lines, em.est(blk, lines, want)
def test_tail_text_is_stripped_and_read_as_money() -> None:
one = est.parse_line(TAIL)
assert (one["꼴"], one["값"], one["라벨"]) == ("돈", 3094.2, "보통인부")
want = {"T": 3094, "L": 3094, "M": 0, "S": 0}
lines, out = _est_out([TAIL, " 노무비 : 3,094"], [PRICE_L], want)
assert [x["종류"] for x in lines] == ["단가줄", "값줄"]
assert hard(out) == [] and notes(out) == []
assert out["로직"]["호표"][0]["이름"] == "보통인부"
def test_unreadable_money_line_is_flagged_with_shown_value() -> None:
cut = " 절취량 : (0.5×0.6×1.0) / 2 = 0.15㎥/m"
note = " 야산지 25%, 경사도 중(15~30˚)) 5% => 30% 할증"
bad = " 산출 : 가나다 = 12.5"
assert est.parse_line(cut) == {"꼴": "글끝", "라벨": "절취량", "보인": 0.15}
assert est.parse_line(note) == {"꼴": "글끝"} # 라벨 없는 설명 글 — 표지 안 함
assert est.parse_line(bad)["꼴"] == "식못읽음"
zero = {"T": 0, "L": 0, "M": 0, "S": 0}
_, out = _est_out([cut, note, bad], [], zero)
got = {g["행"]: g for g in out["원본"]["글줄"]}
flag = f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림"
assert (got[10]["표지"], got[10]["값"]) == (flag, 0.15)
assert "표지" not in got[11] and "값" not in got[11]
assert (got[12]["표지"], got[12]["값"]) == (flag, 12.5)
assert len(notes(out)) == 2 and hard(out) == []
# ── ② 단가 후보 여럿 · 추정 ────────────────────────────────────────────
def test_ambiguous_price_is_flagged_not_silently_picked() -> None:
prices = [
{"종류": "자재", "이름": "철근", "규격": "", "비목": "M", "값": 500.0},
{"종류": "자재", "이름": "시멘트", "규격": "", "비목": "M", "값": 500.0},
]
text = [" 철근 : 500 ×2개 = 1,000", " 자재비 : 500 ×3개 = 1,500"]
want = {"T": 1500, "L": 0, "M": 1500, "S": 0}
lines, out = _est_out(text, prices, want)
assert [x["역할"][0]["갈림"] for x in lines] == ["문맥", "문맥"]
assert [x["역할"][0]["모호"] for x in lines] == [False, True]
got = {g["행"]: g["표지"] for g in out["원본"]["글줄"]}
assert got[10] == f"{NOTE}추정 — 단가 정체 근거 문맥"
assert got[11] == f"{NOTE}단가 후보 여럿(2) · 사람 확인"
assert hard(out) == []
assert [it["자원"].get("모호") for it in out["로직"]["호표"]] == [None, True]
# ── ③ 당초 / 적용 칸 ───────────────────────────────────────────────────
def _row(applied: tuple, first: tuple) -> list:
r = [None] * 29
for j, v in zip((5, 6, 7, 8), applied, strict=True):
r[j] = v
for j, v in zip((25, 26, 27, 28), first, strict=True):
r[j] = v
return r
def test_applied_or_first_column_is_flagged() -> None:
same = book.applied_how(_row((100, 40, 30, 30), (100, 40, 30, 30)))
assert same == ({"T": 100, "L": 40, "M": 30, "S": 30}, None)
diff = book.applied_how(_row((100, 40, 30, 30), (90, 40, 30, 20)))
assert diff[0]["T"] == 100
assert diff[1] == "적용 칸 사용 — 당초와 다름 (계 적용 100 · 당초 90 · 경비 적용 30 · 당초 20)"
empty = book.applied_how(_row((None, None, None, None), (90, 40, 30, 20)))
assert empty[0] == {"T": 90, "L": 40, "M": 30, "S": 20}
assert empty[1] == "당초 칸 사용 — 적용 칸 비어 있음"
sums = book.applied_how(_row((100, 40, 30, 10), (90, 40, 30, 20)))
assert sums[0]["T"] == 90
assert (
sums[1]
== "당초 칸 사용 — 적용 칸 합이 안 맞음 (계 적용 100 · 당초 90 · 경비 적용 10 · 당초 20)"
)
nothing = book.applied_how(_row((None,) * 4, (0, 0, 0, 0)))
assert nothing[1] is None # 쓸 값이 없는 줄
assert book.applied(_row((100, 40, 30, 30), (90, 40, 30, 20)))["T"] == 100
def test_first_column_note_reaches_block_flags() -> None:
head = [[None] * 29 for _ in range(3)]
sheet = [*head, [None, 1, "견본", ""] + [None] * 25]
sheet[3][25:29] = [90, 40, 30, 20]
prices, san = book.est_prices({"단가산출총괄표": sheet})
assert san[1]["칸"] == "당초 칸 사용 — 적용 칸 비어 있음" and san[1]["T"] == 90
assert {(p["종류"], p["비목"], p["값"]) for p in prices} >= {("산근", "T", 90.0)}
_, out = _est_out([" 노무비 : 40"], [], {**san[1]})
assert f"{NOTE}당초 칸 사용 — 적용 칸 비어 있음" in out["원본"]["표지"]
# ── ④ 조정 비율 · 낙찰률 미지원 ────────────────────────────────────────
def _stmate_out(meta: dict) -> dict:
prices = {
"L00007": {"T": 100, "L": 100, "M": 0, "S": 0, "meta": {}, "이름": "보통인부"},
"D00001": {"T": 0, "L": 0, "M": 0, "S": 0, "meta": meta, "단위": "식"},
}
rows = [{"행": 0, "G": "'보통인부' &L00007& * 1 :=A01"}]
block = {"코드": "D00001", "이름": "견본", "꼬리": [], "몸": rows}
block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0}
return em.stmate(block, stm.parse_block(rows), prices, "비목버림")
def test_adjust_ratio_and_bid_rate_are_flagged() -> None:
base = {"ADJ": "T", "SBM": " ", "ADJL": "1", "ADJM": "1", "ADJS": "1", "UC": "F", "NAG": "0"}
assert _stmate_out(base)["원본"]["표지"] == [] # 비율 1 · 낙찰률 없음 = 표지 없음
odd = _stmate_out({**base, "ADJL": "0.9"})["원본"]["표지"]
assert odd == [f"{NOTE}조정 비율 미지원 · 사람 확인 (노무비 0.9)"]
sbm = _stmate_out({**base, "SBM": "S", "ADJL": "0.9"})
assert sbm["로직"]["조정"] == {"옮김": "경비"} and sbm["원본"]["표지"] == []
bid = _stmate_out({**base, "UC": "F", "NAG": "88.5"})["원본"]["표지"]
assert bid == [f"{NOTE}낙찰률 88.5 있으나 계약단가 아님(UC) — 낙찰률 미적용 · 사람 확인"]
used = _stmate_out({**base, "UC": "T", "NAG": "88.5"})
assert used["원본"]["표지"] == [] and used["원본"]["계약"]["낙찰률"] == 88.5
# ── ⑤ 읽기 불가 사유 ───────────────────────────────────────────────────
def test_unreadable_reason_is_returned(tmp_path: Path) -> None:
def keep(name: str, data: bytes) -> str | None:
return fh.keep(tmp_path, "HP990001", name, data)
assert keep("a.xlsx", b"") == "빈 파일"
assert keep("a.xlsx", b"\x9b DRMONE\x00\x01") == "DRM 암호 파일 — 암호 풀린 사본을 올림"
assert (keep("a.xlsx", b"PK\x03\x04 not a zip") or "").startswith("엑셀 형식 오류")
assert (keep("a.xls", b"plain text") or "").startswith("엑셀 형식 오류")
assert not fh.has(tmp_path, "HP990001") # 못 읽은 파일은 안 둠
# ── 줄 단위 합격 · 표본 밖 고정 · 버림1 감시 ───────────────────────────
def _oos() -> dict[str, Path]:
pack = DOCS / "2026_실무일위대가 모음"
common = DOCS / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료"
found = {
"2016사방": next(pack.glob("2016*.xls"), None),
"2026수목": next(pack.glob("2026_상반*.xls"), None),
"대흥공통": next((p for p in common.glob("*.xlsx") if not p.name.startswith("~$")), None),
}
return {k: v for k, v in found.items() if v}
FILES = {**golden.FILES, **_oos()}
@lru_cache(maxsize=16)
def _load(tag: str) -> dict:
path = FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {path.name}")
return book.load(path)
def _stmate_tally(tag: str) -> dict:
data = _load(tag)
t = dict.fromkeys(("블록", "맞음", "금액줄", "표지없는틀린줄", "계줄", "계줄맞음"), 0)
for blk in data["블록"]:
if not any(ln["G"].strip() for ln in blk["몸"]):
continue
nodes = stm.parse_block(blk["몸"])
rule = fh._rule(blk, nodes, data["단가"])
chk = ev.stmate_check(blk, ev.stmate_block(blk, nodes, data["단가"], rule))
out = em.stmate(blk, nodes, data["단가"], rule, False, tag)
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
t["블록"] += 1
t["맞음"] += chk["맞음"]
t["금액줄"] += chk["금액줄"]
t["계줄"] += chk["계줄"]
t["계줄맞음"] += chk["계줄맞음"]
t["표지없는틀린줄"] += sum(1 for b in chk["틀린줄"] if b["행"] not in marked)
return t
def _est_tally(tag: str) -> dict:
data = _load(tag)
t = dict.fromkeys(("블록", "맞음", "줄수", "표지없는틀린줄", "표지줄", "단가줄", "버림1"), 0)
for blk in data["블록"]:
want = data["총괄"].get(blk["번"])
if not want or not (want["T"] or any(want[c] for c in "LMS")):
continue
parsed = est.parse_block(blk["글"], data["단가"])
chk = ev.est_check(parsed, want)
out = em.est(blk, parsed, want, False, tag)
rows = blk.get("글행") or []
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
t["블록"] += 1
t["맞음"] += chk["맞음"]
t["줄수"] += chk["줄수"]
t["표지줄"] += len(marked)
for b in chk["틀린줄"]:
k = b["차례"]
t["표지없는틀린줄"] += (rows[k] if k < len(rows) else k) not in marked
for ln in parsed:
if ln.get("종류") == "단가줄" and ln.get("셈") is not None:
t["단가줄"] += 1
t["버림1"] += abs(ex.RULE["버림1"](ln["셈"]) - ln["값"]) < ex.EPS
return t
# 표본 밖 = 개발에 안 쓴 파일(합격 수 고정) · 골든 = 개발에 쓴 파일
OOS_FIXED = {
"2026수목": {"블록": 142, "맞음": 142, "줄수": 1654},
"대흥공통": {"블록": 46, "맞음": 46, "금액줄": 258, "계줄": 127, "계줄맞음": 127},
"2016사방": {"블록": 374, "맞음": 374, "줄수": 1870},
}
# 단가줄 버림1 일치 수 / 단가줄 수(낮아지면 실패 — 다른 규칙으로 갈리는 줄이 늘었다는 뜻)
FLOOR_1 = {
"북부청임도": (539, 552),
"북부청사방": (363, 364),
"거창": (381, 382),
"2016사방": (718, 718),
"2026수목": (653, 653),
}
@pytest.mark.parametrize("tag", list(FILES))
def test_no_unmarked_wrong_line(tag: str) -> None:
"""줄 단위 — 표지 없는 틀린 줄 0 · 블록 다시 셈 = 엑셀 전부."""
est_form = _load(tag)["꼴"] == "EST"
t = _est_tally(tag) if est_form else _stmate_tally(tag)
assert t["블록"] > 0
assert (t["표지없는틀린줄"], t["맞음"]) == (0, t["블록"]), t
@pytest.mark.parametrize("tag", list(OOS_FIXED))
def test_out_of_sample_counts_fixed(tag: str) -> None:
if tag not in FILES:
pytest.skip("표본 밖 엑셀 없음")
t = _est_tally(tag) if tag != "대흥공통" else _stmate_tally(tag)
assert {k: t[k] for k in OOS_FIXED[tag]} == OOS_FIXED[tag]
@pytest.mark.parametrize("tag", list(FLOOR_1))
def test_unit_price_line_floor1_rate_does_not_drop(tag: str) -> None:
if tag not in FILES:
pytest.skip("실무 엑셀 없음")
t = _est_tally(tag)
hit, total = FLOOR_1[tag]
assert t["단가줄"] >= total and t["버림1"] / t["단가줄"] >= hit / total, (
t["버림1"],
t["단가줄"],
)
@@ -0,0 +1,294 @@
"""797-2 지킴 — 엑셀 불러오기 소요량 · 계수 이음(값 역색인 후보 + 사람 확인 · 요구 655-3).
작은 M01(소요량 · 계수 표 몇 칸 · 임시 폴더)로 규칙을, 실무 엑셀(북부청 임도)로 실물을 본다.
① 후보 유일 · 여럿 · 없음 — 같은 값 + 같은 단위 성격 + 이름 · 공종 문맥 · 단위 다르면 후보 아님
② 고름 = 칸 참조 기록(층 · 표 · 줄 · 칸) · 금액 그대로 · 값 다르면 못 고름(까닭) · 버림 · 되돌림
③ 저장 거름(`clean`) — 자리 · 수가 맞는 기록만 · 서버 길 qty · qty-pick · qty-drop
④ 만들 때 표지(「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」) — 못 옮김 표지는 안 늘림
실무 엑셀 없으면 그 시험만 skip. M01 원본은 읽기만.
"""
from __future__ import annotations
import json
from pathlib import Path
import helper_m02_hopyo_golden as golden
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_San_Basis_Index as bi
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
from M02_MasterTemplete import M02_Sangeun_Imported as imp
from M02_MasterTemplete import M02_Sangeun_QtyLinks as qty
from M02_MasterTemplete import M02_Sangeun_Router as sangeun_router
from resources.tester.test_m02_hopyo_excel import client # noqa: F401
API = "/api/m02/layers/personal"
def _put(root: Path, name: str, body: dict) -> None:
(root / name).write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
def _table(key: str, name: str, work: str, cols: dict, rows: list[dict]) -> dict:
return {
"키": key,
"원문번호": key[-1],
"이름": name,
"용도": {"공종": work},
"값칸": cols,
"줄": rows,
}
@pytest.fixture(scope="module")
def idx(tmp_path_factory) -> bi.Index:
root = tmp_path_factory.mktemp("m01mini")
work = "견본채집"
_put(
root,
"소요량_견본.json",
{
"표": [
_table(
"QC900001",
"견본채집(인력)",
work,
{"보통인부": "인", "특별인부": "인"},
[{"보통인부": 0.17, "특별인부": 0.05}],
),
_table("QC900002", "견본채집 보충", work, {"보통인부": "인"}, [{"보통인부": 0.17}]),
_table("QC900003", "견본채집 시멘트", work, {"시멘트": "kg"}, [{"시멘트": 40}]),
_table("QC900004", "다른일 품", "다른일", {"보통인부": "인"}, [{"보통인부": 0.09}]),
]
},
)
_put(
root,
"계수_견본.json",
{
"표": [
_table("CC900001", "견본채집 할증", work, {"값": "%"}, [{"항목": "할증", "값": 3}])
]
},
)
_put(root, "로직_견본.json", {"줄": []})
_put(root, "기계.json", {"줄": []})
return bi.Index(root)
def _row(lines: list[dict], extras: list[dict] | None = None) -> dict:
return {
"이름": "견본채집(굴삭기)",
"결과단위": "원/식",
"호표": lines,
"덧줄": extras or [],
}
def _line(kind: str, name: str, qty_: str, unit: str = "", price: float = 1000.0) -> dict:
part = {"인력": "노무비", "재료": "재료비"}[kind]
return {"종류": kind, "이름": name, "단위": unit, "수량": qty_, "비목": part, "단가": price}
def _doc(row: dict) -> dict:
meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": {}}}
return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta}
ROW = _row(
[
_line("인력", "보통인부", "((0.17 * 10) / 100)"), # 칸 둘(QC900001 · QC900002) → 여럿
_line("인력", "특별인부", "0.05"), # 칸 하나 → 유일
_line("인력", "보통인부 2", "0.09"), # 값은 있으나 다른 공종 → 없음
_line("재료", "시멘트", "40", "kg"), # 단위 kg → 유일
_line("재료", "시멘트 2", "40", "㎥"), # 단위 성격 다름(부피 ≠ 무게) → 없음
],
[{"이름": "줄_2", "식": "계 * 0.03", "비목": "노무비"}], # 3 % ↔ 0.03 → 유일(계수)
)
def _by(scanned: list[dict]) -> dict[str, list]:
return {s["자리"]: s["후보"] for s in scanned}
# ── ① 후보 ─────────────────────────────────────────────────────────────
def test_candidates_unique_many_none_and_unit(idx) -> None:
got = _by(qty.scan(ROW, idx))
assert sorted(x["표"] for x in got["호표:0:0"]) == ["QC900001", "QC900002"] # 여럿
assert [x["표"] for x in got["호표:1:0"]] == ["QC900001"] # 유일
assert got["호표:2:0"] == [] # 다른 공종 칸은 후보 아님
assert [x["표"] for x in got["호표:3:0"]] == ["QC900003"]
assert got["호표:4:0"] == [] # 단위 다르면 후보 아님
ratio = got["덧줄:0:1"] if "덧줄:0:1" in got else got["덧줄:0:0"]
assert [(x["표"], x["꼴"], x["갈래"]) for x in ratio] == [("CC900001", "pct", "계수")]
assert {s["자리"] for s in qty.slots(ROW)} >= {"호표:0:0", "호표:1:0", "덧줄:0:0"}
assert not any(
s["자리"].startswith("중간")
for s in qty.slots({**ROW, "중간": [{"이름": "q", "식": "20"}]})
)
def test_general_numbers_and_compare_are_not_slots() -> None:
row = _row(
[_line("인력", "보통인부", "(1 / 100) * 10")], [{"이름": "x", "식": "만약(L > 30, 0.5, 1)"}]
)
assert [s["수"] for s in qty.slots(row)] == [0.5] # 1 · 10 · 100 일반 수 · 비교 30 뺌
def test_flags_say_count_and_empty(idx) -> None:
msgs = qty.flags(ROW, idx)
assert f"{em.NOTE}소요량 후보 2 · 사람 확인 — 보통인부 0.17" in msgs
assert f"{em.NOTE}표 칸 없음 — 보통인부 2 0.09" in msgs
assert all(m.startswith(em.NOTE) for m in msgs) # 못 옮김 표지 아님
t = qty.tally(qty.scan(ROW, idx))
assert (t["유일"], t["여럿"], t["없음"]) == (3, 1, 2) and t["자리"] == 6
# ── ② 고름 · 금액 그대로 ───────────────────────────────────────────────
def _money(doc: dict) -> dict:
whole = m01.loaded()[1]
return {"검산": imp.check(doc, whole)["셈"], "엑셀": imp.to_price(doc, "엑셀")["후"]}
def test_pick_records_cell_and_money_unchanged(idx) -> None:
doc = _doc(ROW)
cell = {"표": "QC900001", "줄": 0, "칸": "특별인부"}
got = qty.pick(doc, "호표:1:0", cell, idx)["문서"]
one = got[imp.KEY]["연결"]["고름"][0]
assert (one["층"], one["표"], one["줄"], one["칸"], one["꼴"]) == (
"M01",
"QC900001",
0,
"특별인부",
"eq",
)
assert one["수"] == one["값"] == 0.05
assert _money(got) == _money(doc) # 기록만 — 금액 그대로
assert got[imp.KEY]["로직"] == doc[imp.KEY]["로직"] # 엑셀 줄 안 건드림
view = {x["자리"]: x for x in qty.view(got, idx)["줄"]}
assert view["호표:1:0"]["상태"] == "고름" and view["호표:1:0"]["고른"]["같음"] is True
assert view["호표:0:0"]["상태"] == "후보" and view["호표:2:0"]["상태"] == "없음"
def test_pick_pct_form_and_wrong_value_refused(idx) -> None:
doc = _doc(ROW)
cell = {"표": "CC900001", "줄": 0, "칸": "값"}
got = qty.pick(doc, "덧줄:0:0", cell, idx)["문서"]
assert got[imp.KEY]["연결"]["고름"][0]["꼴"] == "pct"
with pytest.raises(sd.DocError) as why: # 0.17 인데 칸은 0.05
qty.pick(doc, "호표:0:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)
assert "0.17" in str(why.value) and "0.05" in str(why.value)
for bad in (
{"표": "없는표", "줄": 0, "칸": "값"},
{"표": "QC900001", "줄": 9, "칸": "특별인부"},
):
with pytest.raises(sd.DocError):
qty.pick(doc, "호표:1:0", bad, idx)
with pytest.raises(sd.DocError):
qty.pick(doc, "호표:9:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)
def test_pick_twice_replaces_and_drop_undo(idx) -> None:
doc = _doc(ROW)
a = qty.pick(doc, "호표:0:0", {"표": "QC900001", "줄": 0, "칸": "보통인부"}, idx)["문서"]
b = qty.pick(a, "호표:0:0", {"표": "QC900002", "줄": 0, "칸": "보통인부"}, idx)["문서"]
assert [x["표"] for x in b[imp.KEY]["연결"]["고름"]] == ["QC900002"] # 자리마다 하나
gone = qty.drop(b, "호표:0:0")["문서"] # 버림 = 고른 것도 풂
assert "고름" not in gone[imp.KEY]["연결"]
states = {x["자리"]: x["상태"] for x in qty.view(gone, idx)["줄"]}
assert states["호표:0:0"] == "버림"
back = qty.drop(gone, "호표:0:0", True)["문서"]
assert "연결" not in back[imp.KEY] # 기록 없으면 칸 자체가 없음
assert {x["자리"]: x["상태"] for x in qty.view(back, idx)["줄"]}["호표:0:0"] == "후보"
with pytest.raises(sd.DocError):
qty.drop(doc, "호표:9:0")
with pytest.raises(sd.DocError):
qty.view({"키": "SK-000001"}, idx) # 가져온 식 아님
# ── ③ 저장 거름 · 서버 길 ──────────────────────────────────────────────
def test_clean_keeps_valid_and_drops_stale(idx) -> None:
whole = m01.loaded()[1]
got = qty.pick(_doc(ROW), "호표:1:0", {"표": "QC900001", "줄": 0, "칸": "특별인부"}, idx)[
"문서"
]
kept = sd.clean(got, None, whole)[imp.KEY]["연결"]
assert kept["고름"][0]["표"] == "QC900001"
got[imp.KEY]["연결"]["고름"][0]["수"] = 0.5 # 로직 수와 안 맞음 → 거름
got[imp.KEY]["연결"]["버림"] = ["호표:0:0", "호표:99:0", {"x": 1}]
again = sd.clean(got, None, whole)[imp.KEY]["연결"]
assert "고름" not in again and again["버림"] == ["호표:0:0"]
got[imp.KEY]["연결"] = "엉뚱"
with pytest.raises(sd.DocError):
sd.clean(got, None, whole)
def test_server_routes(client, monkeypatch, idx) -> None: # noqa: F811
monkeypatch.setattr(bi, "get", lambda: idx)
client.app.include_router(sangeun_router.router)
doc = _doc(ROW)
seen = client.post(f"{API}/sangeun/imported/qty", json={"문서": doc})
assert seen.status_code == 200, seen.text
assert seen.json()["집계"]["유일"] == 3
cell = {"표": "QC900001", "줄": 0, "칸": "특별인부"}
done = client.post(
f"{API}/sangeun/imported/qty-pick", json={"문서": doc, "자리": "호표:1:0", "칸": cell}
)
assert done.status_code == 200, done.text
got = done.json()["문서"]
assert got[imp.KEY]["연결"]["고름"][0]["칸"] == "특별인부"
bad = client.post(
f"{API}/sangeun/imported/qty-pick", json={"문서": doc, "자리": "호표:0:0", "칸": cell}
)
assert bad.status_code == 400 and "0.17" in bad.text
cut = client.post(f"{API}/sangeun/imported/qty-drop", json={"문서": got, "자리": "호표:1:0"})
assert cut.status_code == 200 and cut.json()["문서"][imp.KEY]["연결"]["버림"] == ["호표:1:0"]
undo = client.post(
f"{API}/sangeun/imported/qty-drop",
json={"문서": cut.json()["문서"], "자리": "호표:1:0", "되돌리기": True},
)
assert "연결" not in undo.json()["문서"][imp.KEY]
# ── ④ 실물(북부청 임도) ────────────────────────────────────────────────
def _north(tmp_path):
path = golden.FILES["북부청임도"]
if not path.exists():
pytest.skip("실무 엑셀 없음")
hs._dir(tmp_path).mkdir(parents=True, exist_ok=True)
(hs._dir(tmp_path) / "HP-000001.json").write_text(
f'{{"키": "HP-000001", "판": {hs.BOOK}}}', "utf-8"
)
fh.keep(tmp_path, "HP-000001", path.name, path.read_bytes())
sheets = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["시트"]
return sheets
def test_real_north_block_has_unique_candidate_and_flags(tmp_path) -> None:
sheets = _north(tmp_path)
_, total = book.est_prices(sheets)
nos = [int(b["번"]) for b in book.est_blocks(sheets) if b["번"] in total]
found = None
for no in nos:
out = fh.emit(tmp_path, "HP-000001", no)
if "야면석채집및운반(계곡내)" in out["로직"]["이름"]:
found = out
break
assert found, "야면석채집및운반(계곡내) 블록"
row = found["로직"]
got = _by(qty.scan(row))
labor = next(i for i, x in enumerate(row["호표"]) if x["이름"] == "보통인부")
cells = got[f"호표:{labor}:0"]
assert [x["표"] for x in cells] == ["QF000428"] and cells[0]["값"] == 0.17 # 야면석 채집(인력)
flags = qty.flags(row)
assert f"{em.NOTE}소요량 후보 1 · 사람 확인 — 보통인부 0.17" in flags
assert all(f.startswith(em.NOTE) for f in flags)
doc = _doc(row)
kept = qty.pick(doc, f"호표:{labor}:0", {k: cells[0][k] for k in ("표", "줄", "칸")})["문서"]
assert imp.to_price(kept, "엑셀")["전"] == imp.to_price(doc, "엑셀")["전"]
+134
View File
@@ -0,0 +1,134 @@
"""798-1 M01 요율 제비율 내장 엑셀 — 빌더 결과 · 서식 · RT 칸 = 정본 값 · 판 바뀌면 --check 실패.
서버 · 화면 없이 도는 지킴 시험(읽기만 · 정본 JSON 을 쓰지 않음).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import openpyxl
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
import build_제비율책 as builder # noqa: E402
from M01_MasterData import M01_MasterData_RateSheet as rate_sheet # noqa: E402
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
MAIN = "26. 4. 13."
@pytest.fixture(scope="module")
def data() -> dict:
return json.loads(builder.OUT.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def xlsx():
wb = openpyxl.load_workbook(builder.XLSX_PATH)
wv = openpyxl.load_workbook(builder.XLSX_PATH, data_only=True)
return wb, wv
def _main(data: dict) -> dict:
return next(s for s in data["책"]["시트"] if s["이름"] == MAIN)
def test_book_matches_xlsx_and_rates():
"""--check 가 통과 = 저장된 책이 xlsx 를 다시 지은 것과 같고 RT 칸 값이 정본과 같음."""
assert builder.run_check() == []
def test_sheets_and_merges(data, xlsx):
wb, _ = xlsx
names = [s["이름"] for s in data["책"]["시트"]]
assert names == [ws.title for ws in wb.worksheets] and len(names) == 2
main = _main(data)
assert len(main["병합"]) == 433
assert len(data["책"]["시트"][1]["병합"]) == 7
assert data["책"]["활성"] == main["id"]
def test_format_survives(data, xlsx):
wb, _ = xlsx
book, ws = data["책"], wb[MAIN]
cells, styles = _main(data)["칸"], data["책"]["서식"]
fills = {s["채움"] for s in styles if "채움" in s}
assert fills == {"#FFEBEB", "#EBF7FF", "#EBFFEB", "#FFFF00"}
bordered = sum(1 for c in cells.values() if "테두리" in styles[c.get("서식", 0)])
src = sum(
1
for row in ws.iter_rows()
for c in row
if any(getattr(c.border, s).style for s in ("top", "bottom", "left", "right"))
)
assert bordered == src and bordered > 1300
assert any(s.get("굵게") for s in styles)
assert any(s.get("글자색") == "#FF0000" for s in styles)
assert any(s.get("형식") == "0.0" for s in styles)
assert len(_main(data)["열"]) == 189 # 잔 열 189
assert _main(data)["열"]["B"]["폭"] == pytest.approx(1.625)
assert _main(data)["행"]["2"]["높이"] == pytest.approx(62.25)
assert book["기본"]["글꼴"] == "맑은 고딕"
def test_formula_results_not_formulas(data, xlsx):
wb, wv = xlsx
cells = _main(data)["칸"]
formulas = [
c.coordinate
for row in wb[MAIN].iter_rows()
for c in row
if isinstance(c.value, str) and c.value.startswith("=")
]
assert len(formulas) == 40
for a1 in formulas:
assert "식" not in cells[a1]
assert cells[a1]["값"] == wv[MAIN][a1].value
def test_rt_cells_match_master(data):
rates = json.loads(builder.rate_check.RATE_JSON.read_text(encoding="utf-8"))
keys = {r["키"] for r in rates["줄"]}
assert set(data["대응"]) <= keys and "RT000019" not in data["대응"] # 부가세는 이 엑셀 밖
assert builder.check_values(data) == []
cells = _main(data)["칸"]
for spot in data["대응"].values():
assert cells[spot["칸"]].get("값") is not None
def test_check_fails_when_edition_changes(tmp_path, monkeypatch, data):
out = tmp_path / "책.json"
monkeypatch.setattr(builder, "OUT", out)
out.write_text(builder.dump(data), encoding="utf-8")
assert builder.run_check() == []
# 판이 바뀐 흉내 ① 책의 한 칸 값이 xlsx 와 달라짐
changed = json.loads(json.dumps(data))
_main(changed)["칸"]["BZ51"]["값"] = "(노) x 9.99"
out.write_text(builder.dump(changed), encoding="utf-8")
errors = builder.run_check()
assert any("판이 바뀜" in e for e in errors)
assert any("산재보험료" in e for e in errors) # RT000001 값 대조도 깨짐
# ② 책 파일이 없음
out.unlink()
assert "책 JSON 없음" in builder.run_check()[0]
def test_api_reads_book(data, tmp_path, monkeypatch):
assert rate_sheet.load()["대응"] == data["대응"]
monkeypatch.setattr(store, "FOLDER", tmp_path)
with pytest.raises(store.StoreError) as err:
rate_sheet.load()
assert err.value.status == 404
def test_not_picked_up_as_rate_file():
"""요율 파일 목록(`요율_*.json` 글롭)에 책이 섞이지 않음."""
names = [p.name for p in store.FOLDER.glob("요율_*.json")] + [
p.name for p in store.FOLDER.glob("요율.json")
]
assert names == ["요율_조달청제비율.json"]
+100
View File
@@ -0,0 +1,100 @@
"""799-1 단가산출 소계 · 끝수 · 합계 지킴(보이기만 — 금액 셈은 그대로).
① 예시 셋 — 굴착기(블록 한 줄) · 거푸집(자원 + 덧줄) · 지게(자원) : 줄 합 + 끝수 = 합계(비목마다).
② 표본 — 호표 로직 여럿 : 합계 = 엔진 끝수 입힌 비목 합(전후 같음) · 줄 합 + 끝수 = 합계 · 결과 로직은 소계 없음.
③ 부품 짝 — `ui_template_equation_sum.ts` 가 서버 칸 이름을 그대로 읽음.
"""
from __future__ import annotations
import importlib
from decimal import Decimal as D
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
meq = importlib.import_module("master_equation")
COSTS = ("노무비", "재료비", "경비", "계")
def _whole():
return store.loaded()[1]
def _logics() -> list[dict]:
"""M01 로직 줄 전부(키 순)."""
w = _whole()
return [
r
for tid, rows in sorted(w.index.items())
if mf.mk.GROUP_OF.get(tid) == "로직"
for r in rows.values()
]
def _export(key: str) -> dict:
w = _whole()
return meq.export(w, key, meq.given_of(w, w.logic(key), None))
def _closes(eq: dict) -> None:
sub = eq["소계"]
for k in COSTS:
got = sum((r[k] for r in sub["줄"]), D(0)) + sub["끝수"][k]
assert got == eq["합계"][k], (eq["열쇠"], k, got, eq["합계"][k])
def test_굴착기_블록_한_줄_끝수():
eq = _export("GC001148")
rows = eq["소계"]["줄"]
assert [r["갈래"] for r in rows] == ["블록"] and rows[0]["이름"] == "굴착기(무한궤도)"
assert (rows[0]["노무비"], rows[0]["재료비"], rows[0]["경비"]) == (
D("437.1"),
D("88.2"),
D("116.0"),
)
assert eq["합계"]["계"] == D("641.0") and eq["소계"]["끝수"]["계"] == D("-0.3")
_closes(eq)
def test_거푸집_자원_덧줄_지게_자원():
eq = _export("GC000211")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["직접"] # 801-3 직접 줄 한 묶음
_closes(eq)
eq = _export("GC001140")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["직접"]
_closes(eq)
def test_표본_합계는_엔진_끝수_그대로():
w = _whole()
keys = [x["키"] for x in _logics() if x.get("호표")][::25]
assert keys, "표본 없음"
seen = 0
for key in keys:
try:
eq = _export(key)
except mf.FormulaError:
continue
if "소계" not in eq:
continue
seen += 1
_closes(eq)
raw = mf.run(w, key, meq.given_of(w, w.logic(key), None))
cut = mcut.engine(w, key, raw)
assert {k: eq["합계"][k] for k in COSTS} == {k: cut[k] for k in COSTS}, key
assert seen >= 10
def test_결과_로직은_소계_없음():
key = next(x["키"] for x in _logics() if "결과" in x)
assert "소계" not in _export(key)
def test_화면_부품이_서버_칸을_읽음():
ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8")
for name in ("노무비", "재료비", "경비", "끝수", "합계"):
assert name in ts
@@ -0,0 +1,91 @@
# ruff: noqa: E501
"""799-2 옛 탭 넷 걷음 지킴 — 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 (M01 로직 상세 · M02 단가산출 상세).
- 걷은 파일은 없고 어디서도 안 부름 · 보기 = 수식 · 단가산출 상세 둘 · 기본정보는 맨 위
- 셈 구동은 화면 칸 없이 `_Logic_Drive.ts`(서버 셈) — M01 상세는 구동만 · 시험 계산 칸(`Logic_Calc`)은 로직 복사 화면 몫
- 서버 길은 남김(`/logic/sheet` · 시트 문서 칸 · 수식 검사가 같은 엔진)
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
M01 = ROOT / "M01_MasterData"
M02 = ROOT / "M02_MasterTemplete"
GONE = [
M01 / "M01_MasterData_UI_LogicLab_Sheet.ts",
M01 / "logic" / "M01_Logic_Sheet_Model.ts",
M01 / "M01_MasterData_UI_LogicLab_Formula.ts",
M01 / "M01_MasterData_UI_LogicLab_FormulaTokens.ts",
M01 / "M01_MasterData_UI_LogicLab_Formula.css",
M01 / "M01_MasterData_UI_LogicLab_Est.ts",
M02 / "M02_MasterTemplete_Sangeun_Sheet.ts",
M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts",
M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts",
M02 / "M02_MasterTemplete_Sangeun_Sheet.css",
M02 / "logic" / "M02_Sangeun_Sheet_Model.ts",
M02 / "logic" / "M02_Sangeun_Const_Model.ts",
M02 / "logic" / "M02_Sangeun_Formula_Model.ts",
]
def _text(p: Path) -> str:
return p.read_text(encoding="utf-8")
def _tracked() -> list[Path]:
out = subprocess.run(
["git", "-c", "core.quotepath=off", "ls-files", "*.ts", "*.css"],
cwd=ROOT, capture_output=True, text=True, encoding="utf-8", check=True,
).stdout # fmt: skip
return [ROOT / x for x in out.splitlines() if x and not x.startswith(("docs/", "tmp/"))]
def test_걷은_파일_없고_부르는_곳_없음():
assert not [p for p in GONE if p.exists()]
names = [p.name.rsplit(".", 1)[0] for p in GONE]
for p in _tracked():
text = _text(p)
for n in names:
assert f'/{n}"' not in text and f'./{n}"' not in text and f"{n}.css" not in text, (
p.name,
n,
)
def test_보기는_수식_단가산출상세_둘():
m01 = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Tabs.ts")
for src in (m01, m02):
assert re.findall(r'\{ value: "(\w+)", label: ', src) == ["stack", "ho"] # 804-6
assert 'export type LabView = "stack" | "ho";' in m01
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
assert '[data-view="ho"] [data-block]:not([data-block="ho"])' in css
assert 'data-view="sheet"' not in css and 'data-view="calc"' not in css
def test_셈_구동은_화면_없이_상세는_구동만():
drive = _text(M01 / "M01_MasterData_UI_Logic_Drive.ts")
assert "document." not in drive and "sessionStorage." not in drive
assert "export async function driveRun(" in drive and "export function createDrive(" in drive
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
assert "createDrive()" in lab and "buildCalc" not in lab and "data-calc-run" not in lab
assert "rerun: () => void drive.run()," in lab
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
assert "calcHost" not in detail and "data-calc-run" not in detail and "ctx.rerun?.()" in detail
calc = _text(
M01 / "M01_MasterData_UI_Logic_Calc.ts"
) # 로직 복사 화면이 쓰는 시험 계산 칸 — 구동은 공용
assert "driveRun(ctx)" in calc and "runCalc" not in calc
m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
assert "buildCalc" not in m02 and "calcHost" not in m02 and "createSangeunTabs(editor" in m02
def test_파일_700줄_안():
for p in (M01 / "M01_MasterData_UI_LogicLab.ts", M01 / "M01_MasterData_UI_LogicLab_Detail.ts",
M02 / "M02_MasterTemplete_Sangeun_Detail.ts", M02 / "M02_MasterTemplete_Sangeun_Tabs.ts",
M01 / "M01_MasterData_UI_Logic_Drive.ts"): # fmt: skip
assert len(_text(p).splitlines()) <= 700, p.name
+211
View File
@@ -0,0 +1,211 @@
"""800-1 변수 기호 기본값 지킴 (요구 660-1 · 659-2) — 수식 탭 왼쪽 식에 한글 변수가 안 보임.
① 규칙(`master_symbol.rule`) 표본 — 끝말 → 머리글자 · 아래첨자 · 갈래별(찾기 위 「표」 · 요소 · 합 · 결과).
② 입력 · 중간 변수 — 기호에 한글 0 · 한 로직 안 겹침 0 · 같은 이름 같은 기호(원문 796-3 제외).
③ 빌더 — 채운 정본에 쓸 것 없음 · 채운 것을 걷으면 같은 수만큼 다시 채움.
④ 수식 내보내기 — 표본 로직 전 변수 기호에 한글 0(찾기 · 요소 · 합 · 결과 포함) · 한 로직 안 겹침 없음.
⑤ 보이기만 — 변수표를 걷어도 엔진 셈 · 끝수 지문 같음(금액 전후 같음).
"""
from __future__ import annotations
import copy
import hashlib
import importlib
import json
import re
from functools import lru_cache
import pytest
from M01_MasterData import M01_MasterData_Store as store
meq = importlib.import_module("master_equation")
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
ma = importlib.import_module("master_auto")
sym = importlib.import_module("master_symbol")
build = importlib.import_module("build_변수기호_800_1")
build796 = importlib.import_module("build_변수표_796_3")
HAN = re.compile(r"[가-힣]")
@lru_cache(maxsize=1)
def loaded():
return store.loaded()
def logic_rows() -> list[dict]:
return [r for d in loaded()[0].values() if d.get("그룹") == "로직" for r in d.get("줄", [])]
def test_규칙_표본():
rule = sym.rule
assert rule("합판율")[:3] == ("r", "합판", "")
assert rule("사용횟수")[:3] == ("n", "사용", "")
assert rule("지형계수")[:3] == ("k", "지형", "")
assert rule("작업효율")[:3] == ("E", "작업", "")
assert rule("운반거리")[:3] == ("L", "운반", "")
assert rule("교량받침중량")[:3] == ("W", "교량받침", "") # 「량」 보다 「중량」 먼저
assert rule("버킷용량")[:3] == ("q", "버킷", "")
assert rule("보통인부")[:3] == ("Q", "보통인부", "")
assert rule("지역")[:3] == ("S", "지역", "") # 고르는 말
assert rule("E입력")[:3] == ("E", "입력", "") # 앞 영문 = 기호
assert rule("폼타이", hint="고름")[0] == "S" and rule("구멍땜")[0] == "X"
assert rule("시공량", "찾기")[:3] == ("Q", "시공", "표") # 표 칸 = 위 「표」
assert rule("보통인부노임", "요소")[:3] == ("w", "보통인부", "")
assert rule("합판단가", "요소")[:3] == ("P", "합판", "")
assert rule("형틀목공", "합")[:3] == ("C", "형틀목공", "")
assert rule("원/㎡", "결과")[:3] == ("P", "", "") and rule("㎥/hr", "결과")[:3] == ("Q", "", "")
assert sym.short("폼타이 12.7㎜") == "폼타이127" and len(sym.short("플랜트기계설치공")) <= 6
assert sym.is_symbol("K") and sym.is_symbol("V_1") and not sym.is_symbol("합판율")
def test_기호에_한글_0_한_로직_겹침_0():
for r in logic_rows():
seen: dict[str, str] = {}
for v in meq.table(r):
assert not HAN.search(v["기호"]), (r["키"], v["열쇠"], v["기호"])
for word in {meq.token(v), v["이름"]}:
assert seen.get(word, v["열쇠"]) == v["열쇠"], (r["키"], word)
seen.setdefault(word, v["열쇠"])
def test_같은_이름_같은_기호():
orig = build796.decisions()
got: dict[str, set[str]] = {}
for r in logic_rows():
for key, look in (r.get(meq.FIELD) or {}).items():
if (orig.get(r["키"]) or {}).get(key, {}).get("기호") or not look.get("기호"):
continue # 원문 · 추정(796-3) · 기호 없음
if HAN.search(key) or not sym.is_symbol(key):
got.setdefault(key, set()).add(look["기호"])
rules = {sym.rule(key, "변수", h)[:2] for h in ("", "고름", "중간")}
assert look["기호"] in {a for a, _ in rules}, (r["키"], key)
assert any(look.get("아래", "").startswith(b[:4]) for _, b in rules), (r["키"], key)
assert len(got) >= 1000
assert all(len(v) == 1 for v in got.values())
def test_빌더_쓸것_없음_그리고_걷으면_다시_채움():
batch, stat = build.plan(loaded()[0])
assert batch == [] and stat["변수"] == 0
orig, files, cut = build796.decisions(), copy.deepcopy(loaded()[0]), 0
for data in files.values():
if data.get("그룹") != "로직":
continue
for r in data["줄"]:
for key in list((r.get(meq.FIELD) or {}).keys()):
if not (orig.get(r["키"]) or {}).get(key, {}).get("기호"):
look = r[meq.FIELD][key]
if look.get("기호") and not set(look) - {"기호", "아래", "위"}:
del r[meq.FIELD][key]
cut += 1
_, again = build.plan(files)
assert cut >= 6000 and again["변수"] == cut
def _words(tree, out: list) -> None:
if isinstance(tree, dict):
if tree.get("꼴") == "변수":
out.append(tree)
for v in tree.values():
_words(v, out)
elif isinstance(tree, list):
for v in tree:
_words(v, out)
SAMPLE = ("GC000211", "GC001148", "GC001147", "GC001140", "GC001144", "GC000087", "GC000303")
def test_수식_내보내기_표본_한글_기호_0():
whole = loaded()[1]
rows = [r["키"] for r in logic_rows()]
for code in (*SAMPLE, *rows[::60]):
eq = meq.export(whole, str(code))
found: list[dict] = []
_words(eq["줄"], found)
_words(eq["풀이"], found)
assert found, code
for v in found:
assert not HAN.search(v.get("기호") or v.get("열쇠") or ""), (
code,
v["갈래"],
v["열쇠"],
)
def test_합판거푸집_표본_글():
eq = meq.export(loaded()[1], "GC000211")
head = {x["열쇠"]: x for x in eq["줄"]}
tok = lambda n: sym._text(n["기호"], n.get("아래", ""), n.get("위", "")) # noqa: E731
assert tok(head["합판율"]["결과"]) == "r_합판"
found: list[dict] = []
_words(head["합판율"]["이름식"], found)
assert {tok(v) for v in found} >= {"n_사용", "Q_합판^표"}
tokens = []
_words(eq["줄"], found := [])
tokens = {tok(v) for v in found}
assert {"Q_시공", "Q_시공^표", "w_형틀목공", "C_형틀목공", "P_합판", "S_폼타이"} <= tokens
def test_한_로직_안_다른_이름_같은_글이면_숫자():
out = {
"결과단위": "원/㎡",
"변수표": [],
"줄": [
{
"갈래": "호표",
"결과": {
"꼴": "변수",
"갈래": "합",
"열쇠": n,
"이름": n,
"기호": n,
"아래": "",
"위": "",
},
}
for n in ("공기압축기 21㎥/min", "공기압축기 10.5㎥/min", "공기압축기 21㎥/min")
],
}
sym.apply(out)
got = [meq.token(x["결과"]) for x in out["줄"]]
assert got == ["C_공기압축기", "C_공기압축기2", "C_공기압축기"]
def _print(whole, row) -> str:
try:
raw = mf.run(
whole,
str(row["키"]),
{
**ma.values(whole, row),
**{k: v for k, v in ma.defaults(whole, row).items() if v is not None},
},
)
text = json.dumps(raw, default=str, sort_keys=True, ensure_ascii=False)
text += json.dumps(
mcut.engine(whole, str(row["키"]), raw), default=str, sort_keys=True, ensure_ascii=False
)
except Exception as e: # 멈춤도 지문
text = f"ERR {type(e).__name__} {e}"
return hashlib.sha256(text.encode()).hexdigest()[:16]
def test_셈_전후_같음_변수표_걷어도():
now = loaded()[1]
files = copy.deepcopy(loaded()[0])
for data in files.values():
if data.get("그룹") == "로직":
for row in data["줄"]:
row.pop(meq.FIELD, None)
before = mf.Master(files)
for code in [r["키"] for r in logic_rows()][::6]:
assert _print(now, now.logic(code)) == _print(before, before.logic(code)), code
if __name__ == "__main__":
pytest.main([__file__, "-q"])
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""PLAN 800-2 · 800-3 — 수식 탭 변수 정의 영역 · 바로 고침 · 새 기호 연결 (서버 몫 · 임시 쓰기 없음)."""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "resources/master_data/scripts")]
from M01_MasterData import M01_MasterData_Equation as eq # noqa: E402
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
KEY = "GC000211"
OFF = {"폼타이": "아님", "측압": "3t/㎡"}
ON = {"폼타이": "사용", "측압": "4t/㎡"}
def _defs(inputs):
return eq.read(KEY, inputs)["수식"]["정의"]
def _text(defs):
return json.dumps(defs, ensure_ascii=False)
def test_defs_merge_and_clean():
defs = _defs(OFF)
signs = [(d["기호"], d["위"], d["아래"]) for d in defs]
assert len(signs) == len(set(signs)), "같은 변수는 한 번만"
assert not re.search(r"[A-Z]{2}\d{6}", _text(defs)), "DB 열쇠 글 0"
assert all(
set(d) <= {"열쇠", "기호", "위", "아래", "이름", "갈래", "연결", "고침", "안씀"}
for d in defs
)
by = {
(d["이름"], d["갈래"]): d for d in defs
} # 800-1 기호가 생겨 중간 · 표 찾기는 다른 기호 = 다른 정의
sign = lambda d: (d["기호"], d["위"], d["아래"])
assert sign(by[("보통인부", "중간")]) != sign(by[("보통인부", "찾기")])
assert by[("사용횟수", "입력")]["연결"] == ["입력 — 옵션에서 고름"]
def test_unpicked_branches_hidden():
off, on = _text(_defs(OFF)), _text(_defs(ON))
for col in ("4t/㎡", "5t/㎡", "6t/㎡", "폼타이 소요량"):
assert col not in off
assert "칸 4t/㎡" in on and "칸 3t/㎡" not in on and "칸 5t/㎡" not in on
assert "측압" in on and "측압" not in off # 폼타이 아님이면 측압 갈래도 안 탐
def test_live_edit_preview_changes_total():
spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2"}
got = eq.preview(KEY, [spot], OFF)
assert got["되나"] and got["후"]["계"] > got["전"]["계"]
def test_unknown_symbol_asks_link_then_links():
spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2 + Z"}
got = eq.preview(KEY, [spot], OFF)
assert not got["되나"] and "모르는 기호 「Z」" in got["까닭"][0]
for link in (
{"갈래": "상수", "값": "0"},
{"갈래": "입력", "기본값": "0"},
{"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {"유형": "유형"}},
{"갈래": "요소", "요소": "LB000007"},
):
add = {"할일": "연결", "열쇠": "Z", "이름": "새값", **link}
got = eq.preview(KEY, [add, spot], OFF)
assert got["되나"], (link, got["까닭"])
assert any(d["기호"] == "Z" for d in got["수식"]["정의"])
def test_link_bad_input_blocked():
bad = [
{"갈래": "표", "표": "QC000431", "칸": "없는칸", "조건": {"유형": "유형"}},
{"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {}},
{"갈래": "요소", "요소": "GC000211"},
{"갈래": "상수", "값": "abc"},
]
for link in bad:
got = eq.preview(KEY, [{"할일": "연결", "열쇠": "Z", **link}], OFF)
assert not got["되나"], link
def test_drop_guard_and_unused_input():
got = eq.preview(KEY, [{"할일": "지움", "열쇠": "사용횟수"}], OFF)
assert not got["되나"] and "쓰는 곳" in got["까닭"][0]
add = {"할일": "연결", "열쇠": "Y", "갈래": "입력"}
got = eq.preview(KEY, [add], OFF)
unused = [d for d in got["수식"]["정의"] if d["기호"] == "Y"]
assert unused and unused[0].get("안씀")
assert eq.preview(KEY, [add, {"할일": "지움", "열쇠": "Y"}], OFF)["되나"]
def test_candidates():
whole = store.loaded()[1]
got = eq.links({"갈래": "표", "검색": "합판거푸집 폼타이", "분류": ""})
assert got["후보"] and "조건" in got["후보"][0]
assert eq.links({"갈래": "요소", "검색": "형틀목공", "분류": "인력"})["후보"]
assert eq.links({"갈래": "로직", "검색": "합판거푸집", "분류": ""})["후보"]
assert whole is not None
def test_groups_add_up_to_total():
"""800-4 — 묶음(블록 · 자원 · 덧줄) 소계 합 + 끝수 = 합계(비목마다) · 한 묶음 소계 = 노 + 재 + 경."""
for key in ("GC001148", "GC000211"):
eqn = eq.read(key, {})["수식"]
sub, total = eqn["소계"], eqn["합계"]
for k in ("노무비", "재료비", "경비"):
got = sum(float(r[k]) for r in sub["줄"]) + float(sub["끝수"][k])
assert abs(got - float(total[k])) < 1e-6, (key, k)
assert all(
abs(float(r["계"]) - sum(float(r[k]) for k in ("노무비", "재료비", "경비"))) < 1e-6
for r in sub["줄"]
)
def test_screen_source_groups_and_inline_edit():
ui = ROOT / "ui_template"
sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8")
line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8")
assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음
assert "o.after?.(line)" in line_ts # 블록 식 줄 바로 아래
assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음
assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음
+140
View File
@@ -0,0 +1,140 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""800-5 기호 남은 한글 — 임시 storage 에서만.
① 호표 줄 금액 참조(`줄.`) 기호 = 수식 영역 · 정의 영역 같은 글(한글 0) ② 새 사본 = 이미 새 기호
③ 옛 사본 변수표 새로 씀 = 사용자 고침 칸 그대로 · 금액 같음 · 백업 되돌림 ④ M01 전수 한글 기호(하위 로직 칩 제외) 0.
"""
from __future__ import annotations
import copy
import importlib
import json
import re
from M02_MasterTemplete import M02_Copy_Migrate as mig
from M02_MasterTemplete import M02_Copy_Symbols as sym
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
LINE = {"로직": "GC000211", "수량": {"손": 1}} # 변수 많은 합판거푸집
def _make(me):
with store.at(me):
sk = refs.create(
"sangeun",
{"이름": "sub2임시", "단위": "㎡", "바탕": {"로직": "GC000211"}, "더한줄": []},
)["이름"]
path = me / "sangeun" / f"{sk}.json"
raw = {k: v for k, v in json.loads(path.read_bytes()).items() if k != skc.KEY}
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
return {path: raw}
def _mine(rows, olds):
keys = {p.stem for p in olds}
return {r["키"]: r for r in rows if r["어디"].startswith("개인") and r["키"] in keys}
meq = importlib.import_module("master_equation")
HAN = re.compile(r"[가-힣]")
def _walk(tree, out: list) -> None:
if isinstance(tree, dict):
if tree.get("꼴") == "변수":
out.append(tree)
for v in tree.values():
_walk(v, out)
elif isinstance(tree, list):
for v in tree:
_walk(v, out)
def test_줄_참조_기호_같은_글_한글_0():
eq = meq.export(m01.loaded()[1], "GC000211")
refs = []
_walk(eq["줄"], refs)
refs = [v for v in refs if str(v["열쇠"]).startswith("줄.")]
assert refs and all(not HAN.search(v["기호"]) and v["기호"] == "C" for v in refs)
defs = {(d["기호"], d["아래"]): d for d in eq["정의"]}
for v in refs:
d = defs[(v["기호"], v["아래"])]
assert d["갈래"] == "요소" and not HAN.search(d["기호"])
assert any("호표 줄 금액" in c for d in defs.values() for c in d["연결"])
def test_M01_전수_정의_한글_기호는_하위_로직_칩뿐():
whole = m01.loaded()[1]
keys = [r["키"] for d in m01.loaded()[0].values() if d.get("그룹") == "로직" for r in d["줄"]]
for code in keys[::25]:
for d in meq.export(whole, str(code))["정의"]:
assert d["갈래"] == "블록" or not HAN.search(d["기호"]), (code, d["이름"])
def _old(path):
"""사본 변수표를 걷어 옛 사본 흉내 → (옛 문서, 걷은 줄 수)."""
doc = json.loads(path.read_bytes())
cut = 0
for r in doc[skc.KEY]["로직"]:
cut += r.pop(sym.FIELD, None) is not None
doc[skc.KEY]["판"] = skc._fp(doc[skc.KEY]["로직"])
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
return doc, cut
def test_새_사본은_이미_새_기호_옛_사본은_새로_씀(world, tmp_path):
olds = _make(world["personal"])
back = tmp_path / "stamp"
mig.migrate(True, back) # 새 사본 찍음
paths = list(olds)
for p in paths:
rows = json.loads(p.read_bytes())[skc.KEY]["로직"]
assert any(r.get(sym.FIELD) for r in rows) # ② 새 사본 = 변수표 있음
assert {r["상태"] for r in _mine(sym.run(), olds).values()} == {"채울것없음"}
stripped = {p: _old(p) for p in paths}
assert all(cut for _, cut in stripped.values())
# 사용자 고침 칸 하나 — 영문 기호 Z 는 그대로
sk = next(p for p in paths if p.parent.name == "sangeun")
doc = json.loads(sk.read_bytes())
row = next(r for r in doc[skc.KEY]["로직"] if r.get("입력"))
name = str(row["입력"][0]["이름"])
row[sym.FIELD] = {name: {"기호": "Z", "아래": "내것"}}
sk.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
before = {p: p.read_bytes() for p in paths}
rows = _mine(sym.run(), olds) # 보기만 = 안 씀
assert {r["상태"] for r in rows.values()} == {"같음"} and not any(
r["씀"] for r in rows.values()
)
assert all(p.read_bytes() == b for p, b in before.items())
back = tmp_path / "back"
rows = _mine(sym.run(True, back), olds)
assert all(r["씀"] and r["채움"] > 0 and r["못닿음"] == 0 for r in rows.values())
for p in paths:
for r in json.loads(p.read_bytes())[skc.KEY]["로직"]:
for part in ("입력", "중간"):
for spec in r.get(part) or []:
look = (r.get(sym.FIELD) or {})[str(spec["이름"])]
assert look.get("기호")
if not (r is row and spec["이름"] == name):
assert not HAN.search(look["기호"]), (r["키"], spec["이름"])
now = next(r for r in json.loads(sk.read_bytes())[skc.KEY]["로직"] if r["키"] == row["키"])
assert now[sym.FIELD][name] == {"기호": "Z", "아래": "내것"}
assert {r["상태"] for r in _mine(sym.run(), olds).values()} == {"채울것없음"} # 다시 돌림
mig.restore(back)
assert all(p.read_bytes() == before[p] for p in paths)
def test_채운_줄은_M01_줄_변수표_그대로():
live = m01.loaded()[1]
row = copy.deepcopy(live.logic("GC000211"))
src = row[sym.FIELD]
stripped = {k: v for k, v in row.items() if k != sym.FIELD}
got, done, lost = sym.fill({"로직": [json.loads(json.dumps(stripped, default=str))]}, live)
names = {str(s["이름"]) for k in ("입력", "중간") for s in row[k]}
assert done == len(names & set(src)) and lost == len(names - set(src))
assert all(got["로직"][0][sym.FIELD][n]["기호"] == src[n]["기호"] for n in names & set(src))
+117
View File
@@ -0,0 +1,117 @@
"""801-3 수식 탭 소계 묶음(요구 662-3) 지킴 — 금액 셈은 그대로 · 묶음만 다시.
① 묶음 = 부른 단가산출(갈래 블록) 하나씩 + 직접 줄 한 묶음(갈래 직접) · 돈 없는 줄 = 묶음 아님 · 안쪽 말 0
② M01 전수 — 묶음 합 + 끝수 = 합계(엔진 비목 합 그대로) · 직접 묶음 하나뿐
③ 부품 — 묶음 하나 = 머리 없음 · 계 / 둘 이상 = 머리 · 소계 / 단가산출 둘 이상 = 합계 · 안쪽 말 글 0
"""
from __future__ import annotations
import importlib
from decimal import Decimal as D
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
mf = importlib.import_module("master_formula")
meq = importlib.import_module("master_equation")
grp = importlib.import_module("master_equation_group")
COSTS = ("노무비", "재료비", "경비", "계")
BAD = ("자기", "자원", "덧줄", "블록") # 코드 안쪽 말 — 화면 글에 0
def _w():
return store.loaded()[1]
def _export(key: str) -> dict:
w = _w()
return meq.export(w, key, meq.given_of(w, w.logic(key), None))
def _row(kind, name, cost=(0, 0, 0), **more):
got = dict(zip(COSTS[:3], (D(x) for x in cost), strict=True))
return {"갈래": kind, "이름": name, **got, "계": sum(got.values(), D(0))} | more
def test_묶음_함수_하나뿐_직접_한_묶음():
got = grp.bundles(
[
_row("블록", "굴착기", (1, 2, 3)),
_row("블록", "쉬는 단가산출"), # 돈 0 = 묶음 아님
_row("자원", "보통인부", (5, 0, 0), 종류="인력"),
_row("자원", "시멘트", (0, 7, 0), 종류="재료"),
_row("덧줄", "공구손료", (0, 0, 1)),
]
)
assert [r["갈래"] for r in got["줄"]] == ["블록", "직접"]
assert got["줄"][0]["이름"] == "굴착기" and got["줄"][1]["이름"] == "인력 · 재료"
assert (got["줄"][1]["노무비"], got["줄"][1]["재료비"], got["줄"][1]["경비"]) == (5, 7, 1)
only = grp.bundles([_row("덧줄", "공구손료", (0, 0, 1))])["줄"]
assert [r["이름"] for r in only] == ["경비"] and not grp.bundles([_row("블록", "빈")])["줄"]
def test_M02_상태_줄():
got = grp.bundles(
[
_row("자원", "보통인부", (5, 0, 0), 상태="더함", 종류="인력"),
_row("자원", "형틀목공", (4, 0, 0), 상태="덮음", 원래=D(9), 종류="인력"),
_row("자원", "지운 줄", 상태="뺌", 종류="인력"),
_row("자원", "밖 줄", 합밖=True, 밖금액=D(8)),
]
)
assert [x["이름"] for x in got["줄"][0]["낱줄"]] == ["보통인부", "형틀목공"]
assert got["밖"] == [{"이름": "밖 줄", "밖금액": D(8)}] and got["줄"][0]["계"] == 9
def test_거푸집_한_묶음_굴착기_한_묶음():
eq = _export("GC000211")
assert [r["갈래"] for r in eq["소계"]["줄"]] == ["직접"]
assert eq["소계"]["줄"][0]["이름"] == "인력 · 재료"
eq = _export("GC001148")
rows = eq["소계"]["줄"]
assert [(r["갈래"], r["이름"]) for r in rows] == [("블록", "굴착기(무한궤도)")]
assert eq["합계"]["계"] == D("641.0") and eq["소계"]["끝수"]["계"] == D("-0.3")
def test_M01_전수_묶음_합_끝수_합계_안쪽_말_0():
w = _w()
many = seen = 0
for tid, rows in sorted(w.index.items()):
if mf.mk.GROUP_OF.get(tid) != "로직":
continue
for logic in rows.values():
if not logic.get("호표"):
continue
try:
eq = _export(logic["키"])
except mf.FormulaError:
continue
sub = eq.get("소계")
if not sub:
continue
seen += 1
lines = sub["줄"]
assert sum(r["갈래"] == "직접" for r in lines) <= 1, logic["키"]
assert all(r["계"] != 0 for r in lines), logic["키"]
for r in lines:
assert not any(b in r["이름"] for b in BAD), (logic["키"], r["이름"])
for k in COSTS:
got = sum((r[k] for r in lines), D(0)) + sub["끝수"][k]
assert got == eq["합계"][k], (logic["키"], k)
many += sum(r["갈래"] == "블록" for r in lines) >= 2
assert seen > 400 and many >= 10, (seen, many)
def test_부품_머리_소계_합계_규칙_안쪽_말_0():
ts = Path("ui_template/ui_template_equation_sum.ts").read_text(encoding="utf-8")
assert "sub.줄.length >= 2" in ts # 묶음 하나 = 머리 · 소계 없음
assert '"합계" : "계"' in ts and '=== "블록").length >= 2' in ts # 합계 = 단가산출 둘 이상
ui = "".join(
line
for line in ts.splitlines(keepends=True)
if not line.lstrip().startswith(("*", "/*", "//", "/**"))
)
for word in ("자기 자원", "덧줄", "KIND"):
assert word not in ui, word
+138
View File
@@ -0,0 +1,138 @@
"""801-5 M01 로직 단위 글 = 건설품셈 원문 꼴(요구 663-2) 지킴 — 글만 바꿈 · 금액 그대로.
① 대응표 근거 — 규칙마다 근거 절 파일이 있고 그 안에 원문 글이 실제로 있음(+ 지금 글은 같은 꼴로 안 쓰임)
② 섞임 0 — M01 로직 입력 · 중간 · 결과단위가 원문 꼴로 다 바뀌어 있음(규칙이 더 바꿀 것 없음 · 두 번 돌려도 같음)
③ 금액 같음 — 변수 단위 글을 지워도 엔진 셈 · 끝수 지문이 같음(단위 글은 셈에 안 듦)
④ M02 사본 도구 — 같은 규칙으로 사본 줄 단위만 바뀌고 `판` 다시 셈
"""
from __future__ import annotations
import copy
import importlib
import re
import pytest
from M01_MasterData import M01_MasterData_Store as store
ut = importlib.import_module("master_unit_text")
mf = importlib.import_module("master_formula")
build = importlib.import_module("build_단위표기_801_5")
def _rows():
for data in store.loaded()[0].values():
if data.get("그룹") == "로직":
yield from data["줄"]
def test_대응표_근거_절에_원문_글이_있음():
assert len(ut.RULES) >= 7
for name, _, to, token, files in ut.RULES:
assert files and to == token, name
for rel in files:
path = ut.EVIDENCE_ROOT / rel
assert path.is_file(), (name, rel)
assert token in path.read_text(encoding="utf-8"), (name, rel)
def test_규칙_예():
got = {
u: ut.canon(u)
for u in ("㎥/시간", "㎡/시간", "㎢/시간", "원/시간", "초", "초/m", "mm", "TON/㎡")
}
assert got == {
"㎥/시간": "㎥/hr",
"㎡/시간": "㎡/hr",
"㎢/시간": "㎢/시간", # 원문 9-5-4 가 쓰는 꼴 — 안 건드림
"원/시간": "원/시간",
"초": "sec",
"초/m": "sec/m",
"mm": "㎜",
"TON/㎡": "ton/㎡",
}
for u in got.values():
assert ut.canon(u) == u # 두 번 돌려도 같음
def test_섞임_0_원문_꼴로_다_바뀜():
left = []
for row in _rows():
units = [row.get("결과단위")] + [
s.get("단위") for k in ("입력", "중간") for s in row.get(k) or []
]
left += [(row["키"], u) for u in units if u and ut.canon(u) != u]
assert not left, left[:5]
def test_굴착기_Cm_sec_Q_hr():
row = store.loaded()[1].logic("GF000088") # 토사깍기(굴착기 0.7㎥) 시간당 작업량
assert {s["단위"] for s in row["중간"] if s["이름"] == "Cm"} == {"sec"}
assert row["결과단위"] == "㎥/hr"
_OLD = ( # 원문 꼴 → 옛 글(되돌려 본 꼴) — 두 꼴로 셈이 같음을 대조
(re.compile(r"(?<![가-힣A-Za-z])sec(?![A-Za-z])"), "초"),
(re.compile(r"(?<=[㎥㎡])/hr"), "/시간"),
(re.compile("㎜"), "mm"),
(re.compile("㎝"), "cm"),
(re.compile("㎏"), "kg"),
(re.compile("㎞/hr"), "km/hr"),
)
def _legacy(text: str) -> str:
for pat, old in _OLD:
text = pat.sub(old, text)
return text
def test_금액_같음_옛_글로_되돌려도_셈_지문_같음():
files = store.loaded()[0]
old = copy.deepcopy(files)
for data in old.values():
if data.get("그룹") == "로직":
for row in data["줄"]:
for k in ("입력", "중간"):
for s in row.get(k) or []:
s["단위"] = _legacy(s.get("단위") or "")
row["결과단위"] = _legacy(row.get("결과단위") or "")
now, was = mf.Master(files), mf.Master(old)
keys = [r["키"] for r in _rows()]
hit = {
r["키"]
for r in _rows()
if any(
_legacy(s.get("단위") or "") != (s.get("단위") or "")
for k in ("입력", "중간")
for s in r.get(k) or []
)
}
assert len(hit) >= 40
for key in sorted(hit) + keys[::25]:
assert build.fingerprint(now, now.logic(key)) == build.fingerprint(was, was.logic(key)), key
def test_M02_사본_도구():
cu = importlib.import_module("M02_MasterTemplete.M02_Copy_Units")
old = {
"로직": [
{
"키": "GC000001",
"결과단위": "㎥/시간",
"입력": [{"이름": "a", "단위": "mm"}],
"중간": [{"이름": "Cm", "단위": "초"}],
}
],
"판": "x",
}
new, done, _ = cu.fill(old, None)
row = new["로직"][0]
assert done == 3 and row["결과단위"] == "㎥/hr" and row["입력"][0]["단위"] == "㎜"
assert row["중간"][0]["단위"] == "sec" and new["판"] != "x"
assert cu.fill(new, None)[1] == 0 # 두 번째 = 바꿀 것 없음
if __name__ == "__main__":
pytest.main([__file__, "-q"])
+588
View File
@@ -0,0 +1,588 @@
"""802-1 단가산출 수식 문서 — 꼴 · 셈 · 지움 견딤 · 표지 · 거울(파이썬 ↔ TS) 지킴 시험.
꼴 = `resources/master_data/_수식문서_틀.md` · 파이썬 = `master_doc*.py` · TS = `common_util_formula_doc*.ts`.
① 식 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파.
② 셈 — 입력 · 선택 · 숫자 · 식 · 만약 · % · 표 조건(범위 규칙) · 요소 · 단가산출 부름 · 묶음 소계 · 합계 ·
반올림 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 · 결과 문서.
③ 표지 — 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 · 겹침 = 금액 「—」 · 예외 없음.
④ 지움 견딤 — 변수 · 식 줄 · 금액 줄 · 식 글 하나씩 모두 지워도 예외 0.
⑤ 거울 — 같은 문서 · 같은 연결 값으로 파이썬 · TS 답이 같음(실제 M01 문서 표본 + 위 문서들).
"""
import copy
import importlib
import json
import os
import subprocess
from decimal import Decimal as D
import pytest
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
md = importlib.import_module("master_doc")
mc = importlib.import_module("master_doc_calc")
ml = importlib.import_module("master_doc_link")
cv = importlib.import_module("master_doc_convert")
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
# ── 시험 연결 ─────────────────────────────────────────────────────────
TABLE = {
"키": "QX990001",
"원문번호": "9-9-9",
"조건": {"유형": "고르기", "두께": "범위"},
"범위규칙": "초과·이하",
"값칸": {"품": "인", "율": "%"},
"줄": [
{"유형": "보통", "두께": [None, D(10)], "품": D("0.1"), "율": D(5)},
{"유형": "보통", "두께": [D(10), D(20)], "품": D("0.2"), "율": D(6)},
{"유형": "복잡", "두께": [None, D(20)], "품": D("0.3")},
{"유형": "분수", "품": "1/1.30"},
],
}
class Fake:
"""시험 연결 — 요소 {키: 값 | Gap 까닭} · 표 {키: 표} · 문서 {키: 문서}."""
def __init__(self, elements=None, tables=None, docs=None):
self.elements = elements or {}
self.tables = {"QX990001": TABLE, **(tables or {})}
self.docs = docs or {}
def element(self, p):
key = p.get("키") or json.dumps(p.get("재료"), ensure_ascii=False, sort_keys=True)
if key not in self.elements:
raise mc.Broken(f"없는 요소 「{key}」")
got = self.elements[key]
if isinstance(got, str) and got.startswith("빈:"):
raise mc.Gap(got[2:])
return got[p["칸"]] if p.get("칸") else got
def table(self, p):
if p["표"] not in self.tables:
raise mc.Broken(f"없는 표 「{p['표']}」")
return ml.Links.table(_TableOnly(self.tables[p["표"]]), p)
def doc(self, key):
return self.docs.get(key)
class _TableOnly:
def __init__(self, table):
self.t = table
def _get(self, ref):
return self.t
def _var(sid, link, key=None, name=""):
sign, low, up = md.split_id(sid)
return {
"열쇠": key or sid,
"기호": sign,
"아래": low,
"위": up,
"이름": name or sid,
"연결": link,
}
def _doc(variables=(), lines=(), money=(), **more):
out = md.blank("GX990001", "시험")
out["변수"] = [_var(*v) if isinstance(v, tuple) else v for v in variables]
out["식"] = [{"기호": a, "식": b} for a, b in lines]
out["금액"] = list(money)
out.update(more)
return out
def _man(name, qty, price, kind="노무비", **more):
return {"이름": name, "단위": "인", "비목": kind, "수량": qty, "단가": price, **more}
def _calc(doc, links=None, given=None):
return mc.calc(doc, links or Fake(), given)
def _money(got):
return {k: got[k] for k in COSTS}
def SAMPLE():
"""합판거푸집 꼴 견본 — 선택 · 입력 · 표(범위) · 요소 · 식 · 만약 · % · 덧줄 꼴 금액."""
return _doc(
[
("S_유형", {"갈래": "선택", "목록": ["보통", "복잡"], "값": "보통"}, "유형"),
("t", {"갈래": "입력", "값": D(15), "범위": [0, 100]}, "두께"),
(
"Q^표",
{
"갈래": "표",
"표": "QX990001",
"조건": {"유형": "S_유형", "두께": "t"},
"칸": "품",
},
),
("w_형틀", {"갈래": "요소", "키": "LB990001"}),
("w_보통", {"갈래": "요소", "키": "LB990002"}),
("r_할증", {"갈래": "숫자", "값": D(10)}),
("Q_형틀", {"갈래": "식"}),
],
[("Q_형틀", "Q^표 × (1 + 만약(S_유형 == '복잡', r_할증, 0)%)")],
[
_man("형틀목공", "Q_형틀", "w_형틀", 기호="C_형틀"),
_man("보통인부", "0.05", "w_보통"),
{"이름": "공구손료", "단위": "식", "비목": "경비", "단가": "노무비 × 3%"},
{"이름": "소모", "단위": "식", "비목": "재료비", "단가": "C_형틀 × 1 ÷ 3"},
],
)
LABOR = {"LB990001": D(274978), "LB990002": D(165545)}
# ── ① 꼴 ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"text, want",
[
("a*b/c", "a × b ÷ c"),
("a/(b*c)", "a ÷ (b × c)"),
("a-(b-c)", "a - (b - c)"),
("(a-b)-c", "a - b - c"),
("-x*2", "-x × 2"),
("-(a+b)", "-(a + b)"),
("x %", "x%"),
("(a+b)%", "(a + b)%"),
("3600×q_s×K×f×E_s÷C_m", "3600 × q_s × K × f × E_s ÷ C_m"),
("만약(S=='보통' 그리고 t<=10, 1, 0)", "만약(S == '보통' 그리고 t <= 10, 1, 0)"),
("a ≤ b", "a <= b"),
("(a<b) == (c<d)", "(a < b) == (c < d)"),
("B_굴착기.경비 × Q", "B_굴착기.경비 × Q"),
("번지.A01.재료비 × 11 ÷ 100", "번지.A01.재료비 × 11 ÷ 100"),
("단계.'설치 소계'.계", "단계.'설치 소계'.계"),
("Q_시공^표 − 0.50", "Q_시공^표 - 0.50"),
("C_{m 3} + x_1", "C_{m 3} + x_1"),
("반올림(3600 × q ÷ C, 3)", "반올림(3600 × q ÷ C, 3)"),
("a 또는 b 그리고 c", "a 또는 b 그리고 c"),
("(a 또는 b) 그리고 c", "(a 또는 b) 그리고 c"),
],
)
def test_식_풀이_되돌림(text, want):
got = md.text_of(md.parse(text))
assert got == want
assert md.text_of(md.parse(got)) == got
@pytest.mark.parametrize(
"text", ["", "a +", "(a", "모름(1)", "a b", "'열림", "1 ++", "만약", "x._"]
)
def test_틀린_식(text):
with pytest.raises(md.DocError):
md.parse(text)
def test_기호_글():
assert md.sym_id("Q", "시공", "표") == "Q_시공^표"
assert md.sym_id("C", "m 3") == "C_{m 3}"
assert md.split_id("Q_시공^표") == ("Q", "시공", "표")
assert md.split_id("C_{m 3}") == ("C", "m 3", "")
assert md.good_id("q′_s") and not md.good_id("노무비") and not md.good_id("1a")
def test_이름_바꿈_전파():
doc = SAMPLE()
doc["변수"].append(_var("P_x", {"갈래": "요소", "키": "EQ:{S_유형}", "칸": "가격"}))
md.rename(doc, "S_유형", "U", "형")
assert doc["변수"][0]["기호"] == "U" and doc["변수"][0]["열쇠"] == "유형"
assert doc["변수"][2]["연결"]["조건"]["유형"] == "U_형"
assert doc["식"][0]["식"] == "Q^표 × (1 + 만약(U_형 == '복잡', r_할증, 0)%)"
assert doc["변수"][-1]["연결"]["키"] == "EQ:{U_형}"
md.rename(doc, "C_형틀", "M")
assert doc["금액"][0]["기호"] == "M" and doc["금액"][3]["단가"] == "M × 1 ÷ 3"
with pytest.raises(md.DocError):
md.rename(doc, "M", "t") # 같은 기호
with pytest.raises(md.DocError):
md.rename(doc, "t", "노무비") # 못 쓰는 기호
# ── ② 셈 ──────────────────────────────────────────────────────────────
def test_셈_기본_금액_식():
got = _calc(SAMPLE(), Fake(LABOR))
assert got["셈됨"] and got["표지"] == []
# 형틀 0.2 × 274,978 = 54,995.6 · 보통 0.05 × 165,545 = 8,277.25 → 8,277.2
# 공구손료 = (54,995.6 + 8,277.2) × 3% = 1,898.184 → 1,898.1 · 소모 = 54,995.6 ÷ 3 = 18,331.8
assert [x["금액"] for x in got["줄"]] == [D("54995.6"), D("8277.2"), D("1898.1"), D("18331.8")]
assert _money(got) == {"노무비": 63272, "재료비": 18331, "경비": 1898, "계": 83501}
assert got["끝수"]["줄"] == {"방법": "버림", "자리": 1} and got["머리"] == "계"
got = _calc(SAMPLE(), Fake(LABOR), {"유형": "복잡", "두께": D(5)}) # 넘긴 입력 = 열쇠
assert got["값"]["Q_형틀"] == D("0.33")
def test_표_조건_범위_분수():
links = Fake(LABOR)
doc = SAMPLE()
assert _calc(doc, links, {"두께": D(10)})["값"]["Q^표"] == D("0.1") # 초과·이하 — 10 은 아래 줄
assert _calc(doc, links, {"두께": D("10.5")})["값"]["Q^표"] == D("0.2")
got = _calc(doc, links, {"두께": D(25)}) # 맞는 줄 없음 = 값 없음 → 그 줄만 비움
assert got["셈됨"] and "Q^표" in got["빈값"] and got["줄"][0]["금액"] is None
assert got["노무비"] == 8277 # 형틀 줄 빠짐
doc["변수"].append(
_var("k", {"갈래": "표", "표": "QX990001", "조건": {"유형": "'분수'"}, "칸": "품"})
)
assert _calc(doc, links)["값"]["k"] == D(1) / D("1.30")
doc["변수"].append(
_var("z", {"갈래": "표", "표": "QX990001", "조건": {"유형": "'복잡'"}, "칸": "율"})
)
assert "z" in _calc(doc, links)["빈값"] # 그 줄에 칸 없음 = 값 없음
doc["변수"].append(
_var("y", {"갈래": "표", "표": "QX990001", "조건": {"없는": "1"}, "칸": "품"})
)
assert any(m["갈래"] == "연결 끊김" for m in _calc(doc, links)["표지"]) # 없는 조건 = 끊김
def test_요소_값_없음_줄만_비움():
got = _calc(
SAMPLE(), Fake({"LB990001": D(274978), "LB990002": "빈:값 없음 — 관리자가 채울 값"})
)
assert got["셈됨"] and got["줄"][1]["까닭"].startswith("값 없음")
assert got["노무비"] == 54995
def test_단가산출_부름_묶음_칸_결과():
hour = _doc(
[("w", {"갈래": "숫자", "값": D(100000)}), ("m", {"갈래": "숫자", "값": D("3000.7")})],
[],
[_man("조종원", "1", "w"), {"이름": "연료", "비목": "재료비", "단가": "m"}],
)
work = _doc(
[("q", {"갈래": "입력", "값": D("0.6")}, "버킷"), ("Q", {"갈래": "식"})],
[("Q", "반올림(3600 × q ÷ 18, 3)")],
결과="Q",
)
main = _doc(
[
("B", {"갈래": "단가산출", "키": "GX990002", "입력": {}}),
("B_경", {"갈래": "단가산출", "키": "GX990002", "입력": {}, "칸": "경비"}),
("Q_작업", {"갈래": "단가산출", "키": "GX990003", "입력": {"버킷": "0.7"}}),
],
[],
[
{"이름": "굴착기", "비목": "묶음", "수량": "1 ÷ Q_작업", "단가": "B", "묶음": "굴착기"},
_man("보통인부", "0.01", "B.계", 묶음="인력"),
],
)
links = Fake(docs={"GX990002": hour, "GX990003": work})
assert _calc(work, links)["결과"] == D(120)
got = _calc(main, links)
assert got["값"]["Q_작업"] == D(140) and got["값"]["B"]["계"] == D(103000)
assert got["값"]["B_경"] == 0
assert [x["이름"] for x in got["묶음"]] == ["굴착기", "인력"] and got["머리"] == "합계"
# 굴착기 = 100,000 ÷ 140 = 714.28 → 714.2 · 3,000 ÷ 140 = 21.42 → 21.4 · 인력 0.01 × 103,000 = 1,030
assert got["줄"][0]["비목"] == {"노무비": D("714.2"), "재료비": D("21.4"), "경비": D(0)}
assert got["묶음"][0]["계"] == D("735.6") and _money(got)["계"] == 1765
def test_반올림_자리_끝전_계_조정():
doc = _doc(
[("a", {"갈래": "숫자", "값": D("0.35")})],
[],
[
_man("갑", "1", "반올림(a × 10, 0)", 자리={"방법": "반올림", "자리": 0}),
_man("을", "1", "올림(a, 1) + 버림(a, 1)", "재료비"),
_man("병", "1", "1.6", "경비"),
],
)
got = _calc(doc)
assert [x["금액"] for x in got["줄"]] == [D(4), D("0.7"), D("1.6")]
assert _money(got) == {"노무비": 4, "재료비": 0, "경비": 1, "계": 5}
doc["끝수"]["끝전"] = "큰나머지" # 4 + 0.7 + 1.6 = 6.3 → 6 · 모자란 1 원 = 원 미만 큰 재료비
assert _money(_calc(doc)) == {"노무비": 4, "재료비": 1, "경비": 1, "계": 6}
doc["조정"] = {"배율": {"노무비": D("0.5")}, "옮김": "경비"}
assert _money(_calc(doc)) == {"노무비": 0, "재료비": 0, "경비": 4, "계": 4}
doc.pop("조정")
doc["끝수"] = {
"줄": {"방법": "버림", "자리": 0},
"비목": {"방법": "버림", "자리": 0},
"계": {"방법": "반올림", "자리": -1},
}
assert any(m["갈래"] == "끝수" for m in _calc(doc)["표지"]) # 자리 꼴 틀림 = 표지 · 멈춤
def test_번지_단계_합밖():
doc = _doc(
[],
[],
[
{"이름": "갑", "비목": "재료비", "단가": "1000", "번지": "A01"},
{"이름": "을", "비목": "재료비", "단가": "500", "번지": "A01"},
{"이름": "형틀", "비목": "노무비", "단가": "2000", "번지": "A02"},
{"이름": "소모", "비목": "재료비", "단가": "번지.A01.재료비 × 10 ÷ 100"},
{"이름": "참고", "비목": "경비", "단가": "999", "합밖": True},
{"이름": "손료", "비목": "경비", "단가": "단계.'설치'.계 × 1 ÷ 100"},
],
단계=[
{"이름": "자재", "단": 1, "줄": ["갑", "을", "소모"]},
{"이름": "설치", "단": 1, "줄": ["형틀"]},
{"이름": "계", "단": 2},
],
)
got = _calc(doc)
assert [x["금액"] for x in got["줄"]] == [1000, 500, 2000, 150, 999, 20]
assert _money(got) == {"노무비": 2000, "재료비": 1650, "경비": 20, "계": 3670}
assert [x["계"] for x in got["단계"]] == [1650, 2000, 3650] and got["줄"][4]["합밖"]
def test_결과_문서_입력_빔():
doc = _doc(
[("q", {"갈래": "입력", "값": None}), ("Q", {"갈래": "식"})], [("Q", "q × 2")], 결과="Q"
)
got = _calc(doc)
assert got["결과"] is None and not got["셈됨"] and "비어 있음" in got["까닭"]
assert _calc(doc, given={"q": "3"})["결과"] == 6 # 글 수 = 수
# ── ③ 표지 ────────────────────────────────────────────────────────────
def _kinds(got):
return {m["갈래"] for m in got["표지"]}
@pytest.mark.parametrize(
"lines, kind",
[
([("a", "b + 1")], "정의 필요"),
([("a", "b + 1"), ("b", "a × 2")], "순환"),
([("a", "1 ÷ (2 - 2)")], "0 나눔"),
([("a", "1 +")], "식 틀림"),
([("a", "'글' × 2")], "셈 틀림"),
([("a", "노무비 × 2")], "금액 이름"),
],
)
def test_표지_금액_줄표(lines, kind):
doc = _doc([], lines, [_man("갑", "1", "a")])
got = _calc(doc)
assert kind in _kinds(got)
assert not got["셈됨"] and got["계"] is None and got["노무비"] is None
def test_빈_문서_겹침_순환_부름():
got = _calc(md.blank())
assert not got["셈됨"] and got["계"] is None and _kinds(got) == {"빈 문서"}
doc = _doc([("a", {"갈래": "숫자", "값": D(1)})], [("a", "2")], [_man("갑", "1", "a")])
assert "겹침" in _kinds(_calc(doc)) and _calc(doc)["계"] is None
loop = _doc(
[("B", {"갈래": "단가산출", "키": "GX990001", "입력": {}})], [], [_man("갑", "1", "B.계")]
)
got = _calc(loop, Fake(docs={"GX990001": loop}))
assert not got["셈됨"] and "순환" in _kinds(got)
def test_정적_검사():
doc = SAMPLE()
assert md.check(doc) == []
doc["식"].append({"기호": "x", "식": "y + Q_형틀"})
doc["식"].append({"기호": "y", "식": "x"})
kinds = [m["갈래"] for m in md.check(doc)]
assert "순환" in kinds and "정의 필요" not in kinds
# ── ④ 지움 견딤 ───────────────────────────────────────────────────────
_ROWS: dict = {}
class Lazy(ml.Links):
"""M01 연결 + 부르는 문서는 그때 변환(옛 로직 → 수식 문서)."""
def doc(self, key):
try:
row = self.master.get(key)
except Exception:
return None
key = str(row["키"])
if key not in self.docs and key in _ROWS:
self.docs[key] = cv.convert(self.master, _ROWS[key])[0]
return self.docs.get(key)
def _real_docs(keys):
files, whole = m01.loaded()
_ROWS.update({r["키"]: r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]})
links = Lazy(whole, {})
return links, {key: links.doc(key) for key in keys}
REAL = (
"GC000211",
"GF000294",
"GC001147",
"GC000100",
"GF000185",
"GC000087",
"GF000221",
"GC000278",
)
def _cuts(doc):
"""지운 문서들 — 변수 · 식 줄 · 금액 줄 하나씩 · 식 글 하나씩 빈 글 · 반 토막."""
for kind in ("변수", "식", "금액"):
for i in range(len(doc.get(kind) or [])):
one = copy.deepcopy(doc)
del one[kind][i]
yield one
for i, line in enumerate(doc.get("식") or []):
for text in ("", line["식"][: len(line["식"]) // 2]):
one = copy.deepcopy(doc)
one["식"][i]["식"] = text
yield one
for i, line in enumerate(doc.get("금액") or []):
for k in ("수량", "단가"):
one = copy.deepcopy(doc)
one["금액"][i][k] = ""
yield one
one = copy.deepcopy(doc)
one.pop("결과", None)
yield one
def test_지움_견딤_예외_0():
links, docs = _real_docs(REAL)
count = 0
for doc in [SAMPLE(), *docs.values()]:
base = _calc(doc, links if doc.get("키") in docs else Fake(LABOR))
assert base["셈됨"]
for one in _cuts(doc):
got = _calc(one, links if doc.get("키") in docs else Fake(LABOR)) # 예외 없음
count += 1
assert set(got) >= {"값", "표지", "셈됨", "끝수"}
if not got["셈됨"]:
assert got.get("계") is None and got.get("결과") is None
assert got["표지"] or got.get("까닭")
assert count > 300
# ── ⑤ 거울 ────────────────────────────────────────────────────────────
def _canon(v):
if isinstance(v, (D, int, float)) and not isinstance(v, bool):
return mc.plain_text(D(str(v)))
if isinstance(v, list):
return [_canon(x) for x in v]
if isinstance(v, dict):
return {k: _canon(v[k]) for k in sorted(v)}
return v
class Rec:
"""연결 기록 — 같은 연결 값을 TS 에 넘김."""
def __init__(self, links):
self.links, self.log = links, {}
def _keep(self, kind, p, fn):
key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}"
try:
got = fn(p)
except mc.Gap as e:
self.log[key] = {"빈": str(e)}
raise
except mc.Broken as e:
self.log[key] = {"끊김": str(e)}
raise
self.log[key] = {"값": got}
return got
def element(self, p):
return self._keep("요소", p, self.links.element)
def table(self, p):
return self._keep("표", p, self.links.table)
def doc(self, key):
return self.links.doc(key)
def _ts(payload):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_802_1_formula_doc.cjs")],
input=json.dumps(ml._norm(payload), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def _same(a, b, where=""):
if isinstance(a, dict):
assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {}))
for k in a:
_same(a[k], b[k], f"{where}.{k}")
elif isinstance(a, list):
assert isinstance(b, list) and len(a) == len(b), where
for i, (x, y) in enumerate(zip(a, b)):
_same(x, y, f"{where}[{i}]")
elif isinstance(a, (D, int, float)) and not isinstance(a, bool):
assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b)
assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b)
else:
assert a == b, (where, a, b)
def test_거울_파이썬_TS_같은_답():
links, _ = _real_docs(())
picks = sorted(_ROWS)[::30] + list(REAL)
for key in picks:
links.doc(key)
rec = Rec(links)
want = {key: mc.calc(links.docs[key], rec) for key in picks}
all_docs = links.docs
fakes = {
"SAMPLE": (SAMPLE(), Fake(LABOR)),
"빈": (md.blank(), Fake()),
"순환": (_doc([], [("a", "b"), ("b", "a")], [_man("갑", "1", "a")]), Fake()),
"0나눔": (_doc([], [("a", "1 ÷ 0")], [_man("갑", "1", "a")]), Fake()),
}
for name, (doc, links) in fakes.items():
one = Rec(links)
want[name] = mc.calc(doc, one)
rec.log.update(one.log)
all_docs[name] = doc
texts = [
"a*b/c",
"-(a+b)%",
"만약(S=='x', 1, 2)",
"단계.'소계'.계 × 2",
"a +",
"C_{m 3}^표 ÷ 2",
]
cases = [
{"조건": {"유형": "보통", "두께": D(t)}, "칸": "품"}
for t in ("5", "10", "10.5", "20", "25")
] + [{"조건": {"유형": "분수"}, "칸": "품"}, {"조건": {"유형": "복잡"}, "칸": "율"}]
got = _ts(
{
"문서들": all_docs,
"대상": list(want),
"기록": rec.log,
"글": texts,
"표": [{"표": TABLE, **c} for c in cases],
}
)
for key, answer in want.items():
_same(ml._norm(answer), got["답"][key], key)
for text, ts_text in zip(texts, got["글"]):
try:
assert md.text_of(md.parse(text)) == ts_text
except md.DocError:
assert ts_text == {"틀림": True}
for case, ts_value in zip(cases, got["표"]):
try:
py = {"값": ml.Links.table(_TableOnly(TABLE), {"표": "QX990001", **case})}
except mc.Gap:
py = {"빈": True}
_same(ml._norm(py), ts_value, str(case))
assert len(want) >= 60
+103
View File
@@ -0,0 +1,103 @@
"""802-2 옛 M01 로직 → 수식 문서 변환 · 금액 전수 대조 지킴 시험.
빌더 `resources/master_data/scripts/build_수식문서_802_2.py`(한 번 돌림 · 2 분 안팎):
① --check — 다시 돌린 결과 = 저장본(`resources/master_data/수식문서/`) · 목록 xlsx.
② 로직 1,519 전수 기본 옵션 = 옛 엔진 노 · 재 · 경 · 계(결과) 원 단위 같음.
③ 옵션 표본 — 로직마다 고르기 입력의 모든 갈래 한 번 이상 · 같음 수 · 다름 = 목록 · 까닭 모름 0.
④ 저장 = 같은 것만(부르는 단가산출까지) · 저장본을 읽어 다시 셈해도 같음.
⑤ 791 실무 손 옮김 견본 넷(번지 · 단계 · 합밖 · 줄 자리 · 끝전 · 조정 · 값묶음)도 변환 셈 = 엑셀 금액.
"""
import importlib
import sys
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
HERE = Path(__file__).resolve().parent
build = importlib.import_module("build_수식문서_802_2")
mc = importlib.import_module("master_doc_calc")
ml = importlib.import_module("master_doc_link")
cv = importlib.import_module("master_doc_convert")
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
@pytest.fixture(scope="module")
def result():
return build.build(log=lambda *a: None)
def test_빌더_check_저장본_목록_같음(result):
assert build.check(result) == []
def test_로직_전수_기본_옵션_같음(result):
files, _ = m01.loaded()
count = sum(len(d.get("줄") or []) for d in files.values() if d.get("그룹") == "로직")
info = result["요약"]
assert info["로직"] == count >= 1519
assert info["기본 같음"] == count and info["기본 다름"] == 0
assert info["변환 알림"] == 0
def test_옵션_표본_모든_갈래_한_번_이상(result):
files, _ = m01.loaded()
seen = {(x["로직"], x["옵션"]) for x in result["대조"] if x["옵션"]}
want = 0
for _, row in build.rows_of(files):
base = build.meq.given_of(_whole(), row)
for spec in row.get("입력") or []:
for opt in spec.get("고르기") or []:
if str(opt) != str(base.get(spec["이름"])):
want += 1
assert (row["키"], f"{spec['이름']}={opt}") in seen
info = result["요약"]
assert info["옵션 표본"] == want == info["옵션 같음"] + info["옵션 다름"]
assert info["옵션 같음"] >= 18700 and info["옵션 다름"] <= 1
def _whole():
return m01.loaded()[1]
def test_다름_목록_까닭(result):
rows = build.table_rows(result)
sheet = build.read_list()["다름"]
assert sheet[0] == list(build.HEAD) and sheet[1:] == rows
diff = [x for x in result["대조"] if not x["같음"]]
assert all(x["까닭"] != "까닭 모름" for x in diff)
assert [(x["로직"], x["옵션"], x["까닭"]) for x in diff] == [
("GC000100", "지역=경북", build.RAW_SUB)
]
def test_저장_같은_것만_다시_셈(result):
saved = ml.load_all()
assert set(saved) == result["저장"]
assert "GC000100" not in saved and len(saved) == result["요약"]["저장"] >= 1518
for key in ("GC000211", "GC001147", "GF000294", "GC000087"):
doc = saved[key]
got = build.new_answer(ml.Links(_whole(), saved), doc)
old = next(x for x in result["대조"] if x["로직"] == key and not x["옵션"])["옛"]
assert got == old
# ── ⑤ 791 실무 견본 ────────────────────────────────────────────────────
def _semantics():
sys.path.insert(0, str(HERE))
return importlib.import_module("test_791_1_engine_semantics")
@pytest.mark.parametrize("name", ["봉화", "소광", "영월", "임도"])
def test_791_실무_견본_변환_셈_같음(name):
t = _semantics()
master, row, want = getattr(t, name)()
old = mcut.engine(master, row["키"], mf.run(master, row["키"], {}))
doc, notes = cv.convert(master, row, {})
got = mc.calc(doc, ml.Links(master, {row["키"]: doc}))
assert got["셈됨"], got["표지"]
assert {k: got[k] for k in want} == {k: old[k] for k in want} == want
assert not [x for x in notes if not x.startswith("기호")]
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""PLAN 802-3 — M01 단가산출 기초 수식 문서 화면 서버 길(요구 660~665 · 요구서_664).
① 읽기 {문서, 셈, 연결} · 없는 문서 = 빈 문서 · 없는 키 404
② 미리 셈 = 쓰기 없음 · 연결 기록(표 통째 · 단가산출 문서 · 요소 값)
③ 저장 = 서버가 정본으로 다시 셈(보낸 셈 값 무시) · 임시 폴더 파일에만 씀 · 다시 읽어 같음
④ 시스템 관리자 아니면 막힘(main.py 와 같은 의존성)
⑤ 지움 견딤 — 기호 · 줄 · 금액 줄 · 연결 대상 지움 · 순환 · 0 나눔 · 목록 밖 · 전부 지움 = 표지 · 금액 없음(500 없음)
"""
from __future__ import annotations
import copy
import shutil
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_auth as auth
from M01_MasterData import M01_MasterData_Doc as doc_screen
from M01_MasterData import M01_MasterData_Router as router_module
ROOT = Path(__file__).resolve().parents[2]
KEY = "GC000211" # 합판거푸집 설치 및 해체 — 계 124,419
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in doc_screen.folder().glob("수식_*.json"):
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path)
doc_screen._memo.clear()
app = FastAPI()
app.include_router(router_module.router)
yield TestClient(app)
doc_screen._memo.clear()
def _read(c: TestClient, key: str = KEY) -> dict:
res = c.get("/api/m01/logic/doc", params={"key": key})
assert res.status_code == 200, res.text
return res.json()
def _prev(c: TestClient, doc: dict, given: dict | None = None) -> dict:
res = c.post("/api/m01/logic/doc/preview", json={"문서": doc, "입력": given or {}})
assert res.status_code == 200, res.text
return res.json()
def test_읽기_미리셈_연결기록(client):
got = _read(client)
assert got["셈"]["계"] == 124419 and got["셈"]["머리"] == "계" and got["새"] is False
rec = got["연결"]
assert {"QC000428", "QC000429", "QC000431"} <= set(
rec["표"]
) # 표 통째(옵션 바뀌어도 화면이 찾음)
assert all("줄" in t for t in rec["표"].values() if t)
assert any(e["p"].get("키") == "LB000007" and "값" in e for e in rec["요소"])
assert rec["이름"]["QC000431"].startswith("합판거푸집")
# 옵션(입력) 바꿈 = 문서 안 바뀜 · 값만
other = _prev(client, got["문서"], {"폼타이": "사용", "측압": "4t/㎡"})
assert got["셈"]["값"]["Q_조"] == 0
assert other["셈"]["값"]["Q_조"] == other["셈"]["값"]["Q_4t^표"] > 0
def test_없는문서_빈문서_없는키_404(client):
fresh = _read(client, "GC000100") # 802-2a 로 문서 안 넣음
assert fresh["새"] is True and fresh["문서"]["식"] == [] and fresh["문서"]["금액"] == []
assert fresh["셈"]["셈됨"] is False and fresh["셈"]["계"] is None
assert client.get("/api/m01/logic/doc", params={"key": "GX999999"}).status_code == 404
def test_저장_서버가_다시셈_파일에씀(client, tmp_path):
doc = _read(client)["문서"]
doc["식"].append({"기호": "Z_시험", "식": "3600 × 2 ÷ 4"})
doc["셈"] = {"계": 1} # 브라우저 값 — 서버는 안 받음
res = client.post("/api/m01/logic/doc/save", json={"문서": doc})
assert res.status_code == 200, res.text
out = res.json()
assert out["셈"]["값"]["Z_시험"] == 1800 and out["셈"]["계"] == 124419
assert out["파일"] == "수식_건설품셈_06장_철근콘크리트공사.json"
assert "Z_시험" in (tmp_path / out["파일"]).read_text(encoding="utf-8")
again = _read(client)
assert again["문서"]["식"][-1] == {"기호": "Z_시험", "식": "3600 × 2 ÷ 4"}
assert again["셈"]["계"] == 124419
# 정본 폴더는 안 바뀜
real = (ROOT / "resources/master_data/수식문서" / out["파일"]).read_text(encoding="utf-8")
assert "Z_시험" not in real
# 꼴 아님 · 키 없음 = 400
assert client.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "x"}}).status_code == 400
assert (
client.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "수식문서"}}).status_code == 400
)
def test_새문서_저장_빈데서_시작(client, tmp_path):
blank = _read(client, "GC000100")["문서"]
blank["식"] = [{"기호": "Q", "식": "3600 × q ÷ C_m"}]
blank["변수"] = [
{"열쇠": "q", "기호": "q", "연결": {"갈래": "숫자", "값": 0.7}},
{"열쇠": "C_m", "기호": "C", "아래": "m", "연결": {"갈래": "숫자", "값": 18}},
{"열쇠": "w", "기호": "w", "연결": {"갈래": "요소", "키": "LB000002"}},
]
blank["금액"] = [{"이름": "보통인부", "비목": "노무비", "단가": "w ÷ Q"}]
out = client.post("/api/m01/logic/doc/save", json={"문서": blank}).json()
assert out["셈"]["셈됨"] and out["셈"]["값"]["Q"] == 140
assert _read(client, "GC000100")["새"] is False
def test_관리자_아니면_막힘(monkeypatch):
for role, code in (("USER", 403), ("COMPANY_ADMIN", 403), ("SYSTEM_ADMIN", 200)):
app = FastAPI()
app.include_router(
router_module.router,
dependencies=[Depends(auth.verify_session), Depends(auth.require_system_admin)],
)
app.dependency_overrides[auth.verify_session] = lambda role=role: {
"role": role,
"is_master": False,
"company_id": 1,
}
c = TestClient(app)
assert c.get("/api/m01/logic/doc", params={"key": KEY}).status_code == code
if code == 403:
res = c.post("/api/m01/logic/doc/save", json={"문서": {"꼴": "수식문서", "키": KEY}})
assert res.status_code == 403
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
def _cuts(doc: dict):
"""지운 · 망가뜨린 문서들."""
for i in range(len(doc["식"])):
one = copy.deepcopy(doc)
del one["식"][i] # 줄 하나 지움
yield one
one = copy.deepcopy(doc)
one["식"][i]["식"] = "없는기호 × 2" # 없는 기호
yield one
for i in range(len(doc["금액"])):
one = copy.deepcopy(doc)
del one["금액"][i]
yield one
for i in range(len(doc["변수"])):
one = copy.deepcopy(doc)
del one["변수"][i] # 기호 하나 지움 → 정의 필요
yield one
one = copy.deepcopy(doc)
kind = one["변수"][i]["연결"]["갈래"]
one["변수"][i]["연결"] = {"갈래": kind} # 연결 대상 아직 안 고름
yield one
one = copy.deepcopy(doc)
one["식"].append({"기호": "Q_시공^표", "식": "Q_시공 × 2"}) # 겹침
yield one
one = copy.deepcopy(doc)
for v in one["변수"]:
if v["열쇠"] == "유형":
v["연결"]["목록"] = ["보통"] # 목록 밖
yield one
one = copy.deepcopy(doc)
one["변수"], one["식"], one["금액"] = [], [], [] # 전부 지움
yield one
def test_지움_견딤_표지_금액없음(client):
doc = _read(client)["문서"]
n = 0
for one in _cuts(doc):
got = _prev(client, one)["셈"]
n += 1
if not got["셈됨"]:
assert got["계"] is None and (got["표지"] or got.get("까닭"))
assert n > 80
@pytest.mark.parametrize(
"line, kind",
[
({"기호": "Q_시공", "식": "Q_형틀목공 × 2"}, "순환"),
({"기호": "Q_시공", "식": "Q_시공^표 ÷ 0"}, "0 나눔"),
({"기호": "Q_시공", "식": "Q_시공^표 × ("}, "식 틀림"),
({"기호": "Q_시공", "식": "Z_없음 × 2"}, "정의 필요"),
],
)
def test_표지_갈래(client, line, kind):
doc = _read(client)["문서"]
doc["식"] = [line if x["기호"] == line["기호"] else x for x in doc["식"]]
got = _prev(client, doc)["셈"]
assert not got["셈됨"] and got["계"] is None
assert kind in {m["갈래"] for m in got["표지"]}
def test_연결_후보_표(client):
res = client.post(
"/api/m01/logic/doc/links", json={"갈래": "단가산출", "검색": "굴착기 시간당"}
)
hits = res.json()["후보"]
assert hits and all("입력" in h for h in hits)
res = client.post("/api/m01/logic/doc/links", json={"갈래": "표", "검색": "토사깍기 기계"})
assert any(h["키"] == "QF000249" for h in res.json()["후보"])
table = client.get("/api/m01/logic/doc/table", params={"key": "QF000249"}).json()
assert "조건" in table and table["줄"]
assert "끊김" in client.get("/api/m01/logic/doc/table", params={"key": "LB000002"}).json()
def test_옮김_군더더기_없음():
"""저장 문서 식 · 금액 글에 `+ 0` · `× 1` · `÷ 1` · `× k ÷ 100` 없음(옮김 `slim` · 값 같음)."""
import master_doc as md
import master_doc_convert as cv
import master_doc_link as ml
def junk(n) -> bool:
if n[0] == "bin":
a, b = n[2], n[3]
if n[1] in ("+", "-", "*", "/") and cv.slim(n) != n:
return True
return junk(a) or junk(b)
if n[0] in ("neg", "pct", "field"):
return junk(n[1])
return n[0] == "call" and any(junk(x) for x in n[2])
bad = []
for key, doc in ml.load_all().items():
texts = [x.get("식") for x in doc.get("식") or []]
texts += [x.get(k) for x in doc.get("금액") or [] for k in ("수량", "단가")]
for t in texts:
if t and junk(md.parse(t)):
bad.append((key, t))
assert not bad, bad[:5]
assert md.text_of(cv.slim(md.parse("(r + 0) × 1 × 2 ÷ 100 ÷ 1"))) == "r × 2%"
+260
View File
@@ -0,0 +1,260 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""802-4a M02 단가산출 · 일위대가 · B09 사본 = 수식 문서 — 임시 storage · M01 은 메모리 덮개.
① 찍기 = M01 문서 사본(금액 = M01 문서 셈) ② 고침 = 사본 문서(M01 안 바뀜 · 옛 칸 고침 409)
③ [단가 갱신] = 자원 단가만(문서 그대로) ④ [재생성] = 지금 M01 문서 · 고친 것 버림
⑤ 단가산출 연결 = [재생성] 때 따라감 · 끊기 = 사본 안에 굳힘 ⑥ SC · 일위대가 = 문서 셈(옛 금액 같음 ·
줄 규칙 켜쌓기율 · 고른 자재 · 별도계상) ⑦ B09 사본 `수식.json` ⑧ 권한 ⑨ 지움 견딤 · 빈 SK 「—」.
"""
from __future__ import annotations
import copy
import pytest
from fastapi import HTTPException
from M02_MasterTemplete import M02_Doc_B09 as doc_b09
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Router as router
from M02_MasterTemplete import M02_Doc_Sk as dsk
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Item_Source as source
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_795_1_sk_copy import _calc, _changed, _doc, _fake, _live, _save, _sk
from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor, _sc
from resources.tester.test_795_2_sc_copy import _calc as _sc_calc
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
FORM = "GC000211" # 합판거푸집(옵션 많음)
BASIC = "GC001148" # 굴착기 시간당
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"}
def _sum(folder, key):
return _calc(folder, _doc(folder, key))["기준"]["계"]
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _twice(doc, at=0):
"""금액 줄 하나 수량 × 2 — 사용자 고침 흉내."""
doc = copy.deepcopy(doc)
line = doc["금액"][at]
line["수량"] = f"({line.get('수량') or 1}) × 2"
return doc
def test_stamp_is_m01_doc_copy(world):
"""① 만들 때 M01 문서를 찍음 — 키 = SK · 옵션 값 · 금액 = M01 문서 셈 = 옛 금액."""
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM}, 입력값={"유형": "복잡", "사용횟수": "3회"})
got = _doc(me, key)[dc.KEY]
root, m01 = got["문서"], dc.folder()[FORM]
assert root["키"] == key and [v["기호"] for v in root["변수"]] == [
v["기호"] for v in m01["변수"]
]
assert next(v for v in root["변수"] if v["열쇠"] == "유형")["연결"]["값"] == "복잡"
assert not got.get("따로") and got["로직"] and got["단가"]
m01_calc = dc.mc.calc(root, dc.ml.Links(_live(), dc.folder()))
assert _sum(me, key) == m01_calc["계"]
token = dsk.OLD.set(True)
try:
assert _sum(me, key) == m01_calc["계"] # 옛 엔진 금액과 같음
finally:
dsk.OLD.reset(token)
read = _at(me, path.read, key)
assert read["셈"]["계"] == m01_calc["계"] and read["사본"]["따로"] is False
def test_edit_goes_to_copy_doc(world):
"""② 고침 = 그 사본 문서(따로) · M01 문서 그대로 · 옛 칸 고침 저장 409 · 다시 열어 같음."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
was, m01_before = _sum(me, key), dc.fp(dc.folder()[PCT])
read = _at(me, path.read, key)
pre = _at(me, path.preview, key, {"문서": _twice(read["문서"])})
assert pre["전"]["계"] == was and pre["후"]["계"] > was
saved = _at(me, path.save, key, {"문서": _twice(read["문서"]), "판": read["판"]})
assert saved["사본"]["따로"] is True and saved["셈"]["계"] == pre["후"]["계"]
assert _sum(me, key) == pre["후"]["계"] # SK 금액(일위대가가 쓰는 값) = 고친 문서
assert dc.fp(dc.folder()[PCT]) == m01_before
assert _at(me, path.read, key)["문서"] == saved["문서"]
doc = _doc(me, key)
with pytest.raises(store.StoreError) as e:
_save(me, key, {**doc, "입력값": {"x": 1}, "덮개": {"labor:LB000036#0": {"qty": 3}}})
assert e.value.status == 409
_save(me, key, {**doc, "이름": "sub2임시 이름만"}) # 머리 칸 = 그대로 받음
assert _sum(me, key) == pre["후"]["계"]
with pytest.raises(store.StoreError) as e: # 판 다름
_at(me, path.save, key, {"문서": read["문서"], "판": read["판"]})
assert e.value.status == 409
def test_price_refresh_values_only(world, monkeypatch):
"""③ [단가 갱신] = 자원 단가만 — M01 문서 바뀜은 안 따라감 · 바뀐자원 · 누르기 전 그대로."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
was = _sum(me, key)
root = _at(me, path.read, key)["문서"]
_fake(monkeypatch, [_changed(_live().logic(PCT)), _double(LB)])
assert _sum(me, key) == was
pre = _at(me, path.preview, key, {"할일": "단가갱신"})
assert [x["키"] for x in pre["바뀐자원"]] == [LB] and pre["문서"] == root
fresh = _sum(me, _sk(me, 바탕={"로직": PCT})) # 새 문서 + 새 단가
assert pre["후"]["계"] != was and pre["후"]["계"] != fresh
read = _at(me, path.read, key)
got = _at(me, path.save, key, {"할일": "단가갱신", "판": read["판"]})
assert got["셈"]["계"] == pre["후"]["계"] == _sum(me, key) and got["문서"] == root
assert got["사본"]["단가새값"] is False and PCT in got["사본"]["문서새판"]
def test_regen_takes_m01_doc_drops_edits(world, monkeypatch):
"""④ [재생성] = 지금 M01 문서로 · 고친 문서 버림(알림) · 파생으로 돌아감."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
read = _at(me, path.read, key)
_at(me, path.save, key, {"문서": _twice(read["문서"], 1), "판": read["판"]})
_fake(monkeypatch, [_changed(_live().logic(PCT))])
fresh = _sum(me, _sk(me, 바탕={"로직": PCT}))
pre = _at(me, path.preview, key, {"할일": "재생성", "문서": {"아무": "버려짐"}})
assert pre["버림"]["문서고침"] is True and pre["후"]["계"] == fresh
read = _at(me, path.read, key)
got = _at(me, path.save, key, {"할일": "재생성", "판": read["판"]})
assert got["사본"]["따로"] is False and got["셈"]["계"] == fresh == _sum(me, key)
assert got["사본"]["M01새판"] is False and got["사본"]["문서새판"] == []
def test_link_follows_on_regen_unlink_freezes(world, monkeypatch):
"""⑤ 단가산출 연결 = 부름 사본(M01 바뀌어도 그대로 · [재생성] 때 따라감) · 끊기 = 사본 안 고침."""
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)])
was = _sum(me, key)
read = _at(me, path.read, key)
assert BASIC in read["부름"]
cut = {BASIC: _twice(read["부름"][BASIC])}
pre = _at(me, path.preview, key, {"끊음": cut})
assert pre["후"]["계"] > was
saved = _at(me, path.save, key, {"끊음": cut, "판": read["판"]})
assert saved["끊음"][BASIC] == {**cut[BASIC], "키": BASIC} and _sum(me, key) == pre["후"]["계"]
_fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 원본 바뀜
assert _sum(me, key) == pre["후"]["계"] # 끊은 것 · 연결 모두 그대로(누르기 전)
with pytest.raises(store.StoreError) as e: # 부르지 않는 키 끊기
_at(me, path.preview, key, {"끊음": {"GC999999": read["부름"][BASIC]}})
assert e.value.status == 400
read = _at(me, path.read, key)
pre = _at(me, path.preview, key, {"할일": "재생성"})
assert pre["버림"]["끊음"] == [BASIC]
got = _at(me, path.save, key, {"할일": "재생성", "판": read["판"]})
assert got["끊음"] == {} and got["셈"]["계"] > was # 연결 = 새 원본 따라감
def test_sc_and_labor_use_doc(world, monkeypatch):
"""⑥ SC · 일위대가 로직 줄 = 문서 셈(부름 사본) · 옛 엔진 금액과 같음 · M01 바뀌어도 그대로."""
me = world["personal"]
sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}])
_labor(me, [{"로직": FORM, "수량": {"손": 2}, "입력값": {"유형": "보통"}}])
stored = _at(me, store.read, "precombo", sc)["문서"]
assert dc.has(stored) and PCT in stored[dc.KEY]["부름"]
runs = []
real = dc.run
monkeypatch.setattr(dc, "run", lambda *a, **k: runs.append(1) or real(*a, **k))
new = (_sc_calc(me, sc)["기준합"], _sc_calc(me, "sub2임시")["반영합"])
assert runs
token = dsk.OLD.set(True)
try:
assert (_sc_calc(me, sc)["기준합"], _sc_calc(me, "sub2임시")["반영합"]) == new
finally:
dsk.OLD.reset(token)
_fake(monkeypatch, [_changed(_live().logic(PCT))])
assert _sc_calc(me, sc)["기준합"] == new[0]
@pytest.mark.parametrize(
"code, rate, picks, marks",
[
("GC000261", 90, None, None), # 켜쌓기율 = 변수 r · 수량 × r ÷ 100
("GF000156", None, {"3": "MT004176", "4": "MT003834"}, None), # 고른 자재 = 요소 키
("GC000665", None, None, None), # 별도계상(기본) = 합밖 · 「재료비의 %」 밑엔 듦
],
)
def test_line_rules_in_doc(code, rate, picks, marks):
"""⑥ 일위대가 줄 옵션 = 문서 규칙(브레인 ④) — 옛 `run_line` · `drop_apart` 와 같은 금액."""
whole = _live()
old = labor.drop_apart(labor.run_line(whole, code, {}, rate, picks), marks)
assert old["기준"]
apart = dc.apart_of(marks, old.get("재료"))
doc = dc.line_doc(dc.folder()[code], rate, picks, apart)
if rate:
assert dc.RATE_ID in str(doc["금액"][0]["수량"])
if apart:
assert all(doc["금액"][n].get("합밖") for n in apart)
got = dc.mc.calc(doc, dc.ml.Links(whole, dc.folder()), dc.given_of(whole, code, {}))
assert {k: got[k] for k in dc.COSTS} == {k: old["기준"][k] for k in dc.COSTS}
assert dc.line_doc(dc.folder()[code]) is dc.folder()[code] # 옵션 없음 = 그 문서
def test_b09_copy_has_doc_file(world, monkeypatch):
"""⑦ B09 사본 = 단가 묶음 옆 `수식.json`(만든 때 부름) · 원본판엔 안 듦 · M01 바뀌어도 그대로."""
me = world["personal"]
doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}])
rows = source.labor_doc_rows(doc)
files = doc_b09.attach({"일위대가.json": rows, "단가.json": source.price_bundle(rows, me)})
made = files[doc_b09.FILE]
assert made["그룹"] == "수식문서" and PCT in made["문서"]
assert doc_b09.FILE not in snap.source_files(files)
assert doc_b09.attach(files) is files # 이미 있음 = 그대로
_fake(monkeypatch, [_changed(_live().logic(PCT))])
again = doc_b09.build(source.price_bundle(rows, me), rows)
assert again["문서"][PCT] == made["문서"][PCT] # 일위대가 수식 사본 = 만든 때
def _session(role="USER"):
return {"role": role, "company_id": 7, "user_id": 42}
def test_permission_layer(world):
"""⑧ 읽기 = 층 읽기 · 미리 셈 · 저장 = 층 쓰기(마스터 = 시스템 관리자 · 회사 = 회사 관리자)."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
body = router.DocBody(문서=_at(me, path.read, key)["문서"])
for layer in ("system", "company"):
for fn in (router.preview_doc, router.save_doc):
with pytest.raises(HTTPException) as e:
fn(layer, key, body, None, _session())
assert e.value.status_code == 403
assert router.read_doc("personal", key, None, _session())["셈"]["셈됨"]
got = router.preview_doc("personal", key, body, None, _session())
assert got["후"] == got["전"]
def test_delete_tolerant_and_blank(world):
"""⑨ 무엇을 지워도 오류 없음 — 변수 지움 = 정의 필요 · 금액 「—」 · 저장됨 · 빈 SK = 「—」."""
me = world["personal"]
key = _sk(me, 바탕={"로직": PCT})
read = _at(me, path.read, key)
gone = copy.deepcopy(read["문서"])
gone["변수"] = [v for v in gone["변수"] if v["열쇠"] != "w_지붕잇기공"]
pre = _at(me, path.preview, key, {"문서": gone})
assert pre["셈"]["셈됨"] is False and pre["후"] is None
assert any(m["갈래"] == "정의 필요" for m in pre["셈"]["표지"])
_at(me, path.save, key, {"문서": gone, "판": read["판"]})
got = _calc(me, _doc(me, key))
assert got["기준"] is None and got["수식셈"]["셈됨"] is False
lost = copy.deepcopy(read["문서"])
lost["변수"].append({"열쇠": "b", "기호": "b", "연결": {"갈래": "단가산출", "키": "GC999999"}})
lost["금액"].append({"이름": "없는 기초", "비목": "묶음", "단가": "b"})
pre = _at(me, path.preview, key, {"문서": lost})
assert any(m["갈래"] == "연결 끊김" for m in pre["셈"]["표지"])
for bad in ({"문서": "글"}, {"문서": {"금액": "x"}}, {"끊음": []}, {"할일": "몰라"}):
with pytest.raises(store.StoreError) as e:
_at(me, path.preview, key, bad)
assert e.value.status == 400
empty = _sk(me)
got = _calc(me, _doc(me, empty))
assert got["기준"] is None and "빈 문서" in got["수식셈"]["까닭"]
+227
View File
@@ -0,0 +1,227 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""802-4b-2 M02 단가산출(SK) 수식 탭 = 공용 수식 문서 화면 — 임시 storage · M01 은 메모리 덮개.
① 길 앞머리 — M02 수식 탭 = 공용 화면(`createDocScreen` · `/m02/layers/{층}/sangeun/{키}/doc`) ·
옛 M02 수식 나무 화면 걷음 · 연결 후보 · 표 길 등록
② 읽기 연결 기록만으로 같은 셈(화면이 네트워크 없이 다시 셈) — 끊은 단가산출 = 끊음 문서 먼저
③ 권한 — 회사 층 일반 계정 = 읽기 · 후보 · 표만(미리 셈 · 저장 403) · 화면 = 읽기만(옵션 잠금)
④ 꺼진 줄 — 만약 0 갈래가 계산 변수를 거쳐 와도 꺼짐(폼타이 「아님」 = 세 줄) · 입력 0 안 숨김 ·
1,518 문서 기본 옵션 — 옛 판정 ⊆ 새 판정 · 꺼진 줄 금액 모두 0
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import HTTPException
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Router as router
from M02_MasterTemplete import M02_Doc_Screen as screen
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_795_1_sk_copy import _sk
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
from resources.tester.test_m02_structure import write_sample_table
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
FORM = "GC000211" # 합판거푸집 — 폼타이 · 측압 · 규격 옵션
BASIC = "GC001148" # 굴착기 시간당
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub3임시 굴착기"}
def _at(folder, fn, *args):
with store.at(folder):
return fn(*args)
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_길_앞머리_M02_공용화면():
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
assert "createStackScreen({" in tabs and "copy: { route: (k, p) => skDocPath(" in tabs
assert "/m02/layers/${scope.layer}/sangeun/${encodeURIComponent(key)}/doc${tail}" in tabs
assert "admin: opt.edit" in tabs # 층 쓰기 권한 = 고침
scr = _src("A00_Common/line_stack_screen.ts") # 804-6 수식 탭 = 줄 쌓기
assert "cp ? cp.route(key, path) : opt.base + path" in scr
assert "{ 문서: doc, 끊음: cut, 판: version }" in scr # 저장 몸
detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
assert 'tabs.draw(/^SK-\\d{6}$/.test(doc.키 ?? "")' in detail # 저장한 SK 만 · 줄 모달 = 글
assert not (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts").exists()
main = _src("main.py")
assert '__import__("M02_MasterTemplete.M02_Doc_Screen", fromlist=["router"]).router' in main
for name in ("line_stack_screen", "formula_doc_copy"):
assert len(_src(f"A00_Common/{name}.ts").splitlines()) <= 700, name
class _Replay:
"""화면 캐시 흉내 — 서버 연결 기록만으로 셈(`common_util_formula_doc_cache.ts` 짝)."""
def __init__(self, rec: dict):
self.rec = rec
self.els = {self._k(e["p"]): e for e in rec.get("요소") or []}
@staticmethod
def _k(p) -> str:
return json.dumps(p, ensure_ascii=False, sort_keys=True, default=str)
def element(self, p):
got = self.els[self._k(dc.ml._norm(p))]
if "빈" in got:
raise dc.mc.Gap(got["빈"])
if "끊김" in got:
raise dc.mc.Broken(got["끊김"])
return Decimal(str(got["값"])) if isinstance(got["값"], (int, float)) else got["값"]
def table(self, p):
return dc.ml.Links(_Tables(self.rec["표"]), {}).table(p)
def doc(self, key):
d = (self.rec.get("문서") or {}).get(key)
return json.loads(json.dumps(d), parse_float=Decimal, parse_int=Decimal) if d else d
class _Tables:
"""기록 표만 든 색인(`Links.table` 이 `master.get` 으로 찾음)."""
def __init__(self, tables: dict):
self.tables = tables
def get(self, ref):
return json.loads(json.dumps(self.tables[ref]), parse_float=Decimal, parse_int=Decimal)
def _replay(read: dict) -> dict:
doc = json.loads(json.dumps(read["문서"]), parse_float=Decimal, parse_int=Decimal)
return dc.mc.calc(doc, _Replay(read["연결"]))
def test_연결기록만으로_같은셈_끊음먼저(world):
me = world["personal"]
key = _sk(
me,
바탕={"로직": FORM},
입력값={"폼타이": "사용", "측압": "4t/㎡"},
더한줄=[copy.deepcopy(ADD)],
)
read = _at(me, path.read, key)
rec = read["연결"]
assert {"요소", "표", "문서", "이름"} <= set(rec) and BASIC in rec["문서"]
assert _replay(read)["계"] == read["셈"]["계"]
cut = copy.deepcopy(read["부름"][BASIC])
line = cut["금액"][0]
line["수량"] = f"({line.get('수량') or 1}) × 2" # 끊은 문서 안 줄 고침
saved = _at(me, path.save, key, {"끊음": {BASIC: cut}, "판": read["판"]})
assert saved["연결"]["문서"][BASIC]["금액"][0]["수량"] == line["수량"] # 끊음 먼저
assert saved["셈"]["계"] > read["셈"]["계"]
assert _replay(saved)["계"] == saved["셈"]["계"]
again = _at(me, path.read, key)
assert again["끊음"][BASIC]["금액"][0]["수량"] == line["수량"] and again["판"] == saved["판"]
got = _at(me, path.save, key, {"끊음": {}, "판": again["판"]}) # 다시 잇기
assert got["끊음"] == {} and got["셈"]["계"] == read["셈"]["계"]
def _session(role="USER"):
return {"role": role, "company_id": 7, "user_id": 42}
def test_권한_회사층_일반계정_읽기만(world):
company = world["company"]
company.mkdir(parents=True, exist_ok=True)
write_sample_table(company)
with store.at(company):
store.create_structure("rv")
key = _sk(company, 바탕={"로직": FORM})
read = router.read_doc("company", key, None, _session())
assert read["셈"]["셈됨"] and read["연결"]["표"]
body = router.DocBody(문서=read["문서"])
for fn in (router.preview_doc, router.save_doc):
with pytest.raises(HTTPException) as e:
fn("company", key, body, None, _session())
assert e.value.status_code == 403
found = screen.doc_links(
"company", key, screen.LinksBody(갈래="단가산출", 검색="굴착기 시간당"), None, _session()
)
assert any(c["키"] == "GC000267" for c in found["후보"])
assert "줄" in screen.doc_table("company", key, "QC000431", None, _session())
scr = _src("A00_Common/line_stack_screen.ts")
assert "buildOptions(doc, given, !!cp && !opt.admin," in scr # 사본 읽기만 = 옵션 잠금
copy_src = _src("A00_Common/formula_doc_copy.ts")
assert "if (!edit) return [];" in copy_src and 'createBadge(ft("Read_Only")' in copy_src
# ── ④ 꺼진 줄(TS 판정 그대로 · Node) ─────────────────────────────────
def _off(cases: list[dict]) -> list[dict]:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_802_4b2_off.cjs")],
input=json.dumps(dc.ml._norm(cases), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def _vals(doc: dict, given: dict | None = None) -> dict:
links = dc.ml.Links(dc.skc.whole_(), dc.folder())
return dc.mc.calc(doc, links, given).get("값") or {}
def test_꺼진줄_계산변수_거침():
doc = dc.folder()[FORM]
names = [x["이름"] for x in doc["금액"]]
tie = [i for i, n in enumerate(names) if n.startswith("폼타이")]
hole = [i for i, n in enumerate(names) if "구멍땜" in n]
assert len(tie) == 3 and hole
off, on, on2 = _off(
[
{"문서": doc, "값": _vals(doc)}, # 폼타이 아님 · 규격 12.7
{"문서": doc, "값": _vals(doc, {"폼타이": "사용"})},
{"문서": doc, "값": _vals(doc, {"폼타이": "사용", "폼타이규격": "9.5㎜"})},
]
)
assert set(tie) <= set(off["새"]) and tie[0] not in off["옛"] # Q_조 = 만약 0 을 거침
assert set(on["새"]) == set(tie) - {tie[0]} and set(on2["새"]) == set(tie) - {tie[1]}
assert not set(hole) & set(off["새"]) # 구멍땜 0 개소 = 입력 0 · 안 숨김
plain = {
"식": [{"기호": "Z", "식": "0"}, {"기호": "Y", "식": "만약(S == 'a', 0, 2)"}],
"금액": [
{"이름": "글 0", "비목": "노무비", "수량": "Z × 3", "단가": "w"},
{"이름": "입력 0", "비목": "노무비", "수량": "X × 3", "단가": "w"},
{"이름": "거침", "비목": "노무비", "수량": "Y × 3 ÷ 10", "단가": "w"},
{"이름": "합", "비목": "노무비", "수량": "Y + 1", "단가": "w"},
{"이름": "순환", "비목": "노무비", "수량": "V", "단가": "w"},
],
}
plain["식"].append({"기호": "V", "식": "V × 2"})
got = _off([{"문서": plain, "값": {"S": "a", "Z": 0, "X": 0, "Y": 0, "w": 5}}])[0]
assert got["새"] == [2]
def test_꺼진줄_전수_1518():
docs = dc.folder()
links = dc.ml.Links(dc.skc.whole_(), docs)
keys, cases, rows = [], [], []
for key, doc in docs.items():
ans = dc.mc.calc(doc, links)
keys.append(key)
cases.append({"문서": doc, "값": ans.get("값") or {}})
rows.append(ans.get("줄") or [])
got = _off(cases)
assert len(keys) >= 1518
new = sum(len(g["새"]) for g in got)
assert all(set(g["옛"]) <= set(g["새"]) for g in got) and new > sum(len(g["옛"]) for g in got)
for key, g, r in zip(keys, got, rows):
for i in g["새"]:
amt = r[i].get("금액") if i < len(r) else None
assert amt is None or float(amt) == 0, (key, i, amt)
+338
View File
@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""PLAN 804-2 — 단가산출 「줄 쌓기」 탭(요구 670~677) · 수식 문서 ↔ 줄 쌓기 한 벌.
① 왕복 1,518 전수 — 문서 → 줄 → 문서 같음 · 금액 줄 · 기호 모두 한 번 ·
차림 고침(항목 · 글 줄 · 가나다) 다시 읽어 같음 · 차림 칸 `쌓기` 든 문서도 서버 셈 같음
② 금액 — 화면 길(연결 기록 + TS 계산기) = 서버 정본 셈(1,518 전수)
③ 기계 펼침 — 시간당 경비 문서를 넘긴 입력으로 셈 = 부른 쪽 단가 묶음(원 단위) ·
`M02_Machine_Hourly.hourly`(같은 로직 · 같은 입력) 와 같음
④ 틀 풀기 — 단가산출 결과 연결을 이 문서 식으로 옮김 = 금액 같음(전수) ·
Cm 고침 → 값 · 지움 = 표지(오류 없음)
⑤ 저장 — `쌓기` 든 문서 저장(임시 폴더) · 다시 읽어 같음 · 정본 폴더 안 바뀜
⑥ 화면 이음 — M01 · M02 보기 셋 · 같은 길 · 같은 캐시 열쇠 · KaTeX 미리 묶기 뺌
"""
from __future__ import annotations
import copy
import importlib
import json
import os
import shutil
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Doc as doc_screen
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Machine_Hourly as mh
mr = importlib.import_module("master_doc_record") # M01 store 가 scripts 길을 둔 뒤
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
def _ts(job: str, cases: list[dict]) -> list:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_804_2_line_stack.cjs")],
input=json.dumps(dc.ml._norm({"할일": job, "문서들": cases}), ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr[-3000:]
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def world():
docs = dc.folder()
links = dc.ml.Links(dc.skc.whole_(), docs)
cases, py = [], {}
for key, doc in docs.items():
rec = mr.Rec(links)
rec.prefetch(doc)
py[key] = dc.mc.calc(doc, rec)
cases.append({"문서": doc, "연결": rec.out()})
return {"docs": docs, "links": links, "py": py, "cases": dict(zip(docs, cases))}
def _num(v):
return None if v is None else float(v)
def test_왕복_전수_1518(world):
docs = world["docs"]
assert len(docs) >= 1518
got = _ts("왕복", [{"문서": d} for d in docs.values()])
bad = {f: [k for k, g in zip(docs, got) if not g[f]] for f in ("같음", "금액", "기호", "차림")}
assert bad == {"같음": [], "금액": [], "기호": [], "차림": []}
assert not any(g["쌓기"] for g in got) # 기본 차림 = 문서에 `쌓기` 안 씀
# 차림 칸이 든 문서 — 서버 셈이 같음(계산기는 차림을 안 봄)
for key, g in list(zip(docs, got))[::7]:
laid = json.loads(json.dumps(g["차림문서"]), parse_float=Decimal, parse_int=Decimal)
assert "쌓기" in laid
a, b = dc.mc.calc(docs[key], world["links"]), dc.mc.calc(laid, world["links"])
assert [_num(a.get(f)) for f in (*COSTS, "결과")] == [
_num(b.get(f)) for f in (*COSTS, "결과")
], key
# 못 고치는 칸 목록(보존됨) — 지금 문서엔 금액 줄 기호만
notes = {n for g in got for n in g["못"]}
assert notes <= {"단계", "조정", "번지", "합밖", "줄 자리", "금액 줄 기호"}
def test_금액_화면길_서버정본_전수(world):
keys = list(world["docs"])
got = _ts("셈", [world["cases"][k] for k in keys])
diff = [
(k, f)
for k, g in zip(keys, got)
for f in (*COSTS, "결과")
if _num(world["py"][k].get(f)) != (None if g.get(f) is None else pytest.approx(g[f]))
]
assert diff == [] and not any(g["누락"] for g in got)
world["셈"] = dict(zip(keys, got))
def _hourly_inputs(logic_key: str) -> dict:
"""hourly 가 고른 로직 · 그 로직의 기본 입력(기계 밖)."""
rows = {r["키"]: r for r in mh._family()}
row = rows.get(logic_key)
if not row:
return {}
return {
i["이름"]: str((i.get("고르기") or [""])[0])
for i in row.get("입력") or []
if i.get("이름") != "기계"
}
def test_기계펼침_원단위_hourly(world):
got = world.get("셈") or dict(zip(world["docs"], _ts("셈", list(world["cases"].values()))))
eq = m01.loaded()[1].index["EQ"]
by_code: dict[str, list[str]] = {}
for key, row in eq.items():
by_code.setdefault(str(row.get("원문번호")), []).append(key)
lines = same_parent = same_hourly = 0
memo: dict[str, dict] = {}
family = mh._family()
for k, g in got.items():
for s in g["펼침"]:
if s["갈래"] != "기계":
continue
lines += 1
# 펼친 셈 = 부른 쪽 단가 묶음(화면 「노무비 : 59,020」 의 그 수)
assert all(float(s["값"][f]) == float(s[f]) for f in COSTS), (k, s["키"])
same_parent += 1
code = str(s["입력"].get("기계"))
for key in by_code.get(code, []):
if key not in memo: # 같은 기계는 한 번만 셈
memo[key] = mh.hourly(key)
h = memo[key]
if not h.get("ok"):
continue
pick = min(
(r for r in family if code in [str(c) for c in mh._codes(r)]),
key=mh._rank,
)
want = _hourly_inputs(pick["키"])
given = {n: str(v) for n, v in s["입력"].items() if n != "기계"}
if pick["키"] != s["키"] or any(want.get(n) != v for n, v in given.items()):
break # 다른 로직 · 다른 입력(조종원 직종 등)을 넘긴 줄
assert all(float(h[f]) == float(s[f]) for f in COSTS), (k, key, h, s)
same_hourly += 1
break
assert lines >= 1000 and same_parent == lines and same_hourly >= 800
def test_틀풀기_전수_금액같음_Cm고침(world):
cases = list(world["cases"].values())
got = [x for g in _ts("펼침", cases) for x in g]
assert len(got) >= 20 # 단가산출 결과 연결(굴착기 Q 등) 전부
bad = [g["기호"] for g in got if g["전"] != g["후"] or not g["왕복"]]
assert bad == []
one = _ts(
"펼침",
[{**world["cases"]["GF000099"], "기호": "Q_작업", "고침": [{"기호": "Cm", "글": "20"}]}],
)[0][0]
assert one["전"]["계"] == 5286 and one["후"]["계"] == 5286
assert one["옮김"][-1] == "Q_작업" and {"q", "K", "f", "E", "Cm"} <= set(one["옮김"])
assert one["고친"]["계"] == 5434 # Cm 18 → 20 = Q 58.212 → 52.391
gone = _ts("펼침", [{**world["cases"]["GF000099"], "기호": "Q_작업", "지움": ["Cm"]}])[0][0]
assert gone["고친"]["계"] is None and "정의 필요" in gone["표지"] # 지워도 오류 없음
@pytest.fixture
def client(tmp_path: Path, monkeypatch):
for path in doc_screen.folder().glob("수식_*.json"):
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path)
doc_screen._memo.clear()
app = FastAPI()
app.include_router(router_module.router)
yield TestClient(app)
doc_screen._memo.clear()
def test_저장_쌓기_다시읽어같음(client, tmp_path):
read = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
doc = copy.deepcopy(read["문서"])
got = _ts("왕복", [{"문서": doc}])[0]
laid = got["차림문서"]
res = client.post("/api/m01/logic/doc/save", json={"문서": laid})
assert res.status_code == 200, res.text
assert res.json()["셈"]["계"] == read["셈"]["계"] == 5286
again = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
assert (
again["문서"]["쌓기"] == laid["쌓기"] and _ts("왕복", [{"문서": again["문서"]}])[0]["같음"]
)
text = (tmp_path / res.json()["파일"]).read_text(encoding="utf-8")
assert '"쌓기": [' in text and "< 근거 0 >" in text
real = (ROOT / "resources/master_data/수식문서" / res.json()["파일"]).read_text(
encoding="utf-8"
)
assert "< 근거 0 >" not in real
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_화면_이음():
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab
assert "stack: stack.root" in lab and "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기
assert '{ value: "stack", label: tl("View_Equation") }' in _src(
"M01_MasterData/M01_MasterData_UI_LogicLab_View.ts"
)
assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 1
vite = _src("vite.config.ts")
assert 'exclude: ["0_old", "katex"]' in vite and "katex/dist/katex.mjs" in vite
pkg = json.loads(_src("config/package.json"))
assert "katex" in pkg["dependencies"]
lic = json.loads(_src("config/node_modules/katex/package.json"))["license"]
assert lic == "MIT"
for f in (ROOT / "A00_Common").glob("line_stack*.ts"):
assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name
# ── 804-3 줄 쌓기 수정 1차(요구 678) ──
def _tbl(world, key, **kw):
return _ts("표고름", [{**world["cases"][key], **kw}])[0]
def _calc(world, doc):
return dc.mc.calc(
json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal), world["links"]
)
def test_표고름_값_출처(world):
# 옵션에 매인 조건(규격 = S_폼타이규격) — 줄은 옵션 그대로 · 칸만 바뀜
a = _tbl(world, "GC000211", 기호="Q_3t^표", 줄=1, 칸="4t/㎡")
assert a["묶임"] == {"규격": ["폼타이규격"]} and a["전줄"] == 2
assert a["연결"] == {
"갈래": "표",
"표": "QC000429",
"조건": {"규격": "S_폼타이규격"},
"칸": "4t/㎡",
}
assert a["값"] == 0.72 and a["후줄"] == 2 # 12.7㎜ 줄 · 4t/㎡
# 옵션에 안 매인 조건 — 고른 줄 값이 조건으로 · 값 = 표 칸 · 서버 셈 같음
b = _tbl(
world,
"GC000211",
기호="Q_시공^표",
줄=4,
칸="시공량",
앞=[{"기호": "Q_시공^표", "조건": {"유형": "'보통'"}}],
)
assert b["묶임"] == {} and b["전줄"] == 3
assert b["연결"]["조건"] == {"유형": "'간단'"} and b["연결"]["표"] == "QC000431"
assert b["값"] == 50 and b["후줄"] == 4
py = _calc(world, b["문서"])
assert [_num(py.get(f)) for f in COSTS] == [b[f] for f in COSTS]
assert b["계"] != world["py"]["GC000211"]["계"] # 시공량 20 → 50 = 금액 바뀜
# 조건 없는 표(한 줄) — 칸만
c = _tbl(world, "GC000211", 기호="Q_합판2^표", 줄=0, 칸="각재")
assert (
c["연결"] == {"갈래": "표", "표": "QC000427", "조건": {}, "칸": "각재"} and c["값"] == 0.038
)
def test_찾아넣기_요소키_금액(world):
h = mh.hourly("EQ000016")
assert h["ok"] and h["로직"] and h["원문번호"] == "0201-0012"
picks = [
{"갈래": "인력", "고른": {"키": "LB000002", "이름": "보통인부", "단위": "인"}},
{"갈래": "재료", "고른": {"키": "MT000018", "이름": "합판(내수), 서울", "규격": "12t×1220×2440mm", "단위": "㎡"}},
{"갈래": "기계", "고른": {"키": h["로직"], "이름": "굴착기(무한궤도)", "규격": "0.12㎥", "입력": {"기계": "'0201-0012'"}}},
{"갈래": "단가산출", "고른": {"키": "GF000099", "이름": "측구터파기 토사", "단위": "㎥"}},
] # fmt: skip
got = _ts("넣기", [{"문서": world["docs"]["GC000211"], "넣을": picks}])[0]
assert got["갈래"] == ["인력", "재료", "기계", "단가산출"]
doc = got["문서"]
links = {v["기호"] + ("_" + v["아래"] if v.get("아래") else ""): v["연결"] for v in doc["변수"]}
assert [links[i] for i in got["기호"]] == [
{"갈래": "요소", "키": "LB000002"},
{"갈래": "요소", "키": "MT000018"},
{"갈래": "단가산출", "키": h["로직"], "입력": {"기계": "'0201-0012'"}},
{"갈래": "단가산출", "키": "GF000099", "입력": {}},
]
new = doc["금액"][got["처음"] :]
assert [(m["단위"], m["비목"], m["수량"]) for m in new] == [
("인", "노무비", "1"),
("㎡", "재료비", "1"),
("hr", "묶음", "1"),
("㎥", "묶음", "1"),
]
# 금액 = 서버 정본 셈 — 넣은 넷 = 노임 + 자재 + 기계 한 시간(hourly) + 단가산출 계
before, after = world["py"]["GC000211"], _calc(world, doc)
want = 172068 + 11354 + float(h["계"]) + float(world["py"]["GF000099"]["계"])
assert float(after["계"]) - float(before["계"]) == pytest.approx(want, abs=2)
ts = _ts("셈", [{"문서": doc, "연결": _record(world, doc)}])[0]
assert not ts["누락"], ts
assert [ts[f] for f in COSTS] == [_num(after.get(f)) for f in COSTS]
# 다시 고름 — 키 · 이름만 바뀜
again = _ts(
"넣기",
[{"문서": world["docs"]["GC000211"], "넣을": picks[:1], "바꿈": [{"차례": 0, "고른": {"키": "LB000007", "이름": "형틀목공"}}]}],
)[0] # fmt: skip
m = again["문서"]["금액"][again["처음"]]
assert m["이름"] == "형틀목공" and m["수량"] == "1"
assert any(v["연결"] == {"갈래": "요소", "키": "LB000007"} for v in again["문서"]["변수"])
# 기계 다시 고름(같은 로직) — 기종만 바뀌고 지역 · 보정작업 넘김 그대로
gf = world["docs"]["GF000099"]
i = next(k for k, m in enumerate(gf["금액"]) if m["단가"] == "P_굴착기무한궤")
one = _ts("바꿈", [{"문서": gf, "줄": i, "기호": "P_굴착기무한궤", "갈래": "기계", "고른": picks[2]["고른"]}])[0] # fmt: skip
link = next(v["연결"] for v in one["변수"] if v.get("아래") == "굴착기무한궤")
assert link["입력"] == {"기계": "'0201-0012'", "지역": "S_지역", "보정작업": "S_보정작업"}
assert one["금액"][i]["이름"] == "굴착기(무한궤도)" and one["금액"][i]["규격"] == "0.12㎥"
def _record(world, doc):
rec = mr.Rec(world["links"])
d = json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal)
rec.prefetch(d)
dc.mc.calc(d, rec) # 요소 값은 셈하며 기록됨(화면 미리 셈과 같은 길)
return rec.out()
def test_들여쓰기_수준():
got = _ts("넣기", [{"문서": {"금액": [], "변수": []}, "넣을": []}])[0]
assert got["수준"] == [{"item": 2, "line": 3}] # 가나다 항목 = 2 · 줄 3
out = _src("A00_Common/line_stack_out.ts")
assert "stackLevels(items)" in out and '"--lstk-depth", String(r.lv)' in out
assert "rows(sub, got, h, depth + 1, path, lv + 1)" in out # 펼친 블록 = 그 줄 + 1
edit = _src("A00_Common/line_stack_edit.ts")
assert "openStackPick" in edit and "/links" not in edit and "Machine" not in edit
@@ -0,0 +1,257 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""PLAN 804-4 — 줄 쌓기 다른 단가산출 · 일위대가 복사해 펼침(요구 680-1 · 681) — 임시 storage.
① M01 줄 쌓기 = M01 전체만(찾기 창 탭 · 복사 길 없음) · M01 문서 길은 M02 키를 모름
② M01 단가산출 복사 = 전체 항목 · 줄 · 옵션 굳힘(옵션 0) · 금액 = 원본 · 원본 연결 없음
③ M02 SK 복사 = 그 SK 수식 문서 금액 · 항목 그대로
④ 일위대가(SC) 복사 = 항목 여러 개 · 금액 = 일위대가 계 · 고정(노임 · 손 단가) · 율 · 안 SK(× 수량 ÷ 배)
⑤ 앞 줄 있는 문서에 넣음 = 앞 금액 + 원본(율 밑 = 그 조각만 · 끝수 1 원)
⑥ 권한 — 위 층만(남의 개인 층 403) · 저장 = 서버 정본 셈 같음 · M01 노임 바뀜 → [단가 갱신] 뒤 따라감
"""
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Doc_Sk_Path as path
from M02_MasterTemplete import M02_Doc_Take as tk
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_795_1_sk_copy import _fake, _live, _sk
from resources.tester.test_795_2_sc_copy import _sc
from resources.tester.test_804_2_line_stack import _ts
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
ROOT = Path(__file__).resolve().parents[2]
FORM = "GC000211" # 합판거푸집(옵션 많음)
DIG = "GF000099" # 측구터파기(굴착기 시간당 경비 부름)
STONE = "GF000219" # 원/㎡
LB = "LB000002" # 보통인부
ME = {"층": "personal", "주인": "7/42"}
def _norm(v):
return json.loads(json.dumps(dc.ml._norm(v), ensure_ascii=False, default=str))
def _take(folder, src):
with store.at(folder):
return _norm(tk.take(src))
def _into(doc, *taken):
return _ts("복사", [{"문서": doc, "받음": list(taken)}])[0]
def _money(doc):
d = json.loads(
json.dumps(dc.ml._norm(doc), default=str), parse_float=Decimal, parse_int=Decimal
)
return dc.mc.calc(d, dc.ml.Links(_live(), dc.folder()))
def _rid(key, at=ME):
return refs.ref_id({**at, "키": key})
def _src(rel):
return (ROOT / rel).read_text(encoding="utf-8")
def test_M01_은_M01전체만():
pick = _src("A00_Common/line_stack_pick.ts")
assert 'kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)' in pick
assert 'const copy = kind === "단가산출" && m02 ? m02 : null;' in pick
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
assert "scope: opt.scope, // 804-4" in tabs
screen = _src("A00_Common/line_stack_screen.ts")
assert "cp && opt.scope && key" in screen # 복사 길 = M02 저장한 SK 만
# M01 문서 길 = M02 키 모름(부르면 「없는 단가산출」)
doc = dc.md.blank("X", "x")
doc["변수"] = [
{"기호": "B", "아래": "", "위": "", "연결": {"갈래": "단가산출", "키": _rid("SK-000001")}}
]
doc["금액"] = [{"이름": "남", "비목": "묶음", "수량": "1", "단가": "B"}]
got = dc.mc.calc(doc, dc.ml.Links(_live(), dc.folder()))
assert not got["셈됨"] and "없는 단가산출" in json.dumps(got["표지"], ensure_ascii=False)
def test_M01단가산출_복사_전체_옵션굳힘(world):
me = world["personal"]
t = _take(me, FORM)
assert t["출처"] == f"M01 {FORM}" and [p["갈래"] for p in t["조각"]] == ["문서"]
g = _into(dc.md.blank("SK-000009", "빈"), t)
assert g["옵션"] == [] and g["왕복"] and "번지" not in g["못"]
assert g["항목"][0]["줄"][0] == f"글:M01 {FORM} 「{t['이름']}」에서 복사"
lines = [x for it in g["항목"] for x in it["줄"]]
assert len(lines) > 5 and any(x.startswith("공식:") for x in lines) # 공식 묶음까지 전체
assert _money(g["문서"])["계"] == _money(dc.folder()[FORM])["계"]
# 원본 연결 없음 — 복사본에 원본 키 단가산출 연결 없음 · 기호 모두 이 문서
keys = {(v.get("연결") or {}).get("키") for v in g["문서"]["변수"]}
assert FORM not in keys
def test_SK_복사_그_SK_금액(world):
me = world["personal"]
key = _sk(me, 바탕={"로직": FORM}, 입력값={"유형": "복잡", "사용횟수": "3회"})
with store.at(me):
want = _norm(path.read(key))["셈"]["계"]
t = _take(me, _rid(key))
assert t["출처"] == f"개인 {key}" and t["조각"][0]["갈래"] == "문서"
g = _into(dc.md.blank("SK-000009", "빈"), t)
assert g["옵션"] == [] and _money(g["문서"])["계"] == want
# 옵션 「복잡」 그대로 굳힘
assert any(x.get("식") == "'복잡'" for x in g["문서"]["식"])
def _combo(world):
me = world["personal"]
sk = _sk(me, 바탕={"로직": STONE})
fixed = {"이름": "보통인부", "단위": "인", "단가": {"노무비": 0}, "이음": {"노임": LB}}
hand = {"이름": "sub3임시 잡자재", "단위": "식", "단가": {"재료비": 1234}, "이음": {}}
rate = {"이름": "공구손료", "단위": "%", "단가": {}, "비율": {"기준": "노무비", "비목": "경비"}}
lines = [
{"로직": DIG, "수량": {"손": 2}},
{"로직": "", "고정": fixed, "수량": {"손": 0.3}},
{"로직": "", "고정": hand, "수량": {"손": 2}},
{"참조": {**ME, "키": sk}, "수량": {"손": 3}},
{"로직": "", "고정": rate, "수량": {"손": 3}},
]
return me, sk, _sc(me, lines)
def test_일위대가_복사_여러항목_금액같음(world):
me, sk, sc = _combo(world)
t = _take(me, _rid(sc))
kinds = [p["갈래"] for p in t["조각"]]
assert kinds == ["문서", "인력", "손", "문서", "율"], kinds
assert t["조각"][0]["곱"] == "2" and t["조각"][3]["곱"] == "3"
assert t["조각"][4]["줄"]["단가"] == "노무비 × 3 %"
g = _into(dc.md.blank("SK-000009", "빈"), t)
assert g["옵션"] == [] and g["왕복"] and "번지" not in g["못"]
titles = [it["제목"] for it in g["항목"]]
assert titles[0] == t["이름"] and len(g["항목"]) >= 4 # 머리 + 문서 둘 + 줄 조각 항목
got = float(_money(g["문서"])["계"])
# 일위대가 계 — 끝수 차례만 다름(원본 = 문서 계를 버린 뒤 × 수량 · 복사 = 줄마다 × 수량 뒤 버림)
assert got == pytest.approx(float(t["계"]), abs=10)
# 율 밑 = 그 일위대가 번지 합(앞 줄 섞임 없음)
rate = g["문서"]["금액"][-1]
assert rate["이름"] == "공구손료" and "번지.복사" in rate["단가"]
def test_앞줄있는문서_넣음_율밑안섞임(world):
me = world["personal"]
gc = dc.folder()[FORM]
one = _take(me, DIG)
g = _into(gc, one)
diff = float(_money(g["문서"])["계"]) - float(_money(gc)["계"])
assert diff == pytest.approx(float(_money(dc.folder()[DIG])["계"]), abs=1)
# 일위대가(율 줄 있음) 둘을 잇달아 = 각자 계 합
_, _, sc = _combo(world)
t = _take(me, _rid(sc))
two = _into(dc.md.blank("SK-000009", "빈"), t, t)
one_sc = float(_money(_into(dc.md.blank("SK-000009", "빈"), t)["문서"])["계"])
assert float(_money(two["문서"])["계"]) == pytest.approx(2 * one_sc, abs=2)
def test_권한_저장_단가갱신(world, monkeypatch):
me, sk, sc = _combo(world)
with pytest.raises(store.StoreError) as e: # 회사 층에서 개인 층 = 위 층 아님
_take(world["company"], _rid(sc))
assert e.value.status == 403
with pytest.raises(store.StoreError):
_take(me, "참조|personal|7/43|SC-000001") # 남의 개인 층
# 저장 = 서버 정본 셈 · 다시 읽어 같음
target = _sk(me) # 빈 SK(바탕 · 줄 없음) — 줄 쌓기로 처음부터 복사해 채움
with store.at(me):
read = _norm(path.read(target))
g = _into(read["문서"], _take(me, _rid(sc)))
with store.at(me):
saved = _norm(path.save(target, {"문서": g["문서"], "판": read["판"]}))
again = _norm(path.read(target))
assert saved["셈"]["계"] == again["셈"]["계"] == _money(g["문서"])["계"]
# 페이지 [저장](옛 칸 · 이름) 뒤에도 고친 문서 그대로(빈 SK 씨로 덮지 않음)
with store.at(me):
file = store.read("sangeun", target)
store.write("sangeun", target, file["판"], {**file["문서"], "이름": "sub3임시 이름바꿈"})
again = _norm(path.read(target))
assert again["셈"]["계"] == saved["셈"]["계"] and again["사본"]["따로"]
# M01 노임 바뀜 → [단가 갱신] 미리 셈 = 그 자원 단가 따라감(구조 · 품 그대로) — 값 줄 있는 SK(바탕 = 보통인부 든 로직)
other = _sk(me, 바탕={"로직": STONE})
with store.at(me):
read = _norm(path.read(other))
g = _into(read["문서"], _take(me, _rid(sc)))
with store.at(me):
again = _norm(path.save(other, {"문서": g["문서"], "판": read["판"]}))
row = dict(_live().get(LB))
row["값"] = row["값"] + 1000
_fake(monkeypatch, [row])
with store.at(me):
got = _norm(path.preview(other, {"할일": "단가갱신"}))
assert any(x["키"] == LB for x in got["바뀐자원"])
assert (
got["후"]["계"] > again["셈"]["계"] + 1000 * 0.3
) # 보통인부 줄들(복사한 0.3 인 포함) 따라감
assert got["문서"]["금액"] == again["문서"]["금액"]
def test_끌어옮김_번지이름(world):
"""804-5 — 줄 끌기(항목 안 · 사이) · 항목 끌기 = 되돌려 끌면 같은 문서 · 율 줄 번지 = 읽는 이름."""
gc = dc.folder()[DIG] # 항목 둘(인력 · 굴착기)
one = _ts("옮김", [{"문서": gc, "옮김": []}])[0]
first = one["항목"][0]["줄"][0]
gone = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"]]}])[0]
assert (
gone["항목"][1]["줄"][0] == first
and len(gone["항목"][0]["줄"]) == len(one["항목"][0]["줄"]) - 1
)
back = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"], ["1.0", "0.0"]]}])[0]
assert back["문서"] == one["문서"]
# 같은 항목 안 아래로(빼기 전 차례) — 첫 줄을 끝 자리 = 둘째로
inner = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "0.2"]]}])[0]
assert inner["항목"][0]["줄"] == one["항목"][0]["줄"][::-1]
n = len(one["항목"])
rot = _ts("옮김", [{"문서": gc, "옮김": [["0", str(n)]]}])[0]
assert rot["항목"][-1] == one["항목"][0] and rot["항목"][0] == one["항목"][1]
assert (
_money(rot["문서"])["계"] == _money(gc)["계"]
) # 항목만 옮김 = 금액 같음(율 줄은 그 항목 안)
# 율 줄 번지 — 일위대가 복사 = 번지마다 읽는 이름(「복사N」 아님)
me, sk, sc = _combo(world)
g = _into(dc.md.blank("SK-000009", "빈"), _take(me, _rid(sc)))
names = _ts("번지", [{"문서": g["문서"]}])[0]
places = {m["번지"] for m in g["문서"]["금액"] if m.get("번지")}
assert places and set(names) == places
assert all(v and not v.startswith("복사") for v in names.values()), names
def test_표값_화면이름_전수():
"""요구 683 — 표 값 기호(「Q_수량2^표」) = 화면 이름(「Q_작업반장」) · 겹침 없음 · 식 글 왕복 = 같은 식(1,518 전수)."""
docs = dc.folder()
keys = sorted(docs)
got = _ts("별칭", [{"문서": docs[k]} for k in keys])
named = 0
for k, g in zip(keys, got):
assert not g["틀림"], (k, g["틀림"][:3])
alias = g["별칭"]
assert len(set(alias.values())) == len(alias), k # 겹치면 2 · 3
assert all("^표" not in v and k.endswith("^표") for k, v in alias.items()), k
named += len(alias)
assert named > 500
gc = dict(zip(keys, got))[FORM]["별칭"]
assert gc and "Q_보통인부22" not in gc.values() # 번호 꼬리 뗀 뒤 2 · 3
# 문서 기호는 그대로(화면만) — 원본 문서 변수에 별칭 이름 없음
names = {
v["기호"]
+ (f"_{v['아래']}" if v.get("아래") else "")
+ (f"^{v['위']}" if v.get("위") else "")
for v in docs[FORM]["변수"]
}
assert not set(gc.values()) & names
@@ -0,0 +1,70 @@
"""804-6(요구 686-1) — 옛 수식 문서 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭 지킴.
① 걷은 파일 없고 부르는 곳 없음 ② M01 · M02 보기 = 수식(줄 쌓기) · 단가산출 상세
③ 줄 쌓기가 사본 [단가 갱신] · [재생성] · 판 바뀜 다시 저장을 이어 받음.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
GONE = [
"A00_Common/formula_doc_screen.ts",
"A00_Common/formula_doc_defs.ts",
"A00_Common/formula_doc_edit.ts",
"A00_Common/formula_doc_lines.ts",
"A00_Common/formula_doc_math.ts",
"A00_Common/formula_doc_math_model.ts",
"A00_Common/formula_doc_rows.ts",
"A00_Common/formula_doc_sub.ts",
"A00_Common/formula_doc.css",
"A00_Common/sheet_formula_cell.ts",
"M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts",
"M01_MasterData/M01_MasterData_UI_LogicLab_Equation.css",
"M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts",
"M01_MasterData/M01_MasterData_UI_LogicLab_EquationVar.ts",
"M01_MasterData/logic/M01_Logic_Equation_Model.ts",
"ui_template/ui_template_equation_defs.ts",
"ui_template/ui_template_equation_edit.ts",
]
def _text(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_걷은_파일_없고_부르는_곳_없음():
assert not [g for g in GONE if (ROOT / g).exists()]
names = [Path(g).name.rsplit(".", 1)[0] for g in GONE]
out = subprocess.run(
["git", "-c", "core.quotepath=off", "ls-files", "*.ts", "*.css", "*.html"],
cwd=ROOT, capture_output=True, text=True, encoding="utf-8", check=True,
).stdout # fmt: skip
for rel in out.splitlines():
if rel.startswith(("docs/", "tmp/")) or not (ROOT / rel).exists():
continue
text = (ROOT / rel).read_text(encoding="utf-8", errors="ignore")
for n in names:
assert f'/{n}"' not in text and f"{n}.css" not in text, (rel, n)
def test_보기_수식은_줄쌓기_첫탭():
for rel in (
"M01_MasterData/M01_MasterData_UI_LogicLab_View.ts",
"M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts",
):
src = _text(rel)
assert '{ value: "stack", label: tl("View_Equation") },' in src, rel
assert 'value: "stack",\n' in src and '"equation"' not in src, rel
lab = _text("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
assert "createStackScreen({" in lab and "createDocScreen" not in lab
def test_줄쌓기가_사본_할일_이어받음():
src = _text("A00_Common/line_stack_screen.ts")
assert "copyTools(true, (job) => void runJob(job))" in src
assert 'call<JobReply>("/preview", { 할일: job })' in src
assert "e.status !== 409" in src and "#끊음" not in src
assert len(src.splitlines()) <= 700