Files
Aislo/resources/tester/test_b08_structure_sheet_router.py
T
eomsangdonandClaude Opus 5 cf0ccfac36 feat(b08): 구조물도 줄마다 반올림 칸 — 엑셀 함수 이름을 함께, 원단위도 m당 반올림 × 연장
- 식 칸 옆 반올림 고르개·자리수 · 고르개에 「버림 — 엑셀 ROUNDDOWN」처럼 엑셀 이름과 음수 보기
- 고친 반올림은 고친 식과 같은 자리(양식 + 프로젝트)에 저장 · 양식과 같으면 지움 · [양식대로]로 식·반올림 함께 되돌림
- build_table 도 양식을 m당(L=1)으로 풀고 연장을 곱함 — 실무 시트처럼 뒷줄이 반올림한 값을 보고 구조물도와 안 갈림
- 「대안 후보」(물구멍 2.5㎡ 등) 표 밑에 보임 · UI 700줄 넘어 식 칸 표를 _Formula.ts 로 뗌
- 시험 2개 추가 · 전체 1533 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:47:35 +09:00

299 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""구조물도 창구(B08) — B07 에서 옮긴 뒤 조회·제원 저장이 한 벌로 도는지 (2026-09-13, PLAN 3장 ②).
겨누는 것 셋
① 조회가 장을 냄 — 옛 B07 `/standard-sheets` 자리
② ⚠ 기초잡석 두께가 **산출 조건 값을 따름** — 옛 B07 창구는 두께를 안 넘겨 늘 0.2 로 섰음
(원단위 탭과 구조물도가 같은 구조물에서 다른 값을 냈음)
③ 제원 저장이 그 장의 개소에 걸리고, 다시 조회하면 새 제원으로 섬
"""
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
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
PROJECT_ID = "22222222-2222-2222-2222-222222222222"
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
@pytest.fixture()
def project(tmp_path: Path) -> Path:
root = tmp_path / "project"
root.mkdir()
wall = StructureInstance.model_validate(
{
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 100.0,
"end_m": 110.0,
"options": {"height_m": 2.5, "back_len_cm": 45},
}
)
save_structures(str(root), [wall], base_revision=0)
return root
@pytest.fixture()
def client(project: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
async def fake_root(project_id):
return str(project)
async def no_route(project_id):
return {}
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(material_module, "_section_modes", no_route)
monkeypatch.setattr(material_module, "_ground_types", no_route)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _sheet(client: TestClient) -> dict:
response = client.get(SHEETS)
assert response.status_code == 200, response.text
sheets = response.json()["sheets"]
assert len(sheets) == 1, sheets
return sheets[0]
def _unit_amount(sheet: dict, name: str) -> float:
return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name)
def test_조회가_장을_낸다(client: TestClient) -> None:
sheet = _sheet(client)
assert sheet["type_id"] == "masonry_wet"
assert sheet["member_count"] == 1
assert sheet["billing_unit"] == "m" and sheet["billing_total"] == pytest.approx(10.0)
assert all("spec" in row for row in sheet["rows"])
def test_양식이_있는_종류는_줄마다_식을_싣는다(client: TestClient) -> None:
"""PLAN 3장 ④ · 명세 13장 — 찰쌓기는 양식으로 줄이 서고 식·설명·갈 곳·출처가 실림."""
sheet = _sheet(client)
assert sheet["library_item"]["type_id"] == "masonry_wet"
rows = {row["name"]: row for row in sheet["rows"]}
돌쌓기 = rows["돌쌓기"]
assert 돌쌓기["formula"] == "H*L*SQRT(1+N^2)"
assert "비탈면적" in 돌쌓기["basis"]
assert 돌쌓기["source"] == "library" and 돌쌓기["destination"] == "unit_price"
assert all(row["destination"] for row in sheet["rows"]) # 갈 곳 빈 줄 없음
# 돌 줄은 이름 고정 — 종류를 안 골라 규격이 빔(매칭 성공 아님, 명세 13장 Ⓒ).
assert rows["돌"]["spec"] == ""
# m당 값 — 제원(H=2.5·뒷길이 45·1:0.3)으로 다시 셈한 값.
assert 돌쌓기["unit_amount"] == pytest.approx(2.5 * (1 + 0.3**2) ** 0.5)
def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None:
"""버림을 「안 넣음」으로 저장하면 버림·기초잡석이 0 이 아니라 「안 섬」으로 옴."""
sheet = _sheet(client)
saved = client.put(
f"{SHEETS}/spec",
json={"sheet_key": sheet["key"], "base_revision": 1, "blinding_concrete": "안 넣음"},
)
assert saved.status_code == 200, saved.text
rows = {row["name"]: row for row in _sheet(client)["rows"]}
for name in ("버림콘크리트", "기초잡석"):
assert rows[name]["skipped"] is True and rows[name]["unit_amount"] is None, rows[name]
assert rows[name]["reason"], rows[name]
def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다(
client: TestClient, project: Path
) -> None:
"""PLAN 3장 ⑤ — 프로젝트 장 단위 저장 · 값은 서버가 다시 냄 · 고친 줄은 출처 user."""
sheet = _sheet(client)
rows = {row["name"]: row for row in sheet["rows"]}
원래 = rows["모르터"]
saved = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": "A*0.018"}]},
)
assert saved.status_code == 200, saved.text
assert saved.json()["changed"] == 1 and saved.json()["errors"] == []
after = {row["name"]: row for row in _sheet(client)["rows"]}
assert after["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"] * 2)
assert after["모르터"]["source"] == "user"
assert after["모르터"]["default_formula"] == 원래["formula"]
assert after["돌쌓기"]["source"] == "library" # 안 고친 줄은 그대로 양식
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
# 장 이름이 아니라 양식(type_id)에 묶임 — 브레인 판정(PLAN 3장 ⑤ ⓒ).
assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == {
str(원래["no"]): {"formula": "A*0.018"}
}
# 원단위·자재총괄(build_table)도 같은 고친 식을 봄 — 구조물도와 안 갈림.
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
structures, names, _ = _collect_structures(str(project))
table = build_table(
structures,
names,
None,
None,
None,
structure_formulas=stored["quantity"]["structure_formula_overrides"],
)
mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터")
assert mortar["amount"] == pytest.approx(after["모르터"]["unit_amount"] * 10.0)
assert mortar["source"] == "user" and "사용자 식" in mortar["basis"]
# 되돌리기 — 빈 식이면 양식 식으로, 저장 칸에서도 지워짐.
reverted = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": 원래["no"], "formula": None}]},
)
assert reverted.status_code == 200 and reverted.json()["changed"] == 1
back = {row["name"]: row for row in _sheet(client)["rows"]}
assert back["모르터"]["unit_amount"] == pytest.approx(원래["unit_amount"])
assert back["모르터"]["source"] == "library"
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"] == {}
def test_줄마다_반올림을_고치면_m당에서_반올림하고_뒷줄이_그_값을_본다(
client: TestClient, project: Path
) -> None:
"""PLAN 3장 반올림 칸 — 실무 구조물도처럼 m당 값을 반올림 · 원단위는 m당 × 연장."""
sheet = _sheet(client)
rows = {row["name"]: row for row in sheet["rows"]}
area = rows["돌쌓기"]["unit_amount"] # 2.5 × √1.09 ≈ 2.61
saved = client.put(
f"{SHEETS}/formulas",
json={
"sheet_key": sheet["key"],
"rows": [
{
"seq": rows["돌쌓기"]["no"],
"formula": None,
"rounding": {"mode": "floor", "digits": 0},
}
],
},
)
assert saved.status_code == 200 and saved.json()["changed"] == 1, saved.text
after = {row["name"]: row for row in _sheet(client)["rows"]}
assert after["돌쌓기"]["unit_amount"] == pytest.approx(2.0) and area > 2.5
assert after["돌쌓기"]["source"] == "user"
assert after["돌쌓기"]["formula"] == rows["돌쌓기"]["formula"] # 식은 그대로
assert after["돌쌓기"]["default_rounding"] == {"mode": "none", "digits": 0}
# 뒷줄(모르터 = A × 0.009)이 반올림한 A 를 봄.
assert after["모르터"]["unit_amount"] == pytest.approx(2.0 * 0.009)
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == {
str(rows["돌쌓기"]["no"]): {"rounding": {"mode": "floor", "digits": 0}}
}
# 원단위(build_table) = m당 반올림 값 × 연장 10m — 합계를 반올림하지 않음.
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
structures, names, _ = _collect_structures(str(project))
table = build_table(
structures, names, structure_formulas=stored["quantity"]["structure_formula_overrides"]
)
parts = {c["name"]: c for c in table["structures"][0]["components"]}
assert parts["돌쌓기"]["amount"] == pytest.approx(20.0)
assert parts["모르터"]["amount"] == pytest.approx(0.18)
assert parts["돌쌓기"]["basis"] == "사용자 반올림 = INT 0자리 (양식 안 함)"
# 되돌리기 — 반올림 null 이면 양식대로, 저장 칸에서도 지워짐.
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": rows["돌쌓기"]["no"], "rounding": None}]},
)
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"] == {}
# 모르는 갈래는 받지 않음.
bad = client.put(
f"{SHEETS}/formulas",
json={
"sheet_key": sheet["key"],
"rows": [{"seq": 1, "rounding": {"mode": "bankers", "digits": 0}}],
},
)
assert bad.status_code == 422
def test_대안_후보를_싣는다(client: TestClient) -> None:
candidates = {item["name"]: item for item in _sheet(client)["var_candidates"]}
assert candidates["HOLE_AREA"]["value"] == 2
assert candidates["HOLE_AREA"]["candidates"][0]["value"] == 2.5
def test_제원을_고쳐도_고친_식이_따라간다(client: TestClient) -> None:
"""고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음."""
sheet = _sheet(client)
seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터")
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.018"}]},
)
spec = client.put(
f"{SHEETS}/spec",
json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"},
)
assert spec.status_code == 200, spec.text
after = _sheet(client)
assert after["key"] != sheet["key"]
mortar = next(row for row in after["rows"] if row["name"] == "모르터")
assert mortar["source"] == "user" and mortar["formula"] == "A*0.018"
def test_틀린_식도_막지_않고_저장하되_오류를_돌려준다(client: TestClient) -> None:
sheet = _sheet(client)
seq = next(row["no"] for row in sheet["rows"] if row["name"] == "모르터")
saved = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*없는칸"}]},
)
assert saved.status_code == 200, saved.text
assert any("모르터" in error and "모르는 이름" in error for error in saved.json()["errors"])
bad = client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": 999, "formula": "1"}]},
)
assert bad.status_code == 400
def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None:
기본 = _unit_amount(_sheet(client), "기초잡석")
(project / "project_settings.json").write_text(
json.dumps({"quantity": {"rubble_base_thickness_m": 0.3}}), encoding="utf-8"
)
바꿈 = _unit_amount(_sheet(client), "기초잡석")
# 기본 0.2 → 0.3 — 버림 폭에 두께 비를 곱하는 식이라 1.5 배.
assert 바꿈 == pytest.approx(기본 * 1.5)
def test_제원_저장이_그_장에_걸린다(client: TestClient) -> None:
sheet = _sheet(client)
saved = client.put(
f"{SHEETS}/spec",
json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"},
)
assert saved.status_code == 200, saved.text
assert saved.json()["changed"] == 1
assert _sheet(client)["options"]["back_len_cm"] == 55
def test_없는_장이면_404(client: TestClient) -> None:
response = client.put(f"{SHEETS}/spec", json={"sheet_key": "없는 장", "base_revision": 1})
assert response.status_code == 404