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:
@@ -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,
|
||||||
|
|||||||
@@ -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]:
|
||||||
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
|
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
|
||||||
|
|
||||||
|
|||||||
@@ -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 }>,
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user