From 480e62c674132458497c80d1ba7874b37dbf5c7f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 18:10:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(439=20B06=20=EC=B9=B4=EB=93=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94):=20=EC=A0=80=EC=9E=A5=20=EB=85=B8=EC=84=A0=20cross?= =?UTF-8?q?=20=EC=B0=A8=EB=A1=80=20=3D=20=EC=B9=B4=EB=93=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EB=8A=98=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EB=8C=80=EC=A1=B0=EB=8A=94=20=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EC=A4=84=20=EA=B0=92=EB=A7=8C=20=C2=B7=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=ED=8F=B4=EB=B0=B1=EC=97=90=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=A4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_shared refreshSavedCross — 측점 어긋남(1 mm)에도 refreshCrossDesigns 로 카드 설계 고침 · 다르면 null(묶음 엔진) - refreshFromServer — 지반 · 측구 · 복합경사 선택을 design_choices 로 · 서버 미리보기가 저장분 위에 얹음 - 시험 test_266_card_toggle_mismatch(측점 같음 · 다름 · 서버 폴백 · 서버 얹기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/design/design_shared.ts | 40 +++-- B06_Section/B06_Section_Api_Fetch.ts | 4 + B06_Section/B06_Section_Cross_Refresh.ts | 10 +- B06_Section/B06_Section_Router.py | 13 ++ B06_Section/B06_Section_Schema.py | 2 + resources/tester/helper_266_card_toggle.mjs | 150 ++++++++++++++++++ .../tester/helper_266_card_toggle_entry.ts | 5 + .../tester/test_266_card_toggle_mismatch.py | 137 ++++++++++++++++ 8 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 resources/tester/helper_266_card_toggle.mjs create mode 100644 resources/tester/helper_266_card_toggle_entry.ts create mode 100644 resources/tester/test_266_card_toggle_mismatch.py diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 830f1364b..66069900c 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -44,9 +44,13 @@ import { restoreSectionDetail, swapSectionStations, } from "../../B06_Section/B06_Section_Section_Store"; -import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh"; +import { + refreshCrossDesigns, + type CrossRefreshInput, +} from "../../B06_Section/B06_Section_Cross_Refresh"; import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import type { CrossOutput, PipelineStation } from "./logic/design_pipeline_types"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -229,16 +233,17 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []); return (chainageM) => bermSpecAt(spans, chainageM); }, - // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을 - // 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진 - savedCross: async (detail, routeId, stations) => { - if (!detailCross(detail, stations)) return null; - const edits = profile.pipelineSource().edits; - const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits }); - if (updated.length) crossDesignsUpdated(shared, updated); - profile.redraw(); - return detailCross(detail, stations); - }, + // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 늘 고치고 그 + // 값을 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진(439) + savedCross: (detail, routeId, stations) => + refreshSavedCross( + { projectId, routeId, detail, edits: profile.pipelineSource().edits }, + stations, + (updated) => { + if (updated.length) crossDesignsUpdated(shared, updated); + profile.redraw(); + }, + ), plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관) structureAreas: (detail) => { // 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞 @@ -262,6 +267,19 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo }); } +/** + * 저장 노선 cross 차례(387-4 · 439) — 카드 자료(상세 설계)는 **늘** 다시 셈 · 묶음에 돌려줄 값만 측점 대조 + * (상세 측점 ≠ 묶음 측점이면 null = 묶음 엔진). 대조가 앞서면 1 mm 어긋남에 카드 단추가 안 먹었다(요구 266). + */ +export async function refreshSavedCross( + input: CrossRefreshInput, + stations: ReadonlyArray, + onUpdated: (updated: number[]) => void, +): Promise { + onUpdated(await refreshCrossDesigns(input)); + return detailCross(input.detail, stations); +} + /** 「구조물 배치」 폼 한 벌 — 알림은 지금 보기로, 상세 옵션은 횡단 보기에서만 그린다. */ export function sharedStructures(shared: DesignPageShared): StructuresSection { if (shared.structures) return shared.structures; diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index d40878897..deb5804c1 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -20,6 +20,7 @@ import { clearState, readState, stateKey, writeState } from "../A00_Common/b_pag import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { t as L } from "@ui/ui_template_locale"; +import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import type { CrossDesign, CrossDesignRequest, @@ -306,6 +307,8 @@ export async function previewCrossDesigns( >; /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ cutSlopeRatios?: Record; + /** 측점별 카드 선택(chainage 키 → 지반 · 측구 · 복합경사 …) 세션값 — 저장분 위에 얹음(439). */ + designChoices?: Record; }, ): Promise { return requestJson( @@ -319,6 +322,7 @@ export async function previewCrossDesigns( rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, berms: options?.berms ?? null, cut_slope_ratios: options?.cutSlopeRatios ?? null, + design_choices: options?.designChoices ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 62ac31348..058354b46 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -183,7 +183,7 @@ export function staleKeyAgainstScreen(detail: SectionDetailResponse, routeId: nu /** * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. * 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. - * 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` savedCross · 387-4) — 화면은 `pipelineCross` 로 부탁. + * 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` refreshSavedCross · 387-4) — 화면은 `pipelineCross` 로 부탁. * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { @@ -486,6 +486,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { /** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */ async function refreshFromServer(input: CrossRefreshInput): Promise { const { projectId, routeId, detail, edits, shouldApply } = input; + const choices = crossDesignChoices(projectId, routeId); const response = await previewCrossDesigns( projectId, routeId, @@ -498,6 +499,13 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). cutSlopeRatios: readCutSlopeSession(projectId, routeId), + // 카드 단추 선택(지반 · 측구 · 복합경사)도 — 안 실으면 폴백에서만 단추가 안 먹음(439) · 열쇠 = 측점 자리 + designChoices: Object.fromEntries( + detail.cross_sections.flatMap((section) => { + const choice = choices.get(rockKey(section.chainage_m)); + return choice ? [[section.chainage_m.toFixed(3), choice]] : []; + }), + ), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 6f996baec..098660552 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -494,6 +494,18 @@ async def regenerate_route_sections( ) +def _with_design_choices( + designs: list[dict[str, Any]], choices: dict[str, dict[str, Any]] | None +) -> list[dict[str, Any]]: + """카드 선택 세션값을 저장분 위에 얹은 행 목록(439) — 뒤 행이 같은 측점 앞 행을 덮음.""" + stored = {round(float(r["chainage_m"]), 3): r.get("design") or {} for r in designs} + picked = {round(float(key), 3): choice for key, choice in (choices or {}).items()} + return designs + [ + {"chainage_m": key, "design": {**stored.get(key, {}), **choice}} + for key, choice in picked.items() + ] + + @router.post( "/{project_id}/sections/{route_id}/cross-design/preview", response_model=CrossDesignPreviewResponse, @@ -522,6 +534,7 @@ async def preview_cross_designs( ) stored_path = await get_project_storage_relative_path(connection, project_id) designs = await get_cross_section_designs(connection, route_id) + designs = _with_design_choices(designs, request.design_choices) project_root = Path(resolve_stored_project_path(stored_path)) detail = await asyncio.to_thread( _read_section_detail, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 30d923cf8..231024484 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -400,6 +400,8 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을 # 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다. cut_slope_ratios: dict[str, float] | None = None + # 측점별 카드 선택(chainage 키 → 지반 · 단면 · 측구 · 복합경사). 위와 같은 성격 — 저장분 위에 얹음(439). + design_choices: dict[str, dict[str, Any]] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/resources/tester/helper_266_card_toggle.mjs b/resources/tester/helper_266_card_toggle.mjs new file mode 100644 index 000000000..ffb22fe62 --- /dev/null +++ b/resources/tester/helper_266_card_toggle.mjs @@ -0,0 +1,150 @@ +/* PLAN 439 헬퍼 — 저장 노선 cross 차례(`refreshSavedCross`)가 카드 단추(복합경사 · 측구 · 지반)를 늘 다시 셈: + * 묶음 측점 = 상세 측점(돌려줌) · 다름 1 mm 넘게(null = 묶음 엔진 · 카드 자료는 고침) · 서버 폴백에 카드 선택 실음. + * node helper_266_card_toggle.mjs <묶음.mjs> + * 짝 = `test_266_card_toggle_mismatch.py` · 표준출력 JSON 한 벌. + */ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); +globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); +globalThis.addEventListener = () => undefined; +globalThis.removeEventListener = () => undefined; +globalThis.screen = { width: 1920, height: 1080 }; +globalThis.location = { + href: "http://localhost/", + pathname: "/", + search: "", + hash: "", + origin: "http://localhost", +}; + +const job = JSON.parse(readFileSync(process.argv[3], "utf8")); +// 서버 폴백 창구만 받아 적음 — 그 밖 요청은 404(격자 없음 = 넓힘 안 함) +const previews = []; +globalThis.fetch = async (url, init) => { + if (String(url).includes("/cross-design/preview")) { + previews.push(JSON.parse(init.body)); + return Response.json({ designs: [] }); + } + return new Response("{}", { status: 404 }); +}; + +const m = await import(pathToFileURL(process.argv[2]).href); +const R = 7; +const EDITS = { station_offsets: {}, curve_radii: {} }; +const alignment = { + stations: job.sections.map((s) => ({ chainage_m: s.chainage_m, plan_elevation_m: s.plan_z })), + samples: job.sections.map((s) => ({ chainage_m: s.chainage_m, elevation_m: s.plan_z })), + balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 }, +}; +const detailOf = (withLine) => ({ + longitudinal: withLine + ? { length_m: 1000, stations: [], profile_alignment: { policy: {} } } + : { length_m: 1000, stations: [] }, + cross_sections: job.sections.map(({ plan_z, ...section }) => ({ + ...structuredClone(section), + design: { + ground_type: "ripping_rock", + section_mode: job.mode, + design_elevation_m: plan_z, + }, + })), +}); +const pick = (design) => ({ + ground_type: design.ground_type, + two_stage_slope: design.two_stage_slope ?? null, + ditch_enabled: design.ditch_enabled ?? null, + cut: design.cut_area_m2 ?? null, + fill: design.fill_area_m2 ?? null, + line: JSON.stringify(design.design_line ?? null), +}); + +/** 카드 단추 줄 차례 — 처음 셈 → 복합경사 켬 → 측구 끔 → 지반 흙. `shift` = 묶음 측점 어긋남(m). */ +async function toggles(projectId, shift) { + m.writeState("std-cross", job.standard, projectId); + m.rememberRockBoundaryDefault(projectId, job.rockDefault); + const detail = detailOf(true); + const target = detail.cross_sections[job.target]; + const stations = detail.cross_sections.map((s, i) => ({ + key: `s${i}`, + chainageM: s.chainage_m + shift, + })); + const steps = []; + const run = async (tag) => { + const updated = []; + const out = await m.refreshSavedCross( + { projectId, routeId: R, detail, edits: EDITS, alignment }, + stations, + (rows) => updated.push(...rows), + ); + steps.push({ + tag, + out: out ? Object.keys(out.byKey).length : null, + updated, + ...pick(target.design), + }); + }; + await run("open"); + const choice = { + ground_type: "ripping_rock", + section_mode: job.mode, + ditch_side: null, + ditch_type: "standard", + paved: false, + two_stage_slope: false, + ditch_choice: null, + }; + for (const [tag, over] of [ + ["two_stage", { two_stage_slope: true }], + ["ditch_off", { ditch_choice: false }], + ["soil", { ground_type: "soil" }], + ]) { + Object.assign(choice, over); + m.writeCrossDesignChoice(projectId, R, target.chainage_m, { ...choice }); + await run(tag); + } + return { target: target.chainage_m, steps }; +} + +const out = { same: await toggles("p266a", 0), mismatch: await toggles("p266b", 0.002) }; + +/* 서버 폴백(선형 저장분 없음) — 카드 선택이 요청에 실림 · 열쇠 = 측점 자리 */ +{ + const projectId = "p266c"; + m.writeState("std-cross", job.standard, projectId); + const detail = detailOf(false); + const at = detail.cross_sections[job.target].chainage_m; + m.writeCrossDesignChoice(projectId, R, at, { + ground_type: "soil", + section_mode: job.mode, + two_stage_slope: true, + ditch_choice: false, + }); + await m.refreshSavedCross({ projectId, routeId: R, detail, edits: EDITS }, [], () => undefined); + out.server = { at, choices: previews.at(-1)?.design_choices ?? null }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_266_card_toggle_entry.ts b/resources/tester/helper_266_card_toggle_entry.ts new file mode 100644 index 000000000..4ec8245d0 --- /dev/null +++ b/resources/tester/helper_266_card_toggle_entry.ts @@ -0,0 +1,5 @@ +/* PLAN 439 시험 묶음 진입점 — 저장 노선 cross 차례(카드 단추 → 다시 셈 · 측점 어긋남) · 진짜 모듈 · vite ssr. */ +export { refreshSavedCross } from "../../A00_Common/design/design_shared"; +export { writeCrossDesignChoice } from "../../B06_Section/B06_Section_Cross_Design_Session"; +export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel"; +export { writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_266_card_toggle_mismatch.py b/resources/tester/test_266_card_toggle_mismatch.py new file mode 100644 index 000000000..6d2d73835 --- /dev/null +++ b/resources/tester/test_266_card_toggle_mismatch.py @@ -0,0 +1,137 @@ +"""PLAN 439 — B06 카드 머리 줄 단추가 저장 노선에서 늘 반영됨(요구 266). + +못박는 것 + - 저장 노선 cross 차례(`refreshSavedCross`) = 카드 자료(상세 설계)를 **늘** 다시 셈 — + 복합경사 · 측구 · 지반 단추마다 그 측점 설계선 · 면적이 바뀜. + - 묶음 측점 = 상세 측점이면 돌려줌 · 1 mm 넘게 다르면 null(묶음 엔진) — 그래도 카드 자료는 고침. + - 서버 폴백(선형 저장분 없음)에도 카드 선택이 실림(열쇠 = 측점 자리) · 서버는 저장분 위에 얹음. +짝: `helper_266_card_toggle.mjs` · 진입점 `helper_266_card_toggle_entry.ts` · +지형 · 노선 틀 = `test_346_2_pipeline_cross_page.py`. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from test_346_2_pipeline_cross_page import _fake, _line, _sections # noqa: E402 + +from B06_Section.B06_Section_Router import _with_design_choices # noqa: E402 +from common_util.common_util_surface_sampler import DtmGridSampler # noqa: E402 +from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +CUT_DEPTH_M = 3.0 + + +@pytest.fixture(scope="module") +def r(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("b439") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_266_card_toggle_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_266_card_toggle_entry.js", bundle) + with tempfile.TemporaryDirectory() as folder: + npz, nodes = _fake(Path(folder)) + sampler = DtmGridSampler.from_npz(npz) + rows = _sections(_line(nodes), sampler, [])["cross_sections"] + middle = len(rows) // 2 + picked = rows[middle - 1 : middle + 2] + sections = [{**s, "plan_z": float(s["center_z"]) - CUT_DEPTH_M} for s in picked] + job = { + "sections": sections, + "target": 1, + "mode": f"{picked[1].get('uphill_side') or 'left'}_cut", + "standard": STANDARD_CROSS_SECTION, + "rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, + } + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_266_card_toggle.mjs"), str(bundle), str(path)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _check_toggles(case: dict, returned) -> None: + steps = {step["tag"]: step for step in case["steps"]} + assert steps["open"]["cut"] > 0.5 # 깎기 측점 + before = steps["open"] + for tag in ("two_stage", "ditch_off", "soil"): + step = steps[tag] + assert case["target"] in step["updated"], tag # 카드 다시 그림 대상 + assert step["line"] != before["line"], tag # 카드 설계선 바뀜 + assert step["out"] == returned, tag + before = step + assert steps["two_stage"]["two_stage_slope"] is True + assert steps["ditch_off"]["ditch_enabled"] is False + assert steps["soil"]["ground_type"] == "soil" + assert steps["soil"]["cut"] != steps["open"]["cut"] # 면적 바뀜 + + +def test_측점_같음_카드_단추마다_설계선_면적_바뀜_돌려줌(r): + _check_toggles(r["same"], 3) + + +def test_측점_어긋남_카드_자료는_고침_돌려줄_값만_null(r): + _check_toggles(r["mismatch"], None) + + +def test_서버_폴백에_카드_선택_실음(r): + key = f"{r['server']['at']:.3f}" + choice = (r["server"]["choices"] or {}).get(key) + assert choice is not None, r["server"] + assert choice["ground_type"] == "soil" + assert choice["two_stage_slope"] is True + assert choice["ditch_choice"] is False + + +def test_서버_카드_선택이_저장분_위에_얹힘(): + stored = [{"chainage_m": 40.0, "design": {"ground_type": "ripping_rock", "berm": {"w": 1}}}] + rows = _with_design_choices(stored, {"40.000": {"ground_type": "soil"}, "60.123": {"x": 1}}) + by_key = {round(float(r["chainage_m"]), 3): r["design"] for r in rows} # 서버 읽는 꼴 + assert by_key[40.0] == {"ground_type": "soil", "berm": {"w": 1}} + assert by_key[60.123] == {"x": 1} + assert _with_design_choices(stored, None) == stored