"""804-7(요구 687 · 688) — 줄 쌓기 「수식」 탭 수정 2차 지킴. ① 로직 삭제 = 서버 정본(로직 줄 + 수식 문서)에서 바로 · 부르는 곳(다른 문서 · M02 단가산출) 있으면 409 {쓰는곳} · 정본 로직은 개정 확인 409 — 임시 폴더만 ② 머리 단추 다섯 — [저장] 한 번 = 로직 줄 + 수식 · [되돌림] = 둘 다 · 줄 쌓기 자기 단추 없음(M01) ③ 수식 문서 1,518 전수 — 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) 0 · 변환기도 자원 이름(`k_하베스터`) ④ 단위 = 공용 단위 고르기(친 글도 씀) · 표준 표 읽기 = 로그인만 ⑤ 시험 페이지(804-1) 걷음 · 줄 쌓기 화면 차림(한 줄 · [+] 메뉴 · 가나다 체크 없음 · 기계 제목 펼침) """ from __future__ import annotations import json import re import shutil 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 store ROOT = Path(__file__).resolve().parents[2] REAL = store.FOLDER CODE = re.compile(r"^(?:EQ|MT|LB)\d") def _src(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") @pytest.fixture def client(tmp_path: Path, monkeypatch) -> TestClient: logic, docs = tmp_path / "logic", tmp_path / "doc" logic.mkdir() for path in REAL.glob("*.json"): if not path.name.startswith("_") or path.name == store.mk.BOOK.name: shutil.copy(path, logic / path.name) shutil.copytree(doc_screen.folder(), docs) monkeypatch.setattr(store, "FOLDER", logic) monkeypatch.setattr(doc_screen.ml, "FOLDER", docs) from M02_MasterTemplete import M02_Template_Layers as layers sk = tmp_path / "SK-1.json" monkeypatch.setattr( layers, "layer_files", lambda kind: [("개인 시험", sk)] if sk.exists() else [] ) app = FastAPI() app.include_router(router_module.router) c = TestClient(app) c.sk = sk # type: ignore[attr-defined] return c def _new_logic(c: TestClient, name: str) -> str: row = { "키": "", "이름": name, "결과단위": "원/㎥", "출처": "자체", "입력": [], "중간": [], "호표": [], "끝수": None, } ver = store.read("로직_자체.json")[1] if (store.FOLDER / "로직_자체.json").is_file() else "" res = c.post( "/api/m01/save", json={ "files": [ {"file": "로직_자체.json", "version": ver, "changes": [{"op": "add", "row": row}]} ] }, ) assert res.status_code == 200, res.text return res.json()["added"][-1]["key"] # fmt: skip def _doc(c: TestClient, key: str, sub: str | None = None) -> None: doc = c.get("/api/m01/logic/doc", params={"key": key}).json()["문서"] doc["금액"] = [ {"이름": "보통인부", "단위": "인", "비목": "노무비", "수량": "1", "단가": "w_보통"} ] doc["변수"] = [ {"기호": "w", "아래": "보통", "위": "", "연결": {"갈래": "요소", "키": "LB000002"}} ] if sub: doc["변수"].append( {"기호": "B", "아래": "", "위": "", "연결": {"갈래": "단가산출", "키": sub, "입력": {}}} ) res = c.post("/api/m01/logic/doc/save", json={"문서": doc}) assert res.status_code == 200, res.text def _remove(c: TestClient, key: str, ok: bool = False): one = c.get("/api/m01/logic", params={"key": key}).json() return c.post( "/api/m01/logic/remove", json={"key": key, "file": one["file"], "version": one["version"], "개정확인": ok}, ) def test_로직_삭제_서버_정본_바로(client: TestClient) -> None: a, b = _new_logic(client, "sub3시험 지울 것"), _new_logic(client, "sub3시험 부르는 것") _doc(client, a) _doc(client, b, sub=a) # 다른 문서가 부름 → 막힘 · 까닭 = 그 문서 res = _remove(client, a) assert res.status_code == 409 and any(b in x for x in res.json()["detail"]["쓰는곳"]) # M02 단가산출 바탕 = 이 로직 → 막힘 client.sk.write_text(json.dumps({"이름": "sub3임시", "바탕": {"로직": b}}), encoding="utf-8") # type: ignore[attr-defined] res = _remove(client, b) assert res.status_code == 409 and res.json()["detail"]["쓰는곳"] == [ "M02 단가산출 개인 시험 sub3임시" ] client.sk.unlink() # type: ignore[attr-defined] # 부르는 쪽부터 지움 — 로직 줄 · 수식 문서 둘 다 없어짐 assert _remove(client, b).status_code == 200 assert _remove(client, a).status_code == 200 for k in (a, b): assert client.get("/api/m01/logic", params={"key": k}).status_code == 404 assert k not in doc_screen.docs() # 정본 로직 = 개정 확인 409(아무것도 안 지움) res = _remove(client, "GF000099") assert res.status_code == 409 and res.json()["detail"].get("개정") assert "GF000099" in doc_screen.docs() def test_정본_폴더_안_바뀜() -> None: assert doc_screen.folder() == ROOT / "resources/master_data/수식문서" assert store.FOLDER == REAL def test_머리_단추_다섯_저장_한_번() -> None: lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") assert "page: () => persist()," in lab assert ( "removeLogic(key, one.file, one.version, ok)" in lab and "show(null); // 로직 목록" in lab ) assert "if (opened) await stack.save();" in lab # [저장] = 로직 줄 + 수식(여는 중이면 기다림) assert "await loading;" in _src("A00_Common/line_stack_screen.ts") assert "await stack.discard();" in lab # [되돌림] = 둘 다 assert "n === 0 && !stack.dirty()" in lab for t in ('tl("New")', "copyButton", "deleteButton", "discardButton", "saveButton"): assert t in lab.split("buttons: [")[1].split("]")[0] text = _src("M01_MasterData/M01_MasterData_UI_Logic_Text.ts") assert 'Bar_Discard: ["되돌림", "Revert"]' in text and "캐시 삭제" not in text screen = _src("A00_Common/line_stack_screen.ts") assert "edit() && key && !opt.page" in screen and "opt.page?.();" in screen def test_요소키_기호_이름_0_전수() -> None: docs = doc_screen.docs() assert len(docs) >= 1513 # 813-2 지움 6 bad = [ (k, v.get("기호"), v.get("아래")) for k, d in docs.items() for v in d.get("변수") or [] if CODE.match(str(v.get("아래") or "")) or CODE.match(str(v.get("열쇠") or "").split("_")[-1]) ] assert bad == [] ids = {doc_screen.md.var_id(v) for v in docs["GF000011"]["변수"]} assert {"k_하베스터", "P_하베스터", "k_굴착기", "P_굴착기"} <= ids import master_doc_convert as cv whole = store.loaded()[1] for key in ("GF000011", "GF000145", "GC001043"): got, _ = cv.convert(whole, store.logic(key)["logic"]) assert not [v for v in got["변수"] if CODE.match(str(v.get("아래") or ""))], key def test_단위_고르기_공용_친글() -> None: edit = _src("A00_Common/line_stack_edit.ts") assert "createUnitPick({" in edit and "free: true," in edit pick = _src("M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts") assert "if (opts.free && typed && !all.includes(typed)) shown.push(typed);" in pick main = _src("main.py") at = main.index("M02_MasterTemplete.DataFlow.M02_DF_Router") assert "dependencies=protected," in main[at : at + 120] def test_시험_페이지_걷음_화면_차림() -> None: assert not (ROOT / "tools/line-stack-test").exists() for rel in ("main.py", "vite.config.ts", "M01_MasterData/M01_MasterData_UI_Side.ts"): assert "line-stack-test" not in _src(rel), rel edit = _src("A00_Common/line_stack_edit.ts") assert "createCheckbox" not in edit and "Gana" not in edit # 가나다 체크 걷음 assert '{ icon: "plus", title: lt("Add_Line"), size: 24 }' in edit # [+] 메뉴 assert '"틀"' not in edit and "K_틀" not in edit # 틀 찾기 걷음 assert "every.has(id) && !taken.has(id)" in edit # 만약 안 탄 갈래 숨김 assert "String(mat?.검색어 ?? line.이름" in edit # 이름 단추 = 그 이름 찾음 text = _src("A00_Common/line_stack_text.ts") assert '["단가산출", "Unit price"]' in text and '["근거 글", "Basis note"]' in text assert "비우면 번호 없음" not in text and "식 고치기" not in text out = _src("A00_Common/line_stack_out.ts") assert "s ? opener(s, eqText(bundle[c]))" not in out and "fold(s, title)" in out css = _src("A00_Common/line_stack.css") assert ".lstk__foot {\n position: sticky;\n inset-block-end: 0;" in css assert "html:not" in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Side.css") for f in (ROOT / "A00_Common").glob("line_stack*.ts"): assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None: """804-8 — M02 단가산출 수식 탭 = M01 과 같은 공용 화면 · 페이지 [되돌림] · [저장] 한 번 · 머리 줄 한 줄.""" screen = _src("A00_Common/line_stack_screen.ts") assert "...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : [])," in screen assert "notesOf" not in screen and "lstk__notes" not in screen # 못 고치는 칸 안내 줄 걷음 assert "Notes:" not in _src("A00_Common/line_stack_text.ts") tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") assert "page: opt.page," in tabs and "save: () => stack.save()," in tabs detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") assert "page: opt.source ? () => report() : undefined," in detail assert "JSON.stringify(doc) !== saved || tabs.dirty()" in detail # 고침 알림 = 기본 + 수식 assert "addButton.hidden = !edit || !!opt.source;" in detail # [항목 추가] · [율 줄 추가] 걷음 assert "rateButton.hidden = !edit || !!opt.source;" in detail assert "if (!(await tabs.save())) throw" in detail and "await refreshing;" in detail assert "if (tabs.dirty()) void tabs.discard();" in detail # 버리고 떠남 = 수식 캐시도 view = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts") assert "getDoc: () => one.flush().then(() => one.getDoc())" in view acts = _src("M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts") assert ( 'Undo: ["되돌림", "Revert"],' in acts and "ctx.page.save.disabled = sk() && !dirty;" in acts ) assert "if (sk()) ctx.page.reload.hidden = true;" in acts # 단가산출 상세 = [새로고침] 숨김 main = _src("M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts") assert "actions.undo,\n save," in main and "actions.dim((dirty = false));" in main assert "onChanged, onVersion, slot }" in main # 보기 고르기 = 머리 줄 assert "fitButtonRow(" in main and "fitButtonRow(root);" in _src( "M01_MasterData/M01_MasterData_UI_Head.ts" ) lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") for i in ('"check"', '"undo"', '"copy"', '"plus"', '"trash"'): assert f"iconStart: {i}" in lab, i css = _src("ui_template/ui_template_button.css") assert ".ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label" in css def test_단위표_더한_단위_문서_전수() -> None: """804-9(요구 692-1) — 표준 단위 표 「더한 단위」 21 · kg ↔ ㎏ · cm ↔ ㎝ 하나만 · 수식 문서 항목 줄 단위 전부 표에 있음.""" from M02_MasterTemplete.DataFlow import M02_DF_Router as df table = df.get_standards()["unit"] same = table["같은 단위"] assert same == {"kg": "㎏", "cm": "㎝"} added = table["더한 단위"] assert len(added) == len(set(added)) == 21 units = { same.get(u, u) for r in table["줄"] for u in (r["규격 단위"], r["단위수량 단위"]) if u } | {same.get(u, u) for u in added} assert "kg" not in units and "cm" not in units and {"㎏", "㎝"} <= units lines: list[str] = [] def walk(o) -> None: if isinstance(o, dict): if "비목" in o and "단위" in o: lines.append(str(o["단위"] or "")) for v in o.values(): walk(v) elif isinstance(o, list): for v in o: walk(v) for f in (ROOT / "resources/master_data/수식문서").glob("*.json"): walk(json.loads(f.read_text(encoding="utf-8"))) assert len(lines) > 5000 missing = {u for u in lines if u and same.get(u, u) not in units} assert not missing, missing pick = _src("M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts") assert ( '...(table["더한 단위"] ?? []),' in pick and '(u && table["같은 단위"]?.[u]) || u' in pick ) def test_표값_직접_침_별칭_그대로() -> None: """804-9(요구 692-1) — 표 값 칸에 숫자 · 식을 쳐도 화면 이름(별칭) 그대로 · 꼬리표 「직접 입력」 · 서버 셈 = 표 정보 없는 숫자와 같음.""" from M02_MasterTemplete import M02_Doc_Copy as dc from resources.tester.test_804_2_line_stack import _ts, mr docs = dc.folder() keys = sorted( k for k, d in docs.items() if any((v.get("위") == "표") for v in d.get("변수") or []) ) got = dict(zip(keys, _ts("직접", [{"문서": docs[k]} for k in keys]))) named = 0 for k, g in got.items(): assert g["전"] == g["숫자"] == g["식"], k assert set(g["갈래"]) <= {"숫자"}, k named += len(g["전"]) assert named > 500 links = dc.ml.Links(dc.skc.whole_(), docs) calc = lambda d: dc.mc.calc(d, (r := mr.Rec(links), r.prefetch(d))[0]) # noqa: E731 docs_sum = {k: calc(docs[k]).get("계") for k in ("GF000099", "GF000226", "GC000211")} for k in ("GF000099", "GF000226", "GC000211"): typed = got[k]["문서"] plain = json.loads(json.dumps(typed)) for v in plain["변수"]: link = v.get("연결") or {} if link.get("갈래") == "숫자": v["연결"] = {"갈래": "숫자", "값": link.get("값")} a, b = calc(typed), calc(plain) assert [a.get(c) for c in ("노무비", "재료비", "경비", "계")] == [ b.get(c) for c in ("노무비", "재료비", "경비", "계") ], k assert a.get("계") and a.get("계") != docs_sum.get(k), (k, a.get("계")) rows = _src("A00_Common/line_stack_rows.ts") assert 'if (link.갈래 === "숫자" && link.표)' in rows # 808-1 — 출처 꼬리표 한 곳(`sourceTag`) assert "const tag = expr ? null : sourceTag(h, id, link);" in rows assert "const from = v?.연결?.표 ?" in _src("common_util/common_util_line_stack.ts") def test_공식_묶음_격자_긴_이름_줄임() -> None: """804-9(요구 692-3) — 공식 묶음 = 폭만큼 여러 열 · 머리 · 식 글 · 고치는 줄 = 열 합침 · 긴 이름 · 표 단추 · 꼬리표 = 줄임표 + 마우스 글.""" css = _src("A00_Common/line_stack.css") assert "grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));" in css assert ".lstk-line.k-공식 > .lstk-frow:is(.is-open, :has(> .lstk-expr))" in css assert ".ui-btn[data-clip] > .ui-btn__label" in _src("ui_template/ui_template_button.css") assert "clip?: boolean;" in _src("ui_template/ui_template_button.ts") rows = _src("A00_Common/line_stack_rows.ts") assert "clip: true," in rows and "attrs: { title: text }" in rows assert "name.title = `${show(id)} — ${name.title}`;" in rows def test_808_1_수량_칸_식_글_꼬리표_글꼴_머리(): """808-1(요구 701) — 수량 칸 = 늘 식 글 칸 + 출처 꼬리표(표 = 표 고르기 · 누름으로 안 바뀜) · 표 값 기호에 수 = 그 값만(별칭) · 글꼴 = 화면이 정함(M01 · M02 같음) · [단가 갱신] · [재생성] = 페이지 머리 칸 · [초기화] 이름 = 뜻대로.""" edit = _src("A00_Common/line_stack_edit.ts") body = edit[edit.index("function qtyBox(") : edit.index("/** [+ 줄] 고르개 값")] assert "createButton" not in body and "h.open" not in body # 단추 꼴 · 눌러 바뀜 없음 assert "sourceTag(h, text, v.연결)" in body and "setRow(doc, text, want)" in body css = _src("A00_Common/line_stack.css") assert ".lstk {\n font-size: var(--text-body-sm);" in css screen = _src("A00_Common/line_stack_screen.ts") assert "opt.tools?.replaceChildren(...tools);" in screen assert "children: opt.tools ? [] : tools" in screen tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") assert 'stack.root.hidden = tools.hidden = v !== "stack";' in tabs assert 'Reset: ["M01 값으로", "To M01"]' in _src( "M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts" )