Files
Aislo/resources/tester/test_m01_sl_import.py
T

300 lines
15 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"
PARSE = IMPORT.with_name("M01_SL_Import_Parse.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())})),"
f" ...(await import({json.dumps(PARSE.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
def test_표_블록_칸_차례_정함() -> None:
"""조건 칸 → 값 칸 · 범위(조건 「범위」 · 값 칸에 범위가 한 줄이라도)는 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸에 같은 수"""
table = {
"조건": {"높이": "범위", "구분": "고르기"},
"값칸": {"증가율": "%", "단위": "글"},
"줄": [
{"높이": [3, 4], "구분": "가", "증가율": 30, "단위": "%"},
{"높이": 5, "구분": "나", "증가율": [80, 100]},
{"높이": [7.5, None], "증가율": 50},
],
}
rows = _node("out(I.tableRows(A));", table)
assert rows == [
["높이·아래", "높이·위", "구분", "증가율·아래", "증가율·위", "단위"],
[3, 4, "가", 30, 30, "%"],
[5, 5, "나", 80, 100, None],
[7.5, None, None, 50, 50, None],
]