Files
Aislo/resources/tester/test_804_2_line_stack.py
eomsangdonandClaude Opus 5.5 7480aebc96 feat(804-6): 옛 수식 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭(요구 686-1)
- M01 단가산출 기초 · M02 단가산출 상세 보기 = 수식(줄 쌓기 · 기본) · 단가산출 상세 둘
- 줄 쌓기에 M02 사본 [단가 갱신] · [재생성](미리 셈 → 알림 → 저장 · 고침 있으면 막음) · 판 바뀜(409) 문서 그대로면 새 판으로 다시 저장 옮김
- 옛 수식 문서 화면 걷음(formula_doc_screen · defs · edit · lines · math · math_model · rows · sub · formula_doc.css) · 이미 안 부르던 796-2 수식 나무 화면(LogicLab_Equation · Defs · Var · css · M01_Logic_Equation_Model · sheet_formula_cell · ui_template_equation_defs · edit) 걷음 — 18 파일 4,860 줄
- formula_doc_render = 줄 쌓기가 쓰는 reach 만 · 옵션 가로 CSS 는 line_stack.css 로
- 남김 = 계산기 common_util_formula_doc* · 서버 길 · 옵션 · 사본 표지 · 합계 상자 · KaTeX · 시험 페이지 tools/line-stack-test
- 시험: test_796_2 지움(걷은 화면 원본 검사뿐) · 799_1 · 800_2 걷은 화면 줄만 뺌 · 799_2 · 802_4b2 · 804_2 보기 · 화면 파일 대상 바꿈 · test_804_6 새로(걷은 파일 · 부르는 곳 없음 · 첫 탭 · 사본 할일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:09:44 +09:00

339 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기
assert '{ value: "stack", label: tl("View_Equation") }' 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)") == 1
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
# ── 804-3 줄 쌓기 수정 1차(요구 678) ──
def _tbl(world, key, **kw):
return _ts("표고름", [{**world["cases"][key], **kw}])[0]
def _calc(world, doc):
return dc.mc.calc(
json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal), world["links"]
)
def test_표고름_값_출처(world):
# 옵션에 매인 조건(규격 = S_폼타이규격) — 줄은 옵션 그대로 · 칸만 바뀜
a = _tbl(world, "GC000211", 기호="Q_3t^표", 줄=1, 칸="4t/㎡")
assert a["묶임"] == {"규격": ["폼타이규격"]} and a["전줄"] == 2
assert a["연결"] == {
"갈래": "표",
"표": "QC000429",
"조건": {"규격": "S_폼타이규격"},
"칸": "4t/㎡",
}
assert a["값"] == 0.72 and a["후줄"] == 2 # 12.7㎜ 줄 · 4t/㎡
# 옵션에 안 매인 조건 — 고른 줄 값이 조건으로 · 값 = 표 칸 · 서버 셈 같음
b = _tbl(
world,
"GC000211",
기호="Q_시공^표",
줄=4,
칸="시공량",
앞=[{"기호": "Q_시공^표", "조건": {"유형": "'보통'"}}],
)
assert b["묶임"] == {} and b["전줄"] == 3
assert b["연결"]["조건"] == {"유형": "'간단'"} and b["연결"]["표"] == "QC000431"
assert b["값"] == 50 and b["후줄"] == 4
py = _calc(world, b["문서"])
assert [_num(py.get(f)) for f in COSTS] == [b[f] for f in COSTS]
assert b["계"] != world["py"]["GC000211"]["계"] # 시공량 20 → 50 = 금액 바뀜
# 조건 없는 표(한 줄) — 칸만
c = _tbl(world, "GC000211", 기호="Q_합판2^표", 줄=0, 칸="각재")
assert (
c["연결"] == {"갈래": "표", "표": "QC000427", "조건": {}, "칸": "각재"} and c["값"] == 0.038
)
def test_찾아넣기_요소키_금액(world):
h = mh.hourly("EQ000016")
assert h["ok"] and h["로직"] and h["원문번호"] == "0201-0012"
picks = [
{"갈래": "인력", "고른": {"키": "LB000002", "이름": "보통인부", "단위": "인"}},
{"갈래": "재료", "고른": {"키": "MT000018", "이름": "합판(내수), 서울", "규격": "12t×1220×2440mm", "단위": "㎡"}},
{"갈래": "기계", "고른": {"키": h["로직"], "이름": "굴착기(무한궤도)", "규격": "0.12㎥", "입력": {"기계": "'0201-0012'"}}},
{"갈래": "단가산출", "고른": {"키": "GF000099", "이름": "측구터파기 토사", "단위": "㎥"}},
] # fmt: skip
got = _ts("넣기", [{"문서": world["docs"]["GC000211"], "넣을": picks}])[0]
assert got["갈래"] == ["인력", "재료", "기계", "단가산출"]
doc = got["문서"]
links = {v["기호"] + ("_" + v["아래"] if v.get("아래") else ""): v["연결"] for v in doc["변수"]}
assert [links[i] for i in got["기호"]] == [
{"갈래": "요소", "키": "LB000002"},
{"갈래": "요소", "키": "MT000018"},
{"갈래": "단가산출", "키": h["로직"], "입력": {"기계": "'0201-0012'"}},
{"갈래": "단가산출", "키": "GF000099", "입력": {}},
]
new = doc["금액"][got["처음"] :]
assert [(m["단위"], m["비목"], m["수량"]) for m in new] == [
("인", "노무비", "1"),
("㎡", "재료비", "1"),
("hr", "묶음", "1"),
("㎥", "묶음", "1"),
]
# 금액 = 서버 정본 셈 — 넣은 넷 = 노임 + 자재 + 기계 한 시간(hourly) + 단가산출 계
before, after = world["py"]["GC000211"], _calc(world, doc)
want = 172068 + 11354 + float(h["계"]) + float(world["py"]["GF000099"]["계"])
assert float(after["계"]) - float(before["계"]) == pytest.approx(want, abs=2)
ts = _ts("셈", [{"문서": doc, "연결": _record(world, doc)}])[0]
assert not ts["누락"], ts
assert [ts[f] for f in COSTS] == [_num(after.get(f)) for f in COSTS]
# 다시 고름 — 키 · 이름만 바뀜
again = _ts(
"넣기",
[{"문서": world["docs"]["GC000211"], "넣을": picks[:1], "바꿈": [{"차례": 0, "고른": {"키": "LB000007", "이름": "형틀목공"}}]}],
)[0] # fmt: skip
m = again["문서"]["금액"][again["처음"]]
assert m["이름"] == "형틀목공" and m["수량"] == "1"
assert any(v["연결"] == {"갈래": "요소", "키": "LB000007"} for v in again["문서"]["변수"])
# 기계 다시 고름(같은 로직) — 기종만 바뀌고 지역 · 보정작업 넘김 그대로
gf = world["docs"]["GF000099"]
i = next(k for k, m in enumerate(gf["금액"]) if m["단가"] == "P_굴착기무한궤")
one = _ts("바꿈", [{"문서": gf, "줄": i, "기호": "P_굴착기무한궤", "갈래": "기계", "고른": picks[2]["고른"]}])[0] # fmt: skip
link = next(v["연결"] for v in one["변수"] if v.get("아래") == "굴착기무한궤")
assert link["입력"] == {"기계": "'0201-0012'", "지역": "S_지역", "보정작업": "S_보정작업"}
assert one["금액"][i]["이름"] == "굴착기(무한궤도)" and one["금액"][i]["규격"] == "0.12㎥"
def _record(world, doc):
rec = mr.Rec(world["links"])
d = json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal)
rec.prefetch(d)
dc.mc.calc(d, rec) # 요소 값은 셈하며 기록됨(화면 미리 셈과 같은 길)
return rec.out()
def test_들여쓰기_수준():
got = _ts("넣기", [{"문서": {"금액": [], "변수": []}, "넣을": []}])[0]
assert got["수준"] == [{"item": 2, "line": 3}] # 가나다 항목 = 2 · 줄 3
out = _src("A00_Common/line_stack_out.ts")
assert "stackLevels(items)" in out and '"--lstk-depth", String(r.lv)' in out
assert "rows(sub, got, h, depth + 1, path, lv + 1)" in out # 펼친 블록 = 그 줄 + 1
edit = _src("A00_Common/line_stack_edit.ts")
assert "openStackPick" in edit and "/links" not in edit and "Machine" not in edit