"""구조물 집계표 (2026-09-13, PLAN 2장). 겨누는 것 ① 한 줄 = 측점 + 종류 + 실치수 + 개소·연장 · 종류별 표 · 개소·연장 합 · 평균치수 ② 칸 출처 — 정본 값은 자동 · 빈 칸은 양식 기본값(라이브러리) · 둘 다 없으면 빈칸 ③ 계곡 통과 시설은 관 지점 정본에서 — 배수관과 세월교는 **다른 표**(브레인 판정) · 관 연장은 B06 횡단 값(0.5m 안) · 없으면 빈칸(0 아님) ④ 손댄 칸은 「사용자」 · 그 값을 B05 가 바꾸면 자동으로 돌아가되 **알림이 남음** ⑤ [저장] — 정본에 바로 씀(덮개층 없음) · 상세 칸만 · 놓기 칸·틀린 값·판번호 어긋남은 아무것도 안 씀 · 고치기 전 값으로 되돌리면 「자동」 · 관 지점 파일은 옵션만 바뀜 """ from __future__ import annotations import json import sys from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 from B05_Profile.B05_Profile_Structures_Schema import ( # noqa: E402 StructureInstance, structure_type_map, ) from B08_Quantity.B08_Quantity_Engine_StructureSummary import build_summary # noqa: E402 from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402 PROJECT_ID = "66666666-6666-6666-6666-666666666666" WALLS = [ { "structure_id": "w1", "type_id": "masonry_wet", "placement": "interval", "start_m": 100.0, "end_m": 110.0, "options": {"height_m": 2.5, "back_len_cm": 35}, }, { "structure_id": "w2", "type_id": "masonry_wet", "placement": "interval", "start_m": 40.0, "end_m": 46.0, "options": {"height_m": 1.5, "length_m": 5.0}, }, ] POINTS = [ {"chainage_m": 60.2, "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 800}}, {"chainage_m": 300.0}, {"chainage_m": 200.0, "facility": "ford_bridge", "options": {"pipe_count": 2}}, ] def _tables(user_cells=None) -> dict: body = build_summary( WALLS, POINTS, structure_type_map(), {"masonry_wet": load_template("masonry_wet")}, {60.0: 8.0}, user_cells, ) return {table["type_id"]: table for table in body["tables"]} | {"_notes": body["notes"]} def test_종류별_표에_측점_실치수_개소_연장이_선다() -> None: walls = _tables()["masonry_wet"] assert [row["id"] for row in walls["rows"]] == ["w2", "w1"] # 측점 차례 w2, w1 = walls["rows"] assert w1["length_m"] == pytest.approx(10.0) and w1["length_basis"] == "시·종점" assert w2["length_m"] == pytest.approx(5.0) and w2["length_basis"] == "제원 연장" assert walls["count"] == 2 and walls["length_total_m"] == pytest.approx(15.0) assert walls["averages"]["height_m"] == pytest.approx(2.0) # 칸 출처 — 정본 값 · 양식 기본(뒷길이 45) · 둘 다 없음. assert w1["cells"]["back_len_cm"] == {"value": 35, "source": "auto"} assert w2["cells"]["back_len_cm"] == {"value": 45, "source": "library"} assert w2["cells"]["face_slope_ratio"] == {"value": None, "source": "empty"} def test_배수관과_세월교는_다른_표이고_관_연장은_B06_값() -> None: tables = _tables() pipes = tables["pipe"] assert [row["chainage_m"] for row in pipes["rows"]] == [60.2, 300.0] near, far = pipes["rows"] assert near["length_m"] == pytest.approx(8.0) and near["length_basis"] == "B06 횡단 관 연장" assert far["length_m"] is None and pipes["length_missing"] == 1 # 0 으로 안 채움 assert near["cells"]["pipe_kind"]["value"] == "흄관" assert tables["ford_bridge"]["rows"][0]["cells"]["pipe_count"]["value"] == 2 assert tables["ford_bridge"]["length_missing"] == 0 # 점 시설은 연장이 없는 것이 정상 def test_손댄_칸은_사용자이고_B05_가_바꾸면_알림이_남는다() -> None: marks = { "w1": {"height_m": {"value": 2.5, "was": 2.0}, "back_len_cm": {"value": 55, "was": 35}} } tables = _tables(marks) w1 = tables["masonry_wet"]["rows"][1] assert w1["cells"]["height_m"] == {"value": 2.5, "source": "user", "was": 2.0} # 뒷길이는 55 로 적었는데 정본이 35 — B05 가 바꿈. assert w1["cells"]["back_len_cm"]["source"] == "auto" assert w1["cells"]["back_len_cm"]["replaced_user_value"] == 55 assert w1["replaced"] == ["뒷길이"] assert any("자동값으로 돌아감" in note for note in tables["_notes"]) @pytest.fixture() def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: root = tmp_path / "project" root.mkdir() save_structures( str(root), [StructureInstance.model_validate(w) for w in WALLS], base_revision=0 ) path = pipe_points_path_in(root) path.parent.mkdir(parents=True) path.write_text(json.dumps({"route_signature": "", "points": POINTS}), encoding="utf-8") async def fake_connection(func, *args): return "stored" async def designs(project_id): return [{"chainage_m": 60.0, "design": {"pipe_length_m": 8.0}}] monkeypatch.setattr(material_module, "run_with_connection", fake_connection) monkeypatch.setattr(material_module, "resolve_stored_project_path", lambda _p: str(root)) monkeypatch.setattr(material_module, "_designs", designs) app = FastAPI() app.include_router(material_module.router) return TestClient(app) def test_창구가_정본_둘과_횡단_관_연장을_읽어_표를_낸다(client: TestClient) -> None: response = client.get(f"/api/projects/{PROJECT_ID}/quantity/structure-summary") assert response.status_code == 200, response.text body = response.json() names = [table["name"] for table in body["tables"]] assert "배수관" in names and "세월교" in names and "돌쌓기(찰)" in names pipes = next(table for table in body["tables"] if table["type_id"] == "pipe") assert pipes["rows"][0]["length_m"] == pytest.approx(8.0) assert body["revision"] == 1 columns = {c["key"]: c for c in pipes["columns"]} assert columns["pipe_diameter_mm"]["editable"] is False # 놓기 칸 assert columns["wing_wall_type"]["editable"] is True # 상세 칸 SUMMARY = f"/api/projects/{PROJECT_ID}/quantity/structure-summary" def _cell(client: TestClient, type_id: str, row_id: str, key: str) -> dict: tables = client.get(SUMMARY).json()["tables"] table = next(t for t in tables if t["type_id"] == type_id) return next(r for r in table["rows"] if r["id"] == row_id)["cells"][key] def test_저장은_정본에_바로_쓰고_손댄_칸을_표시한다(client: TestClient, tmp_path: Path) -> None: from B05_Profile.B05_Profile_Structures_Repository import load_structures from common_util.common_util_project_settings import quantity_settings root = tmp_path / "project" saved = client.put( SUMMARY, json={ "base_revision": 1, "edits": [ {"id": "w1", "key": "back_len_cm", "value": "55"}, {"id": "w2", "key": "stone_kind", "value": "야면석·호박돌"}, {"id": "pipe@60.200", "key": "wing_wall_type", "value": "A-TYPE"}, ], }, ) assert saved.status_code == 200, saved.text assert saved.json()["revision"] == 2 and saved.json()["changed_rows"] == 3 # 정본이 곧 값 — 구조물도·원단위가 읽는 자리에 그대로 적힘. _rev, items = load_structures(str(root)) assert {i.structure_id: i.options.get("back_len_cm") for i in items}["w1"] == 55 document = json.loads(pipe_points_path_in(root).read_text(encoding="utf-8")) assert document["points"][0]["options"] == { "pipe_kind": "흄관", "pipe_diameter_mm": 800, "wing_wall_type": "A-TYPE", } assert document["route_signature"] == "" and len(document["points"]) == 3 assert _cell(client, "masonry_wet", "w1", "back_len_cm") == { "value": 55, "source": "user", "was": 35, } # 고치기 전 값으로 되돌리면 손 표가 빠지고 「자동」. back = client.put( SUMMARY, json={"base_revision": 2, "edits": [{"id": "w1", "key": "back_len_cm", "value": 35}]}, ) assert back.status_code == 200, back.text assert _cell(client, "masonry_wet", "w1", "back_len_cm")["source"] == "auto" marks = quantity_settings(str(root))["structure_summary_user_cells"] assert "w1" not in marks and "w2" in marks def test_놓기_칸이나_틀린_값이나_판번호_어긋남은_아무것도_안_쓴다( client: TestClient, tmp_path: Path ) -> None: from B05_Profile.B05_Profile_Structures_Repository import structures_file_path root = tmp_path / "project" def files() -> tuple[str, str]: return ( Path(structures_file_path(str(root))).read_text(encoding="utf-8"), pipe_points_path_in(root).read_text(encoding="utf-8"), ) before = files() def put(edits: list[dict], revision: int = 1): return client.put(SUMMARY, json={"base_revision": revision, "edits": edits}) good = {"id": "pipe@60.200", "key": "wing_wall_type", "value": "A-TYPE"} height = put([good, {"id": "w1", "key": "height_m", "value": 3}]) assert height.status_code == 422 and "구조물 놓기(B05)" in height.json()["message"] assert put([good, {"id": "w1", "key": "back_len_cm", "value": -1}]).status_code == 422 assert put([good, {"id": "w1", "key": "stone_kind", "value": "없는 돌"}]).status_code == 422 assert put([good, {"id": "nope", "key": "back_len_cm", "value": 1}]).status_code == 422 assert put([good], revision=0).status_code == 409 assert files() == before def test_B05_가_바꾼_칸은_알림_뒤_저장하면_손_표에서_빠진다() -> None: from B08_Quantity.B08_Quantity_Engine_StructureSummary import apply_edits, prune_marks walls = [dict(w, options=dict(w["options"])) for w in WALLS] types = structure_type_map() changed, marks = apply_edits( walls, [], types, [{"id": "w1", "key": "back_len_cm", "value": 55}], {} ) assert changed == ["w1"] and marks == {"w1": {"back_len_cm": {"value": 55, "was": 35}}} walls[0]["options"]["back_len_cm"] = 45 # B05 가 바꿈 assert _tables_from(walls, marks)["masonry_wet"]["rows"][1]["replaced"] == ["뒷길이"] assert prune_marks(marks, walls, []) == {} # B05 가 바꾼 뒤 다시 고치면 「고치기 전」은 B05 값(45). _, again = apply_edits( walls, [], types, [{"id": "w1", "key": "back_len_cm", "value": 60}], marks ) assert again["w1"]["back_len_cm"] == {"value": 60, "was": 45} def _tables_from(walls: list[dict], marks: dict) -> dict: body = build_summary(walls, [], structure_type_map(), {}, {}, marks) return {table["type_id"]: table for table in body["tables"]}