Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
265 lines
12 KiB
Python
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
|