Files
Aislo/resources/tester/test_179_4_detail_table.py
T

265 lines
12 KiB
Python

# -*- 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
length = out["length"]
assert length["src"] == "length"
assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1])
assert out["lengthBad"] is None
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 detachTable = attachDetailTable({" in detail