Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
178 lines
9.3 KiB
Python
178 lines
9.3 KiB
Python
"""구조물 조합 표(PLAN 31-1) — 머리 이름 칸 고침 = 나무 이름 바꾸기 · 단위 줄 숨김 · 열 폭 자동 ·
|
|
고른 열의 마지막 분류 칸 강조 · 옛 안내글 없음.
|
|
|
|
① `headRenameEdit`(Node) — 가장 깊은 칸이면 항목(열 id) · 위 칸이면 그 폴더 경로.
|
|
② 표 부품 `combo` 선택 — 단위 줄 · 안내 글 없음 · 번호 글 폭 · 가장 깊은 머리 칸 ·
|
|
머리 고침은 부른 쪽에.
|
|
③ 잇기 — 상세(Main)가 쓸 수 있을 때만 넘기고 페이지가 나무 고침(`editFromTable`)으로 보냄.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
M02 = ROOT / "M02_MasterTemplete"
|
|
SHEET = ROOT / "ui_template" / "sheet"
|
|
|
|
|
|
def _text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
|
def test_머리_칸_고침은_나무_이름_바꾸기():
|
|
src = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
|
part = re.search(r"^export function headRenameEdit[\s\S]*?^}", src, re.M)
|
|
assert part, "headRenameEdit 를 못 찾음"
|
|
script = (
|
|
"interface StructureColumn { id: string; 머리: (string | null)[] }\n"
|
|
"interface TreeEdit { op: string; 경로?: string[]; 이름?: string; 키?: string }\n"
|
|
+ part.group(0).replace("export ", "")
|
|
+ """
|
|
const col = { id: "item3", 머리: ["옹벽", "콘크리트", "L형", null] };
|
|
const flat = { id: "item9", 머리: ["대피소", null, null, null] };
|
|
console.log(JSON.stringify([
|
|
headRenameEdit(col, 0, "옹벽공"), headRenameEdit(col, 1, "철근"),
|
|
headRenameEdit(col, 2, "L자"), headRenameEdit(col, 3, "L자"), headRenameEdit(flat, 0, "쉼터"),
|
|
]));
|
|
"""
|
|
)
|
|
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) == [
|
|
{"op": "rename", "경로": ["옹벽"], "이름": "옹벽공"}, # 맨 위 폴더
|
|
{"op": "rename", "경로": ["옹벽", "콘크리트"], "이름": "철근"}, # 가운데 폴더
|
|
{"op": "rename", "경로": ["옹벽", "콘크리트"], "키": "item3", "이름": "L자"}, # 항목
|
|
{"op": "rename", "경로": ["옹벽", "콘크리트"], "키": "item3", "이름": "L자"}, # 빈 아래 칸
|
|
{"op": "rename", "경로": [], "키": "item9", "이름": "쉼터"}, # 깊이 1 항목
|
|
]
|
|
|
|
|
|
def test_표_부품_combo_선택():
|
|
sheet = _text(SHEET / "ui_template_sheet.ts")
|
|
assert "combo?: { onHeadEdit?: (colId: string, level: number, name: string) => void };" in sheet
|
|
assert '...(ro || opts.combo ? [] : [el("p", { className: "ui-sheet__hint"' in sheet
|
|
assert (
|
|
"const keys = opts.combo ? [KEY_UNIT] : navRows(state.doc, state.mode);" in sheet
|
|
) # 방향키가 숨은 단위 · 번호 줄을 안 밟음
|
|
assert 'head?.classList.add("is-head-picked")' in sheet
|
|
assert "if (th && opts.combo?.onHeadEdit) editComboHead(th);" in sheet
|
|
assert "if (opts.combo && sel.r === KEY_UNIT)" in sheet # F2 · Enter = 머리 이름 고침
|
|
render = _text(SHEET / "ui_template_sheet_render.ts")
|
|
assert "children: state.combo ? rows : [...rows, unit]" in render # 단위 줄 없음
|
|
# 45-3 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만)
|
|
assert (
|
|
'...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : [])'
|
|
in render
|
|
)
|
|
assert "fitMeta" not in render
|
|
assert "열너비: {}" in render # 저장 폭 안 씀 — 자동
|
|
css = _text(SHEET / "ui_template_sheet.css")
|
|
assert ".ui-sheet--combo .ui-sheet__meta td" not in css and "th.is-head-picked" in css
|
|
|
|
|
|
def test_머리_고침은_쓸_수_있는_층만_나무로():
|
|
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
|
assert "readOnly || !extras.onHeadRename" in main
|
|
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
|
assert "tree.editFromTable(headRenameEdit(col, level, name), col.id)" in page
|
|
assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0
|
|
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
|
assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree
|
|
|
|
|
|
def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
"""86-3 — `structure_numbers` 의 일위대가 합계: 반영합 `계` · 없으면 기준합 `계` + 합계기준 ·
|
|
담은 로직 0 · 계산값 없음 = 칸 없음(목록 `labor` 합계와 같은 규칙)."""
|
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
|
|
|
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
|
(tmp_path / "labor").mkdir()
|
|
docs = {
|
|
"a": {"일위대가번": "SU-000001", "담은로직": ["x"], "계산값": {"반영합": {"계": 1500.4}}},
|
|
"b": {
|
|
"일위대가번": "SU-000002",
|
|
"담은로직": ["x"],
|
|
"계산값": {"반영합": {"계": 0}, "기준합": {"계": 900}},
|
|
},
|
|
"c": {"일위대가번": "SU-000003", "담은로직": [], "계산값": {"반영합": {"계": 5}}},
|
|
"d": {"일위대가번": "SU-000004", "담은로직": ["x"], "계산값": {}},
|
|
}
|
|
for name, doc in docs.items():
|
|
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
|
got = store.structure_numbers()
|
|
assert (got["a"]["일위대가합계"], got["a"]["합계기준"]) == (1500.4, False)
|
|
assert (got["b"]["일위대가합계"], got["b"]["합계기준"]) == (900.0, True)
|
|
assert "일위대가합계" not in got["c"] and "일위대가합계" not in got["d"]
|
|
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
|
|
|
|
|
|
def test_일위대가_합계_상태_넷(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
"""101-1 — 합계 칸 색: 붉음(기준 안 나온 돈 줄 · 단가 없는 자재) > 노랑(수량 없는 줄 · 자재) >
|
|
초록(모두 반영) · 돈 아닌 로직 줄은 판정에서 뺌 · 담은 것 없음 = 상태 없음."""
|
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
|
|
|
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
|
(tmp_path / "labor").mkdir()
|
|
money = {"기준": {"계": 100}, "까닭": None}
|
|
docs = {
|
|
"ok": {"줄": [{**money, "수량": 2}, {"결과": 3, "기준": None, "까닭": None}], "자재": [
|
|
{"기준단가": 5, "수량": 1}
|
|
]},
|
|
"short": {"줄": [{**money, "수량": 2}, {**money, "수량": None}], "자재": []},
|
|
"error": {"줄": [{**money, "수량": None}, {"기준": None, "까닭": "식 오류"}], "자재": [
|
|
{"기준단가": None, "까닭": "단가 없음", "수량": None},
|
|
{"기준단가": 5, "수량": None},
|
|
]},
|
|
} # fmt: skip
|
|
for name, calc in docs.items():
|
|
doc = {"담은로직": ["x"], "계산값": {**calc, "반영합": {"계": 7}, "기준합": {"계": 9}}}
|
|
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
|
empty = {"담은로직": [], "계산값": {"줄": [], "반영합": {"계": 7}}}
|
|
(tmp_path / "labor" / "empty.json").write_text(json.dumps(empty), encoding="utf-8")
|
|
got = store.structure_numbers()
|
|
assert (got["ok"]["합계상태"], got["ok"]["합계까닭"]) == ("ok", "모두 반영")
|
|
assert (got["short"]["합계상태"], got["short"]["합계까닭"]) == ("short", "수량 없는 줄 1")
|
|
assert (got["error"]["합계상태"], got["error"]["합계까닭"]) == (
|
|
"error",
|
|
"기준 단가 없음 2 · 수량 없는 줄 2",
|
|
)
|
|
assert "합계상태" not in got["empty"]
|
|
|
|
|
|
def test_일위대가_합계_줄_표_부품과_연결():
|
|
sheet = _text(SHEET / "ui_template_sheet.ts")
|
|
assert "laborSumLabel: opts.laborSumLabel," in sheet
|
|
ops = _text(SHEET / "ui_template_sheet_ops.ts")
|
|
assert 'export const KEY_SUM = "s:laborsum";' in ops
|
|
assert "[KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops # 읽기만 — 고치기 · 쓰기 막힘
|
|
assert "KEY_SUM" not in re.search(r"MASTER_ROWS = \[[^\]]*\]", ops).group(
|
|
0
|
|
) # 방향키 · 고르기 밖
|
|
render = _text(SHEET / "ui_template_sheet_render.ts")
|
|
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
|
|
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
|
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
|
|
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
|
|
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
|
|
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
|
|
SHEET / "ui_template_sheet.css"
|
|
)
|
|
assert "markSum(c)" in sheet # 101-2 머리 고르면 합계 칸도 같이
|
|
assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
|
|
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
|
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
|
|
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
|
assert "opts.onSaved?.();" in cards
|