- 칸 조작은 캐시(sessionStorage)에, [저장] 때 structures.json·pipe_points.json 에 바로 씀 - 산출 조건에 손댄 칸 표(고치기 전 값) · ↺ 로 되돌리면 자동으로 돌아감 - 상세 칸만 고침 — 자리·길이·높이·관경은 시·종점과 한 벌이라 구조물 놓기(B05) 몫 - 틀린 값·놓기 칸·판번호 어긋남은 아무것도 안 씀 · 값 꼴은 구조물도 제원 저장과 같음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
259 lines
11 KiB
Python
259 lines
11 KiB
Python
"""구조물 집계표 (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"]}
|