refactor(B06): 사용자 값 목록을 한 벌로 — 손나열 두 곳 제거

새 사용자 값을 더할 때 **네 곳을 다 고쳐야** 했고, 하나만 빠지면 그 값이 저장에서
조용히 사라졌음(`extra_spans` 실사고 `b6941bd2`). 나열을 걷어 목록 한 벌로 모음.

- 확정·저장 payload 조립(`_Router_Confirm.py`) — `if patch_item.X is not None:` 11개를
  스키마 통째 덤프 + 최상위 None 걷기로 바꿈. 새 필드가 저절로 실림.
  중첩 None 은 남김(기슭막이 4축 `d: null` 은 「자동」이라는 뜻).
- 브라우저 payload(`_Section_Store.ts`) — `put("X", design.X)` 10줄을 목록 순회로.
- `_Cross_Refresh.ts` 에 `USER_TOUCHED_KEYS` 를 내보내고 `PRESERVED_KEYS` 는
  거기에 상태 둘을 더해 만들게 함. 서버 목록과 짝임을 머리에 적음.

전수 조사 결과 `design` 키는 세 갈래였음 — ① 재계산 인자로 되먹여 사는 것 8개
② 목록으로 베껴야 사는 것 12개 ③ 순수 계산값 24개. 위험한 칸은 ② 하나뿐임.

시험 3건 추가:
- `test_b06_design_key_split.py` — 사용자 값이 ①·② 어디에도 안 걸리면 깨짐(불변식).
  지금 코드는 통과 = 알려지지 않은 구멍 없음.
- `test_b06_recompute_keeps_user_values.py` — 포장 재계산 뒤 ② 값 보존·① 인자 전달.
- `test_b06_user_touched_keys.py` — 손나열이 되살아나면 깨지는 검사 둘 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 10:49:29 +09:00
co-authored by Claude Opus 5
parent 023c0d545a
commit 744059aea1
3 changed files with 33 additions and 63 deletions
+12 -4
View File
@@ -61,10 +61,15 @@ export interface CrossRefreshInput {
shouldApply?: () => boolean;
}
/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */
const PRESERVED_KEYS = [
"status",
"pavement_suggested",
/**
* 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로
* 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS`
* 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`).
*
* 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을
* 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
*/
export const USER_TOUCHED_KEYS = [
"display_half_width_m",
"inlet_structure",
"basin_adjust",
@@ -77,6 +82,9 @@ const PRESERVED_KEYS = [
"revet_follow_grade",
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
+10 -45
View File
@@ -119,51 +119,16 @@ async def _apply_section_edits(
if request and request.cross_patches:
patches: list[tuple[float, dict[str, Any]]] = []
for patch_item in request.cross_patches:
patch: dict[str, Any] = {}
if patch_item.rock_boundary_offset_m is not None:
patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m
if patch_item.display_half_width_m is not None:
patch["display_half_width_m"] = patch_item.display_half_width_m
if patch_item.inlet_structure is not None:
patch["inlet_structure"] = patch_item.inlet_structure
if patch_item.basin_adjust is not None:
patch["basin_adjust"] = patch_item.basin_adjust.model_dump()
# 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 남긴다(2026-08-24).
if patch_item.revet_adjust is not None:
patch["revet_adjust"] = {
role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items()
}
if patch_item.ford_adjust is not None:
patch["ford_adjust"] = patch_item.ford_adjust.model_dump()
if patch_item.box_adjust is not None:
patch["box_adjust"] = patch_item.box_adjust.model_dump()
if patch_item.extra_wall_counts is not None:
patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump()
if patch_item.extra_spans is not None:
patch["extra_spans"] = {
wall: span.model_dump() for wall, span in patch_item.extra_spans.items()
}
if patch_item.revet_link_detached is not None:
patch["revet_link_detached"] = patch_item.revet_link_detached
if patch_item.revet_follow_grade is not None:
patch["revet_follow_grade"] = patch_item.revet_follow_grade
# 카드 버튼 선택 — 브라우저가 고른 값을 그대로 정본에 얹는다(2026-09-06).
for choice_key in (
"ground_type",
"section_mode",
"ditch_side",
"ditch_type",
"paved",
"two_stage_slope",
):
choice = getattr(patch_item, choice_key)
if choice is not None:
patch[choice_key] = choice
# 구조물 폐회로 면적 — 브라우저가 계산해 보낸 값을 그대로 정본에 얹는다.
for area_key in ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"):
value = getattr(patch_item, area_key)
if value is not None:
patch[area_key] = value
# 필드를 손으로 나열하지 않는다(2026-09-07) — 나열이 네 곳에 흩어져 있어 새 값을
# 더할 때 한 곳만 빠지면 그 값이 **조용히 사라졌다**(`extra_spans` 실사고 `b6941bd2`).
# 스키마가 곧 목록이므로 통째로 덤프하고 **최상위 None 만** 걷는다.
# (중첩 None 은 남긴다 — 기슭막이 4축의 `d: null` 은 「자동」이라는 뜻이다.)
dumped = patch_item.model_dump()
patch: dict[str, Any] = {
key: value
for key, value in dumped.items()
if key != "chainage_m" and value is not None
}
if patch:
patches.append((patch_item.chainage_m, patch))
# 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다.
+11 -14
View File
@@ -30,6 +30,7 @@ import {
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
const cache = new Map<string, SectionDetailResponse>();
const pending = new Map<string, Promise<SectionDetailResponse>>();
@@ -160,21 +161,17 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
if (!design) continue;
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
let touched = false;
const put = <K extends keyof CrossSectionPatch>(key: K, value: CrossSectionPatch[K]): void => {
if (value === undefined || value === null) return;
patch[key] = value;
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
// 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
const source = design as unknown as Record<string, unknown>;
const target = patch as unknown as Record<string, unknown>;
for (const key of USER_TOUCHED_KEYS) {
const value = source[key];
if (value === undefined || value === null) continue;
target[key] = value;
touched = true;
};
put("display_half_width_m", design.display_half_width_m);
put("inlet_structure", design.inlet_structure);
put("basin_adjust", design.basin_adjust);
put("revet_adjust", design.revet_adjust);
put("ford_adjust", design.ford_adjust);
put("box_adjust", design.box_adjust);
put("extra_wall_counts", design.extra_wall_counts);
put("extra_spans", design.extra_spans);
put("revet_link_detached", design.revet_link_detached);
put("revet_follow_grade", design.revet_follow_grade);
}
if (touched) patches.push(patch);
}
return patches;