feat(구조물표): 322-2 M02 표 화면 — 탭 셋(한눈 표 · 종류별 · 부품) · 반영 · 부품 · 알약 색 저장 · 목록 + enabled 함께 · 검사 짝(화면 = 서버 · 1-3 규칙 · 만든 뒤 고정) · 명세 폼 보임 따름 · 판 알림

- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로]
- 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만
- 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만)
- 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참
- 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만
- 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름
- 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번
- 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 15:40:38 +09:00
co-authored by Claude Opus 5.5
parent bbd6254892
commit 3abd2bdfd4
26 changed files with 1635 additions and 34 deletions
+9 -1
View File
@@ -22,6 +22,8 @@ import {
linkOf, linkOf,
readState, readState,
solveSplit, solveSplit,
specContainerHidden,
specItemHidden,
TEXT_MAX, TEXT_MAX,
writeOptions, writeOptions,
type SpecContainer, type SpecContainer,
@@ -82,6 +84,8 @@ export interface SpecForm {
groupNames?: Record<string, string>, groupNames?: Record<string, string>,
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */ /** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
defaults?: Record<string, string | number>, defaults?: Record<string, string | number>,
/** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */
hidden?: ReadonlySet<string>,
) => void; ) => void;
read: () => Record<string, string | number>; read: () => Record<string, string | number>;
state: () => SpecState; state: () => SpecState;
@@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
let names: SpecNames = {}; let names: SpecNames = {};
let groupNames: Record<string, string> = {}; let groupNames: Record<string, string> = {};
let defaults: Record<string, string | number> = {}; let defaults: Record<string, string | number> = {};
let hidden: ReadonlySet<string> = new Set();
const recent = new Map<Instance, SplitRole[]>(); const recent = new Map<Instance, SplitRole[]>();
const slots = new Map<string, HTMLElement>(); const slots = new Map<string, HTMLElement>();
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = []; const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
@@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
} }
const fields = c.items const fields = c.items
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
.filter((item) => !specItemHidden(item, hidden))
.map((item) => { .map((item) => {
const input = control(c, item, view, tags.get(item.id)); const input = control(c, item, view, tags.get(item.id));
const wrap = labeled(itemLabel(c, item, names), input); const wrap = labeled(itemLabel(c, item, names), input);
@@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const fitted: SpecContainer[] = []; const fitted: SpecContainer[] = [];
for (const c of spec?.containers ?? []) { for (const c of spec?.containers ?? []) {
const list = (state[c.id] ??= c.required ? [{}] : []); const list = (state[c.id] ??= c.required ? [{}] : []);
if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2)
if (fit(c, list)) fitted.push(c); if (fit(c, list)) fitted.push(c);
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index))); shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
@@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return { return {
root, root,
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) { write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) {
spec = next; spec = next;
defaults = nextDefaults; defaults = nextDefaults;
hidden = nextHidden ?? new Set();
names = nextNames; names = nextNames;
groupNames = nextGroups; groupNames = nextGroups;
state = readState(next, options); state = readState(next, options);
@@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string =>
const text = (value: unknown): string => const text = (value: unknown): string =>
value === undefined || value === null ? "" : String(value); value === undefined || value === null ? "" : String(value);
/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */
export const specItemKeys = (item: SpecItem): string[] =>
[item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter(
(key): key is string => !!key,
);
/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */
export const specItemHidden = (item: SpecItem, hidden: ReadonlySet<string>): boolean => {
const keys = specItemKeys(item);
return keys.length > 0 && keys.every((key) => hidden.has(key));
};
/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */
export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet<string>): boolean =>
c.items.some((item) => specItemKeys(item).length > 0) &&
c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden));
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */ /** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
export function readState( export function readState(
spec: StructureSpec, spec: StructureSpec,
+17
View File
@@ -141,6 +141,8 @@ export interface StructureListResponse {
project_id: string; project_id: string;
revision: number; revision: number;
structures: StructureInstance[]; structures: StructureInstance[];
/** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */
definition_version?: number | null;
} }
export interface StructureSaveResponse { export interface StructureSaveResponse {
@@ -211,6 +213,21 @@ export async function dropStaleStructureTypes(): Promise<boolean> {
return true; return true;
} }
const noticed = new Set<string>();
/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */
export function staleDefinitionNotice(
projectId: string,
saved: number | null | undefined,
): string | null {
if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion)
return null;
const key = `${projectId}:${saved}:${typesVersion}`;
if (noticed.has(key)) return null;
noticed.add(key);
return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`;
}
const staleListeners = new Set<() => void>(); const staleListeners = new Set<() => void>();
function checkStaleOnShow(): void { function checkStaleOnShow(): void {
@@ -69,6 +69,17 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]:
return revision, structures return revision, structures
def load_saved_definition_version(project_root: str) -> int | None:
"""정본을 적을 때의 구조물 정의 판(322-2 판 알림) — 파일이 없거나 옛 파일이면 None."""
path = structures_file_path(project_root)
try:
with open(path, encoding="utf-8") as handle:
value = json.load(handle).get("definition_version")
except (OSError, ValueError, TypeError, AttributeError):
return None
return value if isinstance(value, int) and not isinstance(value, bool) else None
def load_migrated_legacy(project_root: str) -> set[str]: def load_migrated_legacy(project_root: str) -> set[str]:
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합. """이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
+5 -1
View File
@@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stati
from B05_Profile.B05_Profile_Structures_Repository import ( from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict, StructureRevisionConflict,
load_migrated_legacy, load_migrated_legacy,
load_saved_definition_version,
load_structures, load_structures,
requires_downstream_invalidation, requires_downstream_invalidation,
save_structures, save_structures,
@@ -159,7 +160,10 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
revision, structures = load_structures(root) revision, structures = load_structures(root)
return StructureListResponse( return StructureListResponse(
project_id=str(project_id), revision=revision, structures=structures project_id=str(project_id),
revision=revision,
structures=structures,
definition_version=load_saved_definition_version(root),
) )
except LookupError: except LookupError:
# 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다. # 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다.
@@ -305,3 +305,5 @@ class StructureListResponse(BaseModel):
project_id: str project_id: str
revision: int revision: int
structures: list[StructureInstance] = Field(default_factory=list) structures: list[StructureInstance] = Field(default_factory=list)
# 정본을 적을 때의 정의 판(322-2) — 지금 판과 다르면 화면이 「저장하면 반영」 한 줄 알림
definition_version: int | None = None
@@ -50,7 +50,7 @@ import {
outletTiersRow, outletTiersRow,
revetChoiceRow, revetChoiceRow,
} from "./B05_Profile_UI_Drainage_Revet_Choice"; } from "./B05_Profile_UI_Drainage_Revet_Choice";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import { import {
createSpecForm, createSpecForm,
type SpecForm, type SpecForm,
@@ -607,6 +607,7 @@ export function createFacilityOptionsForm(
const names = definition?.spec_names ?? {}; const names = definition?.spec_names ?? {};
// 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈 // 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈
const defaults = optionDefaults(definition?.options ?? []); const defaults = optionDefaults(definition?.options ?? []);
const hidden = hiddenOptionKeys(definition?.options ?? []); // 정의 「표시」 숨김(322-2)
if (facility === "pipe") { if (facility === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names; const { soil_add: _old, ...pipeNames } = names;
@@ -616,11 +617,18 @@ export function createFacilityOptionsForm(
!["revet", "I", "L", "U"].includes(String(kind ?? "")) !["revet", "I", "L", "U"].includes(String(kind ?? ""))
? { structure: kind } ? { structure: kind }
: null; : null;
pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults); pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults, hidden);
} }
if (facility === "box_culvert") { if (facility === "box_culvert") {
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults)); boxForm.write(
BOX_SPEC,
boxOptionsIn(options),
names,
groups,
boxOptionsIn(defaults),
hidden,
);
for (const [id, value] of Object.entries(bodyOf(options))) for (const [id, value] of Object.entries(bodyOf(options)))
boxForm.setValue("body", id, value); boxForm.setValue("body", id, value);
} }
@@ -631,9 +639,10 @@ export function createFacilityOptionsForm(
names, names,
groups, groups,
withLegacyGuards(defaults), withLegacyGuards(defaults),
hidden,
); );
if (facility === "ford_pavement") if (facility === "ford_pavement")
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults); paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults, hidden);
if (facility === "ford_bridge") fordFlowBox.fill(); if (facility === "ford_bridge") fordFlowBox.fill();
if (facility === "ford_pavement") paveFlowBox.fill(); if (facility === "ford_pavement") paveFlowBox.fill();
wingInFields.write(options); wingInFields.write(options);
@@ -19,6 +19,7 @@ import {
fetchStructures, fetchStructures,
dropStaleStructureTypes, dropStaleStructureTypes,
fetchStructureTypes, fetchStructureTypes,
staleDefinitionNotice,
saveStructures, saveStructures,
readPendingStructures, readPendingStructures,
writePendingStructures, writePendingStructures,
@@ -369,6 +370,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
]), ]),
); );
structureRevision = stored.revision; structureRevision = stored.revision;
const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2
if (note) showToast(note);
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
const pending = readPending(); const pending = readPending();
ownStructures = pending ?? stored.structures; ownStructures = pending ?? stored.structures;
@@ -101,6 +101,11 @@ export function optionControl(
return element; return element;
} }
/** 정의 「표시」로 숨긴 옵션 키(서버 `visible` 거짓 · 322-2) — 명세 폼이 그 칸 · 상자를 안 그림. */
export const hiddenOptionKeys = (
options: ReadonlyArray<{ key: string; visible?: boolean }>,
): Set<string> => new Set(options.filter((o) => o.visible === false).map((o) => o.key));
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */ /** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
export function optionDefaults( export function optionDefaults(
options: ReadonlyArray<{ key: string; default: string | number | null }>, options: ReadonlyArray<{ key: string; default: string | number | null }>,
+10 -2
View File
@@ -18,7 +18,7 @@ import {
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier";
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening";
const forms = new WeakMap<HTMLElement, SpecForm>(); const forms = new WeakMap<HTMLElement, SpecForm>();
@@ -86,7 +86,15 @@ export function paintStructureSpec(
form.root.hidden = !spec; form.root.hidden = !spec;
if (!spec) return; if (!spec) return;
const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
form.write(spec, values, type?.spec_names ?? {}, groups, optionDefaults(type?.options ?? [])); const options = type?.options ?? [];
form.write(
spec,
values,
type?.spec_names ?? {},
groups,
optionDefaults(options),
hiddenOptionKeys(options),
);
} }
/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */ /** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */
@@ -22,6 +22,7 @@ import {
fetchStructures, fetchStructures,
dropStaleStructureTypes, dropStaleStructureTypes,
fetchStructureTypes, fetchStructureTypes,
staleDefinitionNotice,
readPendingStructures, readPendingStructures,
pipesToStructureMarks, pipesToStructureMarks,
structureAnchorM, structureAnchorM,
@@ -384,6 +385,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
markTypes = types; markTypes = types;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
structures = readPendingStructures(projectId) ?? stored.structures; structures = readPendingStructures(projectId) ?? stored.structures;
const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2
if (note) showToast(note);
section.setStructures(structures); section.setStructures(structures);
const detail = deps.detail?.() ?? null; const detail = deps.detail?.() ?? null;
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며, // 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
@@ -0,0 +1,169 @@
"""구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 반영 · 부품 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정.
- 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것
- 부품 = `{"갈래.부품": false}`(끈 것만)
- 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 ·
부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면
그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠)
- 앞 판이 없으면(옛 판 읽기) 꼴만 검사
- 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로)
화면 짝 = `M02_SO_Def_Check.ts`(같은 글)
거울 시험 = `resources/tester/test_322_2_check_mirror.py`.
"""
from __future__ import annotations
from typing import Any
from common_util.common_util_structure_catalog import load_parts, resolve_catalog
BRANCHES = ("횡단도", "카드이름", "형상", "3D")
OFF_VALUE = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"}
LISTS = ("both", "b06", "hidden")
_FIELDS = {"목록", "형상칸", *BRANCHES}
def _shape_errors(doc: dict[str, Any], known: set[str]) -> list[str]:
"""반영 · 부품 꼴."""
parts = load_parts()["parts"]
out: list[str] = []
reflect = doc.get("반영", {})
if not isinstance(reflect, dict):
out.append("반영은 {종류: 묶음} 이어야 함")
else:
for tid, row in reflect.items():
if tid not in known or not isinstance(row, dict):
out.append(f"반영 「{tid}」 — 없는 종류이거나 묶음 아님")
continue
for field, value in row.items():
where = f"반영 「{tid}.{field}」"
if field not in _FIELDS:
out.append(f"{where} — 모르는 칸")
elif field == "목록":
if value not in LISTS:
out.append(f"{where} — both · b06 · hidden 중 하나")
elif field == "형상칸":
ok = isinstance(value, dict) and all(
(isinstance(v, str) and bool(v.strip()))
if k == "form"
else (k == "weep" and isinstance(v, bool))
for k, v in value.items()
)
if not ok:
out.append(f"{where} — form 글 · weep 참거짓")
elif not isinstance(value, str) or (
value != OFF_VALUE[field] and value not in parts[field]
):
shown = value if isinstance(value, str) else _js(value)
out.append(f"{where} — 없는 부품 「{shown}」")
off = doc.get("부품", {})
if not isinstance(off, dict):
out.append("부품은 {갈래.부품: 참거짓} 이어야 함")
else:
for key, value in off.items():
branch, _, part = key.partition(".")
if part not in parts.get(branch, {}):
out.append(f"부품 「{key}」 — 없는 부품")
elif not isinstance(value, bool):
out.append(f"부품 「{key}」 — 참거짓")
return out
def _js(value: Any) -> str:
"""TS `String(value)` 와 같은 글(검사 글 짝)."""
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (list, tuple)):
return ",".join(_js(v) for v in value)
if isinstance(value, dict):
return "[object Object]"
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
def _hidden_in(doc: dict[str, Any] | None, tid: str) -> list[str]:
row = ((doc or {}).get("표시") or {}).get(tid) or {}
return [key for key, shown in row.items() if shown is False]
def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]:
"""표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만)."""
parts = load_parts()["parts"]
rows = resolve_catalog(doc)
out: list[str] = []
for t in doc.get("types") or []:
row = rows.get(t["type_id"])
if row is None:
continue
if row["shape"] == "none" and row["station"] != "none":
out.append(f"「{row['name']}」 형상 none — 횡단도도 none 이어야 함")
kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음
hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept]
# 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김
keys = {
o["key"]
for o in t.get("options") or []
if o.get("default") is None and not o.get("empty_means")
}
used = (
("횡단도", row["station"]),
("카드이름", row["title"]),
("형상", row["shape"]),
("3D", row["model3d"]),
)
for branch, part_id in used:
part = parts[branch].get(part_id)
if part is None:
continue
if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]:
out.append(
f"「{row['name']}」 {branch} 「{part_id}」 — 자리꼴 {row['placement']} · "
f"정본 {row['owner']} 에 안 맞음"
)
for key in part["읽는 칸"]:
if key in keys and key in hidden:
out.append(
f"「{row['name']}」 {branch} 「{part_id}」 이 읽는 칸 「{key}」 — "
"숨길 수 없음"
)
return out
def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]:
"""앞 판과 비교 — 만든 뒤 고정."""
out: list[str] = []
now = {t["type_id"]: t for t in doc.get("types") or []}
for old in before.get("types") or []:
tid = old["type_id"]
t = now.get(tid)
if t is None:
out.append(f"종류 「{tid}」 — 지울 수 없음")
continue
fixed = (
("자리꼴", old.get("placement"), t.get("placement")),
("정본", old.get("managed_by"), t.get("managed_by")),
("군 글자", old.get("group"), t.get("group")),
)
for what, a, b in fixed:
if a != b:
out.append(f"「{tid}」 {what} — 만든 뒤 고정")
options = {o["key"]: o for o in t.get("options") or []}
for o in old.get("options") or []:
n = options.get(o["key"])
if o.get("input") != "select" or n is None:
continue
if sorted(o.get("choices") or []) != sorted(n.get("choices") or []):
out.append(f"「{tid}.{o['key']}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)")
return out
def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]:
"""구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 판(없으면 고정 검사 안 함)."""
known = {t["type_id"] for t in doc.get("types") or [] if isinstance(t, dict)}
shape = _shape_errors(doc, known)
if shape:
return shape
return _row_errors(doc, before) + _fixed_errors(doc, before) if before else []
+59 -13
View File
@@ -1,8 +1,10 @@
/* ============================================================================= /* =============================================================================
* M02_SO_Def.ts * M02_SO_Def.ts
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) · * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 탭 셋(322-2): 「한눈 표」(종류마다 반영 한 줄 ·
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) · * `M02_SO_Def_Table`) · 「종류별」(왼쪽 구조물군 · 종류 목록 · 오른쪽 고른 종류 이름 · 반영 · 명세 이름 나무 ·
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄). * 줄마다 보임 · 미리보기) · 「부품」(`M02_SO_Def_Parts`) ·
* 아래 검사(화면 = 서버 짝 `M02_SO_Def_Check`) · [저장](판 +1 · 목록 + enabled 함께 · 시스템 관리자만) ·
* 지난 판 목록(누르면 그 판을 읽기만으로 봄).
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1). * 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
* ========================================================================== */ * ========================================================================== */
@@ -28,6 +30,10 @@ import { buildOptionsBlock } from "./M02_SO_Def_Options";
import { buildRulesBlock } from "./M02_SO_Def_Rules"; import { buildRulesBlock } from "./M02_SO_Def_Rules";
import { buildSpecBlock } from "./M02_SO_Def_Spec"; import { buildSpecBlock } from "./M02_SO_Def_Spec";
import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe";
import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check";
import { buildParts } from "./M02_SO_Def_Parts";
import { setReflect, syncEnabled } from "./M02_SO_Def_Reflect";
import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table";
import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { buildGroupBlock } from "./M02_SO_Groups"; import { buildGroupBlock } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text"; import { to } from "./M02_SO_Text";
@@ -44,10 +50,12 @@ export interface DefScreenOptions {
const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */ /** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */
function tidy(def: StructDef): StructDef { function tidy(def: StructDef): StructDef {
const out = clone(def); const out = clone(def);
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[]) syncEnabled(out);
const reflect = (out.반영 ?? {}) as Record<string, unknown>;
for (const part of [out.그룹, out.배치, out.종류, out.표시, reflect] as Record<string, unknown>[])
for (const [k, v] of Object.entries(part)) for (const [k, v] of Object.entries(part))
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k]; if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
return out; return out;
@@ -88,6 +96,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
let current: string | null = null; let current: string | null = null;
const open = new Set<string>(); // 선택지를 펼친 옵션 const open = new Set<string>(); // 선택지를 펼친 옵션
let versions: DefVersion[] | null = null; let versions: DefVersion[] | null = null;
type Tab = "table" | "kinds" | "parts";
let tab: Tab = "table";
const shown = (): StructDef => past ?? work; const shown = (): StructDef => past ?? work;
const canEdit = (): boolean => opt.isAdmin && !past; const canEdit = (): boolean => opt.isAdmin && !past;
@@ -100,6 +110,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
attrs: { "data-sod-history": "" }, attrs: { "data-sod-history": "" },
}); });
/** 검사 글 — 이름 · 배치 · 규칙(`problems`) + 구조물 표(서버 짝 · 앞 판 = 지금 서버 판) */
const allProblems = (): string[] =>
past ? [] : [...problems(work), ...catalogProblems(work as CheckDef, base as CheckDef)];
const tabBar = el("div", { className: "m02-sod__tabs", attrs: { "data-sod-tabs": "" } });
const body = el("div", { className: "m02-sod__body" });
const panes = el("div", { className: "m02-opts__panes", children: [kindList, detail] });
const changed = (): void => { const changed = (): void => {
dirty = true; dirty = true;
sync(); sync();
@@ -199,10 +216,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
cls: "m02-sod__name", cls: "m02-sod__name",
}), }),
el("small", { className: "m02-opts__key", text: type.type_id }), el("small", { className: "m02-opts__key", text: type.type_id }),
checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), { // 안 씀 = 목록 B06 만(enabled 는 목록 짝 · 322-2) · 숨김(hidden)은 한눈 표 목록 칸에서
edit, checkBox(
attr: "data-sod-kind-off", to("Unused"),
}), type.enabled === false,
(v) => (setReflect(d, type.type_id, "목록", v ? "b06" : "both"), changed()),
{ edit, attr: "data-sod-kind-off" },
),
...(!OLD_EDITOR ...(!OLD_EDITOR
? [] ? []
: [ : [
@@ -225,7 +245,10 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
], ],
}), }),
...(!OLD_EDITOR ...(!OLD_EDITOR
? [buildSpecBlock({ def: d, type, spec, edit, changed })] ? [
buildReflectBlock(d, type, edit, changed),
buildSpecBlock({ def: d, base, type, spec, edit, changed }),
]
: [ : [
buildOptionsBlock({ def: d, type, edit, open, changed }), buildOptionsBlock({ def: d, type, edit, open, changed }),
buildLayoutBlock({ def: d, type, edit, changed }), buildLayoutBlock({ def: d, type, edit, changed }),
@@ -235,6 +258,27 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
); );
}; };
const paintTabs = (): void => {
const pairs: Array<[Tab, string]> = [
["table", to("TabTable")],
["kinds", to("TabKinds")],
["parts", to("TabParts")],
];
tabBar.replaceChildren(
...pairs.map(([key, label]) => {
const b = createButton({ label, variant: key === tab ? "filled" : "ghost", size: "sm" });
b.setAttribute("data-sod-tab", key);
b.addEventListener("click", () => ((tab = key), paint()));
return b;
}),
);
const edit = canEdit();
if (tab === "table")
body.replaceChildren(buildTable({ def: shown(), edit, changed, issues: allProblems() }));
else if (tab === "parts") body.replaceChildren(buildParts({ def: shown(), edit, changed }));
else body.replaceChildren(panes);
};
const paintFoot = (): void => { const paintFoot = (): void => {
banner.replaceChildren( banner.replaceChildren(
...(past ...(past
@@ -248,7 +292,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
]), ]),
); );
banner.classList.toggle("is-past", !!past); banner.classList.toggle("is-past", !!past);
const list = past ? [] : problems(work); const list = allProblems();
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t }))); issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0; save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
}; };
@@ -256,6 +300,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
const paint = (): void => { const paint = (): void => {
paintKinds(); paintKinds();
paintDetail(); paintDetail();
paintTabs();
paintFoot(); paintFoot();
}; };
@@ -273,7 +318,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
const save = createButton({ label: to("Save"), variant: "filled" }); const save = createButton({ label: to("Save"), variant: "filled" });
save.setAttribute("data-sod-save", ""); save.setAttribute("data-sod-save", "");
save.addEventListener("click", async () => { save.addEventListener("click", async () => {
if (problems(work).length) return; if (allProblems().length) return;
save.disabled = true; // 연달아 눌러도 한 번만 save.disabled = true; // 연달아 눌러도 한 번만
try { try {
base = await saveStructureDef(base.판, tidy(work)); base = await saveStructureDef(base.판, tidy(work));
@@ -339,7 +384,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
children: [ children: [
el("p", { className: "m02-side__empty", text: to("DefNote") }), el("p", { className: "m02-side__empty", text: to("DefNote") }),
banner, banner,
el("div", { className: "m02-opts__panes", children: [kindList, detail] }), tabBar,
body,
issues, issues,
el("div", { el("div", {
className: "m02-side__row", className: "m02-side__row",
@@ -0,0 +1,192 @@
/* =============================================================================
* M02_SO_Def_Check.ts
* 구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 정의 `반영` · `부품` 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정.
*
* · 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것
* · 부품 = `{"갈래.부품": false}`(끈 것만)
* · 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 ·
* 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면
* 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠)
* · 앞 판이 없으면(옛 판 읽기) 꼴만 검사
* · 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로)
* 서버 짝 = `M02_SO_Check.py`(같은 글) · 거울 시험 = `resources/tester/test_322_2_check_mirror.py`.
* ========================================================================== */
import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" };
import {
resolveCatalog,
type CatalogDefinition,
} from "../../common_util/common_util_structure_catalog";
type Branch = "횡단도" | "카드이름" | "형상" | "3D";
interface Part {
이름: string;
"읽는 칸": string[];
자리꼴: string[];
정본: string[];
}
const PARTS_BY = (PARTS as unknown as { parts: Record<Branch, Record<string, Part>> }).parts;
export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"];
/** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */
export const OFF_VALUE: Record<Branch, string> = {
횡단도: "none",
카드이름: "name",
형상: "none",
"3D": "none",
};
export const LISTS = ["both", "b06", "hidden"] as const;
const FIELDS = new Set(["목록", "형상칸", ...BRANCHES]);
/** 검사가 읽는 정의 칸. */
export interface CheckDef extends CatalogDefinition {
types?: ReadonlyArray<{
type_id: string;
name?: string;
group?: string;
placement?: string;
managed_by?: string | null;
options?: ReadonlyArray<{
key: string;
input?: string;
choices?: readonly string[];
default?: unknown;
empty_means?: string | null;
}>;
}>;
표시?: Record<string, Record<string, boolean>>;
}
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */
const partOf = (branch: string, id: string): Part | undefined => {
const box = (PARTS_BY as Record<string, Record<string, Part>>)[branch];
return box && Object.hasOwn(box, id) ? box[id] : undefined;
};
/** 반영 · 부품 꼴. */
function shapeErrors(doc: CheckDef, known: Set<string>): string[] {
const out: string[] = [];
const reflect: unknown = doc.반영 ?? {};
if (!isObject(reflect)) out.push("반영은 {종류: 묶음} 이어야 함");
else
for (const [tid, row] of Object.entries(reflect)) {
if (!known.has(tid) || !isObject(row)) {
out.push(`반영 「${tid}」 — 없는 종류이거나 묶음 아님`);
continue;
}
for (const [field, value] of Object.entries(row)) {
const where = `반영 「${tid}.${field}」`;
if (!FIELDS.has(field)) out.push(`${where} — 모르는 칸`);
else if (field === "목록") {
if (!(LISTS as readonly unknown[]).includes(value))
out.push(`${where} — both · b06 · hidden 중 하나`);
} else if (field === "형상칸") {
const ok =
isObject(value) &&
Object.entries(value).every(([k, v]) =>
k === "form"
? typeof v === "string" && !!v.trim()
: k === "weep" && typeof v === "boolean",
);
if (!ok) out.push(`${where} — form 글 · weep 참거짓`);
} else {
const branch = field as Branch;
if (typeof value !== "string" || (value !== OFF_VALUE[branch] && !partOf(branch, value)))
out.push(`${where} — 없는 부품 「${String(value)}」`);
}
}
}
const parts: unknown = doc.부품 ?? {};
if (!isObject(parts)) out.push("부품은 {갈래.부품: 참거짓} 이어야 함");
else
for (const [key, value] of Object.entries(parts)) {
const at = key.indexOf(".");
if (at < 0 || !partOf(key.slice(0, at), key.slice(at + 1)))
out.push(`부품 「${key}」 — 없는 부품`);
else if (typeof value !== "boolean") out.push(`부품 「${key}」 — 참거짓`);
}
return out;
}
const hiddenIn = (doc: CheckDef | null, tid: string): string[] =>
Object.entries(doc?.표시?.[tid] ?? {})
.filter(([, shown]) => shown === false)
.map(([key]) => key);
/** 표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만). */
function rowErrors(doc: CheckDef, before: CheckDef | null): string[] {
const out: string[] = [];
const rows = resolveCatalog(doc);
for (const t of doc.types ?? []) {
const row = rows.get(t.type_id);
if (!row) continue;
if (row.shape === "none" && row.station !== "none")
out.push(`「${row.name}」 형상 none — 횡단도도 none 이어야 함`);
const kept = hiddenIn(before ?? doc, t.type_id); // 앞 판 없음 = 새로 숨긴 것 없음
const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key));
// 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김
const keys = new Set(
(t.options ?? [])
.filter((o) => (o.default === null || o.default === undefined) && !o.empty_means)
.map((o) => o.key),
);
const used: Array<[Branch, string]> = [
["횡단도", row.station],
["카드이름", row.title],
["형상", row.shape],
["3D", row.model3d],
];
for (const [branch, id] of used) {
const part = partOf(branch, id);
if (!part) continue;
if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner))
out.push(
`「${row.name}」 ${branch} 「${id}」 — 자리꼴 ${row.placement} · 정본 ${row.owner} 에 안 맞음`,
);
for (const key of part["읽는 칸"])
if (keys.has(key) && hidden.includes(key))
out.push(`「${row.name}」 ${branch} 「${id}」 이 읽는 칸 「${key}」 — 숨길 수 없음`);
}
}
return out;
}
/** 앞 판과 비교 — 만든 뒤 고정. */
function fixedErrors(doc: CheckDef, before: CheckDef): string[] {
const out: string[] = [];
const now = new Map((doc.types ?? []).map((t) => [t.type_id, t]));
for (const old of before.types ?? []) {
const t = now.get(old.type_id);
if (!t) {
out.push(`종류 「${old.type_id}」 — 지울 수 없음`);
continue;
}
const fixed: Array<[string, unknown, unknown]> = [
["자리꼴", old.placement, t.placement],
["정본", old.managed_by ?? null, t.managed_by ?? null],
["군 글자", old.group, t.group],
];
for (const [what, a, b] of fixed)
if (a !== b) out.push(`「${old.type_id}」 ${what} — 만든 뒤 고정`);
const options = new Map((t.options ?? []).map((o) => [o.key, o]));
for (const o of old.options ?? []) {
const n = options.get(o.key);
if (o.input !== "select" || !n) continue;
const a = [...(o.choices ?? [])].sort().join("\u0000");
const b = [...(n.choices ?? [])].sort().join("\u0000");
if (a !== b)
out.push(`「${old.type_id}.${o.key}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)`);
}
}
return out;
}
/** 구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 서버 판(없으면 고정 검사 안 함). */
export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[] {
const known = new Set((doc.types ?? []).map((t) => t.type_id));
const shape = shapeErrors(doc, known);
if (shape.length) return shape;
return before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : [];
}
@@ -0,0 +1,73 @@
/* =============================================================================
* M02_SO_Def_Parts.ts
* 구조물 정의 「부품」 탭(PLAN 322-2) — 코드가 그리는 방법(부품 상자 `common_util_structure_parts.json`).
* 갈래마다 부품 · 이름 · 읽는 칸 · 맞는 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 `부품` = 끈 것만).
* 끈 부품을 쓰는 종류는 그 갈래가 없음(카드 이름은 종류 이름)으로 — 표 줄(`resolveCatalog`)이 따름.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { StructDef } from "./M02_SO_Def_Model";
import { PART_BOX, partsOff, setPartOn, usersOf } from "./M02_SO_Def_Reflect";
import { checkBox, head } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
type Branch = keyof typeof PART_BOX;
export function buildParts(opt: {
def: StructDef;
edit: boolean;
changed: () => void;
}): HTMLElement {
const { def, edit, changed } = opt;
const off = partsOff(def);
const blocks = (Object.keys(PART_BOX) as Branch[]).flatMap((branch) => [
...head(branch),
el("table", {
className: "m02-sod__table",
attrs: { "data-sod-parts": branch },
children: [
el("thead", {
children: [
el("tr", {
children: ["", "ColName", "PartRead", "PartFits", "PartUsers", "PartOn"].map((key) =>
el("th", { text: key ? to(key as "ColName") : "" }),
),
}),
],
}),
el("tbody", {
children: Object.entries(PART_BOX[branch]).map(([id, part]) => {
const key = `${branch}.${id}`;
// 끈 부품을 쓰던 종류도 보이게 — 끈 동안은 표가 none 으로 풀므로 켠 꼴로 셈
const users = usersOf({ ...def, 부품: {} } as StructDef, branch, id);
return el("tr", {
attrs: { "data-sod-part": key },
className: off[key] === false ? "is-off" : "",
children: [
el("td", { children: [el("small", { className: "m02-opts__key", text: id })] }),
el("td", { text: part.이름 }),
el("td", { text: part["읽는 칸"].join(" · ") || "—" }),
el("td", { text: `${part.자리꼴.join(" · ")} / ${part.정본.join(" · ")}` }),
el("td", { text: users.map((row) => row.name).join(" · ") || "—" }),
el("td", {
children: [
checkBox(
to("PartOn"),
off[key] !== false,
(on) => (setPartOn(def, key, on), changed()),
{ edit, attr: "data-sod-part-on" },
),
],
}),
],
});
}),
}),
],
}),
]);
return el("div", {
className: "m02-sod__tablewrap",
children: [el("p", { className: "m02-side__empty", text: to("PartsNote") }), ...blocks],
});
}
@@ -0,0 +1,176 @@
/* =============================================================================
* M02_SO_Def_Reflect.ts
* 구조물 표 편집(PLAN 322-2)의 순수 손질 — 정의 사본의 `반영` · `부품` · 목록 + enabled · 형상칸.
*
* · 지금 값 = 구조물 표 한 줄(`resolveCatalog` · 정의 반영 ⊕ 처음값 ⊕ 부품 끔)
* · 반영에는 처음 값과 다른 칸만 남김(같으면 지움 = 「기본」) · [처음 값으로] = 그 종류 반영 지움
* · 목록과 enabled 는 함께 씀 — enabled = 목록 both 일 때만 참(옛 짝 · 브레인 322-1 답 ①)
* 화면 그리기 없음 — 표 · 종류별 · 부품 탭이 이 함수들로 사본을 고침.
* ========================================================================== */
import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" };
import {
resolveCatalog,
type CatalogDefinition,
type CatalogRow,
} from "../../common_util/common_util_structure_catalog";
import type { StructDef } from "./M02_SO_Def_Model";
export type ReflectField = "목록" | "횡단도" | "카드이름" | "형상" | "3D";
type Reflect = Partial<Record<ReflectField, string>> & { 형상칸?: Record<string, unknown> };
interface Part {
이름: string;
"읽는 칸": string[];
자리꼴: string[];
정본: string[];
}
type Branch = Exclude<ReflectField, "목록">;
export const PART_BOX = (PARTS as unknown as { parts: Record<Branch, Record<string, Part>> }).parts;
const START = (PARTS as unknown as { 처음값: Record<string, Reflect> }).처음값;
/** 칸 → 표 줄 열쇠. */
const ROW_KEY: Record<ReflectField, keyof CatalogRow> = {
목록: "list",
횡단도: "station",
카드이름: "title",
형상: "shape",
"3D": "model3d",
};
export const reflectOf = (def: StructDef): Record<string, Reflect> =>
((def as { 반영?: Record<string, Reflect> }).반영 ??= {});
export const partsOff = (def: StructDef): Record<string, boolean> =>
((def as { 부품?: Record<string, boolean> }).부품 ??= {});
/** 사본 전체 표 — 종류마다 한 줄. */
export const rowsOf = (def: StructDef): Map<string, CatalogRow> =>
resolveCatalog(def as unknown as CatalogDefinition);
/** 칸 지금 값. */
export const valueOf = (row: CatalogRow, field: ReflectField): string =>
String(row[ROW_KEY[field]]);
/** 반영이 없을 때(처음 값 · 목록은 enabled 짝) 그 칸 값. */
export function defaultOf(def: StructDef, typeId: string, field: ReflectField): string {
const copy = { ...def, 반영: { ...reflectOf(def) } } as unknown as StructDef;
const own = { ...(reflectOf(copy)[typeId] ?? {}) };
delete own[field];
reflectOf(copy)[typeId] = own;
const row = rowsOf(copy).get(typeId);
return row ? valueOf(row, field) : "";
}
/** 반영에 적힌 칸인가(아니면 「기본」). */
export const isSet = (def: StructDef, typeId: string, field: ReflectField | "형상칸"): boolean =>
field in (reflectOf(def)[typeId] ?? {});
const dropEmpty = (def: StructDef, typeId: string): void => {
const all = reflectOf(def);
if (all[typeId] && !Object.keys(all[typeId]).length) delete all[typeId];
};
/** 칸 고침 — 처음 값과 같으면 반영에서 지움 · 목록이면 enabled 도(both 일 때만 참). */
export function setReflect(
def: StructDef,
typeId: string,
field: ReflectField,
value: string,
): void {
const type = def.types.find((t) => t.type_id === typeId);
if (field === "목록" && type) type.enabled = value === "both";
const own = (reflectOf(def)[typeId] ??= {});
delete own[field];
if (defaultOf(def, typeId, field) !== value) own[field] = value;
dropEmpty(def, typeId);
}
/** 형상칸 한 칸 고침(form 글 · weep 참거짓) — 처음 값과 같으면 지움. */
export function setShapeOpt(def: StructDef, typeId: string, key: string, value: unknown): void {
const own = (reflectOf(def)[typeId] ??= {});
const opts = { ...(own.형상칸 ?? {}) };
const start = START[typeId]?.형상칸?.[key];
if (value === start || value === "" || (value === false && start === undefined)) delete opts[key];
else opts[key] = value;
if (Object.keys(opts).length) own.형상칸 = opts;
else delete own.형상칸;
dropEmpty(def, typeId);
}
/** [처음 값으로] — 그 종류 반영 지움 · enabled = 처음 목록 both 일 때만 참. */
export function resetRow(def: StructDef, typeId: string): void {
delete reflectOf(def)[typeId];
const type = def.types.find((t) => t.type_id === typeId);
if (type) type.enabled = START[typeId] ? START[typeId].목록 === "both" : type.enabled;
}
/** [저장] 전 — 모든 종류 enabled = 지금 목록 both 일 때만 참(목록 + enabled 함께 씀). */
export function syncEnabled(def: StructDef): void {
const rows = rowsOf(def);
for (const type of def.types) {
const row = rows.get(type.type_id);
if (row) type.enabled = row.list === "both";
}
}
/** 부품 켬끔 — 끔만 남김. */
export function setPartOn(def: StructDef, key: string, on: boolean): void {
const off = partsOff(def);
if (on) delete off[key];
else off[key] = false;
}
/** 못 숨김 칸 — 종류가 지금 쓰는 부품들이 읽는 칸 중 기본값도 비워 두는 뜻도 없는 옵션
* (숨기면 그림이 받을 값이 없음 · 검사 짝 `M02_SO_Def_Check`). */
export function readKeys(def: StructDef, typeId: string): Set<string> {
const row = rowsOf(def).get(typeId);
if (!row) return new Set();
const type = def.types.find((t) => t.type_id === typeId);
const bare = new Set(
(type?.options ?? [])
.filter((o) => (o.default === null || o.default === undefined) && !o.empty_means)
.map((o) => o.key),
);
const used: Array<[Branch, string]> = [
["횡단도", row.station],
["카드이름", row.title],
["형상", row.shape],
["3D", row.model3d],
];
return new Set(
used
.flatMap(([branch, id]) => PART_BOX[branch][id]?.["읽는 칸"] ?? [])
.filter((key) => bare.has(key)),
);
}
/** 그 갈래 부품을 지금 쓰는 종류들. */
export function usersOf(def: StructDef, branch: Branch, id: string): CatalogRow[] {
const field = ROW_KEY[branch];
return [...rowsOf(def).values()].filter((row) => row[field] === id);
}
/** 정의 「표시」 숨김 키(그 종류). */
export const hiddenKeys = (def: StructDef, typeId: string): Set<string> =>
new Set(
Object.entries(def.표시[typeId] ?? {})
.filter(([, shown]) => shown === false)
.map(([key]) => key),
);
/** 옵션 키들 보임 켬끔 — 정의 옵션인 키만(숨김 = false 만 남김). */
export function setShown(
def: StructDef,
typeId: string,
keys: readonly string[],
on: boolean,
): void {
const type = def.types.find((t) => t.type_id === typeId);
const known = new Set((type?.options ?? []).map((o) => o.key));
const row = (def.표시[typeId] ??= {});
for (const key of keys.filter((k) => known.has(k))) {
if (on) delete row[key];
else row[key] = false;
}
if (!Object.keys(row).length) delete def.표시[typeId];
}
@@ -6,11 +6,13 @@
* 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품 * 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품
* (`field` · `optionControl` · 그룹 상자)으로 같은 차례. * (`field` · `optionControl` · 그룹 상자)으로 같은 차례.
* 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침.
* 줄마다 보임(322-2 · 정의 「표시」 · 컨테이너 = 그 안 칸 전부) · 부품이 읽는 칸 = 「못 숨김」(앞 판에서
* 이미 숨긴 칸은 다시 보이기만) · 미리보기도 숨김을 따름.
* ========================================================================== */ * ========================================================================== */
import { createTag, el } from "@ui/ui_template_elements"; import { createTag, el } from "@ui/ui_template_elements";
import { createSpecForm } from "../../A00_Common/design/design_structure_form"; import { createSpecForm } from "../../A00_Common/design/design_structure_form";
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; import { specItemKeys, type StructureSpec } from "../../A00_Common/design/design_structure_spec";
import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; import type { StructDef, TypeDef } from "./M02_SO_Def_Model";
@@ -20,7 +22,8 @@ import {
specNameRows, specNameRows,
type SpecNameRow, type SpecNameRow,
} from "./M02_SO_Def_Spec_Model"; } from "./M02_SO_Def_Spec_Model";
import { head, textBox } from "./M02_SO_Def_Ui"; import { hiddenKeys, readKeys, setShown } from "./M02_SO_Def_Reflect";
import { checkBox, head, textBox } from "./M02_SO_Def_Ui";
import { to, type OptionsTextKey } from "./M02_SO_Text"; import { to, type OptionsTextKey } from "./M02_SO_Text";
const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = { const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
@@ -36,7 +39,8 @@ const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
/** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */ /** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */
function groupPreview(def: StructDef, type: TypeDef): HTMLElement { function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
const names = def.종류[type.type_id]?.이름 ?? {}; const names = def.종류[type.type_id]?.이름 ?? {};
const options = type.options.filter((o) => o.enabled !== false); const hidden = hiddenKeys(def, type.type_id);
const options = type.options.filter((o) => o.enabled !== false && !hidden.has(o.key));
const cell = (o: (typeof options)[number]): HTMLElement => const cell = (o: (typeof options)[number]): HTMLElement =>
field( field(
o.unit ? `${o.label} (${o.unit})` : o.label, o.unit ? `${o.label} (${o.unit})` : o.label,
@@ -67,8 +71,45 @@ function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
}); });
} }
/** 이름 줄이 쓰는 정의 옵션 키 — 명세 상자 = 그 안 칸 전부 · 칸 = 저장 · 갈래 키 · 그룹 = 그 옵션들. */
function rowKeys(type: TypeDef, spec: StructureSpec | undefined, nameKey: string): string[] {
if (!nameKey || nameKey.includes("=")) return [];
if (!spec) {
if (!nameKey.startsWith("#")) return [nameKey];
return [];
}
const [cid, iid] = nameKey.split(".");
const c = spec.containers.find((x) => x.id === cid);
const items = (c?.items ?? []).filter((i) => !iid || i.id === iid);
const own = new Set(type.options.map((o) => o.key));
return items.flatMap(specItemKeys).filter((key) => own.has(key));
}
/** 줄 보임 칸 — 키 없는 줄은 비움 · 부품이 읽는 보이는 칸은 못 숨김. */
function shownBox(
opt: { def: StructDef; base: StructDef; type: TypeDef; edit: boolean; changed: () => void },
keys: string[],
): HTMLElement[] {
if (!keys.length) return [];
const { def, base, type, edit, changed } = opt;
const hidden = hiddenKeys(def, type.type_id);
const kept = hiddenKeys(base, type.type_id);
const read = readKeys(def, type.type_id);
const shown = keys.some((key) => !hidden.has(key));
const locked = shown && keys.some((key) => read.has(key) && !kept.has(key));
return [
checkBox(to("ShownCol"), shown, (v) => (setShown(def, type.type_id, keys, v), changed()), {
edit: edit && !locked,
attr: "data-sod-spec-shown",
}),
...(locked ? [createTag(to("CannotHide"), "neutral")] : []),
];
}
export function buildSpecBlock(opt: { export function buildSpecBlock(opt: {
def: StructDef; def: StructDef;
/** 지금 서버 판 — 이미 숨긴 칸(못 숨김 예외)을 가름 */
base: StructDef;
type: TypeDef; type: TypeDef;
/** 없으면 정의 그룹 꼴(52-12) */ /** 없으면 정의 그룹 꼴(52-12) */
spec: StructureSpec | undefined; spec: StructureSpec | undefined;
@@ -90,6 +131,12 @@ export function buildSpecBlock(opt: {
: el("span", { text: to("SpecLoose") }), : el("span", { text: to("SpecLoose") }),
el("small", { className: "m02-opts__key", text: row.key }), el("small", { className: "m02-opts__key", text: row.key }),
...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []),
...shownBox(
opt,
row.group && !spec
? ((def.그룹[type.type_id] ?? []).find((g) => g.id === row.group)?.옵션 ?? [])
: rowKeys(type, spec, row.nameKey),
),
], ],
}), }),
); );
@@ -98,7 +145,14 @@ export function buildSpecBlock(opt: {
if (spec) { if (spec) {
const form = createSpecForm(null); const form = createSpecForm(null);
const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름]));
form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); form.write(
spec,
{},
def.종류[type.type_id]?.이름 ?? {},
groups,
{},
hiddenKeys(def, type.type_id),
);
preview = form.root; preview = form.root;
} else preview = groupPreview(def, type); } else preview = groupPreview(def, type);
preview.inert = true; preview.inert = true;
@@ -0,0 +1,327 @@
/* =============================================================================
* M02_SO_Def_Table.ts
* 구조물 정의 「한눈 표」 탭(PLAN 322-2) — 종류마다 B05 · B06 반영 한 줄.
* 군 · 종류(▲▼) · 이름 · 목록 · 자리꼴 · 정본(읽기만 「고정」) · 횡단도 · 카드 이름 · 형상(+ 형상칸) ·
* 알약 색 · 3D · 검사. 흐린 칸 = 처음 값(「기본」) · 진한 칸 = 바꾼 값 · [처음 값으로] = 그 종류 반영 지움.
* 칸 손질 = `M02_SO_Def_Reflect` · 검사 글 = `M02_SO_Def_Check`(서버 짝) · 「종류별」 탭이 같은 칸(`reflectCells`)을 씀.
* ========================================================================== */
import { createButton, createTag, el } from "@ui/ui_template_elements";
import type { CatalogRow } from "../../common_util/common_util_structure_catalog";
import { stepFamily, stepType, type StructDef, type TypeDef } from "./M02_SO_Def_Model";
import {
isSet,
PART_BOX,
resetRow,
rowsOf,
setReflect,
setShapeOpt,
valueOf,
type ReflectField,
} from "./M02_SO_Def_Reflect";
import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
type Pairs = ReadonlyArray<readonly [string, string]>;
const LIST_PAIRS = (): Pairs => [
["both", to("ListBoth")],
["b06", to("ListB06")],
["hidden", to("ListHidden")],
];
/** 갈래 고르기 — 없음(카드 이름은 종류 이름) + 부품 상자 차례. */
const partPairs = (branch: Exclude<ReflectField, "목록">): Pairs => [
[
branch === "카드이름" ? "name" : "none",
branch === "카드이름" ? to("TitleName") : to("OffNone"),
],
...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, `${id} · ${part.이름}`] as const),
];
interface CellCtx {
def: StructDef;
type: TypeDef;
row: CatalogRow;
edit: boolean;
changed: () => void;
}
/** 반영 칸 하나 — 처음 값이면 흐리게 「기본」 · 바꿨으면 진하게. */
function reflectCell(ctx: CellCtx, field: ReflectField): HTMLElement {
const { def, type, row, edit, changed } = ctx;
const pairs = field === "목록" ? LIST_PAIRS() : partPairs(field);
const box = pickBox(
pairs,
valueOf(row, field),
(v) => {
setReflect(def, type.type_id, field, v);
changed();
},
{ edit, attr: `data-sod-reflect-${field}` },
);
return mark(isSet(def, type.type_id, field), box);
}
/** 흐림(기본) · 진함(바꿈) 꼴. */
function mark(changed: boolean, ...children: HTMLElement[]): HTMLElement {
return el("div", {
className: `m02-sod__cell ${changed ? "is-changed" : "is-default"}`,
children: [...children, ...(changed ? [] : [createTag(to("Default"), "neutral")])],
});
}
/** 형상칸 — 벽(형상 wall)만: 기본 형태 글 · 물빼기. */
function shapeOptsCell(ctx: CellCtx): HTMLElement {
const { def, type, row, edit, changed } = ctx;
if (row.shape !== "wall") return el("span", { className: "m02-side__empty", text: "—" });
const form = String(row.shapeOpts.form ?? "");
return mark(
isSet(def, type.type_id, "형상칸"),
textBox(
form,
() => undefined,
() => undefined,
{
edit,
attr: "data-sod-shape-form",
place: to("WallForm"),
max: 20,
},
),
checkBox(
to("Weep"),
row.shapeOpts.weep === true,
(v) => (setShapeOpt(def, type.type_id, "weep", v), changed()),
{ edit, attr: "data-sod-shape-weep" },
),
);
}
/** 형상칸 글은 떠날 때 씀(초점 안 끊김). */
function bindForm(node: HTMLElement, ctx: CellCtx): void {
const box = node.querySelector<HTMLInputElement>("[data-sod-shape-form]");
box?.addEventListener("change", () => {
setShapeOpt(ctx.def, ctx.type.type_id, "form", box.value.trim());
ctx.changed();
});
}
/** 알약 색(style.color). */
function colorCell(ctx: CellCtx): HTMLElement {
const box = el("input", {
className: "m02-sod__color",
attrs: { type: "color", "data-sod-color": "" },
}) as HTMLInputElement;
const style = (ctx.type.style ?? {}) as { color?: string };
box.value = style.color ?? "#888888";
box.disabled = !ctx.edit;
box.addEventListener("change", () => {
ctx.type.style = { ...style, color: box.value };
ctx.changed();
});
return box;
}
const fixed = (text: string): HTMLElement =>
el("span", {
className: "m02-sod__fixed",
children: [el("span", { text }), createTag(to("Fixed"), "neutral")],
});
/** 「종류별」 탭이 쓰는 반영 칸들 — [글, 칸] 짝. */
export function reflectCells(
def: StructDef,
type: TypeDef,
edit: boolean,
changed: () => void,
): Array<[string, HTMLElement]> {
const row = rowsOf(def).get(type.type_id);
if (!row) return [];
const ctx = { def, type, row, edit, changed };
const opts = shapeOptsCell(ctx);
bindForm(opts, ctx);
return [
[to("ColList"), reflectCell(ctx, "목록")],
[to("ColPlacement"), fixed(row.placement)],
[to("ColOwner"), fixed(row.owner)],
[to("ColStation"), reflectCell(ctx, "횡단도")],
[to("ColTitle"), reflectCell(ctx, "카드이름")],
[to("ColShape"), reflectCell(ctx, "형상")],
[to("ColShapeOpts"), opts],
[to("ColColor"), colorCell(ctx)],
[to("Col3D"), reflectCell(ctx, "3D")],
];
}
function moveButton(label: string, run: () => boolean, off: boolean, changed: () => void) {
const b = createButton({ label, variant: "ghost", size: "sm" });
b.disabled = off;
b.addEventListener("click", () => run() && changed());
return b;
}
/** 그 종류 검사 글 — 이름 · 키가 든 줄. */
const issuesOf = (issues: readonly string[], type: TypeDef): string[] =>
issues.filter((t) => t.includes(`「${type.name}」`) || t.includes(`「${type.type_id}`));
export function buildTable(opt: {
def: StructDef;
edit: boolean;
changed: () => void;
issues: readonly string[];
}): HTMLElement {
const { def, edit, changed, issues } = opt;
const heads = [
"ColFamily",
"ColKind",
"ColName",
"ColList",
"ColPlacement",
"ColOwner",
"ColStation",
"ColTitle",
"ColShape",
"ColShapeOpts",
"ColColor",
"Col3D",
"ColCheck",
] as const;
const td = (child: HTMLElement | string): HTMLElement =>
el("td", typeof child === "string" ? { text: child } : { children: [child] });
const body: HTMLElement[] = [];
const families = Object.keys(def.구조물군);
families.forEach((g, gi) => {
const name = textBox(def.구조물군[g], (v) => (def.구조물군[g] = v), changed, {
edit,
attr: "data-sod-family-name",
});
const familyRow = el("tr", {
className: "m02-sod__familyrow",
attrs: { "data-sod-table-family": g },
children: [
el("td", {
attrs: { colspan: String(heads.length) },
children: [
el("strong", { text: g }),
name,
...(edit
? [
moveButton(to("GroupUp"), () => stepFamily(def, g, -1), gi === 0, changed),
moveButton(
to("GroupDown"),
() => stepFamily(def, g, 1),
gi === families.length - 1,
changed,
),
]
: []),
],
}),
],
});
body.push(familyRow);
const kinds = def.types.filter((t) => t.group === g);
kinds.forEach((type, ti) => {
const cells = new Map(reflectCells(def, type, edit, changed));
const bad = issuesOf(issues, type);
const reset = createButton({ label: to("Reset"), variant: "ghost", size: "sm" });
reset.disabled = !edit || !(def as { 반영?: Record<string, unknown> }).반영?.[type.type_id];
reset.addEventListener("click", () => (resetRow(def, type.type_id), changed()));
body.push(
el("tr", {
attrs: { "data-sod-table-kind": type.type_id },
children: [
td(""),
el("td", {
children: [
el("small", { className: "m02-opts__key", text: type.type_id }),
...(edit
? [
moveButton(
to("GroupUp"),
() => stepType(def, type.type_id, -1),
ti === 0,
changed,
),
moveButton(
to("GroupDown"),
() => stepType(def, type.type_id, 1),
ti === kinds.length - 1,
changed,
),
]
: []),
],
}),
td(
textBox(type.name, (v) => (type.name = v), changed, {
edit,
attr: "data-sod-kind-name",
}),
),
...[
"ColList",
"ColPlacement",
"ColOwner",
"ColStation",
"ColTitle",
"ColShape",
"ColShapeOpts",
"ColColor",
"Col3D",
].map((key) => td(cells.get(to(key as (typeof heads)[number])) ?? "")),
el("td", {
attrs: { "data-sod-table-check": "" },
children: [
bad.length
? el("span", { className: "m02-sod__bad", text: bad.join(" · ") })
: createTag(to("Ok"), "accent"),
reset,
],
}),
],
}),
);
});
});
return el("div", {
className: "m02-sod__tablewrap",
children: [
el("p", { className: "m02-side__empty", text: to("TableNote") }),
el("table", {
className: "m02-sod__table",
attrs: { "data-sod-table": "" },
children: [
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: to(h) })) })],
}),
el("tbody", { children: body }),
],
}),
],
});
}
/** 「종류별」 탭 머리 아래 반영 칸 묶음(한눈 표 한 줄과 같은 칸). */
export function buildReflectBlock(
def: StructDef,
type: TypeDef,
edit: boolean,
changed: () => void,
): HTMLElement {
return el("div", {
className: "m02-sod__block",
attrs: { "data-sod-reflect-block": "" },
children: [
el("h4", { className: "m02-opts__group", text: to("ReflectHead") }),
el("div", {
className: "m02-sod__reflect",
children: reflectCells(def, type, edit, changed).map(([label, cell]) =>
el("label", {
className: "m02-sod__reflectrow",
children: [el("span", { text: label }), cell],
}),
),
}),
],
});
}
@@ -18,10 +18,12 @@ from pydantic import ValidationError
from B05_Profile import B05_Profile_Structures_Schema as schema from B05_Profile import B05_Profile_Structures_Schema as schema
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors
FILE = "구조물정의.json" FILE = "구조물정의.json"
HISTORY = "구조물정의_이력" HISTORY = "구조물정의_이력"
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"} # 반영 · 부품 = 구조물 표 칸(322-2 · 없으면 처음값 · 검사 = `M02_SO_Check`)
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types", "반영", "부품"}
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list} SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
SPOT = ("줄", "칸", "너비") SPOT = ("줄", "칸", "너비")
@@ -171,8 +173,8 @@ def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s
return errors return errors
def check(doc: Any) -> list[str]: def check(doc: Any, before: dict[str, Any] | None = None) -> list[str]:
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과).""" """정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과). `before` = 지금 판(만든 뒤 고정 · 322-2)."""
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
from M02_MasterTemplete.StructOptions import M02_SO_Options as options from M02_MasterTemplete.StructOptions import M02_SO_Options as options
@@ -194,7 +196,8 @@ def check(doc: Any) -> list[str]:
fn(doc[section], plain) fn(doc[section], plain)
except store.StoreError as e: except store.StoreError as e:
errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)] errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)]
return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys) errors += _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
return errors + ([] if errors else catalog_errors(doc, before))
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]: def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
@@ -206,7 +209,7 @@ def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) ->
if str(version) != str(have): if str(version) != str(have):
raise store.StoreError(409, {"stale": [FILE], "판": have}) raise store.StoreError(409, {"stale": [FILE], "판": have})
doc = {**change(current), "판": have} doc = {**change(current), "판": have}
errors = check(doc) errors = check(doc, current)
if errors: if errors:
raise store.StoreError(422, {"errors": errors}) raise store.StoreError(422, {"errors": errors})
atomic_write_json(history_path(have), current) atomic_write_json(history_path(have), current)
@@ -368,3 +368,67 @@
font-size: var(--text-caption); font-size: var(--text-caption);
line-height: 1; line-height: 1;
} }
/* ── 322-2 탭 셋 · 한눈 표 · 부품 ── */
.m02-sod__tabs {
display: flex;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.m02-sod__tablewrap {
overflow-x: auto;
}
.m02-sod__table {
border-collapse: collapse;
font-size: var(--text-body-sm);
min-width: 100%;
}
.m02-sod__table th,
.m02-sod__table td {
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-4);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.m02-sod__familyrow td {
background: var(--color-surface-raised);
}
.m02-sod__table tr.is-off {
opacity: 0.5;
}
.m02-sod__cell {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.m02-sod__cell.is-default {
opacity: 0.6;
}
.m02-sod__cell.is-changed {
font-weight: 600;
}
.m02-sod__fixed {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
opacity: 0.7;
}
.m02-sod__color {
width: 32px;
height: 22px;
padding: 0;
border: none;
background: none;
}
.m02-sod__reflect {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-4);
}
.m02-sod__reflectrow {
display: grid;
grid-template-columns: 7em 1fr;
align-items: center;
gap: var(--spacing-4);
}
@@ -125,6 +125,48 @@ const TEXT = {
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"], Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"], Detail: ["B06 전용", "B06 only"],
Save: ["저장", "Save"], Save: ["저장", "Save"],
TabTable: ["한눈 표", "Overview"],
TabKinds: ["종류별", "By type"],
TabParts: ["부품", "Parts"],
TableNote: [
"종류마다 B05 · B06 반영 한 줄 — 목록 · 횡단도 · 카드 이름 · 형상 · 3D 는 코드 부품 중에서 고름 · 흐린 칸 = 처음 값(기본) · 진한 칸 = 바꾼 값 · 자리꼴 · 정본은 만든 뒤 고정",
"One row per type — pick parts for list · cross section · card name · shape · 3D; faded = default, bold = changed",
],
ColFamily: ["군", "Family"],
ColKind: ["종류", "Type"],
ColName: ["이름", "Name"],
ColList: ["목록", "List"],
ColPlacement: ["자리꼴", "Placement"],
ColOwner: ["정본", "Owner"],
ColStation: ["횡단도", "Cross section"],
ColTitle: ["카드 이름", "Card name"],
ColShape: ["형상", "Shape"],
ColShapeOpts: ["형상칸", "Shape fields"],
ColColor: ["알약 색", "Pill color"],
Col3D: ["3D", "3D"],
ColCheck: ["검사", "Check"],
ListBoth: ["B05 · B06", "B05 · B06"],
ListB06: ["B06 만", "B06 only"],
ListHidden: ["안 보임", "Hidden"],
OffNone: ["없음", "None"],
TitleName: ["종류 이름", "Type name"],
Default: ["기본", "Default"],
Fixed: ["고정", "Fixed"],
Reset: ["처음 값으로", "Reset"],
Ok: ["통과", "OK"],
WallForm: ["기본 형태", "Default form"],
Weep: ["물빼기", "Weep holes"],
ReflectHead: ["B05 · B06 반영", "B05 · B06 use"],
ShownCol: ["보임", "Shown"],
CannotHide: ["못 숨김", "Required"],
PartsNote: [
"코드가 그리는 방법(부품) — 끄면 그 부품을 쓰는 종류는 그 갈래가 없음으로 · 읽는 칸 = 그 부품이 쓰는 옵션(숨기지 못함)",
"Code drawing parts — turning a part off makes its users fall back to none; read fields cannot be hidden",
],
PartRead: ["읽는 칸", "Reads"],
PartFits: ["맞는 자리꼴 · 정본", "Fits"],
PartUsers: ["쓰는 종류", "Used by"],
PartOn: ["켬", "On"],
Stale: [ Stale: [
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요", "그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again", "Changed meanwhile — reloaded, please edit again",
+9
View File
@@ -223,6 +223,7 @@ const {
fetchStructureTypes, fetchStructureTypes,
onStaleStructureTypes, onStaleStructureTypes,
pipesToStructureMarks, pipesToStructureMarks,
staleDefinitionNotice,
writePendingStructures, writePendingStructures,
} = loadTs(B05("B05_Profile_Api_Structures")); } = loadTs(B05("B05_Profile_Api_Structures"));
const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store")); const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store"));
@@ -322,6 +323,14 @@ async function main() {
await fetchStructureTypes(true); await fetchStructureTypes(true);
const staleCalls = server.typeCalls - sameCalls; const staleCalls = server.typeCalls - sameCalls;
off(); off();
// 판 알림(322-2) — 프로젝트 정본이 옛 판으로 적혔으면 한 번만 · 같은 판 · 판 없음은 안 알림
const old = server.version - 1;
out.notice = [
staleDefinitionNotice(PROJECT, old),
staleDefinitionNotice(PROJECT, old),
staleDefinitionNotice(PROJECT, server.version),
staleDefinitionNotice(PROJECT, null),
];
out.version = { out.version = {
same, same,
sameCalls, sameCalls,
+109
View File
@@ -0,0 +1,109 @@
/* PLAN 322-2 구조물 표 검사 · 편집 헬퍼 — 진짜 `M02_SO_Def_Check.ts` · `M02_SO_Def_Reflect.ts` ·
* `design_structure_spec.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = argv[2] JSON `{cases: {이름: [정의, 앞 판|null]}, definition}`. 짝 = `test_322_2_check_mirror.py`.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function load(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
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 need = (spec) => {
const base = path.join(path.dirname(full), spec);
const hit = [base, `${base}.ts`, base.replace(/\.js$/, ".ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
);
if (!hit) throw new Error(`모름: ${spec} (${full})`);
return load(hit);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const SO = (name) => path.join(ROOT, "M02_MasterTemplete", "StructOptions", `${name}.ts`);
const check = load(SO("M02_SO_Def_Check"));
const reflect = load(SO("M02_SO_Def_Reflect"));
const spec = load(path.join(ROOT, "A00_Common", "design", "design_structure_spec.ts"));
const { STRUCTURE_SPECS } = load(
path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"),
);
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const clone = (v) => JSON.parse(JSON.stringify(v));
const out = { cases: {} };
for (const [name, [doc, before]] of Object.entries(input.cases))
out.cases[name] = check.catalogProblems(doc, before);
// 반영 손질 — 목록 + enabled 짝 · 처음 값이면 지움 · 형상칸 · 보임 · 부품 쓰는 종류
const def = clone(input.definition);
const enabled = (id) => def.types.find((t) => t.type_id === id).enabled;
const own = (id) => clone(reflect.reflectOf(def)[id] ?? null);
reflect.setReflect(def, "wildlife_path", "목록", "both");
reflect.setReflect(def, "retaining_wall", "목록", "b06");
reflect.setReflect(def, "open_ditch", "횡단도", "none");
reflect.setReflect(def, "cross_drain_exposed", "횡단도", "anchor");
const setBefore = reflect.isSet(def, "cross_drain_exposed", "횡단도");
reflect.setShapeOpt(def, "soil_guard", "weep", true);
const weepOn = own("soil_guard");
reflect.setShapeOpt(def, "soil_guard", "weep", false);
reflect.setShown(def, "pipe", ["inlet_type", "없는키"], false);
out.reflect = {
wildlife: [own("wildlife_path"), enabled("wildlife_path")],
wall: [own("retaining_wall"), enabled("retaining_wall")],
openDitch: own("open_ditch"),
exposed: [own("cross_drain_exposed"), setBefore],
weepOn,
weepOff: own("soil_guard"),
shown: clone(def.표시.pipe),
readPipe: [...reflect.readKeys(def, "pipe")].sort(),
wallUsers: reflect.usersOf(def, "형상", "wall").map((row) => row.typeId),
};
reflect.resetRow(def, "cross_drain_exposed");
reflect.resetRow(def, "retaining_wall");
out.reflect.reset = [own("cross_drain_exposed"), own("retaining_wall"), enabled("retaining_wall")];
// 저장 전 목록 + enabled 함께 — 목록은 그대로 · enabled = both 일 때만 참
const plain = clone(input.definition);
const lists = (d) => [...reflect.rowsOf(d).values()].map((row) => `${row.typeId}:${row.list}`);
const listBefore = lists(plain);
reflect.syncEnabled(plain);
out.sync = {
same: JSON.stringify(lists(plain)) === JSON.stringify(listBefore),
enabled: plain.types.filter((t) => t.enabled).map((t) => t.type_id),
};
reflect.setPartOn(plain, "형상.wall", false);
out.partOff = [clone(plain.부품), reflect.rowsOf(plain).get("retaining_wall").shape];
reflect.setPartOn(plain, "형상.wall", true);
out.partOn = clone(plain.부품);
// 명세 폼 숨김 — 저장 · 갈래 키가 모두 숨김인 칸 · 키 있는 칸이 모두 숨긴 상자
out.spec = {};
for (const [tid, sp] of Object.entries(STRUCTURE_SPECS)) {
const keys = sp.containers.flatMap((c) => c.items.flatMap((i) => spec.specItemKeys(i)));
const hidden = new Set(keys.slice(0, 1));
const all = new Set(keys);
out.spec[tid] = {
first: keys[0] ?? null,
hiddenItems: sp.containers.flatMap((c) =>
c.items.filter((i) => spec.specItemHidden(i, hidden)).map((i) => `${c.id}.${i.id}`),
),
allBoxes: sp.containers.filter((c) => spec.specContainerHidden(c, all)).map((c) => c.id),
keyedBoxes: sp.containers
.filter((c) => c.items.some((i) => spec.specItemKeys(i).length))
.map((c) => c.id),
noneHidden: sp.containers.filter((c) => spec.specContainerHidden(c, new Set())).length,
};
}
process.stdout.write(JSON.stringify(out));
+12 -1
View File
@@ -182,7 +182,7 @@ def _run(work: Path) -> dict:
for index, sample in enumerate(samples): for index, sample in enumerate(samples):
key = f"{sample['type_id']}/{sample['case']}" key = f"{sample['type_id']}/{sample['case']}"
rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)} rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)}
return {"samples": rows, "pick": ts["pick"], "version": ts["version"]} return {"samples": rows, "pick": ts["pick"], "version": ts["version"], "notice": ts["notice"]}
def test_type_cache_follows_definition_version(result: dict) -> None: def test_type_cache_follows_definition_version(result: dict) -> None:
@@ -196,6 +196,17 @@ def test_type_cache_follows_definition_version(result: dict) -> None:
} }
def test_stale_definition_notice_once(result: dict) -> None:
"""322-2 판 알림 — structures.json 판 ≠ 지금 판이면 한 줄 · 탭 수명 동안 한 번 · 같은 판 · 판 없음은 없음."""
now = load_definition()["판"] + 1 # 시험 서버가 판을 하나 올린 뒤
assert result["notice"] == [
f"구조물 정의가 판 {now} 으로 바뀜 — 저장하면 반영",
None,
None,
None,
]
def _cells(table: dict) -> dict[tuple[str, str, str], object]: def _cells(table: dict) -> dict[tuple[str, str, str], object]:
return { return {
(part, key, column): value (part, key, column): value
+238
View File
@@ -0,0 +1,238 @@
"""PLAN 322-2 구조물 표 화면 — 검사 짝(화면 = 서버) · 반영 손질 · [저장] 길 · 명세 폼 숨김.
- 거울: 검사 견본마다 TS `catalogProblems` = 파이썬 `catalog_errors`(글까지 같음).
- 1-3 규칙: 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 부품이 읽는 칸 새로 숨김 금지 ·
만든 뒤 고정(자리꼴 · 정본 · 군 글자 · 종류 지우기 · 선택지 값).
- 반영 손질: 처음 값이면 반영에서 지움 · 목록 + enabled(both 일 때만 참) · 형상칸 · 보임 · 부품 켬끔.
- 서버 [저장]: 반영 · 부품 · 알약 색 · enabled 를 받아 판 +1 · 표(`catalog()`)가 바로 따름 · 틀리면 422.
- 명세 폼 숨김: 칸 = 쓰는 옵션 키가 모두 숨김 · 상자 = 키 있는 칸이 모두 숨김(지금 정의로는 숨는 칸 없음).
TS 쪽 = `helper_322_2_check.cjs`(Node) · 마스터 정의는 읽기만(사본으로 고침).
"""
from __future__ import annotations
import copy
import json
import subprocess
import tempfile
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B05_Profile import B05_Profile_Structures_Schema as schema
from common_util.common_util_auth import verify_session
from common_util.common_util_structure_catalog import catalog
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
URL = "/api/m02/structure-definition"
ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1}
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
def _real() -> dict:
return json.loads(Path(schema.DEFINITION_PATH).read_text(encoding="utf-8"))
def _type(doc: dict, type_id: str) -> dict:
return next(t for t in doc["types"] if t["type_id"] == type_id)
def _cases() -> dict[str, tuple[dict, dict | None]]:
real = _real()
cases: dict[str, tuple[dict, dict | None]] = {"real": (real, real)}
bad = copy.deepcopy(real)
bad["반영"] = {
"nope": {},
"pipe": {
"모름": 1,
"목록": "x",
"횡단도": "zz",
"형상": 3,
"형상칸": {"form": " "},
"3D": "none",
},
}
bad["부품"] = {"형상.zz": False, "횡단도.span3": "no", "끔": False}
cases["shape_bad"] = (bad, real)
rule = copy.deepcopy(real)
rule["반영"] = {"open_ditch": {"횡단도": "anchor"}}
cases["rule218"] = (rule, real)
mismatch = copy.deepcopy(real)
mismatch["반영"] = {"retaining_wall": {"형상": "pipe_set"}, "berm": {"횡단도": "anchor"}}
cases["mismatch"] = (mismatch, real)
hide = copy.deepcopy(real)
hide["표시"]["pipe"]["revet_foundation"] = False # 관 세트가 읽는 기본값 없는 칸 새로 숨김
hide["표시"]["retaining_wall"]["height_m"] = False # 기본값 있는 칸 = 숨겨도 됨(늘 기본값)
cases["hide_read"] = (hide, real)
cases["hide_no_before"] = (hide, None)
fixed = copy.deepcopy(real)
_type(fixed, "box_culvert")["placement"] = "interval"
_type(fixed, "revetment")["managed_by"] = None
_type(fixed, "mirror")["group"] = "F"
dia = next(o for o in _type(fixed, "pipe")["options"] if o["key"] == "pipe_diameter_mm")
dia["choices"] = [*dia["choices"], "2000"]
fixed["types"] = [t for t in fixed["types"] if t["type_id"] != "etc"]
cases["fixed"] = (fixed, real)
off = copy.deepcopy(real)
off["부품"] = {"형상.wall": False}
cases["parts_off"] = (off, real)
return cases
@pytest.fixture(scope="module")
def ts() -> dict:
payload = {"cases": _cases(), "definition": _real()}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "input.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
out = subprocess.run(
["node", str(HERE / "helper_322_2_check.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(out.stdout)
def test_check_mirror_screen_equals_server(ts: dict) -> None:
for name, (doc, before) in _cases().items():
assert catalog_errors(doc, before) == ts["cases"][name], name
def test_check_rules(ts: dict) -> None:
got = ts["cases"]
assert got["real"] == []
assert got["shape_bad"] == [
"반영 「nope」 — 없는 종류이거나 묶음 아님",
"반영 「pipe.모름」 — 모르는 칸",
"반영 「pipe.목록」 — both · b06 · hidden 중 하나",
"반영 「pipe.횡단도」 — 없는 부품 「zz」",
"반영 「pipe.형상」 — 없는 부품 「3」",
"반영 「pipe.형상칸」 — form 글 · weep 참거짓",
"부품 「형상.zz」 — 없는 부품",
"부품 「횡단도.span3」 — 참거짓",
"부품 「끔」 — 없는 부품",
]
assert got["rule218"] == ["「개거(겉도랑)」 형상 none — 횡단도도 none 이어야 함"]
assert got["mismatch"] == [
"「옹벽」 형상 「pipe_set」 — 자리꼴 interval · 정본 structures 에 안 맞음"
]
assert got["hide_read"] == [
"「배관」 형상 「pipe_set」 이 읽는 칸 「revet_foundation」 — 숨길 수 없음"
]
assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음
assert got["fixed"] == [
# 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음
"「BOX암거」 형상 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음",
"「BOX암거」 3D 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음",
"「기슭막이」 형상 「hidden_revet」 — 자리꼴 interval · 정본 structures 에 안 맞음",
"「pipe.pipe_diameter_mm」 선택지 값 — 고칠 수 없음(이름은 이름 표로)",
"「box_culvert」 자리꼴 — 만든 뒤 고정",
"「revetment」 정본 — 만든 뒤 고정",
"「mirror」 군 글자 — 만든 뒤 고정",
"종류 「etc」 — 지울 수 없음",
]
# 형상 부품을 끄면 벽 다섯은 형상 none — 횡단도도 none 으로 바꿔야 저장됨
assert len(got["parts_off"]) == 5 and all("횡단도도 none" in t for t in got["parts_off"])
def test_reflect_edits(ts: dict) -> None:
r = ts["reflect"]
assert r["wildlife"] == [None, True] # b06 → both = enabled 참이 곧 처음 값 짝(반영 안 남음)
assert r["wall"] == [None, False] # both → b06 = enabled 거짓
assert r["openDitch"] is None # 처음 값(none)과 같음 — 반영 안 남음
assert r["exposed"] == [{"횡단도": "anchor"}, True]
assert r["weepOn"] == {"형상칸": {"weep": True}} and r["weepOff"] is None
assert r["shown"] == {"pipe_kind": False, "inlet_type": False} # 정의 옵션만
assert r["readPipe"] == ["inlet_basin_height_m", "revet_foundation"] # 기본값 없는 읽는 칸
assert r["wallUsers"] == [
"retaining_wall",
"masonry_wet",
"masonry_dry",
"soil_guard",
"boulder_masonry",
]
assert r["reset"] == [None, None, True]
def test_save_writes_list_and_enabled_together(ts: dict) -> None:
sync = ts["sync"]
assert sync["same"] is True
rows = catalog()
assert set(sync["enabled"]) == {t for t, row in rows.items() if row["list"] == "both"}
assert len(sync["enabled"]) == 23 # B05 목록 23(브레인 화면 확인)
assert ts["partOff"] == [{"형상.wall": False}, "none"] and ts["partOn"] == {}
def test_spec_form_hidden_rule(ts: dict) -> None:
spec = ts["spec"]
assert spec["pipe"]["hiddenItems"] == ["pipe.diameter"]
assert spec["box_culvert"]["allBoxes"] == spec["box_culvert"]["keyedBoxes"]
assert all(s["noneHidden"] == 0 for s in spec.values())
assert spec["etc"]["keyedBoxes"] == [] # 선택 상자 인스턴스 키는 옵션 밖 — 숨김 대상 아님
@pytest.fixture
def copy_def(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "구조물정의.json"
path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
return path
def _client() -> TestClient:
app = FastAPI()
app.include_router(read_router)
app.dependency_overrides[verify_session] = lambda: ADMIN
return TestClient(app)
def test_server_save_reflect_parts_color_enabled(copy_def: Path) -> None:
client = _client()
doc = client.get(URL).json()
sent = copy.deepcopy(doc)
sent["반영"] = {"open_ditch": {"횡단도": "anchor", "형상": "underdrain", "목록": "b06"}}
sent["부품"] = {"3D.chute": False}
_type(sent, "mirror")["style"]["color"] = "#123456"
_type(sent, "masonry_wet")["enabled"] = False # 목록 hidden ⇒ enabled 거짓(화면이 함께 씀)
res = client.put(URL, json={"판": doc["판"], "정의": sent})
assert res.status_code == 200, res.text
got = res.json()
assert got["판"] == doc["판"] + 1
assert got["반영"] == sent["반영"] and got["부품"] == sent["부품"]
rows = catalog() # 파일 시각으로 바로 새 표
assert (rows["open_ditch"]["station"], rows["open_ditch"]["list"]) == ("anchor", "b06")
assert rows["ford_pavement"]["model3d"] == "none"
assert rows["mirror"]["color"] == "#123456"
assert rows["masonry_wet"]["list"] == "hidden"
@pytest.mark.parametrize(
"change",
[
lambda d: d.update(반영={"open_ditch": {"횡단도": "anchor"}}), # 218
lambda d: d["표시"].setdefault("pipe", {}).update(revet_foundation=False), # 못 숨김 칸
lambda d: next(o for o in _type(d, "pipe")["options"] if o["key"] == "pipe_kind")[
"choices"
].append("새관"), # 선택지 값
lambda d: _type(d, "berm").update(placement="point"), # 자리꼴 고정
],
)
def test_server_rejects_rule_breaks(copy_def: Path, change) -> None:
client = _client()
doc = client.get(URL).json()
sent = copy.deepcopy(doc)
change(sent)
res = client.put(URL, json={"판": doc["판"], "정의": sent})
assert res.status_code == 422, res.text
assert json.loads(copy_def.read_text("utf-8"))["판"] == doc["판"]
@@ -47,7 +47,8 @@ def _screen_edit(doc: dict) -> dict:
pipe["name"] = "배수관(고침)" pipe["name"] = "배수관(고침)"
kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind") kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind")
kind["label"] = "관 종류" kind["label"] = "관 종류"
kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]] # 선택지 값은 만든 뒤 고정(322-2) — 이름은 이름 표(`종류.pipe.이름`)로
doc["종류"].setdefault("pipe", {}).setdefault("이름", {})["pipe.kind=흄관"] = "흄관(고침)"
pipe["options"].append( pipe["options"].append(
{ {
"key": "opt_ab12cd", "key": "opt_ab12cd",
@@ -62,7 +63,7 @@ def _screen_edit(doc: dict) -> dict:
doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]} doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]}
doc["종류"].setdefault("pipe", {})["기준측점"] = False doc["종류"].setdefault("pipe", {})["기준측점"] = False
doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}} doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}}
doc["표시"]["pipe"] = {"inlet_type": False} doc["표시"]["pipe"] = {"wing_wall_type": False} # 부품이 안 읽는 칸(322-2 읽는 칸은 못 숨김)
doc["그룹"]["pipe"][1]["형상"] = { doc["그룹"]["pipe"][1]["형상"] = {
"길이": {"받기": True, "뜻": "유입 보호공 연장"}, "길이": {"받기": True, "뜻": "유입 보호공 연장"},
"높이": {"받기": False, "뜻": ""}, "높이": {"받기": False, "뜻": ""},
@@ -70,7 +71,7 @@ def _screen_edit(doc: dict) -> dict:
doc["규칙"] = [ doc["규칙"] = [
{ {
"종류": "pipe", "종류": "pipe",
"만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]}, "만약": {"옵션": "pipe_kind", "값들": ["흄관"]},
"그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"}, "그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"},
}, },
{ {