Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
278 lines
14 KiB
Python
278 lines
14 KiB
Python
"""M01 시트 로직 가져오기 시험(35-5 · 정함 9) — Node 로 `M01_SL_Import.ts` 를 바로 돌림.
|
|
|
|
· 견본 로직 줄 여섯(정함 9 시험 로직)은 이 시험 안에 둠 — 품셈 표 · 노임 · 자재 · 기존 로직 값은 마스터에서 읽기만
|
|
· 가져온 시트 로직 → 바깥 값 넣기(파이썬 엔진이 마스터에서) → 우리 스프레드시트 엔진(Node 번들)으로 셈
|
|
→ 합계 · 비목 = 지금 `/calc`(`M01_LB_Calc.calc` = `/calc` 와 같은 길) · 끝수 차이는 원 미만
|
|
· 마스터 로직 1,469 개(자체 빼고) 모두 가져와 다 바뀌는 수를 세고 · 바뀐 통합문서를 엔진이 다 풂(#NAME? 없음)
|
|
· 파일 안 씀 — 마스터는 읽기만 · 넘김은 표준입력
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.ts"
|
|
|
|
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
|
|
|
# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ────────────
|
|
HOOKS = r"""
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { stripTypeScriptTypes } from "node:module";
|
|
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.ts)?$/;
|
|
export async function resolve(spec, ctx, next) {
|
|
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
|
|
const u = new URL(spec + ".ts", ctx.parentURL);
|
|
if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true };
|
|
}
|
|
return next(spec, ctx);
|
|
}
|
|
export async function load(url, ctx, next) {
|
|
if (!url.endsWith(".ts")) return next(url, ctx);
|
|
let src = readFileSync(fileURLToPath(url), "utf8");
|
|
src = src.replace(/import\s+"[^"]+\.css";/g, "");
|
|
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})");
|
|
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => {
|
|
if (from.startsWith(".") && ALLOW.test(from)) return m;
|
|
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
|
|
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
|
|
});
|
|
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
|
|
}
|
|
"""
|
|
STUB = (
|
|
"globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : "
|
|
"() => undefined });\n"
|
|
)
|
|
|
|
|
|
def _plain(x: object) -> object:
|
|
return float(x) if isinstance(x, Decimal) else str(x)
|
|
|
|
|
|
def _node(body: str, payload: object) -> object:
|
|
"""`I` = Import 모듈 · `A` = 넘긴 것(표준입력) · `out(x)` 로 답"""
|
|
script = (
|
|
"import { register } from 'node:module';\n"
|
|
"import { readFileSync } from 'node:fs';\n"
|
|
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
|
+ STUB
|
|
+ f"const I = await import({json.dumps(IMPORT.as_uri())});\n"
|
|
"const A = JSON.parse(readFileSync(0, 'utf8'));\n"
|
|
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
|
|
)
|
|
result = subprocess.run(
|
|
["node", "--no-warnings", "--input-type=module", "-e", script],
|
|
input=json.dumps(payload, ensure_ascii=False, default=_plain),
|
|
capture_output=True, text=True, encoding="utf-8", timeout=600, cwd=ROOT,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr[-3000:]
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
# ── 견본 줄 여섯(정함 9) + 넣을 값 ────────────────────────────────────────────
|
|
H = {"원문번호": "", "출처": "자체", "소유": "공용", "끝수": None}
|
|
SAMPLES = [
|
|
(
|
|
{**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
|
|
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
|
|
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
|
|
"덧줄": []},
|
|
{},
|
|
),
|
|
(
|
|
{**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
|
|
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75]},
|
|
{"이름": "돌", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]},
|
|
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]},
|
|
{"이름": "높이", "단위": "m"}, {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
|
|
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
|
|
"호표": [{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "비목": "노무비",
|
|
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"},
|
|
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비",
|
|
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"}],
|
|
"덧줄": []},
|
|
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "높이": 4.5, "초과증가율": 80},
|
|
),
|
|
(
|
|
{**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
|
|
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
|
|
"중간": [],
|
|
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비",
|
|
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)"}],
|
|
"덧줄": []},
|
|
{"품": 2.5, "비탈구배": "평균 비탈구배 20˚~45˚ 이하"},
|
|
),
|
|
(
|
|
{**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
|
|
"입력": [{"이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기"]}],
|
|
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
|
|
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
|
|
"단위": "시간", "수량": "1 / 작업량"}],
|
|
"덧줄": []},
|
|
{"지역": "서울"},
|
|
),
|
|
(
|
|
{**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
|
|
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f"}, {"이름": "E"}, {"이름": "Cm", "단위": "분"}],
|
|
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)"}},
|
|
{"qt": 6, "f": 1.2, "E": 0.9, "Cm": 25},
|
|
),
|
|
(
|
|
{**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
|
|
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
|
|
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
|
|
"덧줄": [{"이름": "공구손료 및 경장비", "비목": "경비",
|
|
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100"}]},
|
|
{},
|
|
),
|
|
] # fmt: skip
|
|
|
|
|
|
# ── 마스터(읽기만) ─────────────────────────────────────────────────────────
|
|
@pytest.fixture(scope="module")
|
|
def master():
|
|
from M01_MasterData import M01_MasterData_Store as store
|
|
|
|
cm = store.cm
|
|
files = cm.load(folder=store.FOLDER)
|
|
tables = {
|
|
str(t["키"]): t
|
|
for data in files.values()
|
|
if data.get("그룹") in cm.mf.TABLE_GROUPS
|
|
for t in data.get("표") or []
|
|
}
|
|
return cm, files, tables
|
|
|
|
|
|
def _tables_of(row: dict, tables: dict) -> dict:
|
|
keys = set(re.findall(r"Q[A-Z]\d{6}", json.dumps(row, ensure_ascii=False)))
|
|
return {k: tables[k] for k in keys if k in tables}
|
|
|
|
|
|
def _num(v) -> float:
|
|
return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan")
|
|
|
|
|
|
def _fill(sl: dict, places: list, py_lines: list, master) -> None:
|
|
"""바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`"""
|
|
cm, files, _ = master
|
|
m = cm.mf.Master(files)
|
|
nodes = {n["id"]: n for n in sl["nodes"]}
|
|
for at in places:
|
|
item = at["item"]
|
|
kind = item["kind"]
|
|
if kind == "표":
|
|
continue
|
|
if kind == "노임":
|
|
value = cm.mf.element(m, item["key"], {})[0]
|
|
elif kind == "자재":
|
|
name = nodes[item["id"].split(":")[0]]["name"]
|
|
value = next(x["단가"] for x in py_lines if x["이름"] == name)
|
|
else:
|
|
key = (
|
|
item["key"]
|
|
if kind == "로직"
|
|
else re.match(r"로직\((\w+)", item["element"]).group(1)
|
|
)
|
|
got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v
|
|
for k, v in item["inputs"].items()}) # fmt: skip
|
|
value = got["결과"] if "결과" in got else got[item.get("field") or "계"]
|
|
if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0)
|
|
continue
|
|
sheet = next(s for s in sl["workbook"]["시트"] if s["id"] == at["sheet"])
|
|
sheet["칸"][at["cell"]] = {"값": float(value)}
|
|
|
|
|
|
def _result_cells(sl: dict) -> dict:
|
|
node = next(n for n in sl["nodes"] if n["kind"] == "result")
|
|
return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]}
|
|
|
|
|
|
@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES])
|
|
def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, master) -> None:
|
|
from common_util.common_util_spreadsheet import recalc_workbooks
|
|
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
|
|
|
rep = _node(
|
|
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });"
|
|
"out({ ...r, places: I.outsideOf(r.sl) });",
|
|
{"row": row, "tables": _tables_of(row, master[2]), "given": given},
|
|
)
|
|
assert rep["problems"] == []
|
|
sl = rep["sl"]
|
|
# 모양 — 상자 = 시트 · 선은 있는 상자 · 알약끼리
|
|
sheets = {s["id"] for s in sl["workbook"]["시트"]}
|
|
assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"])
|
|
ids = {n["id"]: n for n in sl["nodes"]}
|
|
for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴)
|
|
if n["kind"] == "table":
|
|
assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"]
|
|
for e in sl["edges"]:
|
|
assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"])
|
|
assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"])
|
|
|
|
want = lb.calc(row, given, {})
|
|
assert want["ok"], want
|
|
# 1차 셈 → 넘길 값(장비 · 로직 인자) 읽기 → 바깥 값 넣기 → 2차 셈
|
|
first = recalc_workbooks([sl["workbook"]])[0]
|
|
places = _node("out(I.outsideOf(A.sl, A.calc));", {"sl": sl, "calc": first})
|
|
_fill(sl, places, want.get("lines", []), master)
|
|
got = recalc_workbooks([sl["workbook"]])[0]
|
|
cells = _result_cells(sl)
|
|
read = {k: _num(got[s].get(c)) for k, (s, c) in cells.items()}
|
|
print(f"\n{row['키']} 시트 {read} · /calc {want.get('result', want.get('sums'))}")
|
|
if "result" in want:
|
|
assert abs(read["결과"] - float(want["result"])) < 1e-9, (read, want["result"])
|
|
return
|
|
for k in ("노무비", "재료비", "경비", "계"):
|
|
diff = abs(read[k] - float(want["sums"][k]))
|
|
assert diff < 1, f"{row['키']} {k}: 시트 {read[k]} · /calc {want['sums'][k]}"
|
|
|
|
|
|
def test_마스터_1469_가져오기_세기(master) -> None:
|
|
from common_util.common_util_spreadsheet import recalc_workbooks
|
|
|
|
cm, files, tables = master
|
|
rows = [
|
|
r
|
|
for name, data in files.items()
|
|
if data.get("그룹") == "로직" and name != "로직_자체.json"
|
|
for r in data.get("줄") or []
|
|
]
|
|
assert len(rows) == 1469
|
|
got = _node(
|
|
"const res = A.rows.map((row) => { try { const r = I.importLogicReport(row, { tables: A.tables });"
|
|
"return { key: row.키, problems: r.problems, book: r.problems.length ? null : r.sl.workbook }; }"
|
|
"catch (e) { return { key: row.키, problems: ['던짐 ' + e.message], book: null }; } });"
|
|
"out(res);",
|
|
{"rows": rows, "tables": tables},
|
|
)
|
|
thrown = [g["key"] for g in got if any(p.startswith("던짐") for p in g["problems"])]
|
|
assert thrown == []
|
|
full = [g for g in got if not g["problems"]]
|
|
# 다 바뀐 것은 엔진이 다 풂 — 모르는 함수 · 이름(#NAME?) 없음
|
|
values = recalc_workbooks([g["book"] for g in full])
|
|
named = [
|
|
g["key"] for g, v in zip(full, values) if "#NAME?" in json.dumps(v, ensure_ascii=False)
|
|
]
|
|
assert named == []
|
|
reasons: dict[str, int] = {}
|
|
for g in got:
|
|
for p in g["problems"]:
|
|
why = re.sub(r"「[^」]*」", "「…」", p.split(" — ", 1)[-1])
|
|
reasons[why] = reasons.get(why, 0) + 1
|
|
print(
|
|
f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}"
|
|
)
|
|
assert len(full) > 1000
|