"""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)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.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 _names_of(row: dict, master) -> dict: """다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)""" cm, files, _ = master m = cm.mf.Master(files) keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False))) return {k: str(m.get(k).get("이름") or k) for k in keys} def _num(v) -> float: """빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan""" if v is None: return 0.0 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`""" from M01_MasterData.LogicBuilder import M01_LB_Calc as lb 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) elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목 line = { "종류": "로직", "요소": item["element"], "이름": "장비", "단위": "시간", "수량": "1", } inputs = item["inputs"] one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "", "입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0) else: got = cm.mf.run(m, item["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["pillsIn"] + 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, names: A.names });" "out({ ...r, places: I.outsideOf(r.sl) });", { "row": row, "tables": _tables_of(row, master[2]), "given": given, "names": _names_of(row, master), }, ) 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"] # 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액) assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])] for n in sl["nodes"]: if n["kind"] == "item" and n.get("ref", "").startswith("{"): ref = json.loads(n["ref"]) assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율") pills = {p["name"]: p["cell"] for p in n["pillsOut"]} assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6") 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], ]