Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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, "돌": "깬돌"}
|
||||
@@ -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],
|
||||
]
|
||||
@@ -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 == ["「표 · 메쌓기」 표 머리 줄이 바뀜 — 표를 다시 골라야 함"]
|
||||
@@ -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"]})
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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, "버림"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user