From 0747979c5da395b84076b0d41b4b5aa24da4b0ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 23:02:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EA=B4=80=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=EA=B3=B5=20=EB=82=A0=EA=B0=9C=EB=B2=BD=20=EC=9B=90=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EB=8B=A4=EC=84=AF=20=E2=80=94=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=20=ED=83=AD=EC=97=90=EC=84=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EB=8A=94=20=EC=B9=B8=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계획서 4-13. 소광리 `07-구조도-소광리.xlsx` 다섯 탭의 **개소당 소계를 그대로** 옮김 (Ø800 A·C · Ø1000 A·C · Ø800 A+집수정). 비례로 늘리지 않음 — 그 규격에서만 맞는 값임. - 원단위표에 다섯 줄 추가. 거푸집 종류가 탭마다 갈려 이름을 뭉개지 않음 — Ø800 A·C 는 **유로폼**, Ø1000 과 A+집수정은 **합판거푸집(4회)**. - ⚠ 철근·면목 줄은 원본 날개벽 탭에 **없음**(집수정구조도 탭에만 있음). 지어내지 않음. - 등록부·배치 폼에 「관보호공 날개벽」 칸(A-TYPE·C-TYPE·A-TYPE+집수정). 비우면 안 셈. - ⚠ 겹쳐 세는 자리 — 「A-TYPE+집수정」은 집수정을 품은 형식이라 집수정 형식도 차 있으면 **사유로 알림**(값은 안 고침). 원단위표 `double_count_rules` 와 짝. ⓘ 집수정 Ø800 은 **이미 표에 있었음**(`pipe_inlet_basin` □형·콘크리트·800) — 다시 만들지 않음. 화면 실측 — 배수관 폼에 칸이 뜨고 선택지 셋 확인. 엔진 실행으로 날개벽 줄이 따로 서고 터파기 13.104 · 콘크리트 2.0595 · 유로폼 12.5376 이 나오는 것까지 봄. 시험 일곱 추가, 전체 1218 통과·실패 0. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 10 + .../B05_Profile_UI_Drainage_Facility.ts | 11 +- .../B08_Quantity_Engine_UnitQuantity.py | 29 +- .../structure_unit_observed_2026-01-01.json | 268 ++++++++++++++++++ resources/tester/test_b08_wing_wall.py | 119 ++++++++ 5 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_b08_wing_wall.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 5a45b7c5..6f76acd7 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -39,6 +39,16 @@ "default": "기슭막이", "required": false }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": ["A-TYPE", "C-TYPE", "A-TYPE+집수정"], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, { "key": "inlet_basin_form", "label": "집수정 형식", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a9f53b27..1caa2b97 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -220,7 +220,11 @@ export function createFacilityOptionsForm( ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), ); pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17) + // 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다 + // (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다. + const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]); const pipeRow = grid(labeled("관종", pipeMaterial), labeled("관경", pipeDiameter)); + const wingRow = grid(labeled("관보호공 날개벽", wingWall)); // ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ───────────────── const inletGroup = group("유입구"); @@ -441,6 +445,7 @@ export function createFacilityOptionsForm( // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( pipeRow, + wingRow, fordWidthRow, fordSlopeRow, fordRow, @@ -465,6 +470,8 @@ export function createFacilityOptionsForm( const isBox = current === "box_culvert"; // 관종·관경은 배수관과 세월교가 공유한다. pipeRow.hidden = !isPipe && current !== "ford_bridge"; + // 관보호공 날개벽 원단위는 **배수관 Ø800·Ø1000** 것뿐이다 — 다른 시설에서는 숨긴다. + wingRow.hidden = !isPipe; inletGroup.root.hidden = !isPipe; outletGroup.root.hidden = !isPipe; boxWrap.hidden = !isBox; @@ -520,7 +527,7 @@ export function createFacilityOptionsForm( callbacks.onChange?.(); } - [inletType, outletType, basinForm, basinMaterial, basinLength].forEach((input) => + [inletType, outletType, wingWall, basinForm, basinMaterial, basinLength].forEach((input) => input.addEventListener("change", emit), ); inletRevet.onChange(emit); @@ -594,6 +601,7 @@ export function createFacilityOptionsForm( ) ?? inletKindOf(savedType) ).label; outletType.value = text("outlet_type") || "기슭막이"; + wingWall.value = text("wing_wall_type"); basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); basinLength.value = text("inlet_basin_length_m") || "2"; @@ -648,6 +656,7 @@ export function createFacilityOptionsForm( options.pipe_diameter_mm = Number(pipeDiameter.value); options.pipe_kind = pipeMaterial.value; const inletKind = inletKindOf(inletType.value); + if (wingWall.value) options.wing_wall_type = wingWall.value; options.inlet_type = inletKind.type; // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). options.inlet_structure = inletKind.structure; diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d0a5c877..03a94a90 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -1141,6 +1141,9 @@ OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { "ford_pavement": ("thickness_cm",), # 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다. "pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"), + # 관보호공 날개벽 — **개소당**이고 치수가 형식마다 붙박이다(원본 탭 다섯). 형식을 고르지 + # 않으면 날개벽 줄 자체가 안 선다(안 놓은 것과 같다). + "pipe_wing_wall": ("wing_wall_type", "pipe_diameter_mm"), } EXPANDERS = { @@ -1216,9 +1219,27 @@ EXPANDER_WITHHELD: dict[str, str] = {} #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = { # (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리) - "pipe": (("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),), + "pipe": ( + ("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"), + ("pipe_wing_wall", "wing_wall_type", "관보호공 날개벽"), + ), } +#: 날개벽 「A-TYPE+집수정」은 집수정을 **품은** 형식이다 — 집수정 줄을 따로 세우면 한 개소를 +#: 두 번 센다(원단위표 `double_count_rules` 와 짝). +WING_WALL_WITH_BASIN = "A-TYPE+집수정" +WING_WALL_DOUBLE_COUNT = ( + "⚠ 날개벽을 「A-TYPE+집수정」으로 골랐는데 집수정 형식도 차 있음 — " + "그 날개벽 값이 집수정을 이미 품고 있어 **두 번 셀 수 있음**. 한쪽을 비울 것" +) + + +def wing_wall_double_count(options: dict[str, Any]) -> str | None: + """겹쳐 세는 자리면 사유 한 줄. 값을 고치지 않고 **드러내기만** 한다.""" + if str(options.get("wing_wall_type") or "") != WING_WALL_WITH_BASIN: + return None + return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None + def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" @@ -1255,7 +1276,11 @@ def _observed_components( from B08_Quantity.B08_Quantity_Wording import option_missing return [], [option_missing(keys[0], type_id)] - return expand_observed(type_id, spec, structure, observed) + components, notes = expand_observed(type_id, spec, structure, observed) + warning = wing_wall_double_count(options) + if warning: + notes.append(warning) + return components, notes def _observed_billing( diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index 4c48dd52..99660f72 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -256,6 +256,266 @@ "destination": "earthwork" } ] + }, + { + "type_id": "pipe_wing_wall", + "spec": { + "wing_wall_type": "A-TYPE", + "pipe_diameter_mm": "800" + }, + "unit": "개소", + "source": "uljin_library", + "source_note": "§1 관보호공 날개벽 — 원본 탭 「날개벽(800)A-TYPE산출근거」", + "components": [ + { + "name": "기초잡석", + "unit": "㎥", + "amount": 1.152, + "destination": "unit_price", + "basis_note": "T=0.2" + }, + { + "name": "콘크리트", + "unit": "㎥", + "amount": 2.0595, + "destination": "unit_price", + "basis_note": "저판 0.952 + 벽체 1.1075" + }, + { + "name": "유로폼", + "unit": "㎡", + "amount": 12.5376, + "destination": "unit_price", + "basis_note": "저판 1.92 + 벽체 10.6176 · 원본 「유로폼」" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 13.104, + "destination": "earthwork" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 6.504, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 6.61, + "destination": "earthwork" + } + ], + "note": "⚠ 철근·면목 줄이 **원본에 없다** — 날개벽 탭 다섯에 그 줄이 없고 집수정구조도 탭에만 D13·면목이 있다. 지어내지 않는다." + }, + { + "type_id": "pipe_wing_wall", + "spec": { + "wing_wall_type": "C-TYPE", + "pipe_diameter_mm": "800" + }, + "unit": "개소", + "source": "uljin_library", + "source_note": "§1 관보호공 날개벽 — 원본 탭 「날개벽(800)C-TYPE산출근거」", + "components": [ + { + "name": "기초잡석", + "unit": "㎥", + "amount": 0.6821, + "destination": "unit_price", + "basis_note": "T=0.2" + }, + { + "name": "콘크리트", + "unit": "㎥", + "amount": 1.109, + "destination": "unit_price", + "basis_note": "저판 0.4847 + 벽체 0.6243" + }, + { + "name": "유로폼", + "unit": "㎡", + "amount": 6.2309, + "destination": "unit_price", + "basis_note": "저판 1.4786 + 벽체 4.7523 · 원본 「유로폼」" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 7.7269, + "destination": "earthwork" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 4.7148, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 3.0122, + "destination": "earthwork" + } + ], + "note": "⚠ 철근·면목 줄이 **원본에 없다** — 날개벽 탭 다섯에 그 줄이 없고 집수정구조도 탭에만 D13·면목이 있다. 지어내지 않는다." + }, + { + "type_id": "pipe_wing_wall", + "spec": { + "wing_wall_type": "A-TYPE", + "pipe_diameter_mm": "1000" + }, + "unit": "개소", + "source": "uljin_library", + "source_note": "§1 관보호공 날개벽 — 원본 탭 「날개벽(1000)A-TYPE산출근거」", + "components": [ + { + "name": "기초잡석", + "unit": "㎥", + "amount": 1.6, + "destination": "unit_price", + "basis_note": "T=0.2" + }, + { + "name": "콘크리트", + "unit": "㎥", + "amount": 2.891, + "destination": "unit_price", + "basis_note": "저판 1.368 + 벽체 1.523" + }, + { + "name": "합판거푸집", + "unit": "㎡", + "amount": 18.255, + "destination": "unit_price", + "basis_note": "저판 2.24 + 벽체 16.015 · 원본 「거푸집 합판(4회)」" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 24.39, + "destination": "earthwork" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 10.478, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 13.912, + "destination": "earthwork" + } + ], + "note": "⚠ 철근·면목 줄이 **원본에 없다** — 날개벽 탭 다섯에 그 줄이 없고 집수정구조도 탭에만 D13·면목이 있다. 지어내지 않는다." + }, + { + "type_id": "pipe_wing_wall", + "spec": { + "wing_wall_type": "C-TYPE", + "pipe_diameter_mm": "1000" + }, + "unit": "개소", + "source": "uljin_library", + "source_note": "§1 관보호공 날개벽 — 원본 탭 「날개벽(1000)C-TYPE산출근거」", + "components": [ + { + "name": "기초잡석", + "unit": "㎥", + "amount": 1.0271, + "destination": "unit_price", + "basis_note": "T=0.2" + }, + { + "name": "콘크리트", + "unit": "㎥", + "amount": 1.9109, + "destination": "unit_price", + "basis_note": "저판 0.7736 + 벽체 1.1373" + }, + { + "name": "합판거푸집", + "unit": "㎡", + "amount": 10.722, + "destination": "unit_price", + "basis_note": "저판 1.9118 + 벽체 8.8102 · 원본 「거푸집 합판(4회)」" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 13.7179, + "destination": "earthwork" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 7.9134, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 5.8046, + "destination": "earthwork" + } + ], + "note": "⚠ 철근·면목 줄이 **원본에 없다** — 날개벽 탭 다섯에 그 줄이 없고 집수정구조도 탭에만 D13·면목이 있다. 지어내지 않는다." + }, + { + "type_id": "pipe_wing_wall", + "spec": { + "wing_wall_type": "A-TYPE+집수정", + "pipe_diameter_mm": "800" + }, + "unit": "개소", + "source": "uljin_library", + "source_note": "§1 관보호공 날개벽 — 원본 탭 「날개벽(800)A-TYPE+집수정산출근거」", + "components": [ + { + "name": "기초잡석", + "unit": "㎥", + "amount": 1.152, + "destination": "unit_price", + "basis_note": "T=0.2" + }, + { + "name": "콘크리트", + "unit": "㎥", + "amount": 2.6395, + "destination": "unit_price", + "basis_note": "저판 0.952 + 벽체 1.6875" + }, + { + "name": "합판거푸집", + "unit": "㎡", + "amount": 18.3376, + "destination": "unit_price", + "basis_note": "저판 1.92 + 벽체 16.4176 · 원본 「거푸집 합판(4회)」" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 16.256, + "destination": "earthwork" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 8.44, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 7.816, + "destination": "earthwork" + } + ], + "note": "⚠ 철근·면목 줄이 **원본에 없다** — 날개벽 탭 다섯에 그 줄이 없고 집수정구조도 탭에만 D13·면목이 있다. 지어내지 않는다." } ], "not_found": { @@ -299,6 +559,14 @@ "why": "물구멍을 자재 줄로 세우면 규격·수량이 눈에 보이고 되짚을 수 있다. 제잡비 윗단은 같은 것을 품 안에 녹이는 다른 방식이라 어느 쪽이든 하나만 골라야 한다.", "scope": "⚠ 지금 쓰는 돌쌓기(13-4 계열)에는 **제잡비 행 자체가 없어** 겹치지 않는다(전수 확인). 이 규칙은 13-6·13-7 을 쓰게 될 때 걸린다.", "guard": "제잡비 윗단과 물구멍 줄이 함께 서면 멈출 것 — B09 ㉥ 가드와 짝." + }, + { + "key": "날개벽 「A-TYPE+집수정」 ↔ 유입부 집수정 줄", + "where": "원본 탭 「날개벽(800)집수정산출근거」", + "quote": "A-TYPE 날개벽에 집수정을 **결합한** 형식의 개소당 물량(터파기 16.256㎥ 등)", + "our_choice": "이 형식을 고르면 **집수정 줄을 따로 세우지 않는다** — 날개벽 값이 이미 품고 있다.", + "why": "따로 세우면 한 개소를 두 번 센다(터파기·콘크리트·거푸집 전부).", + "guard": "`wing_wall_type` 이 「A-TYPE+집수정」인데 `inlet_basin_form` 도 차 있으면 사유로 알린다." } ] }, diff --git a/resources/tester/test_b08_wing_wall.py b/resources/tester/test_b08_wing_wall.py new file mode 100644 index 00000000..a8d0bd61 --- /dev/null +++ b/resources/tester/test_b08_wing_wall.py @@ -0,0 +1,119 @@ +"""관보호공 날개벽 원단위 — 소광리 원본 다섯 탭에서 옮긴 값 (2026-09-09, 계획서 4-13). + +값은 **개소당·붙박이**다. 원본 탭(`07-구조도-소광리.xlsx`)의 소계를 그대로 옮겼고 +비례로 늘리지 않는다 — Ø800·Ø1000, A·C-TYPE, 그리고 「A-TYPE+집수정」 다섯뿐이다. + +⚠ 겹쳐 세는 자리 하나 — 「A-TYPE+집수정」은 집수정을 **품은** 형식이라 집수정 줄을 따로 + 세우면 한 개소를 두 번 센다. 값을 고치지 않고 **사유로 드러낸다.** +⚠ 철근·면목 줄은 원본 날개벽 탭에 **없다**(집수정구조도 탭에만 있음) — 지어내지 않는다. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + ATTACHMENTS, + OBSERVED_SPEC_KEYS, + attachments_of, + wing_wall_double_count, +) + + +def _registry_choices() -> list[str]: + payload = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + ) + types = payload if isinstance(payload, list) else payload["types"] + pipe = next(item for item in types if item["type_id"] == "pipe") + option = next(o for o in pipe["options"] if o["key"] == "wing_wall_type") + return list(option["choices"]) + + +def test_다섯_형식이_표에_있다() -> None: + table = load_observed_table() + specs = { + (spec.get("wing_wall_type"), spec.get("pipe_diameter_mm")) + for spec in table.specs_for("pipe_wing_wall") + } + assert specs == { + ("A-TYPE", "800"), + ("C-TYPE", "800"), + ("A-TYPE", "1000"), + ("C-TYPE", "1000"), + ("A-TYPE+집수정", "800"), + } + + +def test_원본_소계와_같다() -> None: + """Ø800 A-TYPE — 원본 탭 소계(터파기 13.104 · 저판 0.952 + 벽체 1.1075 …).""" + found = load_observed_table().find( + "pipe_wing_wall", {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "800"} + ) + assert found is not None + amounts = {c["name"]: c["amount"] for c in found["components"]} + assert amounts["터파기"] == 13.104 + assert amounts["되메우기"] == 6.504 + assert amounts["잔토처리"] == 6.61 + assert amounts["기초잡석"] == 1.152 + assert abs(amounts["콘크리트"] - (0.952 + 1.1075)) < 1e-9 + assert abs(amounts["유로폼"] - (1.92 + 10.6176)) < 1e-9 + + +def test_거푸집_종류가_탭마다_다르다() -> None: + """Ø800 A 는 유로폼, Ø1000 은 합판(4회) — 단가가 갈리므로 이름을 뭉개지 않는다.""" + table = load_observed_table() + small = table.find("pipe_wing_wall", {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "800"}) + big = table.find("pipe_wing_wall", {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "1000"}) + assert "유로폼" in [c["name"] for c in small["components"]] + assert "합판거푸집" in [c["name"] for c in big["components"]] + + +def test_철근_면목은_안_지어낸다() -> None: + table = load_observed_table() + for spec in table.specs_for("pipe_wing_wall"): + found = table.find("pipe_wing_wall", spec) + names = [c["name"] for c in found["components"]] + assert not any("철근" in name or "면목" in name for name in names), spec + + +def test_형식을_고르면_줄이_선다() -> None: + rows = attachments_of( + { + "structure_id": "s1", + "type_id": "pipe", + "options": {"wing_wall_type": "A-TYPE", "pipe_diameter_mm": "800"}, + } + ) + assert [row["type_id"] for row in rows] == ["pipe_wing_wall"] + # 안 고르면 줄이 없다 — 안 놓은 것과 같다. + assert attachments_of({"structure_id": "s2", "type_id": "pipe", "options": {}}) == [] + + +def test_집수정_결합형은_두_번_세는_것을_알린다() -> None: + assert wing_wall_double_count({"wing_wall_type": "A-TYPE+집수정"}) is None + warn = wing_wall_double_count( + {"wing_wall_type": "A-TYPE+집수정", "inlet_basin_form": "□형(기본형)"} + ) + assert warn is not None and "두 번" in warn + assert ( + wing_wall_double_count({"wing_wall_type": "A-TYPE", "inlet_basin_form": "□형(기본형)"}) + is None + ) + + +def test_등록부_선택지와_표가_한_벌이다() -> None: + """칸에서 고를 수 있는데 표에 없으면 「원단위 미확보」로 끝난다 — 둘을 붙여 둔다.""" + table_types = { + spec.get("wing_wall_type") for spec in load_observed_table().specs_for("pipe_wing_wall") + } + assert set(_registry_choices()) == table_types + assert OBSERVED_SPEC_KEYS["pipe_wing_wall"] == ("wing_wall_type", "pipe_diameter_mm") + assert any(item[0] == "pipe_wing_wall" for item in ATTACHMENTS["pipe"])