- 공용 화면 A00_Common/line_stack_* — 위 옵션 · 왼쪽 줄 쌓기 편집 · 오른쪽 산출근거(경쟁사 시트 꼴) · 맨 아래 합계 상자 - 모델 common_util_line_stack.ts — 수식 문서 ↔ 줄 쌓기 한 벌 왕복(차림 = 문서 칸 `쌓기` · 기본과 다를 때만) - 기계 · 단가산출 줄 단가 수 누름 = 그 문서를 같은 계산기로 셈해 펼침(손료 식 · 운전경비 · 노 · 재 · 경 · 계) - 공식 묶음 · 틀 풀기(단가산출 결과 → 이 문서 식) · 내 틀(이 브라우저) · 「내 틀 (… 에서 변경)」 - 수식 탭과 같은 길 · 같은 캐시 · 탭 바꾸면 다시 읽음 · [수식 저장] = 서버 정본 셈 - KaTeX(MIT) — vite 미리 묶기에서 뺌(rolldown 이 글자 규칙 서로게이트 범위를 깨뜨림) - 시험 test_804_2 6(왕복 1,518 · 금액 · 기계 펼침 = hourly · 틀 풀기 · 저장 · 이음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
9.7 KiB
Python
230 lines
9.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""PLAN 804-2 — 단가산출 「줄 쌓기」 탭(요구 670~677) · 수식 문서 ↔ 줄 쌓기 한 벌.
|
|
|
|
① 왕복 1,518 전수 — 문서 → 줄 → 문서 같음 · 금액 줄 · 기호 모두 한 번 ·
|
|
차림 고침(항목 · 글 줄 · 가나다) 다시 읽어 같음 · 차림 칸 `쌓기` 든 문서도 서버 셈 같음
|
|
② 금액 — 화면 길(연결 기록 + TS 계산기) = 서버 정본 셈(1,518 전수)
|
|
③ 기계 펼침 — 시간당 경비 문서를 넘긴 입력으로 셈 = 부른 쪽 단가 묶음(원 단위) ·
|
|
`M02_Machine_Hourly.hourly`(같은 로직 · 같은 입력) 와 같음
|
|
④ 틀 풀기 — 단가산출 결과 연결을 이 문서 식으로 옮김 = 금액 같음(전수) ·
|
|
Cm 고침 → 값 · 지움 = 표지(오류 없음)
|
|
⑤ 저장 — `쌓기` 든 문서 저장(임시 폴더) · 다시 읽어 같음 · 정본 폴더 안 바뀜
|
|
⑥ 화면 이음 — M01 · M02 보기 셋 · 같은 길 · 같은 캐시 열쇠 · KaTeX 미리 묶기 뺌
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib
|
|
import json
|
|
import os
|
|
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_Doc as doc_screen
|
|
from M01_MasterData import M01_MasterData_Router as router_module
|
|
from M01_MasterData import M01_MasterData_Store as m01
|
|
from M02_MasterTemplete import M02_Doc_Copy as dc
|
|
from M02_MasterTemplete import M02_Machine_Hourly as mh
|
|
|
|
mr = importlib.import_module("master_doc_record") # M01 store 가 scripts 길을 둔 뒤
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
COSTS = ("노무비", "재료비", "경비", "계")
|
|
|
|
|
|
def _ts(job: str, cases: list[dict]) -> list:
|
|
proc = subprocess.run(
|
|
["node", os.path.join(HERE, "helper_804_2_line_stack.cjs")],
|
|
input=json.dumps(dc.ml._norm({"할일": job, "문서들": cases}), ensure_ascii=False),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
assert proc.returncode == 0, proc.stderr[-3000:]
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def world():
|
|
docs = dc.folder()
|
|
links = dc.ml.Links(dc.skc.whole_(), docs)
|
|
cases, py = [], {}
|
|
for key, doc in docs.items():
|
|
rec = mr.Rec(links)
|
|
rec.prefetch(doc)
|
|
py[key] = dc.mc.calc(doc, rec)
|
|
cases.append({"문서": doc, "연결": rec.out()})
|
|
return {"docs": docs, "links": links, "py": py, "cases": dict(zip(docs, cases))}
|
|
|
|
|
|
def _num(v):
|
|
return None if v is None else float(v)
|
|
|
|
|
|
def test_왕복_전수_1518(world):
|
|
docs = world["docs"]
|
|
assert len(docs) >= 1518
|
|
got = _ts("왕복", [{"문서": d} for d in docs.values()])
|
|
bad = {f: [k for k, g in zip(docs, got) if not g[f]] for f in ("같음", "금액", "기호", "차림")}
|
|
assert bad == {"같음": [], "금액": [], "기호": [], "차림": []}
|
|
assert not any(g["쌓기"] for g in got) # 기본 차림 = 문서에 `쌓기` 안 씀
|
|
# 차림 칸이 든 문서 — 서버 셈이 같음(계산기는 차림을 안 봄)
|
|
for key, g in list(zip(docs, got))[::7]:
|
|
laid = json.loads(json.dumps(g["차림문서"]), parse_float=Decimal, parse_int=Decimal)
|
|
assert "쌓기" in laid
|
|
a, b = dc.mc.calc(docs[key], world["links"]), dc.mc.calc(laid, world["links"])
|
|
assert [_num(a.get(f)) for f in (*COSTS, "결과")] == [
|
|
_num(b.get(f)) for f in (*COSTS, "결과")
|
|
], key
|
|
# 못 고치는 칸 목록(보존됨) — 지금 문서엔 금액 줄 기호만
|
|
notes = {n for g in got for n in g["못"]}
|
|
assert notes <= {"단계", "조정", "번지", "합밖", "줄 자리", "금액 줄 기호"}
|
|
|
|
|
|
def test_금액_화면길_서버정본_전수(world):
|
|
keys = list(world["docs"])
|
|
got = _ts("셈", [world["cases"][k] for k in keys])
|
|
diff = [
|
|
(k, f)
|
|
for k, g in zip(keys, got)
|
|
for f in (*COSTS, "결과")
|
|
if _num(world["py"][k].get(f)) != (None if g.get(f) is None else pytest.approx(g[f]))
|
|
]
|
|
assert diff == [] and not any(g["누락"] for g in got)
|
|
world["셈"] = dict(zip(keys, got))
|
|
|
|
|
|
def _hourly_inputs(logic_key: str) -> dict:
|
|
"""hourly 가 고른 로직 · 그 로직의 기본 입력(기계 밖)."""
|
|
rows = {r["키"]: r for r in mh._family()}
|
|
row = rows.get(logic_key)
|
|
if not row:
|
|
return {}
|
|
return {
|
|
i["이름"]: str((i.get("고르기") or [""])[0])
|
|
for i in row.get("입력") or []
|
|
if i.get("이름") != "기계"
|
|
}
|
|
|
|
|
|
def test_기계펼침_원단위_hourly(world):
|
|
got = world.get("셈") or dict(zip(world["docs"], _ts("셈", list(world["cases"].values()))))
|
|
eq = m01.loaded()[1].index["EQ"]
|
|
by_code: dict[str, list[str]] = {}
|
|
for key, row in eq.items():
|
|
by_code.setdefault(str(row.get("원문번호")), []).append(key)
|
|
lines = same_parent = same_hourly = 0
|
|
memo: dict[str, dict] = {}
|
|
family = mh._family()
|
|
for k, g in got.items():
|
|
for s in g["펼침"]:
|
|
if s["갈래"] != "기계":
|
|
continue
|
|
lines += 1
|
|
# 펼친 셈 = 부른 쪽 단가 묶음(화면 「노무비 : 59,020」 의 그 수)
|
|
assert all(float(s["값"][f]) == float(s[f]) for f in COSTS), (k, s["키"])
|
|
same_parent += 1
|
|
code = str(s["입력"].get("기계"))
|
|
for key in by_code.get(code, []):
|
|
if key not in memo: # 같은 기계는 한 번만 셈
|
|
memo[key] = mh.hourly(key)
|
|
h = memo[key]
|
|
if not h.get("ok"):
|
|
continue
|
|
pick = min(
|
|
(r for r in family if code in [str(c) for c in mh._codes(r)]),
|
|
key=mh._rank,
|
|
)
|
|
want = _hourly_inputs(pick["키"])
|
|
given = {n: str(v) for n, v in s["입력"].items() if n != "기계"}
|
|
if pick["키"] != s["키"] or any(want.get(n) != v for n, v in given.items()):
|
|
break # 다른 로직 · 다른 입력(조종원 직종 등)을 넘긴 줄
|
|
assert all(float(h[f]) == float(s[f]) for f in COSTS), (k, key, h, s)
|
|
same_hourly += 1
|
|
break
|
|
assert lines >= 1000 and same_parent == lines and same_hourly >= 800
|
|
|
|
|
|
def test_틀풀기_전수_금액같음_Cm고침(world):
|
|
cases = list(world["cases"].values())
|
|
got = [x for g in _ts("펼침", cases) for x in g]
|
|
assert len(got) >= 20 # 단가산출 결과 연결(굴착기 Q 등) 전부
|
|
bad = [g["기호"] for g in got if g["전"] != g["후"] or not g["왕복"]]
|
|
assert bad == []
|
|
one = _ts(
|
|
"펼침",
|
|
[{**world["cases"]["GF000099"], "기호": "Q_작업", "고침": [{"기호": "Cm", "글": "20"}]}],
|
|
)[0][0]
|
|
assert one["전"]["계"] == 5286 and one["후"]["계"] == 5286
|
|
assert one["옮김"][-1] == "Q_작업" and {"q", "K", "f", "E", "Cm"} <= set(one["옮김"])
|
|
assert one["고친"]["계"] == 5434 # Cm 18 → 20 = Q 58.212 → 52.391
|
|
gone = _ts("펼침", [{**world["cases"]["GF000099"], "기호": "Q_작업", "지움": ["Cm"]}])[0][0]
|
|
assert gone["고친"]["계"] is None and "정의 필요" in gone["표지"] # 지워도 오류 없음
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: Path, monkeypatch):
|
|
for path in doc_screen.folder().glob("수식_*.json"):
|
|
shutil.copy(path, tmp_path / path.name)
|
|
monkeypatch.setattr(doc_screen, "folder", lambda: tmp_path)
|
|
doc_screen._memo.clear()
|
|
app = FastAPI()
|
|
app.include_router(router_module.router)
|
|
yield TestClient(app)
|
|
doc_screen._memo.clear()
|
|
|
|
|
|
def test_저장_쌓기_다시읽어같음(client, tmp_path):
|
|
read = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
|
|
doc = copy.deepcopy(read["문서"])
|
|
got = _ts("왕복", [{"문서": doc}])[0]
|
|
laid = got["차림문서"]
|
|
res = client.post("/api/m01/logic/doc/save", json={"문서": laid})
|
|
assert res.status_code == 200, res.text
|
|
assert res.json()["셈"]["계"] == read["셈"]["계"] == 5286
|
|
again = client.get("/api/m01/logic/doc", params={"key": "GF000099"}).json()
|
|
assert (
|
|
again["문서"]["쌓기"] == laid["쌓기"] and _ts("왕복", [{"문서": again["문서"]}])[0]["같음"]
|
|
)
|
|
text = (tmp_path / res.json()["파일"]).read_text(encoding="utf-8")
|
|
assert '"쌓기": [' in text and "< 근거 0 >" in text
|
|
real = (ROOT / "resources/master_data/수식문서" / res.json()["파일"]).read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert "< 근거 0 >" not in real
|
|
|
|
|
|
def _src(rel: str) -> str:
|
|
return (ROOT / rel).read_text(encoding="utf-8")
|
|
|
|
|
|
def test_화면_이음():
|
|
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
|
|
assert 'createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" })' in lab
|
|
assert (
|
|
"stack: stack.root" in lab
|
|
and "equation.canLeave().then((ok) => ok && stack.canLeave())" in lab
|
|
)
|
|
assert '{ value: "stack", label: lt("Tab") }' in _src(
|
|
"M01_MasterData/M01_MasterData_UI_LogicLab_View.ts"
|
|
)
|
|
assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
|
|
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
|
|
assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 2
|
|
vite = _src("vite.config.ts")
|
|
assert 'exclude: ["0_old", "katex"]' in vite and "katex/dist/katex.mjs" in vite
|
|
pkg = json.loads(_src("config/package.json"))
|
|
assert "katex" in pkg["dependencies"]
|
|
lic = json.loads(_src("config/node_modules/katex/package.json"))["license"]
|
|
assert lic == "MIT"
|
|
for f in (ROOT / "A00_Common").glob("line_stack*.ts"):
|
|
assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name
|