fix(B05): 크로스체크 5건 반영 — 마이그레이션 문자열 판별·서버 검증·기본값 원칙·404·STALE 정합

외부 AI 교차검증 미통과 지적을 전부 수정한다.

1. 마이그레이션: 확정 저장분은 structure 문자열뿐(structureType 없음) —
   라벨 파싱 판별 추가(기성막이/대피로 X.Xm/관종 D직경). 명시 필드가
   라벨 파싱보다 우선.
2. 서버 검증 강화(_validate_types 확장): 레지스트리 배치형태 대조,
   미정의 옵션 거절, number 옵션 유한·0 이상, select 선택지 검사,
   required 옵션 누락 거절, structure_id 중복 거절, 노선 연장 범위
   검증(라우터가 get_latest_route로 총연장 주입, 없으면 생략).
3. 기본값 원칙: 법정 명시값(별표2 측구 30cm·대피소 5/15m 등)·사용자
   기확정값(골막이)만 default 유지. 옹벽·돌쌓기 높이, 사토장·토취장
   면적/용량, 포장·쇄석 두께 등 미확정 수치는 default 제거 + required
   (화면 placeholder "필수 입력"+빈 값 추가 차단, 서버도 거절).
4. 404: get_project_storage_relative_path는 없는 프로젝트에서
   LookupError를 던짐 — _project_root에서 잡아 None, 라우터 예외
   사다리에도 LookupError→404 분기.
5. STALE 정합: _invalidate_downstream이 성공 여부 반환 —
   invalidated_downstream은 실제 전파 성공 시에만 true.

pytest 42건(tmp/tests) 통과 · tsc 0 · ruff 통과.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:58:43 +09:00
co-authored by Claude Fable 5
parent 61108f540d
commit 4af0dbdb8a
7 changed files with 1106 additions and 100 deletions
+20 -1
View File
@@ -160,7 +160,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
let types: StructureType[] = [];
let structures: StructureInstance[] = [];
let editingId: string | null = null;
let optionInputs: Array<{ key: string; read: () => string | number }> = [];
let optionInputs: Array<{
key: string;
required: boolean;
input: HTMLInputElement | HTMLSelectElement;
read: () => string | number;
isEmpty: () => boolean;
}> = [];
function typeMap(): Map<string, StructureType> {
return new Map(types.map((type) => [type.type_id, type]));
@@ -201,6 +207,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
// 미확정 항목(기본값 없음)은 비워 두면 저장이 거절된다 — 칸에서 미리 알린다.
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
@@ -210,7 +218,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
optionRow.append(field(label, input));
optionInputs.push({
key: option.key,
required: !!option.required,
input,
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
});
});
}
@@ -303,6 +314,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
function readOptions(): Record<string, string | number> {
const values: Record<string, string | number> = {};
optionInputs.forEach((input) => {
// 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다.
if (input.isEmpty()) return;
values[input.key] = input.read();
});
return values;
@@ -330,6 +343,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
return;
}
}
// 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다.
const missing = optionInputs.find((entry) => entry.required && entry.isEmpty());
if (missing) {
missing.input.focus();
return;
}
const base = {
type_id: type.type_id,