Files
Aislo/resources/tester/test_804_2_line_stack.py
eomsangdonandClaude Opus 5.5 abe722f378 feat(804-7): 줄 쌓기 「수식」 탭 수정 2차(요구 687 · 688)
- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`)
- 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로)
- 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝
- 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김
- 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글
- 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음)
- 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음
- 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록)
- 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음)

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

341 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 (
'base: "/m01/logic/doc",' in lab and 'cache: "m01_doc_drafts",' in lab
) # 804-7 page = 페이지 [저장]
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