From a8a1471d716539824874ce6bf33d866ea3092c66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 12:02:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05,b06):=20=EC=82=AC=EB=A9=B4=EC=95=88?= =?UTF-8?q?=EC=A0=95=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=C2=B7=20=EC=98=B9?= =?UTF-8?q?=EB=B2=BD=20=EC=B6=94=EA=B0=80=20=EB=8B=A8=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A8=EB=A7=88=EB=8B=A4=20=ED=98=95=ED=83=9C=20?= =?UTF-8?q?=C2=B7=20=EB=86=92=EC=9D=B4=20=ED=9A=A1=EB=8B=A8=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20(178-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추가 단 상자 = 유출 흙막이 상자 양식 · 공용 [추가] · [빼기] · 흙막이 형태 · 높이 · 옹벽 높이(콘크리트) · 키 tier_{n}_* - 정의 그룹 종류 덧붙임 명세(ADDON_SPECS) — 좌측 폼만 · 그룹 상자 · 목록 · M02 그대로 - 벽 제원 짝(TS tierExtras · 파이썬 tier_extras) · 단 수 = 1 + 상자 수 · 횡단 단 조정 m · h - 새 시험 test_178_3_slope_tiers · walls_mirror extras 대조 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- .../design/design_structure_slope_tier.ts | 34 +++++ B05_Profile/B05_Profile_UI_Structures_Spec.ts | 6 +- .../B06_Section_Engine_Structures_Wall.py | 32 ++++- B06_Section/B06_Section_UI_Cross_Revetment.ts | 9 +- common_util/common_util_structure_walls.ts | 31 +++- resources/tester/helper_178_3_slope_tiers.cjs | 112 +++++++++++++++ resources/tester/test_178_3_slope_tiers.py | 135 ++++++++++++++++++ .../tester/test_b06_structure_walls_mirror.py | 39 ++++- 8 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 A00_Common/design/design_structure_slope_tier.ts create mode 100644 resources/tester/helper_178_3_slope_tiers.cjs create mode 100644 resources/tester/test_178_3_slope_tiers.py diff --git a/A00_Common/design/design_structure_slope_tier.ts b/A00_Common/design/design_structure_slope_tier.ts new file mode 100644 index 000000000..eba011102 --- /dev/null +++ b/A00_Common/design/design_structure_slope_tier.ts @@ -0,0 +1,34 @@ +/* ============================================================================= + * design_structure_slope_tier.ts + * 사면안정 흙막이 · 옹벽 「추가 단」 상자(178-3) — 배관 유출 흙막이 상자 양식(`design_structure_guard`) + * 재사용 · 공용 [추가] · [빼기] · 상자 수 = 추가 단. + * + * · 흙막이 = 형태 · 높이 · 옹벽 = 높이만(형태 = 콘크리트) · 저장 = structures.json `options` 키 `tier_{n}_*` + * · 정의 그룹 종류에 덧붙임 — 좌측 폼 선택 컨테이너만(그룹 상자 · 목록 · 벌룬 · M02 는 정의 그룹 그대로 · + * `STRUCTURE_SPECS` 에 안 넣음) + * · 단 그림 = `common_util_structure_walls`(`tierExtras` · 파이썬 짝) → 횡단 다단 + * import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecContainer, SpecItem, StructureSpec } from "./design_structure_spec"; +import { guardItems } from "./design_structure_guard"; + +const TIER_ITEMS = guardItems().filter((item) => item.id === "form" || item.id === "height"); + +const tierAdd = (items: SpecItem[]): SpecContainer => ({ + id: "tier", + title: "추가 단", + required: false, + prefix: "tier", + link: "gtier", + items, +}); + +export const ADDON_SPECS: Readonly> = { + soil_guard: { typeId: "soil_guard", containers: [tierAdd(TIER_ITEMS)], retired: [] }, + retaining_wall: { + typeId: "retaining_wall", + containers: [tierAdd(TIER_ITEMS.filter((item) => item.id === "height"))], + retired: [], + }, +}; diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index ff2ad8b7e..a9ab220df 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -16,6 +16,7 @@ import { type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; @@ -24,9 +25,10 @@ const forms = new WeakMap(); /** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */ const widenings = new WeakMap(); -/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */ +/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */ export const structureSpecOf = (type: StructureType | null): StructureSpec | null => - (type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null; + (type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) || + null; /** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ export function createStructureSpecForm( diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 1343e98bf..af7ae6052 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -17,6 +17,8 @@ """ import logging +import math +import re from pathlib import Path from typing import Any @@ -39,6 +41,31 @@ _FORM_BY_TYPE = { } +# 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`. +_TIER_KEY = re.compile(r"^tier_(\d+)_") + + +def _num(value: Any) -> float | int | None: + """유한한 수만(TS `num` 짝 — 글 · 참거짓은 None).""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if math.isfinite(value) else None + + +def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]: + """「추가 단」 상자 → 추가 단 [{형태 · 높이}](n 차례) — 옹벽 = 콘크리트 · TS `tierExtras` 짝.""" + ns = sorted({int(m.group(1)) for key in options if (m := _TIER_KEY.match(key))}) + return [ + { + "form": "콘크리트" + if type_id == "retaining_wall" + else (options.get(f"tier_{n}_form") or None), + "height_m": _num(options.get(f"tier_{n}_height")), + } + for n in ns + ] + + # 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). BERM_TYPE_ID = "berm" @@ -60,6 +87,7 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: if start is None or end is None: continue options = structure.options or {} + extras = tier_extras(structure.type_id, options) found.append( { "structure_id": structure.structure_id, @@ -76,7 +104,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). "foundation": options.get("foundation"), - "tiers": options.get("tiers"), + # 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3) + "tiers": len(extras) + 1 if extras else options.get("tiers"), + "extras": extras, "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), # 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키). diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 0c802f065..db6536233 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -56,6 +56,8 @@ export interface RevetmentSpec { foundation?: string | null; /** 단 수(다단). 1이면 단일 벽. */ tiers?: number | null; + /** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3). 빈 값 = 본 벽 형태 · 기본 높이. */ + extras?: ReadonlyArray<{ form: string | null; height_m: number | null }> | null; /** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */ lift_m?: number | null; shift_m?: number | null; @@ -254,7 +256,12 @@ export function computeRevetmentLayout( outward, groundAt, limitOffset: limit, - adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })), + // 단마다 상자 형태 · 높이(178-3) — 자리(x · d)는 다단 자동 규칙 그대로 + adjusts: Array.from({ length: requestedTiers - 1 }, (_, i) => ({ + ...ZERO_ADJUST, + m: spec.extras?.[i]?.form ?? null, + h: spec.extras?.[i]?.height_m ?? null, + })), // 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로 // 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우). ownerForm: spec.form, diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 435e33c4a..8e1d65f49 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -35,6 +35,8 @@ export interface WallSpec { /** 기초 축 — "기초유" | "기초버림". 저장 칸과 같은 글자(터파기 그림이 이 값으로 갈린다). */ foundation: string | null; tiers: number | null; + /** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3 · `tier_{n}_*`). 없으면 빈 목록. */ + extras: WallTier[]; lift_m: number | null; shift_m: number | null; /** 전면 기울기 판정 칸 — 성토/절토 · 전면 기울기(저장 원본 · B08 과 같은 칸, 2026-09-14 B5). */ @@ -42,6 +44,12 @@ export interface WallSpec { face_slope_ratio: number | string | null; } +/** 추가 단 한 벌 — 형태 빈 값 = 본 벽 형태 · 높이 빈 값 = 기본 단 높이(그리는 쪽). */ +export interface WallTier { + form: string | null; + height_m: number | null; +} + /** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */ const EDGE_TOLERANCE_M = 0.02; @@ -67,6 +75,24 @@ export const FORM_BY_TYPE: Record = { const num = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; +const TIER_KEY = /^tier_(\d+)_/; + +/** 「추가 단」 상자(`tier_{n}_*` · n 차례) → 추가 단 목록 — 옹벽 = 콘크리트(178-3) · 파이썬 `tier_extras` 짝. */ +export function tierExtras(typeId: string, options: Record): WallTier[] { + const ns = [ + ...new Set( + Object.keys(options) + .map((key) => TIER_KEY.exec(key)?.[1]) + .filter((n): n is string => !!n) + .map(Number), + ), + ].sort((a, b) => a - b); + return ns.map((n) => ({ + form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null, + height_m: num(options[`tier_${n}_height`]), + })); +} + /** * C군 벽 구조물을 제원 목록으로 바꾼다. 이름표(`names`)는 타입 레지스트리에서 온다. * 구간(start·end)이 없는 항목은 건너뛴다 — 벽은 구간형이다. @@ -83,6 +109,7 @@ export function wallSpecsFrom( const end = num(structure.end_m); if (start === null || end === null) continue; const options = (structure.options ?? {}) as Record; + const extras = tierExtras(structure.type_id, options); specs.push({ structure_id: structure.structure_id ?? null, type_id: structure.type_id, @@ -95,7 +122,9 @@ export function wallSpecsFrom( side: (options.side as string) ?? null, // 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다. foundation: (options.foundation as string) ?? null, - tiers: num(options.tiers), + // 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3) + tiers: extras.length ? extras.length + 1 : num(options.tiers), + extras, lift_m: num(options.lift_m), shift_m: num(options.shift_m), face_role: (options.face_role as string) ?? null, diff --git a/resources/tester/helper_178_3_slope_tiers.cjs b/resources/tester/helper_178_3_slope_tiers.cjs new file mode 100644 index 000000000..4c155ed27 --- /dev/null +++ b/resources/tester/helper_178_3_slope_tiers.cjs @@ -0,0 +1,112 @@ +/* 178-3 사면안정 흙막이 · 옹벽 「추가 단」 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * ① 명세 상자 저장 왕복(readState → writeOptions) ② 구조물 정본 → 벽 제원(`wallSpecsFrom`) → 횡단 형상 + * (`computeRevetmentLayout`) 단 수 · 단마다 형태 · 높이. 입력 측점 = 73-4 픽스처(helper_79 와 같은 180 측점). + * 결과 = 표준출력 JSON 한 벌 · 짝 = `test_178_3_slope_tiers.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const design = (f) => path.join(ROOT, "A00_Common", "design", f); +const spec = loadTs(design("design_structure_spec.ts")); +const { ADDON_SPECS } = loadTs(design("design_structure_slope_tier.ts")); +const { STRUCTURE_SPECS } = loadTs(design("design_structure_pipe.ts")); +const { wallSpecsFrom } = loadTs(path.join(ROOT, "common_util", "common_util_structure_walls.ts")); +const { computeRevetmentLayout } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Revetment.ts"), +); + +const r3 = (v) => Math.round(v * 1000) / 1000; +const out = {}; + +// ① 저장 왕복 — 상자 둘(흙막이) · 하나(옹벽) · 빈 상자(값 없이 추가만) +const soil = ADDON_SPECS.soil_guard; +const wall = ADDON_SPECS.retaining_wall; +const soilOptions = { + form: "돌쌓기(찰)", + height_m: 2, + tier_1_form: "콘크리트", + tier_1_height: 2, + tier_2_form: "돌망태", + tier_2_height: 1.2, +}; +const soilState = spec.readState(soil, soilOptions); +out.roundTrip = { + items: Object.fromEntries( + Object.entries(ADDON_SPECS).map(([k, s]) => [k, s.containers[0].items.map((i) => i.id)]), + ), + soilState: soilState.tier, + soilBack: spec.writeOptions(soil, soilState), + wallBack: spec.writeOptions(wall, { tier: [{ height: "1.8" }] }), + emptyBox: spec.writeOptions(soil, { tier: [{}] }), + notMainSpecs: Object.keys(ADDON_SPECS).filter((k) => k in STRUCTURE_SPECS), +}; + +// ② 형상 — 정본 → 벽 제원 → 횡단 단 +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const base = fixture.cross_sections.find((s) => Math.abs(s.chainage_m - 180) < 0.6); +const names = new Map([ + ["soil_guard", "흙막이"], + ["retaining_wall", "옹벽"], +]); +function shape(typeId, options) { + const [wallSpec] = wallSpecsFrom( + [{ structure_id: "t", type_id: typeId, chainage_m: 180, start_m: 170, end_m: 190, options }], + names, + ); + const section = JSON.parse(JSON.stringify(base)); + section.revetment = { ...wallSpec, side: "우" }; + const layout = computeRevetmentLayout(section, null); + return { + tiers: wallSpec.tiers, + extras: wallSpec.extras, + requested: layout?.requestedTiers ?? null, + walls: (layout?.walls ?? []).map((w) => ({ form: w.form, pure: r3(w.height + 0.5) })), + }; +} +out.soil2 = shape("soil_guard", soilOptions); +out.wall1 = shape("retaining_wall", { height_m: 2.5, tier_1_height: 1.8 }); +out.emptyBox = shape("soil_guard", { form: "돌망태", height_m: 2, tier_1_form: "" }); +out.legacy = shape("soil_guard", { form: "돌망태", height_m: 2, tiers: 2 }); +out.none = shape("retaining_wall", { height_m: 2.5 }); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_178_3_slope_tiers.py b/resources/tester/test_178_3_slope_tiers.py new file mode 100644 index 000000000..85fc6bbdf --- /dev/null +++ b/resources/tester/test_178_3_slope_tiers.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +"""178-3 사면안정 흙막이 · 옹벽 「추가 단」 상자 → 여러 단 횡단 형상. + +못박는 것 + - 상자 = 유출 흙막이 상자 양식(공용 [추가] · [빼기]) · 흙막이 = 형태 · 높이 · 옹벽 = 높이만 · 키 `tier_{n}_*`. + - 명세 저장 왕복 그대로 · 빈 상자도 한 단으로 남음 · 정의 그룹 종류 그대로(`STRUCTURE_SPECS` 에 안 넣음). + - 서버 저장(structures.json) → 벽 제원(파이썬) 단 수 = 1 + 상자 수 · 단마다 형태 · 높이 · 옹벽 단 = 콘크리트. + - 횡단 형상(진짜 `computeRevetmentLayout`) — 단 수 · 단마다 형태 · 높이(빈 값 = 본 벽 형태 · 1.5 m) · 옛 `tiers` 그대로. + +Node 헬퍼 `helper_178_3_slope_tiers.cjs`. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +SOIL = { + "form": "돌쌓기(찰)", + "length_m": 20, + "height_m": 2, + "before_m": 10, + "after_m": 10, + "tier_1_form": "콘크리트", + "tier_1_height": 2, + "tier_2_form": "돌망태", + "tier_2_height": 1.2, +} + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_178_3_slope_tiers.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_상자_칸_흙막이_형태_높이_옹벽_높이(out): + assert out["roundTrip"]["items"] == { + "soil_guard": ["form", "height"], + "retaining_wall": ["height"], + } + assert out["roundTrip"]["notMainSpecs"] == [] + + +def test_명세_저장_왕복(out): + trip = out["roundTrip"] + assert trip["soilState"] == [ + {"form": "콘크리트", "height": "2"}, + {"form": "돌망태", "height": "1.2"}, + ] + assert trip["soilBack"] == { + "tier_1_form": "콘크리트", + "tier_1_height": 2, + "tier_2_form": "돌망태", + "tier_2_height": 1.2, + } + assert trip["wallBack"] == {"tier_1_height": 1.8} + assert trip["emptyBox"] == {"tier_1_form": ""} # 값 없이 추가만 해도 단 하나 + + +def test_서버_저장_뒤_벽_제원(tmp_path): + from B05_Profile.B05_Profile_Structures_Repository import save_structures + from B06_Section import B06_Section_Engine_Structures_Wall as wall + + items = [ + StructureInstance( + type_id="soil_guard", + placement="interval", + chainage_m=180, + start_m=170, + end_m=190, + options=SOIL, + ), + StructureInstance( + type_id="retaining_wall", + placement="interval", + chainage_m=300, + start_m=290, + end_m=310, + options={"height_m": 2.5, "tier_1_height": 1.8}, + ), + ] + save_structures(str(tmp_path), items, base_revision=0) + got = {spec["type_id"]: spec for spec in wall.load_wall_structures(tmp_path)} + assert got["soil_guard"]["tiers"] == 3 + assert got["soil_guard"]["extras"] == [ + {"form": "콘크리트", "height_m": 2}, + {"form": "돌망태", "height_m": 1.2}, + ] + assert got["retaining_wall"]["tiers"] == 2 + assert got["retaining_wall"]["extras"] == [{"form": "콘크리트", "height_m": 1.8}] + + +def test_횡단_형상_단_수_단마다_형태_높이(out): + assert out["soil2"]["requested"] == 3 + assert out["soil2"]["walls"] == [ + {"form": "돌쌓기(찰)", "pure": 2}, + {"form": "콘크리트", "pure": 2}, + {"form": "돌망태", "pure": 1.2}, + ] + assert out["wall1"]["walls"] == [ + {"form": "콘크리트", "pure": 2.5}, + {"form": "콘크리트", "pure": 1.8}, + ] + # 빈 상자 = 본 벽 형태 · 기본 1.5 m · 상자 없으면 옛 tiers 그대로 · 둘 다 없으면 1단 + assert out["emptyBox"]["walls"][1] == {"form": "돌망태", "pure": 1.5} + assert out["legacy"]["requested"] == 2 and out["legacy"]["extras"] == [] + assert out["none"]["requested"] == 1 + + +def test_배선(): + spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts") + assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec + walls = _read("common_util", "common_util_structure_walls.ts") + assert "파이썬 `tier_extras` 짝" in walls + engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py") + assert "TS `tierExtras` 짝" in engine diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py index e8a2bf1df..d16dc199c 100644 --- a/resources/tester/test_b06_structure_walls_mirror.py +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -46,6 +46,24 @@ STRUCTURES = [ "end_m": None, "options": {}, }, + { + # 178-3 「추가 단」 상자 둘(둘째 상자 높이 빈 값 · 글 값은 수 아님) + "structure_id": "tiers", + "type_id": "soil_guard", + "placement": "interval", + "chainage_m": 260.0, + "start_m": 250.0, + "end_m": 270.0, + "options": { + "form": "돌망태", + "height_m": 2.0, + "tiers": 5, + "tier_2_form": "", + "tier_2_height": "1.5", + "tier_1_form": "콘크리트", + "tier_1_height": 2.0, + }, + }, ] NAMES = {"masonry_wet": "돌쌓기(찰)", "retaining_wall": "옹벽", "soil_guard": "흙막이"} CHAINAGES = [20.0, 40.0, 45.02, 60.0, 115.0, 200.0] @@ -62,7 +80,11 @@ writeFileSync(process.argv[3], JSON.stringify({ specs, sections })); def _python_result() -> dict: - from B06_Section.B06_Section_Engine_Structures_Wall import _FORM_BY_TYPE, attach_wall_structures + from B06_Section.B06_Section_Engine_Structures_Wall import ( + _FORM_BY_TYPE, + attach_wall_structures, + tier_extras, + ) specs = [] for item in STRUCTURES: @@ -71,6 +93,7 @@ def _python_result() -> dict: if not name or start is None or end is None: continue options = item["options"] or {} + extras = tier_extras(item["type_id"], options) specs.append( { "structure_id": item["structure_id"], @@ -85,7 +108,9 @@ def _python_result() -> dict: # 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면 # 터파기가 안 그려져 「그림이 값에 안 따라간다」가 된다. "foundation": options.get("foundation"), - "tiers": options.get("tiers"), + # 178-3 단 수 = 1 + 「추가 단」 상자 수 · 상자 없으면 옛 tiers + "tiers": len(extras) + 1 if extras else options.get("tiers"), + "extras": extras, "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), # 전면 기울기 판정 칸(2026-09-14 B5) — 저장 원본 그대로. @@ -145,8 +170,16 @@ def test_wall_specs_mirror(): with tempfile.TemporaryDirectory() as workdir: ts = _ts_result(Path(workdir)) py = _python_result() - assert len(ts["specs"]) == len(py["specs"]) == 2 + assert len(ts["specs"]) == len(py["specs"]) == 3 assert ts["specs"] == py["specs"] + tiered = ts["specs"][2] + assert tiered["tiers"] == 3 + assert tiered["extras"] == [ + {"form": "콘크리트", "height_m": 2.0}, + {"form": None, "height_m": None}, + ] + assert ts["specs"][1]["extras"] == [] # 상자 없는 옹벽 = 옛 tiers 2 그대로 + assert ts["specs"][1]["tiers"] == 2 ts_marks = [ (s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"] ]