# -*- coding: utf-8 -*- """PLAN 179-4 — B06 [상세] 표 손질(그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움). 못박는 것 - 칸 값 짝(① 파이썬 `cell_value` ↔ TS `localCellValue`) 거울 · 면적 → 설계 칸 값 · 길이 = hypot. - 길이 검산 — 두 끝이 설계선 밖이면 값 비움 + 「다시 고름」(브라우저 값 안 받음). - 그래프 고르기(띠 → 표 키 · 판정 선 px → m) · 우클릭 메뉴 항목 · 세션 초안 → patch → 스키마. - 살림 목록 짝(RD ↔ CR)에 `detail_table` · 표 배치 한 벌(모달 sheet · B07 도면 표 · 높이). ./venv/Scripts/python.exe -m pytest resources/tester/test_179_4_detail_table.py -q """ import json import os import re import subprocess from math import hypot from pathlib import Path import pytest from pydantic import ValidationError from B06_Section import B06_Section_Router_CrossTable as table_mod from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS from B06_Section.B06_Section_Schema import CrossSectionPatch from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( _cross_table_entities, cross_table_height, extract_quantity_table, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value ROOT = Path(__file__).resolve().parents[2] HERE = Path(__file__).resolve().parent LINE = [(-6.0, 104.0), (-1.0, 110.0), (1.0, 110.0), (5.0, 108.0), (9.0, 112.0)] DESIGN = { "design_elevation_m": 110.0, "cut_soil_area_m2": 1.234, "cut_rock_area_m2": 0.5, "fill_area_m2": 2.25, "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], } SOURCES = [ {"src": "area", "key": "cut_soil"}, {"src": "area", "key": "embankment"}, {"src": "area", "key": "spoil_fill"}, # 설계에 없음 → 빈 값 {"src": "length", "start": [5.0, 108.0], "end": [1.0, 110.0]}, {"src": "length", "start": [3.0, 109.0], "end": [9.0, 112.0]}, # 조각 가운데 점도 선 위 {"src": "length", "start": [5.0, 108.5], "end": [1.0, 110.0]}, # 0.5 m 벗어남 {"src": "length", "start": [5.0, 108.0], "end": [1.0, 110.04]}, # 허용 0.05 m 안 {"src": "text", "text": "L형"}, ] PICK = [[5.0, 108.0], [1.0, 110.0]] pytestmark = pytest.mark.skipif( not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" ) @pytest.fixture(scope="module") def out() -> dict: proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_179_4_detail_table.cjs")], input=json.dumps({"design": DESIGN, "sources": SOURCES, "pick": PICK}), capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=os.fspath(ROOT), ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) # ── 칸 값 짝 · 검산 ────────────────────────────────────────────────────── def test_칸_값_파이썬_TS_거울(out): for source, local in zip(SOURCES, out["local"], strict=True): number, _text, _again = cell_value(source, DESIGN) if number is None: assert local is None, source else: assert local == pytest.approx(number), source def test_면적_출처는_설계_칸_값_길이는_hypot(): assert cell_value(SOURCES[0], DESIGN) == (1.234, None, False) assert cell_value(SOURCES[1], DESIGN) == (2.25, None, False) # 쌓기 = fill_area_m2 assert cell_value(SOURCES[2], DESIGN) == (None, None, False) assert cell_value(SOURCES[3], DESIGN)[0] == pytest.approx(hypot(4.0, 2.0)) assert cell_value(SOURCES[4], DESIGN)[0] == pytest.approx(hypot(6.0, 3.0)) assert cell_value(SOURCES[7], DESIGN) == (None, "L형", False) def test_길이_검산_설계선_밖이면_비우고_다시_고름(): assert cell_value(SOURCES[5], DESIGN) == (None, None, True) assert cell_value(SOURCES[6], DESIGN)[0] == pytest.approx(hypot(4.0, 2.04)) # 설계선이 바뀌면(옛 두 끝이 선 밖) 다시 고름 moved = {**DESIGN, "design_line": [{"offset_m": x, "elevation_m": y + 1} for x, y in LINE]} assert cell_value(SOURCES[3], moved) == (None, None, True) assert cell_value(SOURCES[3], {**DESIGN, "design_line": []})[2] is True # ── 그래프 고르기 · 메뉴 ───────────────────────────────────────────────── def test_그래프_고르기_띠_키_판정_선_m(out): area = out["area"] assert area["cut_soil"] == {"src": "area", "key": "cut_soil"} assert area["cut_rock"] == {"src": "area", "key": "cut_rock"} assert area["fill"] == {"src": "area", "key": "embankment"} assert area["spoil"] == {"src": "area", "key": "spoil_fill"} assert area["other"] is None # 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로 assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1]) assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0)) def test_우클릭_메뉴_항목(out): pick = ["그래프에서 고르기 — 면적", "그래프에서 고르기 — 길이"] add = ["행 더함 — 표 끝", "열 더함 — 오른쪽 끝"] assert out["menuHeader"] == [*pick, "-", *add, "-"] # 머리 칸 = 줄 없음 · 자동값 없음 assert out["menuBase"] == [ *pick, "자동값으로", "-", "행 더함 — 이 행 아래(묶음 안)", *add, "-", ] assert out["menuAdded"][-2:] == ["이 행 지움", "이 열 지움"] assert "자동값으로" not in out["menuAdded"] # 상한(행 12 · 열 4)이면 더함 없음 assert not any(label.startswith(("행 더함", "열 더함")) for label in out["menuFull"]) assert out["menuPicks"] == "length" # ── 세션 초안 → patch → 스키마 → 서버 표 한 벌 ──────────────────────────── def test_세션_초안_patch_스키마_통과(out): assert out["fresh"] == {"cells": {}, "rows": [], "columns": []} assert out["ids"] == ["r1", "r2", "r3", "c1"] assert out["cacheHas"] is True draft = out["drafts"]["40"] assert draft == out["forTable"] # 표 요청 설계에도 초안 그대로 patch = CrossSectionPatch(chainage_m=40.0, detail_table=draft).model_dump() table = patch["detail_table"] assert [row["id"] for row in table["rows"]] == ["r1", "r2", "r3"] assert table["rows"][0]["labels"] == ["풍화암", "", ""] assert table["columns"] == [{"id": "c1", "label": "비고"}] assert table["cells"]["c:c1:r1"]["text"] == "L형" def test_지움_아래_행은_지운_행_자리로_열_칸도_지움(out): removed = out["removed"] assert [(row["id"], row["after"]) for row in removed["rows"]] == [("r2", None), ("r3", "b0")] assert removed["columns"] == [] assert not any(key.startswith(("r:r1:", "c:")) for key in removed["cells"]) assert "cut_soil" not in out["autoBack"] and "fence" in out["autoBack"] assert out["span"] == [3, 1] @pytest.mark.parametrize( "bad", [ {"rows": [{"id": "x1"}]}, {"columns": [{"id": "r1"}]}, {"cells": {"cut_soil": {"src": "volume"}}}, {"cells": {"fence": {"src": "length", "start": [1.0], "end": [2.0, 3.0]}}}, {"rows": [{"id": f"r{i}"} for i in range(1, 14)]}, {"columns": [{"id": f"c{i}"} for i in range(1, 6)]}, ], ) def test_스키마가_틀린_표를_거름(bad): with pytest.raises(ValidationError): CrossSectionPatch(chainage_m=40.0, detail_table=bad) def _stored(out) -> dict: dumped = CrossSectionPatch(chainage_m=40.0, detail_table=out["drafts"]["40"]).model_dump() return {**DESIGN, "detail_table": dumped["detail_table"]} def test_모달_표_배치_값_검산(out): design = _stored(out) sheet = table_mod.cross_quantity_sheet({"chainage_m": 40.0, "center_z": 111.0}, design) layout = sheet["layout"] assert layout["rows"] == ["b0", "r1", "r3", "b1", "b2", "b3", "b4", "b5", "r2"] assert layout["left"][1] == [None, "풍화암", "r:r1:0"] # 깍기 묶음 안 하위 줄 assert layout["left"][2] == [None, "", "r:r3:0"] assert layout["middle"][1] == [None, "", "r:r1:1"] # 면고르기 묶음 안 assert layout["left"][8] == ["", None, "r:r2:0"] # 끝 = 홀로 선 줄 assert layout["right"][8] == ["", "r:r2:2"] assert layout["columns"] == [ {"id": "c1", "label": "비고", "keys": [f"c:c1:{row}" for row in layout["rows"]]} ] table = sheet["quantity_table"] assert table["cut_soil"] == 2.25 # 덮어쓰기 = 쌓기 면적 assert table["fence"] == pytest.approx(hypot(4.0, 2.0)) # 판정 선 길이 assert table["r:r2:2"] == 0.5 assert sheet["text"]["c:c1:r1"] == "L형" and sheet["repick"] == [] # 설계선이 바뀌면 길이 칸 비움 · 다시 고름 moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y + 1} for x, y in LINE]} again = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, moved) assert again["quantity_table"]["fence"] is None and again["repick"] == ["fence"] def test_B07_도면_표_같은_자료_행_열_높이(out): design = _stored(out) from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table values = _quantity_table({"chainage_m": 40.0, "center_z": 111.0}, design) [entity] = _cross_table_entities("d", values, 0.0, "No.2", 0.0, design) shape = entity["shapeData"] assert len(shape["rowHeights"]) == 2 + 6 + 3 assert sum(shape["columnWidths"]) == pytest.approx(108.0) cells = [cell for row in shape["cells"] for cell in row if cell] texts = {cell.get("key"): cell["text"] for cell in cells if cell.get("key")} assert texts["cut_soil"] == "2.25" and texts["fence"] == "4.47" assert texts["c:c1:r1"] == "L형" and texts["r:r1:0"] == "" assert any(cell["text"] == "비고" for cell in shape["cells"][1] if cell) labels = [cell["text"] for cell in cells if not cell.get("key")] assert "풍화암" in labels # 깍기 묶음 = 4줄(토사 · 풍화암 · 더한 줄 · 암석) cut = next(cell for cell in cells if cell["text"] == "깍기") assert cut["rowSpan"] == 4 assert cross_table_height(design) == pytest.approx(5.0 * 11) assert cross_table_height() == pytest.approx(40.0) # 손질 없으면 그대로 # 확정 되읽기 = 양식 칸만(더한 칸은 patch 몫) extracted = extract_quantity_table("d", {"entities": [entity]}) assert extracted["cut_soil"] == 2.25 and "c:c1:r1" not in extracted def test_손질_없으면_도면_표_그대로(): plain = _cross_table_entities("d", {"ground": 1.0}, 0.0, "No.1", 0.0)[0]["shapeData"] empty = {"detail_table": {"cells": {}, "rows": [], "columns": []}} same = _cross_table_entities("d", {"ground": 1.0}, 0.0, "No.1", 0.0, empty)[0]["shapeData"] assert plain == same and len(plain["rowHeights"]) == 8 # ── 살림 목록 짝 · 배선 ──────────────────────────────────────────────── def test_살림_목록_짝에_detail_table(): assert "detail_table" in USER_TOUCHED_KEYS text = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S) assert block and '"detail_table"' in block.group(1) def test_저장_patch_배선_초안_등록(): persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => {" in persist assert "patchFor(chainage).detail_table = table;" in persist state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") assert '"detail-table": { bucket: "draft", scope: "route" }' in state detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8") assert "designForTable(state.section, projectId, routeId)" in detail assert "const tablePick = attachDetailTable({" in detail assert "tablePick.redraw();" in detail