Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-01 19:43:14 +09:00
75 changed files with 476 additions and 22550 deletions
-349
View File
@@ -1,349 +0,0 @@
"""M01 쉬운 단가산출 Api(`M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts`) 시험 — PLAN 34-3b.
· 서버 길 왕복 — Api 가 부르는 몸 그대로 새로 → 고치기 → 시험 계산 → 지우기(임시 폴더 사본에서만)
· 쓰는 곳 — 담은 조합 · 부르는 내 로직이 지우기를 막음
· 기계 짝 규칙 · 쉬운 말 바꾸기 — Api 파일의 「순수」 칸을 Node 로 바로 돌림
견본 로직 줄은 시험 안에 둠 — 단가 값은 사본에서 읽어 비교(진짜 값에 안 기댐).
"""
from __future__ import annotations
import json
import re
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_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
API = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Api.ts"
REAL = store.FOLDER
OWN = "로직_자체.json"
NO_NODE = shutil.which("node") is None
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if path.name == OWN:
continue # 내 로직은 빈 자리에서 시작
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 _call(client: TestClient, url: str, body: dict | None = None, want: int = 200) -> dict:
res = client.get(url) if body is None else client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def _labor() -> tuple[str, Decimal]:
"""값이 있는 인력 한 줄(키 · 노임) — 사본에서 읽음."""
rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"]
row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float)))
return row["키"], Decimal(str(row["값"]))
def _row(name: str, lines: list[dict], inputs: list | None = None) -> dict:
"""쉬운 길이 보내는 최소 줄 — 키 · 갈래 · 소유는 서버가 채움."""
return {
"키": "",
"원문번호": "",
"이름": name,
"결과단위": "원/㎡",
"출처": "자체",
"입력": inputs or [],
"중간": [],
"호표": lines,
"덧줄": [],
"끝수": None,
}
def _person(key: str, qty: str) -> dict:
return {
"종류": "인력",
"요소": key,
"이름": "사람",
"단위": "인",
"수량": qty,
"비목": "노무비",
}
def _node(expr: str) -> object:
"""Api 파일의 「순수」 칸 + 식 하나를 Node 로 돌려 JSON 답."""
src = API.read_text(encoding="utf-8")
block = re.search(r"/\* ── 순수 시작[^\n]*\n([\s\S]*?)/\* ── 순수 끝", src)
assert block, "순수 칸 표시를 못 찾음"
script = (
"const currentLanguageIndex = 0;\n"
+ block.group(1).replace("export function", "function")
+ f"\nconsole.log(JSON.stringify({expr}));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
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_새로_고치기_시험계산_지우기_왕복(client: TestClient) -> None:
real_before = (REAL / OWN).exists()
key_l, price = _labor()
made = _call(
client,
"/api/m01/logic/new",
{"logic": _row("시험 쉬운 줄", [_person(key_l, "0.5")]), "owner": "현장"},
)
key = made["key"]
assert key.startswith("GX") and made["file"] == OWN and made["logic"]["소유"] == "현장"
# 목록 · 열기 — 내 것은 사본의 자체 파일에서
listed = _call(client, "/api/m01/logics?q=시험 쉬운 줄")["logics"]
assert [x["file"] for x in listed] == [OWN] and listed[0]["결과단위"].startswith("원")
opened = _call(client, f"/api/m01/logic?key={key}")
assert opened["version"] == made["version"]
# 고치기 → 시험 계산(저장 전 줄을 그대로 보냄) — 금액 = 수량 × 노임
row = {**opened["logic"], "호표": [_person(key_l, "2")]}
edited = _call(
client, "/api/m01/logic/edit", {"key": key, "version": opened["version"], "logic": row}
)
calc = _call(client, "/api/m01/calc", {"key": key, "inputs": {}, "row": edited["logic"]})
assert calc["ok"] is True
assert Decimal(str(calc["sums"]["노무비"])) == 2 * price == Decimal(str(calc["sums"]["계"]))
# 저장 안 한 새 줄도 키 "" 로 셈
draft = _call(
client,
"/api/m01/calc",
{"key": "", "inputs": {}, "row": _row("새 것", [_person(key_l, "1")])},
)
assert Decimal(str(draft["sums"]["계"])) == price
# 낡은 판본 409 · 품셈 기본 고치기 403
_call(
client,
"/api/m01/logic/edit",
{"key": key, "version": made["version"], "logic": row},
want=409,
)
base = next(x for x in _call(client, "/api/m01/logics")["logics"] if x["file"] != OWN)
base_one = _call(client, f"/api/m01/logic?key={base['키']}")
body = {"key": base["키"], "version": base_one["version"], "logic": base_one["logic"]}
_call(client, "/api/m01/logic/edit", body, want=403)
_call(client, "/api/m01/logic/delete", {"key": key, "version": edited["version"]})
assert client.get(f"/api/m01/logic?key={key}").status_code == 404
assert (REAL / OWN).exists() == real_before # 진짜 마스터 폴더는 안 건드림
def test_쓰는_곳_조합과_부르는_로직이_지우기를_막음(client: TestClient) -> None:
key_l, _ = _labor()
a = _call(client, "/api/m01/logic/new", {"logic": _row("바탕 줄", [_person(key_l, "1")])})
call = {"종류": "로직", "요소": f"로직({a['key']})", "이름": "바탕", "단위": "㎡", "수량": "1"}
b = _call(client, "/api/m01/logic/new", {"logic": _row("부르는 줄", [call])})
# 부르는 내 로직 — usesOf 가 훑는 목록(구분 「자체」) · 몸에 「로직(<키>」
mine = _call(client, "/api/m01/logics?sub=자체")["logics"]
assert {x["키"] for x in mine} == {a["key"], b["key"]}
body = json.dumps(_call(client, f"/api/m01/logic?key={b['key']}")["logic"], ensure_ascii=False)
assert f"로직({a['key']}" in body
blocked = client.post("/api/m01/logic/delete", json={"key": a["key"], "version": b["version"]})
assert blocked.status_code == 422, blocked.text
errors = blocked.json()["detail"]["errors"]
if not NO_NODE: # 서버 글 그대로 쉬운 말로 — 키가 안 새어 나감
plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})")
assert "지울 수 없음" in plain and not re.search(r"[A-Z]{2}\d{6}", plain)
# 부르는 줄을 지우면 바탕을 지울 수 있음
_call(client, "/api/m01/logic/delete", {"key": b["key"], "version": b["version"]})
now = _call(client, f"/api/m01/logic?key={a['key']}")["version"]
# 담은 조합(마스터 층 파일)도 막음 — /logic/combos 가 그 조합을 줌
combo_file = store.FOLDER / "일위대가조합.json"
combos = (
json.loads(combo_file.read_text(encoding="utf-8"))
if combo_file.exists()
else {"그룹": "일위대가조합", "판": "2026", "줄": []}
)
combos["줄"].append(
{"키": "UA999999", "이름": "시험 조합", "구분": "자체", "상세구분": "", "단위": "㎡",
"담은로직": [{"로직": a["key"], "메모": None}], "본뜬키": None, "출처": "자체",
"소유": "현장", "비고": None}
) # fmt: skip
combo_file.write_text(json.dumps(combos, ensure_ascii=False, indent=2), encoding="utf-8")
store.cache.stale(["일위대가조합.json"], set())
used = _call(client, f"/api/m01/logic/combos?key={a['key']}")["combos"]
assert [c["키"] for c in used] == ["UA999999"]
_call(client, "/api/m01/logic/delete", {"key": a["key"], "version": now}, want=422)
# ── 기계 짝 규칙 ───────────────────────────────────────────────────────
def _m(key: str, codes: list[str], *extra: str) -> dict:
inputs = [{"이름": "기계", "고르기": codes}] + [{"이름": n, "고르기": ["가"]} for n in extra]
return {"key": key, "name": key, "resultUnit": "원/시간", "inputs": inputs}
FAMILY = [
_m("M_GENERAL", ["A", "B", "C", "D"], "지역"),
_m("M_BOOST", ["A", "B", "C", "D", "E"], "지역", "보정작업"),
_m("M_DIGGER", ["B"], "지역", "보정작업"),
_m("M_NOOP", ["C"], "지역"),
_m("M_CLAW", ["B"], "집게", "지역", "보정작업"),
_m("M_LOSS", ["A", "F", "G"]),
_m("M_LOSS_BOOST", ["F", "G", "H"], "보정작업"),
]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_기계_짝_규칙() -> None:
fam = json.dumps(FAMILY, ensure_ascii=False)
picks = _node(
f"['A','B','C','D','E','F','H','Z'].map((c) => pickMachine({fam}, c)?.key ?? null)"
)
# 일반 > 보정 · 전용(굴착기 · 조종원 없음)이 일반보다 먼저 · 부착 전용은 안 고름 ·
# 손료만은 다른 길이 없을 때만 · 손료만 안에서는 보정 아닌 것 먼저 · 없는 기종 null
assert picks == [
"M_GENERAL",
"M_DIGGER",
"M_NOOP",
"M_GENERAL",
"M_BOOST",
"M_LOSS",
"M_LOSS_BOOST",
None,
]
call = _node(f"machineCall({fam}[2], 'B')")
assert call["element"] == "로직(M_DIGGER, 기계='B', 지역=지역, 보정작업=보정작업)"
assert [i["이름"] for i in call["needInputs"]] == ["지역", "보정작업"]
quoted = _node(
"machineCall({key:'K', name:'', resultUnit:'원/시간', inputs:[{이름:'기계'},{이름:'조종 직종'}]}, 'X')"
)
assert quoted["element"] == "로직(K, 기계='X', '조종 직종'='조종 직종')"
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClient) -> None:
"""사본의 기계 시간당 경비 로직으로 짝을 지어 새 줄에 넣음 — 저장 · 계산이 됨(종류 「기계」 꼴 안 만듦)."""
family = []
for path in store.FOLDER.glob("로직_*건설기계*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
if row["결과단위"].startswith("원"):
family.append(
{
"key": row["키"],
"name": row["이름"],
"resultUnit": row["결과단위"],
"inputs": row["입력"],
}
)
if not family:
pytest.skip("사본에 기계 시간당 경비 로직이 없음")
fam = json.dumps(family, ensure_ascii=False)
code = next(
str(c)
for m in family
for i in m["inputs"]
if i["이름"] == "기계"
for c in i.get("고르기", [])
)
got = _node(
f"(() => {{ const m = pickMachine({fam}, '{code}'); return m && machineCall(m, '{code}'); }})()"
)
assert got and got["element"].startswith("로직(")
line = {"종류": "로직", "요소": got["element"], "이름": "기계", "단위": "시간", "수량": "0.5"}
made = _call(
client, "/api/m01/logic/new", {"logic": _row("기계 시험", [line], got["needInputs"])}
)
trial = {i["이름"]: (i.get("고르기") or [None])[0] for i in got["needInputs"]}
calc = _call(client, "/api/m01/calc", {"key": made["key"], "inputs": trial})
assert calc["ok"] is True, calc
assert Decimal(str(calc["sums"]["계"])) > 0 and "비목" in calc["lines"][0]
_call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]})
# ── 회사 · 개인 층 조합 ────────────────────────────────────────────────
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_층_조합_가운데_이_로직을_담은_것만_자리_글과_함께() -> None:
combos = [
{
"키": "UC3-000001",
"이름": "돌쌓기 한 벌",
"담은로직": [{"로직": "GX000001", "메모": None}],
},
{"키": "UC3-000002", "이름": "다른 것", "담은로직": [{"로직": "GX000002"}]},
{"키": "UC3-000003", "이름": "빈 것"},
{
"키": "UC3-000004",
"이름": "둘째 자리",
"담은로직": [{"로직": "GF000001"}, {"로직": "GX000001"}],
},
]
got = _node(
f"holding({json.dumps(combos, ensure_ascii=False)}, 'GX000001', 'company', '산림조합')"
)
assert got == [
{"kind": "combo", "where": "회사 · 산림조합", "key": "UC3-000001", "name": "돌쌓기 한 벌"},
{"kind": "combo", "where": "회사 · 산림조합", "key": "UC3-000004", "name": "둘째 자리"},
]
person = _node("holding([{키: 'UP15-000001', 담은로직: [{로직: 'K'}]}], 'K', 'personal', '나')")
assert person == [{"kind": "combo", "where": "개인 · 나", "key": "UP15-000001", "name": ""}]
# ── 쉬운 말 ─────────────────────────────────────────────────────────────
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_plainError_상태와_검사글을_쉬운_말로() -> None:
simple = _node("[0, 403, 404, 409, 500].map((s) => plainOf(s, 'x'))")
assert simple[0].startswith("서버와 이어지지")
assert "바탕으로 고치기" in simple[1] and "찾을 수 없음" in simple[2]
assert "다른 곳에서 먼저 고쳤음" in simple[3] and "검사에 걸림" in simple[4]
errors = [
"로직_자체.json · GX000001 호표 사람 수량 · 모르는 이름 「면적」",
"로직_자체.json · GX000001 호표 사람 · 비목 「None」",
"로직_자체.json · GX000001 시험 · 「호표」 도 「결과」 도 없음",
"로직_자체.json · GX000001 호표 기계 요소 · 「기계경비」 입력 「지역」 안 줌",
"알 수 없는 글 GX000009 찾기(QF000001).값",
]
plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})").split("\n")
assert plain[0] == "「면적」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요"
assert "넣을 자리" in plain[1] and "줄이 하나도 없음" in plain[2]
assert plain[3] == "그 밖 2 건" # 셋까지 + 나머지 수
assert not any(re.search(r"[A-Z]{2}\d{6}|찾기\(", x) for x in plain)
assert "「지역」" in _node(
f"plainOf(422, {json.dumps({'errors': errors[3:4]}, ensure_ascii=False)})"
)
assert _node("plainOf(400, '로직 줄에 「이름」 이 없음')") == "이름을 넣어 주세요"
assert _node("plainStop('「GX000001」 입력 「돌」 없음')").startswith("이 값으로는")
def test_화면_글에_금지_낱말이_없음() -> None:
"""설계 1절 8 — 키 · 식 · 호표 · 덧줄 · 비목 · 중간 값 · 끝수 · 정본 · 자체 · 별칭 안 씀(한국어 글)."""
src = API.read_text(encoding="utf-8")
block = re.search(r"const TEXT = \{([\s\S]*?)\} as const", src)
assert block
korean = re.findall(r'\[\s*"([^"]*)",', block.group(1))
assert len(korean) > 20
banned = ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭")
for text in korean:
assert not any(word in text for word in banned), text
-198
View File
@@ -1,198 +0,0 @@
"""M01 쉬운 단가산출 조건 보고 고치기 시험(34-3c) — 읽기 · 짜기 구역을 떼어 Node 로 돌림 · 화면 조작은 ORCA.
· 견본 식은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 만약(사슬) · 최소/최대 · 올림 · 식 속 덩어리 · 중간 값 따라가기 · 못 읽는 꼴 · 고친 뒤 짜기 왕복
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
COND = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Cond.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
INPUTS = [
{"이름": "비탈구배", "고르기": ["해당 없음", "1:1.0", "1:1.5"]},
{"이름": "뒷길이", "단위": "㎝"},
{"이름": "교환측", "고르기": ["양측", "한측"]},
{"이름": "층수", "고르기": [1, 2, 3]},
]
ROW = {"입력": INPUTS, "중간": [{"이름": "석공품", "식": "만약(뒷길이 > 40, 0.4, 0.3)"}]}
def _run(body: str, *args: object) -> object:
"""읽기 · 짜기 구역 + 시험 글 — `A` = 넘긴 인자 · `out(x)` 로 답"""
src = COND.read_text(encoding="utf-8")
part = src[src.index("/* ── 읽기 · 짜기") : src.index("/* ── 화면")]
script = (
"type LogicInput = any; type LogicRow = any;\n"
+ part
+ "\nconst A = JSON.parse(process.argv[1]);\n"
+ "const out = (x: unknown) => console.log(JSON.stringify(x));\n"
+ body
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(list(args))],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _read(formula: str) -> dict | None:
return _run("out(readCond(A[0], A[1]));", formula, ROW)
def _edit(formula: str, js: str) -> str:
"""읽은 뒤 `js` 로 덩어리(`B` = 첫 덩어리 꼴)를 고치고 다시 짬"""
return _run(
f"const R = readCond(A[0], A[1]); const B = R.blocks[0].shape; {js}; out(writeRead(R));",
formula,
ROW,
)
# ── 읽기 ───────────────────────────────────────────────────────────────────
def test_만약_한겹_설계값_표() -> None:
f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)"
r = _read(f)
assert r["via"] is None and r["text"] == f and len(r["blocks"]) == 1
b = r["blocks"][0]
assert b["p"] == [0, len(f)]
assert b["shape"] == {
"type": "if",
"branches": [
{
"test": {
"left": {"kind": "input", "name": "비탈구배"},
"op": "==",
"right": {"kind": "text", "value": "해당 없음"},
},
"value": {"kind": "num", "value": 0},
}
],
"otherwise": {"kind": "table", "src": "찾기(QF000001, 구배=비탈구배).값"},
}
def test_만약_사슬() -> None:
r = _read("만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))")
shape = r["blocks"][0]["shape"]
assert [x["value"] for x in shape["branches"]] == [
{"kind": "num", "value": 2},
{"kind": "num", "value": 3.5},
]
assert shape["otherwise"] == {"kind": "num", "value": -1}
assert shape["branches"][1]["test"]["right"] == {"kind": "num", "value": 2}
def test_최대_올림() -> None:
pick = _read("최대(시공량 * 0.5, 3)")["blocks"][0]["shape"]
assert pick == {
"type": "pick",
"fn": "최대",
"items": [{"kind": "calc", "src": "시공량 * 0.5"}, {"kind": "num", "value": 3}],
}
up = _read("올림(찾기(QC000001).값 / 시공량, 2)")["blocks"][0]["shape"]
assert (up["type"], up["fn"], up["places"], up["hadPlaces"]) == ("round", "올림", 2, True)
assert up["value"] == {"kind": "calc", "src": "찾기(QC000001).값 / 시공량"}
assert _read("버림(뒷길이)")["blocks"][0]["shape"]["hadPlaces"] is False
def test_식_속_덩어리와_중간값() -> None:
f = "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)"
r = _read(f)
assert len(r["blocks"]) == 1 and r["blocks"][0]["p"] == [f.index("만약"), len(f)]
many = _read("(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100")
assert [b["shape"]["type"] for b in many["blocks"]] == ["if", "pick"]
via = _read("석공품")
assert via["via"] == "석공품" and via["text"] == "만약(뒷길이 > 40, 0.4, 0.3)"
@pytest.mark.parametrize(
"formula",
[
"만약(뒷길이 > 40 그리고 층수 < 2, 1, 0)",
"찾기(QC000001, 항목=만약(교환측 == '양측', 'a', 'b')).수량",
"찾기(QC000001).값 * 2",
"0.5",
"만약(뒷길이 > 40, 1",
"",
],
ids=["그리고", "찾기속", "조건없음", "숫자", "괄호깨짐", "빈글"],
)
def test_못_읽는_꼴(formula: str) -> None:
assert _read(formula) is None
# ── 짜기 왕복 ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"formula",
[
"만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)",
"만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))",
"최소(시공량 * 0.5, 3, 뒷길이)",
"반올림(찾기(QC000001).값 / 시공량, 2)",
"찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65) * (1 + 할증 / 100)",
"(용접공 * LB000012 + 특별인부 * LB000003) * 만약(교환측 == '양측', 22, 18) / 100",
],
)
def test_안_고치면_같은_글(formula: str) -> None:
assert _run("out(writeRead(readCond(A[0], A[1])));", formula, ROW) == formula
def test_고친_뒤_짜기() -> None:
f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)"
assert (
_edit(f, "B.branches[0].test.right.value = '1:1.5'; B.branches[0].test.op = '!='")
== "만약(비탈구배 != '1:1.5', 0, 찾기(QF000001, 구배=비탈구배).값)"
)
assert (
_edit(f, "B.otherwise = { kind: 'num', value: 0.25 }")
== "만약(비탈구배 == '해당 없음', 0, 0.25)"
)
assert (
_edit(
"찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)",
"B.branches[0].value.value = 1.1",
)
== "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1.1, 0.65)"
)
assert (
_edit("최대(시공량 * 0.5, 3)", "B.fn = '최소'; B.items[1].value = 4")
== "최소(시공량 * 0.5, 4)"
)
assert _edit("올림(뒷길이 / 3)", "B.places = 1; B.fn = '반올림'") == "반올림(뒷길이 / 3, 1)"
assert _edit("석공품", "B.branches[0].test.left.name = '층수'; B.otherwise.value = 0.2") == (
"만약(층수 > 40, 0.4, 0.2)"
)
def test_둘째_덩어리_고침() -> None:
f = "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100"
got = _run(
"const R = readCond(A[0], A[1]); R.blocks[1].shape.items[1].value = 45; out(writeRead(R));",
f,
ROW,
)
assert got == "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 45)) / 100"
# ── 화면 글 ─────────────────────────────────────────────────────────────────
def test_화면_글에_금지_낱말_없음() -> None:
src = COND.read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert len(korean) >= 30
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
assert bad not in text, (bad, text)
-402
View File
@@ -1,402 +0,0 @@
"""M01 쉬운 단가산출 Model 시험(34-3b) — Node 로 `M01_EasyCalc_Model.ts` 를 바로 돌림 · 화면 조작은 ORCA.
· 견본 로직 줄 셋(인력+재료 · 기계+% 덧줄 · 표 찾기 잠금)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 왕복(toDoc → toRow 가 원래 줄과 같음) · 카드 종류 · 잠금 까닭 · 더하기 · 지우기 · 차례 · 숫자로 바꾸기 · 되돌리기 · 검사
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
MODEL = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Model.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
# ── 견본 줄 ───────────────────────────────────────────────────────────────
HEAD = {"원문번호": "", "구분": "자체", "상세구분": "시험", "출처": "자체", "소유": "현장"}
PERSON_MATERIAL = {
**HEAD,
"키": "GX900001",
"이름": "시험 찰쌓기",
"결과단위": "원/㎡",
"입력": [],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "0.072",
"비목": "노무비",
},
{
"종류": "재료",
"요소": {"구분": "시멘트", "검색어": "시멘트 40kg"},
"이름": "시멘트",
"규격": "40kg",
"단위": "포",
"수량": "0.50",
"비목": "재료비",
"출처": "산림품셈 12-1-1",
},
],
"덧줄": [],
"끝수": None,
"비고": "견본",
}
MACHINE_PERCENT = {
**HEAD,
"키": "GX900002",
"이름": "시험 깬돌 쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "LB000002",
"이름": "보통인부",
"단위": "인",
"수량": "0.036",
"비목": "노무비",
},
{
"종류": "로직",
"요소": "로직(GC000270, 기계='0201-0060', 집게='7206-0070', 지역=지역)",
"이름": "굴삭기+집게",
"규격": "0.6㎥",
"단위": "시간",
"수량": "0.27",
},
{
"종류": "로직",
"요소": "로직(GX000777)",
"이름": "모르터바르기",
"단위": "㎥",
"수량": "0.009",
},
],
"덧줄": [
{"이름": "공구손료", "식": "(0.036 * LB000002) * 2 / 100", "비목": "재료비"},
{"이름": "잡재료비", "식": "노무비 * 3 / 100", "비목": "재료비", "출처": "산림품셈 1-2-6"},
],
"끝수": {"대상": "계", "자리": 0, "방법": "버림"},
}
TABLE_LOCKED = {
**HEAD,
"키": "GX900003",
"이름": "시험 메쌓기",
"결과단위": "원/㎡",
"입력": [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "보정", "단위": "배"}],
"중간": [{"이름": "석공품", "식": "찾기(QF000041, 뒷길이=뒷길이).석공"}],
"호표": [
{
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "석공품",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000002",
"이름": "보통인부",
"단위": "인",
"수량": "만약(뒷길이 > 40, 0.4, 0.3)",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000003",
"이름": "특별인부",
"단위": "인",
"수량": "보정 * 0.1",
"비목": "노무비",
},
{
"종류": "인력",
"요소": "LB000001",
"이름": "작업반장",
"단위": "인",
"수량": "0.1 * 2",
"비목": "노무비",
},
],
"덧줄": [
{
"이름": "공구손료",
"식": "(석공품 * LB000005 + (만약(뒷길이 > 40, 0.4, 0.3)) * LB000002"
" + (보정 * 0.1) * LB000003 + (0.1 * 2) * LB000001) * 3 / 100",
"비목": "경비",
},
{
"이름": "잡재료",
"식": "주재료비 * 찾기(QC000589, 구분='잡재료비').값 / 100",
"비목": "재료비",
},
],
"끝수": None,
}
SAMPLES = [PERSON_MATERIAL, MACHINE_PERCENT, TABLE_LOCKED]
META = {"key": "GX900001", "version": "v1", "isBase": False}
def _run(body: str, *args: object) -> object:
"""견본을 받아 Model 을 돌리는 Node 글 — `M` = Model 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
script = (
f"const M = await import({json.dumps(MODEL.as_uri())});\n"
"const A = JSON.parse(process.argv[1]);\n"
"const out = (x: unknown) => console.log(JSON.stringify(x));\n" + body
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(list(args))],
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _doc(row: dict) -> dict:
return _run("out(M.toDoc(A[0], A[1]));", row, META)
# ── 왕복 ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("row", SAMPLES, ids=["인력재료", "기계덧붙임", "표찾기잠금"])
def test_왕복_같음(row: dict) -> None:
back = _run("out(M.toRow(M.toDoc(A[0], A[1])));", row, META)
assert back == row
def test_새_로직_최소칸() -> None:
row = _run("out(M.toRow(M.newDoc('새 일', 'm')));")
assert row == {
"키": "",
"원문번호": "",
"이름": "새 일",
"결과단위": "원/m",
"출처": "자체",
"입력": [],
"중간": [],
"호표": [],
"덧줄": [],
"끝수": None,
}
# ── 카드 바꾸기 ─────────────────────────────────────────────────────────────
def test_카드_종류와_숫자() -> None:
d = _doc(MACHINE_PERCENT)
assert [c["kind"] for c in d["cards"]] == ["person", "machine", "logic", "percent", "percent"]
person, machine, logic, tool, misc = d["cards"]
assert person["qty"] == 0.036 and person["cost"] == "노무비" and person["qtyFormula"] is None
assert machine["cost"] is None and machine["spec"] == "0.6㎥"
assert logic["cost"] is None
assert (tool["percentBase"], tool["qty"], tool["unit"], tool["cost"]) == (
"사람줄합",
2,
"%",
"재료비",
)
assert misc["percentBase"] == "노무비" and misc["qty"] == 3
assert d["inputs"] == MACHINE_PERCENT["입력"] and d["resultUnit"] == "원/㎡"
def test_잠금_까닭_쉬운말() -> None:
d = _doc(TABLE_LOCKED)
kinds = [c["kind"] for c in d["cards"]]
assert kinds == ["person"] * 4 + ["percent", "locked"]
why = [c["lockedWhy"] for c in d["cards"]]
assert why[:4] == [
"품셈 표에서 찾는 값",
"조건에 따라 달라지는 값",
"설계 때 정하는 값으로 정해짐",
"계산으로 정해지는 값",
]
assert d["cards"][0]["qtyFormula"] == "석공품" and d["cards"][0]["qty"] is None
assert d["cards"][4]["percentBase"] == "사람줄합"
assert why[5] == "품셈 표에서 찾는 값"
for w in filter(None, why):
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in w
# ── 카드 조작 ───────────────────────────────────────────────────────────────
def test_더하기_묶음_뒤_기계는_설계값_더함() -> None:
pick_person = {
"kind": "person",
"name": "석공",
"spec": "",
"unit": "인",
"element": "LB000005",
"qty": 0.5,
}
pick_machine = {
"kind": "machine",
"name": "굴삭기",
"spec": "0.7㎥",
"unit": "시간",
"element": "로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업='해당 없음')",
"qty": 0.3,
"needInputs": [{"이름": "지역", "고르기": ["전국평균"]}],
}
got = _run(
"let d = M.toDoc(A[0], A[1]);\n"
"d = M.addCard(d, A[2]); d = M.addCard(d, A[3]); d = M.addCard(d, A[3]);\n"
"d = M.addPercent(d, '공구손료', '사람줄합', 2, '경비');\n"
"out(M.toRow(d));",
PERSON_MATERIAL,
META,
pick_person,
pick_machine,
)
ho = got["호표"]
assert [h["이름"] for h in ho] == ["석공", "석공", "시멘트", "굴삭기", "굴삭기"]
assert ho[1] == {
"종류": "인력",
"요소": "LB000005",
"이름": "석공",
"단위": "인",
"수량": "0.5",
"비목": "노무비",
}
assert ho[3]["종류"] == "로직" and "비목" not in ho[3] and ho[3]["규격"] == "0.7㎥"
assert got["입력"] == [{"이름": "지역", "고르기": ["전국평균"]}]
assert got["덧줄"] == [
{"이름": "공구손료", "식": "(0.072 * LB000005 + 0.5 * LB000005) * 2 / 100", "비목": "경비"}
]
def test_지우기와_사람줄합_다시짬() -> None:
got = _run(
"let d = M.toDoc(A[0], A[1]);\n"
"const extra = M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005', qty:0.1});\n"
"const removed = M.removeCard(extra, d.cards[0].id);\n"
"out([M.toRow(extra), M.toRow(removed), M.toRow(M.removeCard(d, d.cards[0].id))]);",
MACHINE_PERCENT,
META,
)
two, one, none = got
assert two["덧줄"][0]["식"] == "(0.036 * LB000002 + 0.1 * LB000005) * 2 / 100"
assert two["덧줄"][1] == MACHINE_PERCENT["덧줄"][1]
assert one["덧줄"][0]["식"] == "(0.1 * LB000005) * 2 / 100"
assert [h["이름"] for h in none["호표"]] == ["굴삭기+집게", "모르터바르기"]
assert none["덧줄"][0]["식"] == "(0) * 2 / 100"
def test_차례_같은_묶음_안에서만() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const ids = d.cards.map((c: any) => c.id);\n"
"const names = (x: any) => x.cards.map((c: any) => c.name);\n"
"out([names(M.moveCard(d, ids[1], 1)), names(M.moveCard(d, ids[0], -1)),"
" names(M.moveCard(d, ids[3], 1)), names(M.moveCard(d, ids[0], 1)) ]);",
TABLE_LOCKED,
META,
)
down, top, add_down, end = got
assert down == ["석공", "특별인부", "보통인부", "작업반장", "공구손료", "잡재료"]
assert top == ["석공", "보통인부", "특별인부", "작업반장", "공구손료", "잡재료"]
assert add_down == top # 사람 묶음 끝 → 덧붙임으로 안 넘어감
assert end == ["보통인부", "석공", "특별인부", "작업반장", "공구손료", "잡재료"]
def test_잠금을_숫자로_바꾸기() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const n = M.updateCard(d, d.cards[0].id, { qty: 0.5, qtyFormula: null });\n"
"out([n.cards[0], M.toRow(n), d.cards[0].qtyFormula]);",
TABLE_LOCKED,
META,
)
card, row, before = got
assert card["lockedWhy"] is None and card["qty"] == 0.5
assert row["호표"][0]["수량"] == "0.5"
assert before == "석공품" # 받은 문서는 안 바뀜
# 석공 수량이 바뀌어 사람 줄 합이 다시 짜임
assert row["덧줄"][0]["식"].startswith("(0.5 * LB000005 + ")
def test_되돌리기와_저장안함() -> None:
got = _run(
"const h = new M.EasyHistory();\n"
"const d0 = M.toDoc(A[0], A[1]);\n"
"const log: unknown[] = [];\n"
"h.push(d0); log.push([h.canUndo, h.dirty]);\n"
"const d1 = M.updateCard(d0, d0.cards[0].id, { qty: 0.08 });\n"
"h.push(d1); h.push(d1); log.push([h.canUndo, h.dirty]);\n"
"h.markSaved(); log.push([h.dirty]);\n"
"const back = h.undo(); log.push([back.cards[0].qty, h.canUndo, h.dirty, h.undo()]);\n"
"out(log);",
PERSON_MATERIAL,
META,
)
assert got == [[False, False], [True, True], [False], [0.072, False, True, None]]
# ── 검사 ───────────────────────────────────────────────────────────────────
def test_검사_쉬운말_한줄() -> None:
got = _run(
"const d = M.toDoc(A[0], A[1]);\n"
"const c = d.cards[0];\n"
"const t = (p: any) => M.checkCard({ ...c, ...p }, d);\n"
"out([t({}), t({ qty: null }), t({ qty: Number.NaN }), t({ qty: 0 }), t({ qty: -1 }),"
" t({ name: ' ' }), t({ qty: 12 }), t({ qty: null, qtyFormula: '석공품' }),"
" M.checkCard(M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005',"
" qty:0.1}).cards[1], M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인',"
" element:'LB000005', qty:0.1})),"
" M.checkName('시험 찰쌓기', ['시험 찰쌓기']), M.checkName('', []), M.checkName('새것', ['헌것'])]);",
PERSON_MATERIAL,
META,
)
assert got == [
None,
"얼마인지 숫자로 넣어야 함",
"얼마인지 숫자로 넣어야 함",
"0 은 안 됨 — 빼려면 지움",
"0 보다 큰 수만 됨",
"이름이 비어 있음",
"1㎡ 에 12인 — 너무 큰 수 아닌지?",
None,
"같은 줄이 이미 있음 — 한 줄에 합쳐 넣음",
"내가 만든 것에 같은 이름이 있음",
"이름이 비어 있음",
None,
]
def test_영어_글() -> None:
got = _run(
"M.setEasyModelLanguage(1);\n"
"const d = M.toDoc(A[0], A[1]);\n"
"out([d.cards[0].lockedWhy, M.checkCard({ ...d.cards[1], qty: 0, qtyFormula: null }, d)]);",
TABLE_LOCKED,
META,
)
assert got == [
"Looked up from the standard table",
"0 is not allowed — remove the line instead",
]
def test_화면_글에_금지_낱말_없음() -> None:
src = MODEL.read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert len(korean) == 12
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
assert bad not in text, (bad, text)
-155
View File
@@ -1,155 +0,0 @@
"""쉬운 단가산출 2단계 — 표 보고 고르기(`M01_EasyCalc_Table.ts`)의 식 읽기 · 새 식 짜기.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 화면 부품 · 서버는 빈 흉내(순수 함수만 부름).
견본 식은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Table.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "read") return m.readFinds(job.formula);
if (job.op === "swap") {
const calls = m.readFinds(job.formula);
const swaps = calls.map((c, i) => {
const s = job.swaps[i];
return s ? m.writeFind(c.table, s.args ?? c.args, s.col ?? c.col) : null;
});
return m.replaceFinds(job.formula, calls, swaps);
}
if (job.op === "literal") return m.literal(job.value, job.rule ?? "");
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
MESSANGI = "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"
def test_식_읽기_흔한_꼴():
[calls] = run([{"op": "read", "formula": MESSANGI}])
assert len(calls) == 1
c = calls[0]
assert c["table"] == "QF000421" and c["col"] == "석공"
assert [(a["key"], a["rhs"]) for a in c["args"]] == [
("뒷길이", "뒷길이"),
("돌", "돌"),
("쌓기", "쌓기"),
]
assert MESSANGI[c["start"] : c["end"]] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공"
def test_식_읽기_따옴표_쉼표_괄호_겹침():
f = (
"(찾기(QC000524, 형식=형식).특별인부 - 만약(정밀절단 == '아님', "
"찾기(QC000583, 구분='가, 나 (다)').값, 0)) / 시공량"
)
[calls] = run([{"op": "read", "formula": f}])
assert [c["table"] for c in calls] == ["QC000524", "QC000583"]
assert calls[1]["args"] == [{"key": "구분", "rhs": "'가, 나 (다)'"}]
assert calls[1]["col"] == "값"
def test_식_읽기_못_읽는_꼴과_없는_꼴():
out = run(
[
{"op": "read", "formula": "찾기(QF:{지역}, 구분='a').값"}, # {자리} 낀 키
{"op": "read", "formula": "찾기(QF000001, 구분='a'"}, # 안 닫힘
{"op": "read", "formula": "품 * 1.2"},
{"op": "read", "formula": "'찾기(QF000001, 구분=1).값'"}, # 따옴표 글 안
]
)
assert out[0] is None and out[1] is None
assert out[2] == [] and out[3] == []
def test_새_식_짜기_줄_고정과_값칸_바꾸기():
[new] = run(
[
{
"op": "swap",
"formula": MESSANGI,
"swaps": [
{
"args": [
{"key": "뒷길이", "rhs": "35"},
{"key": "돌", "rhs": "'깬잡석'"},
{"key": "쌓기", "rhs": "쌓기"},
],
"col": "보통인부",
}
],
}
]
)
assert new == (
"찾기(QF000421, 뒷길이=35, 돌='깬잡석', 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
)
def test_새_식_짜기_둘째_찾기만_바꿈_나머지_글_그대로():
f = "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기=공기).손율 / 100"
[new] = run(
[
{
"op": "swap",
"formula": f,
"swaps": [None, {"args": [{"key": "공기", "rhs": "'1개월'"}]}],
}
]
)
assert new == "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기='1개월').손율 / 100"
# 다시 읽어도 같은 모양(왕복)
[calls] = run([{"op": "read", "formula": new}])
assert calls[1]["args"] == [{"key": "공기", "rhs": "'1개월'"}]
def test_칸_값을_식_글로():
out = run(
[
{"op": "literal", "value": 25},
{"op": "literal", "value": "깬잡석"},
{"op": "literal", "value": "it's"}, # 따옴표 든 글은 못 씀
{"op": "literal", "value": [20, 40], "rule": "초과·이하"},
{"op": "literal", "value": [None, 60], "rule": "이하"},
{"op": "literal", "value": [None, 60], "rule": "이상·미만"},
{"op": "literal", "value": [80, None], "rule": "초과·이하"},
{"op": "literal", "value": [80, None], "rule": "이상·미만"},
]
)
assert out == ["25", "'깬잡석'", None, "30", "60", "59", "81", "80"]
-309
View File
@@ -1,309 +0,0 @@
"""M01 로직 만들기 판 Blocks 시험(35-2a) — Node 로 `M01_LB_Blocks.ts` 를 바로 돌림 · 화면 조작은 ORCA.
· 견본 로직 줄 여섯(정함 10)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 왕복(readLogic → writeLogic 이 원래 줄과 같음) · 블록 내용 · 조건 바꾸기 · 표 바꾸기 · 계산 바꾸기 · 빈 로직 · 못 읽는 꼴
· EasyCalc Model · Table · Cond 를 그대로 부름 — 화면 모듈(@ui · 서버 길)은 시험 고리가 빈 것으로 갈음
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
BLOCKS = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Blocks.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)(\.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 : "
"k === 'API_BASE_URL' ? '/api' : () => undefined });\n"
)
def _run(body: str, *args: object) -> object:
"""`B` = Blocks 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
script = (
"import { register } from 'node:module';\n"
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
+ STUB
+ f"const B = await import({json.dumps(BLOCKS.as_uri())});\n"
"const A = JSON.parse(process.argv[1]);\n"
"const out = (x) => console.log(JSON.stringify(x));\n"
"const META = { key: 'GX900001', version: 'v1', isBase: false };\n" + body
)
result = subprocess.run(
["node", "--no-warnings", "--input-type=module", "-e", script, json.dumps(list(args))],
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
# ── 견본 줄 여섯(정함 10) ──────────────────────────────────────────────────
H = {"원문번호": "", "출처": "자체", "끝수": None}
GF000208 = {
**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
"덧줄": [],
} # fmt: skip
GF000219 = {
**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35]}, {"이름": "돌", "고르기": ["깬잡석", "깬돌"]},
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "단위": "m"},
{"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
"호표": [
{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인",
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비"},
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비"},
],
"덧줄": [],
} # fmt: skip
GF000025 = {
**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
"중간": [],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)",
"비목": "노무비"}],
"덧줄": [],
} # fmt: skip
GF000249 = {
**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
"단위": "시간", "수량": "1 / 작업량"}],
"덧줄": [],
} # fmt: skip
GF000133 = {
**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f", "단위": ""}, {"이름": "E", "단위": ""}, {"이름": "Cm", "단위": "분"}],
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)", "출처": "건설품셈 공통 8-1-4 주①"},
"끝수": "소수 3자리 사사오입(건설품셈 공통 8-1-4 주①)",
} # fmt: skip
GC000051 = {
**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
"덧줄": [{"이름": "공구손료 및 경장비",
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100",
"비목": "경비", "출처": "건설품셈 공통 2-9-3"}],
} # fmt: skip
SIX = [GF000208, GF000219, GF000025, GF000249, GF000133, GC000051]
def _lb(row: dict) -> dict:
return _run("const lb = B.readLogic(A[0], META); out({ ...lb, doc: undefined });", row)
# ── 왕복 ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("row", SIX, ids=[r["키"] for r in SIX])
def test_왕복_같음_다_읽힘(row: dict) -> None:
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
assert got == [row, []]
# ── 블록 내용 ───────────────────────────────────────────────────────────────
def test_메쌓기_표조회_조건목록_할증() -> None:
lb = _lb(GF000219)
assert [(x["id"], x["table"], x["col"]) for x in lb["lookups"]] == [
("L1", "QF000422", "증가율"),
("L2", "QF000421", "석공"),
("L3", "QF000421", "보통인부"),
]
assert lb["lookups"][1]["conds"] == [
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "input", "name": "돌"}},
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
]
assert lb["lookups"][1]["at"] == [{"part": "수량", "index": 0, "start": 0, "end": 36}]
mid, stone, worker = lb["calcs"]
assert (mid["name"], mid["kind"], mid["at"]) == (
"증가율",
"조건목록",
{"part": "중간", "index": 0},
)
assert mid["cond"]["blocks"][0]["shape"]["type"] == "if"
assert (stone["kind"], stone["parts"]) == (
"할증",
[{"k": "lookup", "id": "L2"}, {"k": "calc", "name": "증가율"}],
)
assert worker["parts"][0] == {"k": "lookup", "id": "L3"}
assert lb["result"] == {"kind": "금액", "unit": "원/㎡"}
assert [x["이름"] for x in lb["inputs"]] == ["뒷길이", "돌", "쌓기", "높이", "초과증가율"]
def test_떼붙임_다짐_덤프_안전통로() -> None:
turf = _lb(GF000025)
assert turf["lookups"][0]["conds"] == [
{"col": "구분", "from": {"k": "fixed", "value": "비탈구배 해당 시 품 할증"}}
]
c = turf["calcs"][0]
assert c["kind"] == "할증" and c["parts"][0] == {"k": "input", "name": "품"}
assert c["parts"][1]["k"] == "expr" and c["cond"]["blocks"][0]["shape"]["type"] == "if"
tamp = _lb(GF000249)
work, qty = tamp["calcs"]
assert work["kind"] == "값" and work["parts"] == [
{"k": "logic", "key": "GF000114", "field": None}
]
assert (qty["kind"], qty["ops"]) == ("곱나눔", ["/"])
assert qty["parts"] == [{"k": "fixed", "value": 1}, {"k": "calc", "name": "작업량"}]
dump = _lb(GF000133)
assert dump["result"]["kind"] == "값" and dump["result"]["unit"] == "㎥/시간"
assert dump["result"]["calc"]["kind"] == "올림" and dump["calcs"] == [dump["result"]["calc"]]
walk = _lb(GC000051)
rate = walk["calcs"][-1]
assert rate["at"] == {"part": "비율", "index": 0} and rate["ops"] == ["*", "/"]
assert rate["parts"] == [
{"k": "cost", "name": "사람줄합"},
{"k": "lookup", "id": "L2"},
{"k": "fixed", "value": 100},
]
fill = _lb(GF000208)
assert fill["lookups"] == [] and fill["calcs"][0]["parts"] == [{"k": "fixed", "value": 1}]
# ── 고치기 ─────────────────────────────────────────────────────────────────
def _ho(row: dict) -> list[str]:
return [h["수량"] for h in row["호표"]]
def test_조건_바꾸기_빼기_표_바꾸기() -> None:
got = _run(
"let lb = B.readLogic(A[0], META);\n"
"const a = B.writeLogic(B.setCond(lb, 'L2', '돌', { k: 'fixed', value: '깬돌' }));\n"
"const b = B.writeLogic(B.setCond(lb, 'L2', '쌓기', null));\n"
"const c = B.writeLogic(B.setCond(lb, 'L1', '높이', { k: 'fixed', value: 5 }));\n"
"const d = B.writeLogic(B.setLookupTable(lb, 'L3', 'QX000001'));\n"
"out([a, b, c, d]);",
GF000219,
)
a, b, c, d = got
assert _ho(a) == [
"찾기(QF000421, 뒷길이=뒷길이, 돌='깬돌', 쌓기=쌓기).석공 * (1 + 증가율 / 100)",
GF000219["호표"][1]["수량"],
]
assert _ho(b)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)"
assert (
c["중간"][0]["식"]
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=5).증가율, 초과증가율))"
)
assert (
_ho(d)[1] == "찾기(QX000001, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
)
assert _ho(d)[0] == GF000219["호표"][0]["수량"]
def test_계산_바꾸기() -> None:
got = _run(
"const lb = B.readLogic(A[0], META);\n"
"const [mid, stone] = lb.calcs;\n"
"const r1 = B.writeLogic(B.setCalc(lb, stone, { parts: [stone.parts[0], { k: 'fixed', value: 10 }] }));\n"
"const cond = structuredClone(mid.cond); cond.blocks[0].shape.otherwise = { kind: 'num', value: 90 };\n"
"const r2 = B.writeLogic(B.setCalc(lb, mid, { cond }));\n"
"const r3 = B.writeLogic(B.setCalc(lb, stone, { kind: '값', parts: [{ k: 'fixed', value: 0.3 }] }));\n"
"const r4 = B.writeLogic(B.setCalc(lb, stone, { text: '0.25 * 2' }));\n"
"const after = B.readLogic(r1, META);\n"
"out([r1, r2, r3, r4, after.calcs[1].parts, after.lookups.map((x) => x.id)]);",
GF000219,
)
r1, r2, r3, r4, parts, ids = got
assert _ho(r1)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 10 / 100)"
assert _ho(r1)[1] == GF000219["호표"][1]["수량"]
assert (
r2["중간"][0]["식"]
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 90))"
)
assert _ho(r3)[0] == "0.3" and _ho(r4)[0] == "0.25 * 2"
assert parts == [{"k": "lookup", "id": "L2"}, {"k": "fixed", "value": 10}]
assert ids == ["L1", "L2", "L3"]
def test_곱나눔_더하기_괄호() -> None:
got = _run(
"const lb = B.readLogic(A[0], META); const q = lb.calcs[1];\n"
"const r1 = B.writeLogic(B.setCalc(lb, q, { parts: [{ k: 'fixed', value: 2 }, q.parts[1]] }));\n"
"const r2 = B.writeLogic(B.setCalc(lb, q, { parts: [q.parts[0], { k: 'expr', text: '작업량 + 1' }] }));\n"
"const r3 = B.writeLogic(B.setCalc(lb, q, { kind: '더하기', ops: ['-'], parts: [{ k: 'fixed', value: 3 },"
" { k: 'expr', text: '작업량 - 1' }] }));\n"
"out([r1.호표[0].수량, r2.호표[0].수량, r3.호표[0].수량, r1.중간]);",
GF000249,
)
assert got == ["2 / 작업량", "1 / (작업량 + 1)", "3 - (작업량 - 1)", GF000249["중간"]]
# ── 빈 로직 · 못 읽는 꼴 ────────────────────────────────────────────────────
def test_빈_로직() -> None:
got = _run(
"const lb = B.newLogic('새 일', 'm'); out([B.writeLogic(lb), lb.lookups, lb.calcs, lb.result, lb.blocked]);"
)
row, looks, calcs, result, blocked = got
assert row["이름"] == "새 일" and row["결과단위"] == "원/m" and row["출처"] == "자체"
assert (looks, calcs, result, blocked) == ([], [], {"kind": "금액", "unit": "원/m"}, [])
def test_못_읽는_꼴은_쉬운말_왕복은_같음() -> None:
row = {
**GF000219,
"중간": [
{
"이름": "증가율",
"식": "만약(높이 > 3 그리고 높이 <= 7.5, 찾기(QF000422, 높이=최소(높이, 7.5)).증가율, 0)",
}
],
}
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
assert got[0] == row
assert got[1] == [
"증가율 — 조건에 「그리고 · 또는」이 섞임",
"증가율 — 표 조회 조건에 계산이 들어 있음",
]
def test_화면_글에_금지_낱말_없음() -> None:
src = BLOCKS.read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert len(korean) >= 7
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체", "잠금"):
assert bad not in text, (bad, text)
-181
View File
@@ -1,181 +0,0 @@
"""로직 만들기 판 시험 길(`M01_MasterData/LogicBuilder/M01_LB_Calc.py`) 시험 — PLAN 35-2.
· 고친 표 사본으로 셈하면 값이 바뀌고 · 전역 캐시 · 파일은 그대로
· 표 없이 부르면 `/calc` 와 같은 값 · 없는 표는 404
· 사용처 — 표 「용도.로직키」 + 로직 글 대조
· 권한 — 시스템 관리자 아니면 403
견본 표 · 로직 줄은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 마스터 값에 안 기댐).
"""
from __future__ import annotations
import copy
import json
import shutil
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
REAL = store.FOLDER
OWN = "로직_자체.json"
SAMPLE = "QF999999" # 견본 표 — 사본 폴더에만 있음
TABLE = {
"키": SAMPLE,
"원문번호": "",
"구분": "",
"상세구분": "",
"이름": "견본 쌓기 표",
"기준": "1㎡",
"출처": "자체",
"용도": {"공종": "견본", "대상": ["인력"], "로직키": []},
"조건": {"돌": "고르기"},
"값칸": {"석공": "인"},
"줄": [{"돌": "깬돌", "석공": 0.5}, {"돌": "견치돌", "석공": 0.7}],
"주": [],
}
def _labor() -> tuple[str, Decimal]:
rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"]
row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float)))
return row["키"], Decimal(str(row["값"]))
def _row(labor: str) -> dict:
"""견본 로직 — 입력 「돌」 로 견본 표에서 석공 품을 찾아 노임에 곱함."""
return {
"키": "",
"원문번호": "",
"이름": "견본 쌓기",
"결과단위": "원/㎡",
"출처": "자체",
"입력": [{"이름": "돌", "고르기": ["깬돌", "견치돌"]}],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": labor,
"이름": "석공",
"단위": "인",
"수량": f"찾기({SAMPLE}, 돌=돌).석공",
"비목": "노무비",
}
],
"덧줄": [],
"끝수": None,
}
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
for path in REAL.glob("*.json"):
if path.name == OWN:
continue
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
host = sorted(tmp_path.glob("소요량_*.json"))[0] # 견본 표를 사본 소요량 파일 하나에 더함
data = json.loads(host.read_text(encoding="utf-8"))
data["표"].append(TABLE)
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
monkeypatch.setattr(store, "FOLDER", tmp_path)
return tmp_path
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(router_module.router)
app.include_router(lb.router)
return TestClient(app)
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def test_고친_표로_값이_바뀌고_캐시와_파일은_그대로(client: TestClient, folder: Path) -> None:
labor, price = _labor()
row = _row(labor)
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
plain = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}})
assert plain["ok"] is True
assert Decimal(str(plain["sums"]["계"])) == Decimal("0.5") * price
edited = copy.deepcopy(TABLE)
edited["줄"][0]["석공"] = 0.8 # 깬돌 줄 품 0.5 → 0.8
body = {"row": row, "inputs": {"돌": "깬돌"}, "tables": {SAMPLE: edited}}
changed = _post(client, "/api/m01/lb/calc", body)
assert Decimal(str(changed["sums"]["계"])) == Decimal("0.8") * price
assert changed["lines"][0]["수량"] == 0.8
# 전역 캐시 · 파일 · 같은 요청 뒤 표 없는 셈은 그대로
files = store.loaded()[0].values()
cached = next(
t for d in files if d.get("그룹") == "소요량" for t in d["표"] if t["키"] == SAMPLE
)
assert cached["줄"][0]["석공"] == Decimal("0.5")
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before
again = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}})
assert again == plain
# 키는 못 바꿈 — 사본 표의 「키」 가 달라도 그 자리 키로 셈
_post(client, "/api/m01/lb/calc", {**body, "tables": {SAMPLE: {**edited, "키": "QF000001"}}})
def test_표_없이_부르면_calc_와_같고_없는_표는_404(client: TestClient) -> None:
labor, _ = _labor()
row = _row(labor)
for stone in ("깬돌", "견치돌"):
mine = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": stone}})
theirs = _post(client, "/api/m01/calc", {"key": "", "inputs": {"돌": stone}, "row": row})
assert mine == theirs
stop = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "호박돌"}})
assert stop["ok"] is False and stop == _post(
client, "/api/m01/calc", {"key": "", "inputs": {"돌": "호박돌"}, "row": row}
)
_post(client, "/api/m01/lb/calc", {"row": row, "tables": {"QF999998": TABLE}}, want=404)
def test_사용처_용도와_로직_글(client: TestClient, folder: Path) -> None:
labor, _ = _labor()
made = _post(client, "/api/m01/logic/new", {"logic": _row(labor)})
first = next(
r["키"] for p in sorted(folder.glob("로직_*.json")) if p.name != OWN
for r in json.loads(p.read_text(encoding="utf-8"))["줄"]
) # fmt: skip
host = sorted(folder.glob("소요량_*.json"))[0]
data = json.loads(host.read_text(encoding="utf-8"))
next(t for t in data["표"] if t["키"] == SAMPLE)["용도"]["로직키"] = [first, made["key"]]
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
store.cache.stale([host.name])
res = client.get("/api/m01/lb/table-users", params={"key": SAMPLE})
assert res.status_code == 200, res.text
users = {u["key"]: u["via"] for u in res.json()["users"]}
assert users == {first: "용도", made["key"]: "둘"}
assert client.get("/api/m01/lb/table-users", params={"key": "QF999998"}).status_code == 404
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
lb.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
assert client.post("/api/m01/lb/calc", json={"row": {}}).status_code == 403
assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 403
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 200
-382
View File
@@ -1,382 +0,0 @@
"""로직 만들기 판 — 검사(`M01_LB_Check.ts`)의 빈틈 · 중복 · 걸린 줄 · 기대값 맞춤.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 서버는 빈 흉내 · 순수 함수만).
견본 표 · 로직은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Check.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
Promise.all(
jobs.map(async (job) => {
if (job.op === "gaps") return m.gaps(job.lb, job.tables);
if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables);
if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values);
if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? "");
if (job.op === "same") return m.sameAsExpect(job.got, job.expect);
if (job.op === "expect") {
const answers = job.answers;
let i = 0;
return m.runExpect({}, {}, job.rows, async () => answers[i++]);
}
return null;
}),
).then((out) => process.stdout.write(JSON.stringify(out)));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관
STONE = {
"키": "T1",
"조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
"값칸": {"석공": "인"},
"줄": [
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15},
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13},
{"뒷길이": 25, "돌": "호박돌", "석공": 0.10},
{"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22},
{"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복
],
}
# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함)
HEIGHT = {
"키": "T2",
"범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함",
"조건": {"높이": "범위"},
"값칸": {"증가율": "%"},
"줄": [
{"높이": [None, 2], "증가율": 0},
{"높이": [2, 4], "증가율": 30},
{"높이": [4, 6], "증가율": 40},
],
}
# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄)
UPTO = {
"키": "T3",
"범위규칙": "이하",
"조건": {"거리": "범위"},
"값칸": {"값": ""},
"줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}],
}
TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO}
def lk(id_, table, conds, col):
return {"id": id_, "table": table, "conds": conds, "col": col}
LOGIC = {
"inputs": [
{"이름": "뒷길이", "고르기": [25, 30]},
{"이름": "돌", "고르기": ["깬잡석", "호박돌"]},
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]},
{"이름": "높이", "고르기": [1, 3, 6, 7]},
{"이름": "길이"}, # 숫자 입력 — 선택 목록 없음
],
"lookups": [
lk(
"a",
"T1",
[
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "input", "name": "돌"}},
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
],
"석공",
),
lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"),
lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"),
],
}
def by(gaps, lookup):
return sorted(
(
(g["kind"], json.dumps(g["conds"], ensure_ascii=False))
for g in gaps
if g["lookup"] == lookup
)
)
def test_빈틈_중복_무관_빈칸():
[gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}])
got = by(gaps, "a")
# 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합
# 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님)
# 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜
# 중복 — 30 · 깬잡석 · 골쌓기(두 줄)
assert got == sorted(
[
(
"없음",
json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False),
),
(
"없음",
json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False),
),
(
"없음",
json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False),
),
(
"중복",
json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False),
),
]
)
dup = next(g for g in gaps if g["kind"] == "중복")
assert dup["rows"] == [3, 4]
def test_범위_줄_이상미만_마지막칸_이하포함():
[gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}])
# 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음
assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))]
out = run(
[
{"op": "find", "table": HEIGHT, "conds": {"높이": 2}},
{"op": "find", "table": HEIGHT, "conds": {"높이": 4}},
{"op": "find", "table": HEIGHT, "conds": {"높이": 6}},
]
)
assert out == [[1], [2], [2]]
def test_범위_이하_위끝_가장_작은_줄():
out = run(
[
{"op": "find", "table": UPTO, "conds": {"거리": 5}},
{"op": "find", "table": UPTO, "conds": {"거리": 15}},
{"op": "find", "table": UPTO, "conds": {"거리": 25}},
]
)
assert out == [[0], [1], []]
def test_셀_수_없는_조회는_건너뜀():
gaps, skipped = run(
[
{"op": "gaps", "lb": LOGIC, "tables": TABLES},
{"op": "skipped", "lb": LOGIC, "tables": TABLES},
]
)
assert by(gaps, "c") == []
assert skipped == ["c"]
def test_예시값으로_걸린_줄():
[hits] = run(
[
{
"op": "hits",
"lb": LOGIC,
"tables": TABLES,
"values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15},
}
]
)
assert hits == {"a": "중복", "b": 1, "c": 1}
[hits] = run(
[
{
"op": "hits",
"lb": LOGIC,
"tables": TABLES,
"values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"},
}
]
)
assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름
def test_고정값_조건():
lb = {
"inputs": [],
"lookups": [
lk(
"f",
"T1",
[
{"col": "돌", "from": {"k": "fixed", "value": "호박돌"}},
{"col": "뒷길이", "from": {"k": "fixed", "value": 25}},
],
"석공",
),
lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"),
],
}
gaps, hits = run(
[
{"op": "gaps", "lb": lb, "tables": TABLES},
{"op": "hits", "lb": lb, "tables": TABLES, "values": {}},
]
)
assert gaps == []
assert hits == {"f": 2, "g": None}
def test_기대값_시험표():
answers = [
{
"ok": True,
"lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}],
"sums": {"계": 52437.06},
"middle": {},
},
{
"ok": True,
"lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}],
"sums": {"계": 50000},
"middle": {},
},
{"ok": False, "reason": "표에 줄이 없음"},
]
rows = [
{"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}},
{"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}},
{"inputs": {"뒷길이": 99}, "expect": {"계": 1}},
]
[res] = run([{"op": "expect", "rows": rows, "answers": answers}])
assert [r["ok"] for r in res] == [True, False, False]
assert res[0]["got"]["석공 금액"] == 52437.06
assert res[2]["why"] == "표에 줄이 없음"
same = run(
[
{"op": "same", "got": 52437.4, "expect": 52437},
{"op": "same", "got": 52437.6, "expect": 52437},
{"op": "same", "got": 0.264, "expect": 0.26},
]
)
assert same == [True, False, True]
SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts"
RUNNER_UI = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "parse") return m.parseCell(job.text, job.before);
if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]);
if (job.op === "role") return m.roleOf(job.lookups, job.col);
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run_ui(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_원문_칸_고친_글_읽기():
out = run_ui(
[
{"op": "parse", "text": "0.3", "before": 0.26},
{"op": "parse", "text": "1,200", "before": 1000},
{"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음
{"op": "parse", "text": "3~4", "before": [2, 3]},
{"op": "parse", "text": "~5", "before": [None, 4]},
{"op": "parse", "text": "7~", "before": [6, None]},
{"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음
{"op": "parse", "text": "깬돌", "before": "깬잡석"},
{"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로
{"op": "parse", "text": "", "before": 1},
]
)
assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None]
def test_계산에_쓰인_주와_칸_역할():
[notes] = run_ui(
[
{
"op": "notes",
"row": {
"중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}],
"비고": "주① 참고",
},
}
]
)
assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]]
lookups = [
{
"id": "a",
"table": "T1",
"col": "석공",
"conds": [
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}},
],
}
]
out = run_ui(
[
{"op": "role", "lookups": lookups, "col": "석공"},
{"op": "role", "lookups": lookups, "col": "뒷길이"},
{"op": "role", "lookups": lookups, "col": "돌"},
{"op": "role", "lookups": lookups, "col": "쌓기"},
]
)
assert out == ["결과", "조건", "고정", None]
@@ -1,193 +0,0 @@
"""로직 만들기 판 단계 안내 — 답 → 로직 줄 짜기(`M01_LB_Wizard_Build.ts`).
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름.
견본 표 · 답은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Wizard_Build.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "unit") return m.unitOfBase(job.base);
if (job.op === "row") return m.buildRow(job.a);
if (job.op === "trial") return m.trialOf(job.a, m.buildRow(job.a));
return null;
});
process.stdout.write(JSON.stringify(out));
"""
TABLE = {
"key": "QF900001",
"이름": "견본 쌓기",
"기준": "1㎡",
"출처": "산림품셈 9-9-9",
"조건": {"뒷길이": "수", "돌": "고르기"},
"값칸": {"석공": "인", "보통인부": "인"},
"줄": [
{"뒷길이": 25, "돌": "깬돌", "석공": 0.15, "보통인부": 0.12},
{"뒷길이": 30, "돌": "깬돌", "석공": 0.26, "보통인부": 0.21},
],
"주": ["③ 높이 3m 초과 시 할증"],
}
RATE_TABLE = {
"key": "QF900002",
"이름": "견본 증가율표",
"기준": "",
"출처": "산림품셈 9-9-9 주③",
"조건": {"높이": "범위"},
"값칸": {"증가율": "%"},
"줄": [{"높이": [3, 4], "증가율": 30}],
"주": [],
}
def answers(**more) -> dict:
base = {
"book": "산림품셈",
"section": "9-9-9",
"table": TABLE,
"roles": {"뒷길이": "조건", "돌": "조건", "석공": "결과", "보통인부": "결과"},
"fixed": {},
"options": {"뒷길이": [25, 30], "돌": ["깬돌"]},
"resultUnit": "원/㎡",
"mods": [],
"links": [
{
"col": "석공",
"kind": "노무",
"name": "석공",
"spec": "",
"unit": "인",
"element": "LB000033",
},
{
"col": "보통인부",
"kind": "노무",
"name": "보통인부",
"spec": "",
"unit": "인",
"element": "LB000002",
},
],
"extras": [],
"name": "견본",
"kind": "금액",
"valueCol": "",
}
base.update(more)
return base
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_결과단위는_표_기준에서():
out = run([{"op": "unit", "base": b} for b in ("1㎡", "100㎡ 당", "㎡당", "m당", "")])
assert out == ["원/㎡", "원/100㎡", "원/㎡", "원/m", "원/㎡"]
def test_기본_두_줄():
row = run([{"op": "row", "a": answers()}])[0]
assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌"]
assert row["입력"][0]["고르기"] == [25, 30]
assert row["중간"] == []
assert [h["수량"] for h in row["호표"]] == [
"찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공",
"찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부",
]
assert row["호표"][0]["종류"] == "인력" and row["호표"][0]["비목"] == "노무비"
assert row["결과단위"] == "원/㎡" and row["출처"] == "산림품셈 9-9-9"
def test_고정칸은_값으로():
a = answers(
roles={"뒷길이": "고정", "돌": "조건", "석공": "결과", "보통인부": "쓰지 않음"},
fixed={"뒷길이": 25},
)
row = run([{"op": "row", "a": a}])[0]
assert [i["이름"] for i in row["입력"]] == ["돌"]
assert [h["수량"] for h in row["호표"]] == ["찾기(QF900001, 돌=돌, 뒷길이=25).석공"]
def test_문장틀_차례대로_씌움():
cond = {
"t": "cond",
"input": "높이",
"op": "<=",
"limit": 3,
"table": RATE_TABLE,
"col": "증가율",
}
a = answers(
mods=[
{"k": "할증", "rate": cond, "src": "산림품셈 9-9-9 주③"},
{"k": "나누기", "by": {"t": "input", "name": "시공량"}},
{"k": "올림", "fn": "반올림", "places": 3},
]
)
row = run([{"op": "row", "a": a}])[0]
assert row["중간"] == [
{
"이름": "증가율",
"식": "만약(높이 <= 3, 0, 찾기(QF900002, 높이=높이).증가율)",
"출처": "산림품셈 9-9-9 주③",
}
]
assert row["호표"][0]["수량"] == (
"반올림((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)) / 시공량, 3)"
)
assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌", "높이", "시공량"]
def test_비율_덧줄은_사람줄합():
extras = [{"k": "비율", "name": "공구손료", "base": "사람줄합", "rate": 3, "cost": "재료비"}]
row = run([{"op": "row", "a": answers(extras=extras)}])[0]
assert row["덧줄"] == [
{
"이름": "공구손료",
"식": "((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공) * LB000033 + (찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부) * LB000002) * 3 / 100",
"비목": "재료비",
}
]
def test_값_종류는_결과_식_하나():
a = answers(kind="값", valueCol="석공", resultUnit="인/㎡")
row = run([{"op": "row", "a": a}])[0]
assert row["결과"] == {"식": "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공"}
assert "호표" not in row and row["결과단위"] == "인/㎡"
def test_예시값은_첫_줄에_걸림():
out = run([{"op": "trial", "a": answers()}])[0]
assert out == {"뒷길이": 25, "돌": "깬돌"}
-333
View File
@@ -1,333 +0,0 @@
"""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],
]
-413
View File
@@ -1,413 +0,0 @@
"""M01 시트 로직 Model · Eval 시험(35-5) — Node 로 `M01_SL_Model.ts` · `M01_SL_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림.
· 견본 시트 로직 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함
· 입력값 → 계산 → 항목 → 결과 · 선 잇기 / 끊기 → 값 바뀜 · 순환 막음 · 바깥 값 넣기 · 알약 · 상자 지우기 · 검사
· 엔진 · Model 은 TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길)
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SL = ROOT / "M01_MasterData" / "SheetLogic"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
LOADER = r"""
const fs = require("fs");
const path = require("path");
const ROOT = process.argv[1];
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full).exports;
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const mod = { exports: {} };
cache.set(full, mod);
const req = (r) => {
if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 };
if (r.endsWith(".css")) return {};
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`));
return require(r);
};
new Function("exports", "module", "require", js)(mod.exports, mod, req);
return mod.exports;
}
const M = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Model.ts"));
const E = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Eval.ts"));
const out = (x) => console.log(JSON.stringify(x));
const sheet = (sl, id) => M.nodeSheet(sl, id);
const link = (sl, a, ap, b, bp) => {
const r = M.connect(sl, { node: a.id, pill: ap }, { node: b.id, pill: bp });
if (r.why) throw new Error(r.why);
return r.sl;
};
"""
def _run(body: str) -> object:
result = subprocess.run(
["node", "-e", LOADER + body, str(ROOT)],
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.strip().splitlines()[-1])
# 견본 하나 — 품(입력값) → 보통 인부(항목 · 노무) → 결과
ONE = r"""
let sl = M.newLogic("시험 하나", "원/㎡");
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002";
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]);
sl = link(sl, pum, "품", man, "수량");
sl = link(sl, man, "노무비", res, "노무비");
"""
# 견본 둘 — 품 · 할증률(고정값) → 계산(곱) → 석공 · 보통인부 두 항목 → 결과 노무비(두 선을 더함)
TWO = r"""
let sl = M.newLogic("시험 둘");
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
r = M.addNode(sl, "const", "할증률"); sl = r.sl; const rate = r.node;
r = M.addNode(sl, "calc", "할증 품"); sl = r.sl; const calc = r.node;
r = M.addNode(sl, "item", "석공"); sl = r.sl; const stone = r.node;
r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node;
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
sl = E.applyOutside(sl, [
{ node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 },
{ node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 },
]);
sl = link(sl, pum, "품", calc, "값1");
sl = link(sl, rate, "할증률", calc, "값2");
sl = link(sl, calc, "결과", stone, "수량");
sl = link(sl, pum, "품", man, "수량");
sl = link(sl, stone, "노무비", res, "노무비");
sl = link(sl, man, "노무비", res, "노무비");
"""
def test_입력값_항목_결과_수식과_값() -> None:
got = _run(
ONE
+ "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
" sl.workbook.시트.map(s => s.이름)]);"
)
qty, total_in, ev, edges, kinds, names = got
assert qty == {"식": "품!B2"}
assert total_in == {"식": "'보통 인부'!B7"}
assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000}
assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5
assert ev["errors"] == []
assert edges == [
{"id": "e1", "from": {"node": "n1", "pill": "품"}, "to": {"node": "n2", "pill": "수량"}},
{
"id": "e2",
"from": {"node": "n2", "pill": "노무비"},
"to": {"node": "n3", "pill": "노무비"},
},
]
assert kinds == [["input", 0], ["item", 3], ["result", 4]]
assert names == ["품", "보통 인부", "결과"]
def test_계산_두_항목_더함_값바뀜() -> None:
got = _run(
TWO + "const a = E.evalLogic(sl);\n"
"const sl2 = E.applyOutside(sl, [{ node: rate.id, cell: 'B2', value: 1.5 }]);\n"
"out([a.result, a.pills[calc.id], sheet(sl, res.id).칸.B2, E.evalLogic(sl2).result]);"
)
a, c, cell, b = got
# 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000
assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000}
assert c == {"결과": 0.26}
assert cell == {"식": "석공!B7+보통인부!B7"}
assert b["계"] == 0.2 * 1.5 * 200000 + 20000
def test_선_끊기_다시_잇기() -> None:
got = _run(
TWO + "const cut = sl.edges.find(e => e.from.node === stone.id).id;\n"
"const a = M.disconnect(sl, cut);\n"
"const b = M.disconnect(M.disconnect(sl, cut), sl.edges.find(e => e.from.node === man.id && e.to.node === res.id).id);\n"
"const c = link(a, stone, '노무비', res, '경비');\n"
"out([sheet(a, res.id).칸.B2, E.evalLogic(a).result, sheet(b, res.id).칸.B2 ?? null, E.checkLogic(b),"
" E.evalLogic(c).result, a.edges.length]);"
)
cell_a, ra, cell_b, check_b, rc, n_edges = got
assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000
assert cell_b is None
assert "결과 상자에 이어진 선이 없음" in check_b
assert not any("입력 알약" in x for x in check_b)
assert rc == {"노무비": 20000, "재료비": None, "경비": 52000, "계": 72000}
assert n_edges == 5
def test_순환_같은상자_두번_없는알약_막음() -> None:
got = _run(
TWO
+ "let r2 = M.addNode(sl, 'calc', '다른 계산'); const sl3 = r2.sl; const other = r2.node;\n"
"const s4 = link(sl3, calc, '결과', other, '값1');\n"
"const tries = [\n"
" M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).why,\n"
" M.connect(s4, { node: calc.id, pill: '결과' }, { node: calc.id, pill: '값2' }).why,\n"
" M.connect(s4, { node: calc.id, pill: '결과' }, { node: other.id, pill: '값1' }).why,\n"
" M.connect(s4, { node: calc.id, pill: '없음' }, { node: other.id, pill: '값2' }).why,\n"
" M.connect(s4, { node: res.id, pill: '계' }, { node: pum.id, pill: '품' }).why,\n"
"];\n"
"const same = M.connect(s4, { node: other.id, pill: '결과' }, { node: calc.id, pill: '값1' }).sl === s4;\n"
"out([tries, same]);"
)
tries, same = got
assert tries == [
"이으면 돌고 도는 선이 됨",
"같은 상자끼리는 못 이음",
"이미 이어져 있음",
"알약이 없음",
"알약이 없음",
]
assert same is True
def test_엔진_순환은_오류로_알림() -> None:
got = _run(TWO + "sheet(sl, calc.id).칸.B3 = { 식: 'B4' };\nout(E.evalLogic(sl).errors);")
assert got and all("돌고 도는 참조" in e for e in got)
assert got[0].startswith("「할증 품」 ")
def test_바깥값_칸목록과_표_덩어리() -> None:
got = _run(
ONE
+ "r = M.addNode(sl, 'table', '메쌓기 표'); sl = r.sl; const tb = r.node; tb.ref = 'QF000421';\n"
"sl.nodes.find(n => n.id === tb.id).ref = 'QF000421';\n"
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
"r = M.addNode(sl, 'logic', '작업량'); sl = r.sl; const lg = r.node;\n"
"sl.nodes.find(n => n.id === lg.id).ref = 'GF000114';\n"
"const cells = E.outsideCells(sl);\n"
"sl = E.applyOutside(sl, [{ node: tb.id, cell: 'E1', value: [['뒷길이', '석공'], [25, 0.15], [30, null]] },"
" { node: lg.id, cell: 'B2', value: 12.5 }]);\n"
"const s = sheet(sl, tb.id).칸;\n"
"out([cells, s.E1, s.F2, s.E3, s.F3 ?? null, E.evalLogic(sl).pills[lg.id], E.checkLogic(sl)]);"
)
cells, e1, f2, e3, f3, lg, check = got
assert cells == [
{"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"},
{
"id": "n4!E1",
"node": "n4",
"cell": "E1",
"kind": "table",
"ref": "QF000421",
"source": {"kind": "표", "table": "QF000421"},
},
{
"id": "n5!B2",
"node": "n5",
"cell": "B2",
"kind": "logic",
"ref": "GF000114",
"source": {"kind": "로직", "key": "GF000114", "inputs": {}},
},
]
assert (e1, f2, e3, f3) == ({"값": "뒷길이"}, {"값": 0.15}, {"값": 30}, None)
assert lg == {"결과": 12.5}
assert check == []
def test_알약_정하기_이름바꾸면_선이_따라감() -> None:
got = _run(
ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n"
"const e = s2.edges[0].from;\n"
"s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n"
"const moved = sheet(s2, man.id).칸.B1;\n"
"const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n"
"const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n"
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
)
e, moved, n_edges, cleared, ins = got
assert e == {"node": "n1", "pill": "품셈 품"}
assert moved == {"식": "품!D2"}
assert n_edges == 1 and cleared is None
assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]]
def test_상자_지우기() -> None:
got = _run(
TWO + "const s2 = M.removeNode(sl, stone.id);\n"
"out([s2.nodes.map(n => n.name), s2.workbook.시트.map(s => s.이름), s2.edges.length,"
" sheet(s2, res.id).칸.B2, E.evalLogic(s2).result.계, sl.nodes.length]);"
)
names, sheets, n_edges, cell, total, before = got
assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names
assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000
assert before == 6 # 받은 것은 안 바뀜
def test_같은_이름_상자와_새_번호() -> None:
got = _run(
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n"
"r = M.addNode(sl, 'input', '높이'); sl = r.sl;\n"
"sl = M.removeNode(sl, 'n1'); r = M.addNode(sl, 'const', '높이'); sl = r.sl;\n"
"out([sl.nodes.map(n => [n.id, n.name, n.sheetId]), E.checkLogic(sl)]);"
)
nodes, check = got
assert nodes == [["n2", "높이 (2)", "s2"], ["n3", "높이", "s3"]]
assert check == ["결과 상자가 없음"]
def test_화면_글에_금지_낱말_없음() -> None:
for name in ("M01_SL_Model.ts", "M01_SL_Eval.ts"):
src = (SL / name).read_text(encoding="utf-8")
block = src[src.index("const TEXT = {") : src.index("} as const;")]
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
assert korean
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
assert bad not in text, (name, bad, text)
def test_상자_이름_바꾸기_수식이_따라감() -> None:
got = _run(
TWO + "const a = M.renameNode(sl, stone.id, '석공 (돌쌓기)');\n"
"const tries = [M.renameNode(sl, stone.id, '보통인부').why, M.renameNode(sl, stone.id, '').why,"
" M.renameNode(sl, stone.id, '가:나').why, M.renameNode(sl, 'n99', '무엇').why,"
" M.renameNode(sl, stone.id, 'x'.repeat(32)).why];\n"
"out([a.why, a.sl.nodes.find(n => n.id === stone.id).name, sheet(a.sl, stone.id).이름,"
" sheet(a.sl, res.id).칸.B2, E.evalLogic(a.sl).result.계, tries, sl.nodes[3].name]);"
)
why, name, sheet_name, cell, total, tries, before = got
assert why is None and name == sheet_name == "석공 (돌쌓기)"
assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000
assert tries == [
"같은 이름 상자가 있음",
"이름은 1~31 글자",
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
"상자가 없음",
"이름은 1~31 글자",
]
assert before == "석공"
def test_상자_이름_못쓰는_글자는_고쳐_만듦() -> None:
got = _run(
"let sl = M.newLogic(); let r = M.addNode(sl, 'input', '가:나/다'); sl = r.sl;"
" r = M.addNode(sl, 'input', 'x'.repeat(40)); sl = r.sl; r = M.addNode(sl, 'input', 'X'.repeat(40)); sl = r.sl;"
" out(sl.workbook.시트.map(s => s.이름));"
)
assert got == ["가·나·다", "x" * 31, "X" * 27 + " (2)"]
def test_바깥값_답을_넣을값으로_범위머리() -> None:
got = _run(
ONE + "r = M.addNode(sl, 'table', '증가율 표'); sl = r.sl; const tb = r.node;\n"
"sl.nodes.find(n => n.id === tb.id).source = { kind: '표', table: 'QF000422' };\n"
"sl = M.setPill(sl, tb.id, 'E1', 'out', '표', { outside: true });\n"
"const cells = E.outsideCells(sl);\n"
"const head = M.rangeHead('높이');\n"
"const got = E.answersToValues(cells, [\n"
" { id: 'n2!B2', ok: true, value: 172068 },\n"
" { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n"
"]);\n"
"const s2 = E.applyOutside(sl, got.values);\n"
"out([head, got, sheet(s2, tb.id).칸.F1, sheet(s2, tb.id).칸.G2, E.evalLogic(s2).result.계]);"
)
head, got_values, f1, g2, total = got
assert head == ["높이·아래", "높이·위"]
assert got_values["errors"] == []
assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068}
assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034)
missing = _run(
ONE
+ "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));"
)
assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]}
def test_항목_틀은_Item_노무_틀과_같은_칸() -> None:
got = _run(
"let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');"
" out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);"
)
cells, ins, outs, ref = got
assert {k: v for k, v in cells.items() if k.startswith("B")} == {
"B3": {"값": 0},
"B4": {"값": 0},
"B5": {"식": "B2+B3+B4"},
"B6": {"식": "ROUND(B1*B5,1)"},
"B7": {"식": "ROUND(B1*B2,1)"},
"B8": {"식": "ROUND(B1*B3,1)"},
"B9": {"식": "ROUND(B1*B4,1)"},
"B10": {"식": "B1"},
}
assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [
"수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량",
] # fmt: skip
assert ins == [
{"name": "수량", "cell": "B1", "unit": ""},
{"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True},
]
assert outs == [
["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"],
] # fmt: skip
assert ref == '{"what":"노무"}'
# 표 상자 이음매 — Table_Build(sub3) 가 짠 품셈 표 상자에 서버 꼴 블록(머리 줄 + 줄 · A1 부터)을 넣고 찾기
TABLE = r"""
const T = loadTs(path.join(ROOT, "M01_MasterData", "SheetLogic", "M01_SL_Table_Build.ts"));
let sl = M.newLogic("표 시험");
let r = M.addNode(sl, "table", "메쌓기"); sl = r.sl; const tb = r.node;
const info = { key: "QF000421", name: "메쌓기", base: "1㎡", source: "", no: "13-4-1",
conds: { 뒷길이: "수", 돌: "고르기" }, vals: { 석공: "인" }, rule: null, notes: [],
rows: [{ 뒷길이: 25, 돌: "깬잡석", 석공: 0.15 }, { 뒷길이: 30, 돌: "깬돌", 석공: 0.26 },
{ 뒷길이: 35, 돌: "깬돌", 석공: 0.3 }] };
sl = T.applyTable(sl, tb.id, info, { cond: ["뒷길이", "돌"], out: ["석공"] },
{ ref: "QF000421", fillBlock: false, outside: true }).sl;
r = M.addNode(sl, "input", "뒷길이"); sl = r.sl; const len = r.node;
r = M.addNode(sl, "input", "돌"); sl = r.sl; const stone = r.node;
sl = E.applyOutside(sl, [{ node: len.id, cell: "B2", value: 35 }, { node: stone.id, cell: "B2", value: "깬돌" }]);
sl = link(sl, len, "뒷길이", sl.nodes.find(n => n.id === tb.id), "뒷길이");
sl = link(sl, stone, "돌", sl.nodes.find(n => n.id === tb.id), "돌");
const ROWS = [["뒷길이", "돌", "석공"], [25, "깬잡석", 0.15], [30, "깬돌", 0.26], [35, "깬돌", 0.3]];
const cells = E.outsideCells(sl);
"""
def test_표_상자_바깥값_블록_찾기_줄면_옛줄_지움_머리바뀜_알림() -> None:
got = _run(
TABLE
+ "const first = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS }], sl);\n"
"const s1 = E.applyOutside(sl, first.values);\n"
"const shrink = E.answersToValues(cells, [{ id: cells[0].id, ok: true, rows: ROWS.slice(0, 3) }], s1);\n"
"const s2 = E.applyOutside(s1, shrink.values);\n"
"const moved = E.answersToValues(cells, [{ id: cells[0].id, ok: true,"
" rows: [['돌', '뒷길이', '석공'], ['깬돌', 30, 0.26]] }], s1);\n"
"out([cells, first.errors, E.evalLogic(s1).pills[tb.id], shrink.errors, sheet(s2, tb.id).칸.A4 ?? null,"
" sheet(s2, tb.id).칸.C4 ?? null, E.evalLogic(s2).pills[tb.id], moved.errors]);"
)
cells, first_err, found, shrink_err, a4, c4, gone, moved_err = got
assert cells == [
{
"id": "n1!A1",
"node": "n1",
"cell": "A1",
"kind": "table",
"ref": "QF000421",
"source": {"kind": "표", "table": "QF000421"},
}
]
assert first_err == [] and found["석공"] == 0.3 and found["표"] == "뒷길이"
assert shrink_err == [] and a4 is None and c4 is None
assert gone["석공"] == "#N/A"
assert moved_err == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"]
-366
View File
@@ -1,366 +0,0 @@
"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5.
· 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴
· 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림
· 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류
· 권한 없음 403 · 마스터 사본 파일은 안 바뀜
견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.SheetLogic import M01_SL_Router as sl
ROOT = Path(__file__).resolve().parents[2]
REAL = store.FOLDER
OWN = "로직_자체.json"
SAMPLE = "QF999997"
TABLE = {
"키": SAMPLE,
"원문번호": "",
"구분": "",
"상세구분": "",
"이름": "견본 높이 표",
"기준": "1㎡",
"출처": "자체",
"조건": {"돌": "고르기", "높이": "범위"},
"범위규칙": "초과·이하",
"값칸": {"석공": "인", "증가율": "%"},
"줄": [
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]},
{"돌": "견치돌", "높이": 4, "석공": 0.7},
],
"주": [],
}
NO_NODE = shutil.which("node") is None
def _labor() -> tuple[str, Decimal]:
rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"]
row = next(
r
for r in rows
if r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float))
)
return row["키"], Decimal(str(row["값"]))
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
master = tmp_path / "master"
master.mkdir()
for path in REAL.glob("*.json"):
if path.name == OWN:
continue
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, master / path.name)
host = sorted(master.glob("소요량_*.json"))[0]
data = json.loads(host.read_text(encoding="utf-8"))
data["표"].append(TABLE)
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
monkeypatch.setattr(store, "FOLDER", master)
monkeypatch.setattr(sl, "DATA", tmp_path / "_data")
return master
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(router_module.router)
app.include_router(sl.router)
return TestClient(app)
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def _logic(labor: str, key: str | None = None) -> dict:
"""Model SheetLogic — 노무 상자(source 노임 · A1) × 2 = B1 · 표 상자(ref 로 짐작 · A1 부터)
· 출처 없는 바깥 알약은 뺌 · 몸의 outside 목록(Item 꼴)은 C2 에 · 브라우저 계산값은 엉터리."""
return {
"key": key,
"name": "견본 시트 로직",
"resultUnit": "원/㎡",
"workbook": {
"종류": "통합문서",
"판": 1,
"서식": [{}],
"시트": [
{"id": "s1", "이름": "노무", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
{"id": "s2", "이름": "표", "칸": {}},
],
"계산값": {"s1": {"B1": {"수": "999"}}},
},
"nodes": [
{
"id": "n1",
"kind": "item",
"name": "노무 석공",
"sheetId": "s1",
"stage": 3,
"pillsIn": [{"name": "노임", "cell": "A1", "outside": True}],
"pillsOut": [{"name": "금액", "cell": "B1"}],
"source": {"kind": "노임", "key": labor},
},
{
"id": "n2",
"kind": "table",
"name": "견본 표",
"sheetId": "s2",
"stage": 1,
"pillsIn": [],
"pillsOut": [{"name": "표", "cell": "A1", "outside": True}],
"ref": SAMPLE,
},
{
"id": "n3",
"kind": "calc",
"name": "계산",
"sheetId": "s1",
"stage": 2,
"pillsIn": [],
"pillsOut": [{"name": "x", "cell": "C1", "outside": True}],
},
], # fmt: skip
"edges": [
{"id": "e1", "from": {"node": "n2", "pill": "표"}, "to": {"node": "n1", "pill": "노임"}}
],
"outside": [
{"item": {"id": "n1:노무비", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "C2"}
],
}
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, folder: Path) -> None:
labor, price = _labor()
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
assert saved["key"] == "SL-0001"
book = saved["logic"]["workbook"]
cells = book["시트"][0]["칸"]
assert cells["A1"]["값"] == price == cells["C2"]["값"] # 바깥 값을 마스터에서 다시 넣음(①②)
assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림)
assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음
table = book["시트"][1]["칸"]
head = [table[c]["값"] for c in ("A1", "B1", "C1", "D1", "E1", "F1")]
assert head == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"] # A1 부터
assert (
"B2" not in table and table["C2"]["값"] == 3 and table["F2"]["값"] == 100
) # 빈 끝은 빈 칸
assert table["B3"]["값"] == table["C3"]["값"] == 4 and "E3" not in table # 수 하나 → 같은 수
assert saved["logic"]["edges"] == _logic(labor)["edges"]
answers = {v["id"]: v for v in saved["values"]}
assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all(
v["ok"] for v in answers.values()
)
# 블록 왼위 = 표 상자 「표」 바깥 알약 칸(고정 A1 아님 — Import 는 E1)
at_e1 = _logic(labor)
at_e1["nodes"][1]["pillsOut"][0]["cell"] = "E1"
second = _post(client, "/api/m01/sl/save", {"logic": at_e1})
assert second["key"] == "SL-0002"
moved = second["logic"]["workbook"]["시트"][1]["칸"]
assert moved["E1"]["값"] == "돌" and moved["J2"]["값"] == 100 and "A1" not in moved
renamed = {**saved["logic"], "name": "고친 이름"}
assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001"
listed = client.get("/api/m01/sl/list").json()["logics"]
assert [(x["key"], x["이름"]) for x in listed] == [
("SL-0001", "고친 이름"),
("SL-0002", "견본 시트 로직"),
]
got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"]
assert got["workbook"]["계산값"] == book["계산값"]
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"})
assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404)
_post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404)
_post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "name": " "}}, want=400)
assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로
assert sorted(p.name for p in sl.DATA.iterdir()) == ["SL-0001.json"]
def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
labor, price = _labor()
line = {
"종류": "인력",
"요소": labor,
"이름": "석공",
"단위": "인",
"수량": "품",
"비목": "노무비",
}
row = {"원문번호": "", "이름": "견본 노무", "결과단위": "원/㎡", "출처": "자체",
"입력": [{"이름": "품"}], "중간": [], "호표": [line], "덧줄": [],
"끝수": None} # fmt: skip
made = _post(client, "/api/m01/logic/new", {"logic": row})
items = [
{"id": "t", "kind": "표", "table": SAMPLE},
{"id": "l", "kind": "노임", "key": labor},
{"id": "g", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}},
{"id": "g2", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}, "field": "재료비"},
{"id": "w", "kind": "자재", "key": labor},
{"id": "x", "kind": "표", "table": "QF999996"},
{"id": "z", "kind": "모름"},
]
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
# 정함: 조건 → 값 · 범위가 있는 칸(조건이든 값이든)은 두 칸 · 수 하나면 아래 · 위 같은 수
assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"]
assert got["t"]["rows"][1:] == [
["깬돌", None, 3, 0.5, 80, 100],
["견치돌", 4, 4, 0.7, None, None],
]
assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"]
assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price
assert got["g2"]["value"] == 0
for k in ("w", "x", "z"):
assert got[k]["ok"] is False and got[k]["reason"], got[k]
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
sl.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
assert client.get("/api/m01/sl/list").status_code == 403
assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
assert client.get("/api/m01/sl/list").json() == {"logics": []}
def _node_json(script: str) -> object:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_표_블록이_Table_Import_와_같은_열(client: TestClient) -> None:
"""서버 표 답(머리 줄 + 줄) = sub3 Table_Build `blockCells` = sub6 Import_Parse `tableRows`."""
got = _post(
client, "/api/m01/sl/outside", {"items": [{"id": "t", "kind": "표", "table": SAMPLE}]}
)
rows = got["values"][0]["rows"]
table = json.dumps(
{"조건": TABLE["조건"], "값칸": TABLE["값칸"], "줄": TABLE["줄"]}, ensure_ascii=False
)
info = json.dumps(
{"conds": TABLE["조건"], "vals": TABLE["값칸"], "rows": TABLE["줄"]}, ensure_ascii=False
)
build = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Table_Build.ts").as_uri()
cells = _node_json(
f"import {{ blockCells }} from '{build}';console.log(JSON.stringify(blockCells({info})));"
)
letters = "ABCDEFGHIJ"
mine = {
f"{letters[c]}{r + 1}": {"값": v}
for r, line in enumerate(rows)
for c, v in enumerate(line)
if v is not None
}
assert cells == mine
src = (ROOT / "M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts").read_text(encoding="utf-8")
body = re.search(r"/\*\* 범위 칸 꼬리[\s\S]*?\nexport function tableRows[\s\S]*?\n}\n", src)
assert body, "Import_Parse 의 tableCols · tableRows 를 못 찾음"
script = (
"const currentLanguageIndex = 0;\n"
+ body.group(0).replace("export ", "")
+ f"\nconsole.log(JSON.stringify(tableRows({table})));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == rows
def _material_cond() -> tuple[dict, str]:
"""값 있는 자재품목 한 줄 → 검색어 묶음 · 단위(사본에서 읽음)."""
rows = json.loads((store.FOLDER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"]
row = next(r for r in rows if isinstance(r.get("물가자료"), (int, float)) and r.get("구분"))
cond = {
"구분": row["구분"],
"상세구분": row["상세구분"],
"검색어": f"{row['이름']} {row['규격'] or ''}".strip(),
}
return cond, row["단위"]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_자재_검색어_묶음_단가는_calc_재료_줄과_같음(client: TestClient) -> None:
cond, unit = _material_cond()
line = {
"종류": "재료",
"요소": cond,
"이름": "재료",
"단위": unit,
"수량": "1",
"비목": "재료비",
}
row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체",
"입력": [],
"중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip
want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"]
assert want is not None
text = json.dumps(cond, ensure_ascii=False) # sub6 가져오기 꼴 — 묶음 JSON 글
bad = json.dumps({**cond, "검색어": "없는품목가나다"}, ensure_ascii=False)
items = [
{"id": "s", "kind": "자재", "key": text, "unit": unit},
{"id": "d", "kind": "자재", "key": cond, "unit": unit},
{"id": "x", "kind": "자재", "key": bad, "unit": unit},
]
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
assert got["s"]["ok"] and got["s"]["value"] == want == got["d"]["value"]
assert got["x"]["ok"] is False and got["x"]["reason"] == "「없는품목가나다」 맞는 자재 없음"
# 저장 때도 같은 길 — Item · Import 꼴(몸의 outside 목록)
logic = {
"key": None,
"name": "자재 견본",
"resultUnit": "원/㎡",
"workbook": {
"종류": "통합문서",
"판": 1,
"서식": [{}],
"시트": [{"id": "s1", "이름": "재료", "칸": {}}],
},
"nodes": [],
"edges": [],
"outside": [{"item": items[0], "sheet": "s1", "cell": "B2"}],
}
saved = _post(client, "/api/m01/sl/save", {"logic": logic})
assert saved["logic"]["workbook"]["시트"][0]["칸"]["B2"]["값"] == want
_post(client, "/api/m01/sl/delete", {"key": saved["key"]})
-276
View File
@@ -1,276 +0,0 @@
"""시트 로직 표 상자 — 찾기 수식 짜기(`M01_SL_Table_Build.ts`) · 붙여넣기 읽기.
화면 TS 를 그 자리에서 트랜스파일해 node 로 짜고, 스프레드시트 엔진(서버 Node 번들)이 그 수식을 풀어 값을 대조.
견본 표는 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from common_util.common_util_spreadsheet import recalc_workbooks
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Table_Build.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "plan") {
const plan = m.planLookup(job.info, job.roles);
const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells };
for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v };
return { cells, plan };
}
if (job.op === "paste") return m.parsePaste(job.text);
if (job.op === "rename") {
const sl = { key: null, name: "t", resultUnit: "", edges: [],
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [
{ id: "s1", 이름: "표", 칸: {} },
{ id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] },
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl;
const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? ""));
return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts };
}
if (job.op === "reapply") {
const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [],
workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] },
nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] };
const opts = { ref: "T1", fillBlock: true, outside: false };
const first = m.applyTable(base, "n1", job.info, job.roles, opts).sl;
const cell = first.nodes[0].pillsIn[0].cell;
first.workbook.시트[0].칸[cell] = { 식: "'다른 시트'!B2" };
const second = m.applyTable(first, "n1", job.info, job.roles, { ...opts, fillBlock: false }).sl;
return { cell, kept: second.workbook.시트[0].칸[cell], pills: second.nodes[0].pillsIn.map((p) => p.name), name: second.nodes[0].name };
}
return null;
});
process.stdout.write(JSON.stringify(out));
"""
R = {"key": "T1", "name": "견본", "base": "1㎡", "source": "", "no": "", "rule": None, "notes": []}
EXACT = {
**R,
"conds": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"},
"vals": {"석공": "인", "보통": "인"},
"rows": [
{"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15, "보통": 0.12},
{"뒷길이": 25, "돌": "호박돌", "쌓기": None, "석공": 0.1, "보통": 0.09},
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "석공": 0.26, "보통": 0.21},
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.23, "보통": 0.18},
{"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기", "석공": 0.5, "보통": 0.5},
],
}
RATE = {
**R,
"conds": {"높이": "범위"},
"vals": {"증가율": "%"},
"rule": "초과·이하",
"rows": [
{"높이": [3, 4], "증가율": 30},
{"높이": [4, 5.5], "증가율": 40},
{"높이": [5.5, None], "증가율": 60},
],
}
MIXED = {
**R,
"conds": {"구분": "고르기", "높이": "범위"},
"vals": {"율": "%"},
"rule": "초과·이하",
"rows": [
{"구분": "가", "높이": [0, 2], "율": 1},
{"구분": "가", "높이": [2, 4], "율": 2},
{"구분": "나", "높이": [0, 3], "율": 5},
],
}
MINE = {
**R,
"conds": {"경사": "고르기"},
"vals": {"할증": "%"},
"rows": [{"경사": "완", "할증": 0}, {"경사": "급", "할증": 10}],
}
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def solve(info: dict, inputs: dict, out: list[str], cond: list[str] | None = None) -> dict:
"""견본 표를 찾기 판 수식으로 풀어 → {상태, 걸린 줄, 결과 칸들}."""
roles = {"cond": cond or list(info["conds"]), "out": out}
plan = run([{"op": "plan", "info": info, "roles": roles, "inputs": inputs}])[0]
book = {
"종류": "통합문서",
"판": 1,
"열": "t",
"서식": [{}],
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
"활성": "s1",
}
values = recalc_workbooks([book])[0]["s1"]
p = plan["plan"]
def got(a1: str):
v = values.get(a1) or {}
return v.get("수", v.get("글", v.get("오류")))
res = {c: got(a) for c, a in p["outCells"].items()}
return {"상태": got(p["statusCell"]), "줄": got(p["idxCell"]), **res}
def test_같음_조건_셋은_열쇠로_찾음():
r = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공", "보통"])
assert r["상태"] == "찾음" and r["줄"] == "3"
assert float(r["석공"]) == 0.26 and float(r["보통"]) == 0.21
def test_빈칸은_무관_줄로_찾음():
r = solve(EXACT, {"뒷길이": 25, "돌": "호박돌", "쌓기": "켜쌓기"}, ["석공"])
assert r["상태"] == "찾음" and float(r["석공"]) == 0.1
def test_맞는_줄_없음과_둘_이상():
none = solve(EXACT, {"뒷길이": 99, "돌": "깬돌", "쌓기": "골쌓기"}, ["석공"])
assert none["상태"] == "맞는 줄 없음" and none["석공"] == "#N/A"
dup = solve(EXACT, {"뒷길이": 30, "돌": "깬돌", "쌓기": "켜쌓기"}, ["석공"])
assert dup["상태"] == "맞는 줄 둘 이상" and dup["석공"] == "#N/A"
def test_범위_조건은_아래_위_두_칸으로_초과_이하():
assert float(solve(RATE, {"높이": 4}, ["증가율"])["증가율"]) == 30 # 4 는 3~4 줄(이하)
assert float(solve(RATE, {"높이": 4.5}, ["증가율"])["증가율"]) == 40
assert float(solve(RATE, {"높이": 9}, ["증가율"])["증가율"]) == 60 # 위 칸이 빈 줄 = 끝 없음
assert solve(RATE, {"높이": 3}, ["증가율"])["상태"] == "맞는 줄 없음" # 3 은 초과가 아님
assert solve(RATE, {"높이": 1}, ["증가율"])["상태"] == "맞는 줄 없음"
def test_같음_조건과_범위_조건이_섞임():
assert float(solve(MIXED, {"구분": "가", "높이": 3}, ["율"])["율"]) == 2
assert float(solve(MIXED, {"구분": "나", "높이": 3}, ["율"])["율"]) == 5
assert solve(MIXED, {"구분": "다", "높이": 3}, ["율"])["상태"] == "맞는 줄 없음"
assert solve(MIXED, {"구분": "나", "높이": 9}, ["율"])["상태"] == "맞는 줄 없음"
def test_내_표_경사에서_할증_두_줄():
assert float(solve(MINE, {"경사": "급"}, ["할증"])["할증"]) == 10
assert float(solve(MINE, {"경사": "완"}, ["할증"])["할증"]) == 0
def test_조건_칸_역할을_바꾸면_찾기_수식도_바뀜():
r = solve(EXACT, {"뒷길이": 30}, ["석공"], cond=["뒷길이"])
assert r["상태"] == "맞는 줄 둘 이상" # 뒷길이 30 줄이 셋
def test_붙여넣기_읽기():
text = "경사\t범위\t할증\n완\t0~10\t0\n급\t10~\t10"
out = run([{"op": "paste", "text": text}])[0]
info = out["info"]
assert out["error"] is None
assert info["conds"] == {"경사": "고르기", "범위": "범위"}
assert info["vals"] == {"할증": ""}
assert info["rows"][1] == {"경사": "급", "범위": [10, None], "할증": 10}
assert run([{"op": "paste", "text": "한 줄뿐"}])[0]["error"]
def test_다시_짜도_선_수식은_그대로():
out = run([{"op": "reapply", "info": MINE, "roles": {"cond": ["경사"], "out": ["할증"]}}])[0]
assert out["kept"] == {"식": "'다른 시트'!B2"}
assert out["pills"] == ["경사"] and out["name"] == "표 · 견본"
def test_표가_비었어도_수식이_깨지지_않음():
"""내 표를 막 만든 때(줄이 다 빈칸) — 빈칸 변형 열쇠가 빈 수식이 되면 안 됨."""
empty = {**MINE, "rows": [{}, {}]}
r = solve(empty, {"경사": "급"}, ["할증"])
assert r["상태"] == "맞는 줄 없음" and r["할증"] == "#N/A"
def test_값_칸_범위도_아래_위_두_칸_숫자_하나는_같은_수():
"""서버 표 블록 차례 — 범위 값은 「이름·아래」 「이름·위」 · 숫자 하나면 두 칸이 같은 수."""
table = {
**R,
"conds": {"높이": "범위"},
"vals": {"증가율": "%"},
"rule": "초과·이하",
"rows": [{"높이": [3, 4], "증가율": [80, 100]}, {"높이": [4, None], "증가율": 30}],
}
low = solve(table, {"높이": 3.5}, ["증가율"])
assert float(low["증가율·아래"]) == 80 and float(low["증가율·위"]) == 100
one = solve(table, {"높이": 5}, ["증가율"])
assert float(one["증가율·아래"]) == 30 and float(one["증가율·위"]) == 30
def test_블록_머리_차례():
table = {
**R,
"conds": {"가": "고르기", "높이": "범위"},
"vals": {"율": "%", "폭": "m"},
"rows": [{"가": "a", "높이": [1, 2], "율": 1, "폭": [20, 45]}],
}
plan = run([{"op": "plan", "info": table, "roles": {"cond": ["가"], "out": ["율"]}}])[0]["plan"]
assert [c["header"] for c in plan["cols"]] == [
"가",
"높이·아래",
"높이·위",
"율",
"폭·아래",
"폭·위",
]
def test_표가_늘어도_찾기_범위가_따라감():
"""마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다)."""
small = {**RATE, "rows": RATE["rows"][:2]}
extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)]
grown = {**small, "rows": [*small["rows"], *extra]}
roles = {"cond": ["높이"], "out": ["증가율"]}
plan = run(
[{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}]
)[0]
book = {
"종류": "통합문서",
"판": 1,
"열": "t",
"서식": [{}],
"시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}],
"활성": "s1",
}
values = recalc_workbooks([book])[0]["s1"]
assert values[plan["plan"]["statusCell"]]["글"] == "찾음"
assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72
def test_표_상자에_표_번호가_안_보임_이름으로_보임():
info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"}
roles = {"cond": list(info["conds"]), "out": ["석공"]}
out = run([{"op": "rename", "info": info, "roles": roles}])[0]
assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"]
assert out["ref"] == "QF000421" # 번호는 상자 ref 에만
assert "QF000421" not in " ".join(out["texts"])
assert any("13-4-1" in t and "1㎡" in t for t in out["texts"])
assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2"
assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1"
+4 -20
View File
@@ -239,18 +239,10 @@ def test_견본_표_그룹이_마스터와_같음(master) -> None:
def test_견본1_원가_calc_와_같음(master) -> None:
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
from M01_MasterData import M01_MasterData_Store as store # /calc 와 같은 셈
cm, files = master
row = next(
r
for d in files.values()
if d.get("그룹") == "로직"
for r in d["줄"]
if r["키"] == "GF000219"
)
given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]}
want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {})
want = store.calc("GF000219", {**given, "초과증가율": 80})
assert want["ok"], want
got = _run(SAMPLES["masonry"], master)["out-pay"]
print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}")
@@ -258,18 +250,10 @@ def test_견본1_원가_calc_와_같음(master) -> None:
def test_견본3_값_calc_와_같음_견본2_비율(master) -> None:
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
from M01_MasterData import M01_MasterData_Store as store # /calc 와 같은 셈
cm, files = master
row = next(
r
for d in files.values()
if d.get("그룹") == "로직"
for r in d["줄"]
if r["키"] == "GF000133"
)
given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]}
given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」
want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {})
want = store.calc("GF000133", given)
assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"])
assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100
+65
View File
@@ -78,3 +78,68 @@ def test_금액_다섯_줄() -> None:
]
assert all(r["단위"] == "원" and r["비고"] == "미만버림" for r in rows)
assert all(_cells(r["원문 줄"])[0] == r["종목"] for r in rows)
# ── standardsList(단위 고르기 · 끝수 제안이 씀) — Node 로 `M02_DF_Standards.ts` 를 바로 돌림 ──────────
HOOKS = r"""
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripTypeScriptTypes } from "node:module";
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\s+(\w+)\s+from\s+"\.\/_standard\/([^"]+)\.json";/g, "const $1 = globalThis.__std[\"$2\"];");
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => {
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 };
}
"""
def _node(body: str) -> object:
import shutil
import subprocess
assert shutil.which("node")
script = (
"import { register } from 'node:module';\n"
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
"globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : k === 'API_BASE_URL' ? '/api' : () => undefined });\n" # noqa: E501
f"globalThis.__std = {json.dumps({'단위표준': UNIT, '금액단위표준': MONEY}, ensure_ascii=False)};\n"
f"const S = await import({json.dumps((STD.parent / 'M02_DF_Standards.ts').as_uri())});\n"
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
)
result = subprocess.run(
["node", "--no-warnings", "--input-type=module", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
) # fmt: skip
assert result.returncode == 0, result.stderr[-2000:]
return json.loads(result.stdout)
def test_standardsList_목록() -> None:
got = _node("out(S.standardsList());")
units = [
(r["종목"], g, u, p)
for r in UNIT["줄"]
for g, u, p in [
("규격", r["규격 단위"], r["규격 소수자리"]),
("단위수량", r["단위수량 단위"], r["단위수량 소수자리"]),
]
if u and u != "없음"
]
assert [
(x["종목"], x["갈래"], x["단위"], x["소수자리"]) for x in got if x["갈래"] != "금액"
] == [(n, g, u, p or 0) for n, g, u, p in units]
assert all(x["방법"] == "반올림" for x in got if x["갈래"] != "금액")
money = [x for x in got if x["갈래"] == "금액"]
assert [(x["지위"], x["소수자리"], x["방법"]) for x in money] == [
(1000, -3, "버림"),
(1, 0, "버림"),
(1, 0, "버림"),
(1, 0, "버림"),
(0.1, 1, "버림"),
]