Merge commit 'e5e34d82fb9a2361b8bb3f3eebffe20a61ededef' into CODEX
This commit is contained in:
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 — 입력 칩(옵션 풍선)은 텍스트 수식과 함께 걷음
|
||||
|
||||
@@ -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 # 줄마다 붙던 옵션 꼬리표 걷음
|
||||
|
||||
@@ -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(로그인 쿠키)
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 # 공용 쪽은 새 모달을 모름
|
||||
|
||||
|
||||
|
||||
@@ -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"] == "글자 끝수"
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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}}
|
||||
@@ -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_())
|
||||
@@ -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"])
|
||||
@@ -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, "엑셀")["전"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 # 풀이 · 줄 번호 걷음
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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("기호")]
|
||||
@@ -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%"
|
||||
@@ -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["수식셈"]["까닭"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user