feat(구조물표): 322-0 안전망 — 42종 전수 표 시험(지금 결과 기록 · ★ 바뀔 칸 표시) · 「기타」 유령 길 막기(지금 · 지난 판 종류 이름 전부) · 종류 캐시 판 확인(보기 열 때 · 탭 다시 보일 때) · 서버 관 기본값 캐시 걷음

- 전수 표 시험 test_322_0_golden + helper_322_0_golden.cjs · 기록 golden_322_structures.json(측점 · 카드 이름 · 형상 열쇠 · 3D 몸체 · 알약 띠 · 고르기 목록 · 물빼기 · ★ 27칸)
- 이관 = 지금 판 + 구조물정의_이력 판_N 종류 이름 측점은 안 옮김(all_type_names)
- GET /api/m02/structure-definition/version · dropStaleStructureTypes · onStaleStructureTypes
- _registry_defaults lru 걷음(정의 읽기가 이미 파일 시각 캐시)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 14:16:27 +09:00
co-authored by Claude Opus 5.5
parent 2641c15e29
commit 863abc7766
14 changed files with 3449 additions and 18 deletions
+10 -3
View File
@@ -17,9 +17,10 @@ import { leaveForDashboard } from "../b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
import type {
StructureInstance,
StructureType,
import {
onStaleStructureTypes,
type StructureInstance,
type StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import {
fetchSectionContext,
@@ -567,6 +568,12 @@ export async function mountCrossView(
void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
host.onDispose(
onStaleStructureTypes(() => {
if (host.visible()) void structuresPanel.load().then(restoreStructurePick);
}),
);
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
+10 -1
View File
@@ -57,7 +57,10 @@ import {
type SectionDetailResponse,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
import {
migrateLegacyStations,
onStaleStructureTypes,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import {
loadCorridorIfFresh,
@@ -548,6 +551,12 @@ export async function mountProfileView(
onReset: () => uphillOverrides.clear(),
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
host.onDispose(
onStaleStructureTypes(() => {
if (host.visible() && !restoring) void bridge.load();
}),
);
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, {
projectId: activeProjectId,
+40 -2
View File
@@ -192,8 +192,42 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
}
}
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
/** 타입 레지스트리 캐시 — 정의 판(`typesVersion`)이 바뀌면(M02 저장) 버리고 다시 받음(322-0). */
let typesCache: Promise<StructureType[]> | null = null;
let typesVersion: number | null = null;
/** 정의 판이 캐시 판과 다르면 종류 캐시를 버림 — 버렸으면 true(B05 · B06 보기를 열 때 · 탭이 다시 보일 때). */
export async function dropStaleStructureTypes(): Promise<boolean> {
if (!typesCache || typesVersion === null) return false;
const now = await requestJson<{ 판: number }>("/m02/structure-definition/version", {
method: "GET",
})
.then((payload) => payload.판)
.catch(() => null);
if (now === null || now === typesVersion) return false;
typesCache = null;
typesVersion = null;
return true;
}
const staleListeners = new Set<() => void>();
function checkStaleOnShow(): void {
if (document.visibilityState !== "visible") return;
void dropStaleStructureTypes().then((stale) => {
if (stale) staleListeners.forEach((reload) => reload());
});
}
/** 탭이 다시 보일 때 정의 판이 바뀌었으면 `reload`(종류 · 패널 다시 읽음) — 떼는 함수를 돌려줌. */
export function onStaleStructureTypes(reload: () => void): () => void {
if (!staleListeners.size) document.addEventListener("visibilitychange", checkStaleOnShow);
staleListeners.add(reload);
return () => {
staleListeners.delete(reload);
if (!staleListeners.size) document.removeEventListener("visibilitychange", checkStaleOnShow);
};
}
/**
* 구조물 타입 목록.
@@ -212,7 +246,11 @@ export function fetchStructureTypes(includeDisabled = false): Promise<StructureT
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
method: "GET",
})
.then(async (payload) => withDefinition(payload.types, await definition))
.then(async (payload) => {
const defined = await definition;
typesVersion = defined?.판 ?? null;
return withDefinition(payload.types, defined);
})
.catch((error) => {
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
throw error;
@@ -21,7 +21,7 @@ from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
station_planting_labels,
all_type_names,
structure_type_map,
)
from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint
@@ -55,16 +55,13 @@ _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
def _managed_elsewhere_labels() -> set[str]:
"""서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 →
"기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels).
"""정본이 이미 관리하는 측점 이름 — 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트).
관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서
투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관).
지금 판 · 지난 판 정의의 **종류 이름 전부**(322-0) — 심는 측점 이름 · 관 지점 시설 이름만
보던 옛 규칙은 M02 에서 종류 이름을 바꾸면 옛 이름 측점을 못 알아보고 「기타」로 굳혔다
(이름 바꿈 · 심는 규칙 바뀜 둘 다).
"""
owned = {
item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER
}
return station_planting_labels() | owned
return all_type_names()
def _is_managed_elsewhere(entry: dict[str, Any]) -> bool:
@@ -162,6 +162,26 @@ def station_planting_labels() -> set[str]:
return {item.name for item in load_structure_types() if is_station_planting_type(item)}
#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름)
HISTORY_DIRNAME = "구조물정의_이력"
def all_type_names() -> set[str]:
"""지금 판 + 지난 판 정의의 종류 이름 전부 — 이름을 바꾼 뒤 옛 이름 측점도 알아봄(322-0)."""
names = {item.name for item in load_structure_types()}
history = os.path.join(os.path.dirname(DEFINITION_PATH), HISTORY_DIRNAME)
for name in os.listdir(history) if os.path.isdir(history) else []:
if not (name.startswith("판_") and name.endswith(".json")):
continue
try:
with open(os.path.join(history, name), encoding="utf-8") as handle:
types = json.load(handle).get("types", [])
except (OSError, ValueError):
continue
names |= {str(t["name"]) for t in types if isinstance(t, dict) and t.get("name")}
return names
def registry_schema_version() -> int:
return int(_read_definition(*_stamp()).get("schema_version", 1))
@@ -17,6 +17,7 @@ import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
fetchStructures,
dropStaleStructureTypes,
fetchStructureTypes,
saveStructures,
readPendingStructures,
@@ -346,6 +347,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
// · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군).
// · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은
// 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다.
await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0)
const [allTypes, stored] = await Promise.all([
fetchStructureTypes(true),
fetchStructures(deps.projectId),
+2 -3
View File
@@ -25,7 +25,6 @@ import json
import logging
import math
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -147,12 +146,12 @@ def pipe_points_file(project_root: Path) -> Path:
)
@lru_cache(maxsize=4)
def _registry_defaults(type_id: str) -> dict[str, Any]:
"""레지스트리 타입 옵션의 기본값 묶음.
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
기본값과 항상 같은 값이 그림에 들어간다.
기본값과 항상 같은 값이 그림에 들어간다. 캐시 없음(322-0) — 정의 읽기가 이미 파일 시각으로
갈려 M02 저장 즉시 새 기본값(옛 lru 는 서버를 다시 켤 때까지 옛 값).
"""
structure_type = structure_type_map().get(type_id)
if structure_type is None:
@@ -20,6 +20,7 @@ import {
} from "../A00_Common/design/design_shared";
import {
fetchStructures,
dropStaleStructureTypes,
fetchStructureTypes,
readPendingStructures,
pipesToStructureMarks,
@@ -369,6 +370,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
if (!deps.projectId) return;
const projectId = deps.projectId;
try {
await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0)
const [types, stored, pipeResponse] = await Promise.all([
// B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서
// 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이
@@ -12,6 +12,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from B05_Profile.B05_Profile_Structures_Schema import definition_version
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -84,6 +85,13 @@ def get_structure_definition(session=Depends(verify_session)) -> dict:
return _call(definition.read)
@read_router.get("/structure-definition/version")
def get_structure_definition_version(session=Depends(verify_session)) -> dict:
"""지금 정의 판 `{판}` — B05 · B06 종류 캐시가 판을 볼 때(322-0 · 한 벌을 다 받지 않음)."""
access.require(session, "system", None)
return {"판": definition_version()}
@read_router.get("/structure-definition/history")
def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]:
"""지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2)."""
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
/* PLAN 322-0 전수 표 시험 헬퍼 — 42종 × 견본(구조물 · 관 지점 · 빈칸)마다 지금 화면 함수 결과를 표준출력 JSON 한 벌로.
* 측점(`structureStationMarks` · `pipeStationMarks`) · 카드 이름 · 형상 열쇠(`Section_Store` 초안 얹기 → 미리 뜬 횡단 ·
* 벽 · 노면 · 맹암거) · 3D 몸체(`buildCorridorStructures`) · 알약 띠(`buildStructureLane`) · 고르기 목록(B05 · B06 `pickGroups`).
* 입력 = argv[2] JSON(서버 꼴 types · 정의 · 견본 · 격자 · 맨 횡단) — 파이썬 짝이 만듦. 짝 = `test_322_0_golden.py`.
* 진짜 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 부르는 곳 · 무거운 화면 모듈만 흉내).
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const clone = (v) => JSON.parse(JSON.stringify(v));
/* ── 브라우저 흉내 ─────────────────────────────────────────────────────── */
class FakeStorage {
constructor() {
this.map = new Map();
}
get length() {
return this.map.size;
}
key(i) {
return [...this.map.keys()][i] ?? null;
}
getItem(k) {
return this.map.has(k) ? this.map.get(k) : null;
}
setItem(k, v) {
this.map.set(k, String(v));
}
removeItem(k) {
this.map.delete(k);
}
clear() {
this.map.clear();
}
}
class FakeElement {
constructor(tag) {
this.tagName = tag;
this.className = "";
this.style = {};
this.dataset = {};
this.children = [];
this.textContent = "";
this.classList = { toggle: () => {}, add: () => {}, remove: () => {}, contains: () => false };
}
append(...nodes) {
this.children.push(...nodes);
}
prepend(...nodes) {
this.children.unshift(...nodes);
}
appendChild(node) {
this.children.push(node);
return node;
}
addEventListener() {}
setAttribute() {}
}
globalThis.window = {
sessionStorage: new FakeStorage(),
localStorage: new FakeStorage(),
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
requestAnimationFrame: (fn) => fn(),
setTimeout,
clearTimeout,
location: { origin: "http://localhost", pathname: "/" },
};
globalThis.sessionStorage = window.sessionStorage;
globalThis.localStorage = window.localStorage;
globalThis.requestAnimationFrame = (fn) => fn();
const docListeners = new Map();
globalThis.document = {
activeElement: null,
hidden: false,
visibilityState: "visible",
addEventListener: (type, fn) => docListeners.set(type, fn),
removeEventListener: (type, fn) => docListeners.get(type) === fn && docListeners.delete(type),
createElement: (tag) => new FakeElement(tag),
};
/** 서버 흉내 — 정의 판(`server.version`) · 종류 받은 횟수(`server.typeCalls`). */
const server = { version: input.definition.판, typeCalls: 0 };
globalThis.fetch = async (url) => {
const at = String(url);
if (at.includes("/structure-types")) server.typeCalls += 1;
const body = at.includes("structure-definition/version")
? { 판: server.version }
: at.includes("structure-definition")
? { ...clone(input.definition), 판: server.version }
: at.includes("/route/structures")
? { status: "success", project_id: PROJECT, revision: 0, structures: [] }
: { status: "success", schema_version: 1, types: clone(input.types) };
return { ok: true, status: 200, json: async () => body };
};
/* ── TS 싣기 — 흉내 파일만 갈음 ──────────────────────────────────────────── */
const file = (...parts) => path.resolve(ROOT, ...parts);
const B05 = (name) => file("B05_Profile", `${name}.ts`);
const B06 = (name) => file("B06_Section", `${name}.ts`);
const PROJECT = "P322";
let pendingPipes = null;
const STUBS = new Map([
[
B06("B06_Section_Api_Fetch"),
{
fetchSectionDetail: async () => currentDetail(),
saveSections: async () => ({}),
previewStructureSections: async (_p, chainages) =>
chainages.map((at) => ({ ...plainAt(at), kind: "irregular" })),
},
],
[B06("B06_Section_Cross_Refresh"), { USER_TOUCHED_KEYS: [] }],
[
B06("B06_Section_Api_Culvert_Options"),
{ kindChangedChainages: () => [], STRUCTURE_ADJUST_KEYS: [] },
],
[
B06("B06_Section_Structure_Layouts"),
{ applyStructureAreaRows: () => {}, structureAreaRows: () => [] },
],
[
B05("B05_Profile_Api_Pipes_Draft"),
{ readPendingPipes: () => pendingPipes, writePendingPipes: () => {} },
],
[
file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"),
{ fetchDetailPipePoints: async () => ({ pipe_points: [] }), noteSnapshotFailed: () => {} },
],
[file("A00_Common", "touch_bridge.ts"), { pickPx: () => 6 }],
[file("ui_template", "ui_template_toast.ts"), { showToast: () => {} }],
[file("ui_template", "ui_template_elements.ts"), { showToast: () => {} }],
]);
const resolveFile = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const cache = new Map();
function loadTs(target) {
const full = path.resolve(target);
if (STUBS.has(full)) return STUBS.get(full);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
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) => {
if (spec === "three") return require(path.join(ROOT, "config", "node_modules", "three"));
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (spec.startsWith(prefix))
return loadTs(resolveFile(path.join(ROOT, dir, spec.slice(prefix.length))));
if (spec.startsWith(".")) return loadTs(resolveFile(path.join(path.dirname(full), spec)));
return require(spec);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
/* ── 견본 횡단 ───────────────────────────────────────────────────────────── */
const plainAt = (at) => ({ ...clone(input.plain_section), chainage_m: at, kind: "regular" });
const currentDetail = () => ({
longitudinal: { stations: input.grid.map((at) => ({ chainage_m: at, kind: "regular" })) },
cross_sections: input.grid.map(plainAt),
});
const frameAtOf = (sections) => (atM) => {
const s = sections.reduce((b, x) =>
Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b,
);
const [lx, ly] = s.frame.left_xy;
const along = atM - s.chainage_m;
return {
cx: s.center_x - ly * along,
cy: s.center_y + lx * along,
leftX: lx,
leftY: ly,
designZ: s.design?.design_elevation_m ?? null,
};
};
/** 횡단 하나에 얹힌 구조물 그림 자료 — 열쇠 + 고르는 데 쓰는 값만(파이썬 짝 `_shape_of` 와 같은 꼴). */
const SHAPE_KEYS = [
"culvert",
"ford",
"box",
"ford_pavement",
"revetment",
"pavement",
"underdrain",
];
function shapeOf(section) {
const out = {};
for (const key of SHAPE_KEYS) {
const spec = section[key];
if (!spec) continue;
if (key === "culvert") out[key] = { hidden_pipe: !!spec.hidden_pipe, body: !!spec.body };
else if (key === "revetment")
out[key] = { type_id: spec.type_id ?? null, form: spec.form ?? null };
else if (key === "pavement") out[key] = spec.map((p) => p.type_id).sort();
else out[key] = true;
}
return out;
}
const at2 = (n) => Number(n).toFixed(2);
const { structureStationMarks, pipeStationMarks } = loadTs(
file("common_util", "common_util_structure_station.ts"),
);
const {
dropStaleStructureTypes,
fetchStructureTypes,
onStaleStructureTypes,
pipesToStructureMarks,
writePendingStructures,
} = loadTs(B05("B05_Profile_Api_Structures"));
const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store"));
const { forgetDraftStations } = loadTs(B06("B06_Section_Section_Drafts"));
const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures"));
const { computeRevetmentLayout } = loadTs(B06("B06_Section_UI_Cross_Revetment"));
const { buildStructureLane } = loadTs(B05("B05_Profile_UI_Structures_Marks"));
const { pickGroups } = loadTs(B05("B05_Profile_UI_Structures_Panel_Types"));
const { structureDisplayName } = loadTs(B06("B06_Section_UI_Section_Common"));
async function main() {
const types = await fetchStructureTypes(true);
const byId = new Map(types.map((type) => [type.type_id, type]));
const out = { samples: {}, pick: {} };
// 고르기 목록 — 페이지가 넘기는 거르기 그대로(B05 = 켜진 것 · B06 = 전부)
const pagePicks = { b05: (type) => type.enabled, b06: () => true };
for (const [page, pick] of Object.entries(pagePicks)) {
const seen = {};
for (const group of pickGroups(types, pick))
for (const member of group.types)
seen[member.type.type_id] = group.hidden || member.hidden ? "hidden" : "shown";
for (const type of types)
(out.pick[type.type_id] ??= {})[page] = seen[type.type_id] ?? "absent";
}
let route = 0;
for (const sample of input.samples) {
route += 1;
const type = byId.get(sample.type_id);
const row = {};
// 측점
const marks = sample.pipe
? pipeStationMarks(sample.pipe)
: structureStationMarks(sample.structure, type);
row.station = marks.map((m) => `${m.role}@${at2(m.chainage_m)}`);
// 초안 얹기(Section_Store) → 카드 이름 · 형상 열쇠
window.sessionStorage.clear();
forgetDraftStations(PROJECT);
pendingPipes = sample.pipe ? [clone(sample.pipe)] : null;
writePendingStructures(PROJECT, sample.structure ? [clone(sample.structure)] : null);
const detail = await loadSectionDetail(PROJECT, route, { force: true });
const touched = detail.cross_sections.filter(
(section) => section.structure || Object.keys(shapeOf(section)).length,
);
row.cards = Object.fromEntries(
touched.map((section) => [
at2(section.chainage_m),
{
title: section.structure ?? null,
display: section.structure ? structureDisplayName(section.structure) : null,
shape: shapeOf(section),
},
]),
);
// 벽 물빼기(옹벽만 · 형상칸.weep)
row.weep = touched
.filter((section) => section.revetment)
.some((section) =>
(computeRevetmentLayout(section, undefined)?.walls ?? []).some((wall) => wall.retaining),
);
// 3D 몸체
const solids = touched.length ? buildCorridorStructures(touched, frameAtOf(touched)) : [];
row.model3d = [...new Set(solids.map((s) => `${s.kind}:${s.key ?? ""}`))].sort();
// 알약 띠
const lane = buildStructureLane({
structures: sample.pipe
? pipesToStructureMarks([sample.pipe])
: [{ ...sample.structure, structure_id: "s1" }],
types,
x: (m) => m,
chainageAt: (px) => px,
maxChainageM: 400,
widthPx: 400,
axisWidthPx: 40,
stationIntervalM: 20,
selectedId: null,
onSelect: () => {},
});
row.band = lane.children.filter((el) => el.className.startsWith("b05-structure__band")).length;
out.samples[`${sample.type_id}/${sample.case}`] = row;
}
// 판 확인(322-0 ③) — 같은 판 = 캐시 그대로 · 바뀐 판 = 버리고 다시 받음 · 탭이 다시 보이면 알림
await fetchStructureTypes(true);
server.typeCalls = 0;
const same = await dropStaleStructureTypes();
await fetchStructureTypes(true);
const sameCalls = server.typeCalls;
let reloads = 0;
const off = onStaleStructureTypes(() => (reloads += 1));
server.version += 1;
docListeners.get("visibilitychange")?.();
await new Promise((resolve) => setTimeout(resolve, 0));
await fetchStructureTypes(true);
const staleCalls = server.typeCalls - sameCalls;
off();
out.version = {
same,
sameCalls,
reloads,
staleCalls,
removed: !docListeners.has("visibilitychange"),
};
process.stdout.write(JSON.stringify(out));
}
main().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
+73
View File
@@ -0,0 +1,73 @@
"""PLAN 322-0 ② — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 구조물로 되옮기지 않음.
정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꾸고 앞 판을 `구조물정의_이력/판_N.json` 으로 둠 →
옛 이름 · 새 이름 측점 모두 이관 0건. 옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 종전대로 옮김.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from B05_Profile import B05_Profile_Structures_Schema as schema
from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations
MASTER = Path(schema.DEFINITION_PATH)
RENAMED = {
"cross_drain_exposed": "노출수로",
"box_culvert": "박스암거",
"retaining_wall": "옹벽(신)",
}
@pytest.fixture()
def renamed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
old = json.loads(MASTER.read_text(encoding="utf-8"))
history = tmp_path / schema.HISTORY_DIRNAME
history.mkdir()
shutil.copy(MASTER, history / f"판_{old['판']}.json")
new = {**old, "판": old["판"] + 1}
new["types"] = [{**t, "name": RENAMED.get(t["type_id"], t["name"])} for t in old["types"]]
path = tmp_path / "구조물정의.json"
path.write_text(json.dumps(new, ensure_ascii=False), encoding="utf-8")
monkeypatch.setattr(schema, "DEFINITION_PATH", str(path))
return old
def _moved(label: str) -> int:
plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}])
return len(plan.structures) + len(plan.pipe_facilities)
def test_old_and_new_names_are_not_migrated(renamed: dict) -> None:
assert schema.structure_type_map()["box_culvert"].name == "박스암거" # 사본을 읽음
for type_id, name in RENAMED.items():
old_name = next(t["name"] for t in renamed["types"] if t["type_id"] == type_id)
assert _moved(old_name) == 0, old_name
assert _moved(name) == 0, name
def test_every_type_name_of_any_version_is_blocked(renamed: dict) -> None:
names = {t["name"] for t in renamed["types"]} | set(RENAMED.values())
assert names <= schema.all_type_names()
assert [name for name in sorted(names) if _moved(name)] == []
def test_legacy_free_text_still_migrates(renamed: dict) -> None:
assert _moved("기성막이") == 1 # 옛 오기 → 기슭막이(관 지점)
plan = migrate_irregular_stations([{"chainage_m": 300.0, "structure": "대피로 2.0m"}])
assert [s.type_id for s in plan.structures] == ["refuge"]
plan = migrate_irregular_stations([{"chainage_m": 15.0, "structure": "돌망태 보강"}])
assert [(s.type_id, s.options["name"]) for s in plan.structures] == [("etc", "돌망태 보강")]
def test_master_history_names_are_read() -> None:
"""실제 마스터 이력(판_1 ~)의 이름도 모음 — 이력 파일은 읽기만."""
history = MASTER.parent / schema.HISTORY_DIRNAME
names = set()
for path in history.glob("판_*.json"):
names |= {t["name"] for t in json.loads(path.read_text(encoding="utf-8"))["types"]}
assert names <= schema.all_type_names()
+250
View File
@@ -0,0 +1,250 @@
"""PLAN 322-0 전수 표 시험 — 구조물 42종 × 견본마다 일곱 갈래 결과를 기록(`golden_322_structures.json`)과 맞춤.
갈래 = 심는 측점(TS `structureStationMarks` · `pipeStationMarks` · 파이썬 `resolve_extra_stations`) · 카드 이름
(파이썬 측점 이름 · TS 미리 뜬 횡단 이름 · 표시 이름) · 형상 얹기 열쇠(파이썬 attach 차례 · TS `Section_Store` 초안 얹기)
· 3D 몸체 · 알약 띠 · 고르기 목록(B05 · B06 `pickGroups`) · 옹벽 물빼기.
견본 = 종류마다 기본값 한 벌(main) · 빈칸(blank) · 배관은 흄관(hume) 하나 더 — 점형 · 부지형 = 101 m · 구간형 = 93 ~ 113 m
(기준 101) · 관 지점 = 101 m(기슭막이 96 ~ 106 m) · 격자 = 0 ~ 200 m 20 m 마다.
기록 다시 쓰기 = `GOLDEN_322_RECORD=1` · ★ = 계획(PLAN_구조물표 4장)대로 바뀔 칸 — 기록에 「지금 → 다음」을 적고
`STAGE` 가 그 단계를 넘으면 다음 값과 맞춤. TS 쪽 = `helper_322_0_golden.cjs`(Node).
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import tempfile
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
from B05_Profile.B05_Profile_Structures_Schema import load_definition
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains
from common_util.common_util_drainage_pipes import parse_pipe_points
from common_util.common_util_structure_pavement import attach_pavement_structures
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
GOLDEN = HERE / "golden_322_structures.json"
FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json"
#: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤
STAGE = 0
GRID = [float(at) for at in range(0, 201, 20)]
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
def _plain_section() -> dict:
sections = json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"]
plain = copy.deepcopy(next(s for s in sections if s["chainage_m"] == 180))
for key in (*SHAPE_KEYS, "structure"):
plain.pop(key, None)
return plain
def _samples(definition: dict) -> list[dict]:
out: list[dict] = []
for item in definition["types"]:
defaults = {
option["key"]: option["default"]
for option in item.get("options", [])
if option.get("default") is not None
}
cases = {"main": defaults, "blank": {}}
if item["type_id"] == "pipe":
cases["hume"] = {**defaults, "pipe_kind": "흄관", "pipe_diameter_mm": "800"}
for case, options in cases.items():
pipe = structure = None
if item.get("managed_by"):
pipe = {"chainage_m": 101.0, "source": "user", "facility": item["type_id"]}
if item["type_id"] == "revetment":
pipe.update(start_m=96.0, end_m=106.0)
if options:
pipe["options"] = options
else:
structure = {
"structure_id": "s1",
"type_id": item["type_id"],
"placement": item["placement"],
"chainage_m": 101.0,
"options": options,
"memo": "",
"placement_source": "manual",
"status": "draft",
"revision": 0,
"geometry": None,
}
if item["placement"] == "interval":
structure.update(start_m=93.0, end_m=113.0)
out.append(
{"type_id": item["type_id"], "case": case, "pipe": pipe, "structure": structure}
)
return out
def _shape_of(section: dict) -> dict:
"""TS 짝 `shapeOf` 와 같은 꼴."""
out: dict = {}
for key in SHAPE_KEYS:
spec = section.get(key)
if not spec:
continue
if key == "culvert":
out[key] = {
"hidden_pipe": bool(spec.get("hidden_pipe")),
"body": bool(spec.get("body")),
}
elif key == "revetment":
out[key] = {"type_id": spec.get("type_id"), "form": spec.get("form")}
elif key == "pavement":
out[key] = sorted(p["type_id"] for p in spec)
else:
out[key] = True
return out
def _python_row(sample: dict, root: Path, plain: dict) -> dict:
"""서버 길 — 정본 파일을 쓰고 측점 만들기 · 횡단 얹기 차례(`_read_section_detail`)를 그대로."""
if sample["structure"]:
path = Path(structures_file_path(str(root)))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"revision": 1, "structures": [sample["structure"]]}), encoding="utf-8"
)
pipes = parse_pipe_points([sample["pipe"]]) if sample["pipe"] else []
if sample["pipe"]:
path = pipe_points_file(root)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"points": [sample["pipe"]]}), encoding="utf-8")
extras = resolve_extra_stations(root, pipes)
sections = [{**copy.deepcopy(plain), "chainage_m": at, "kind": "regular"} for at in GRID]
for at, label in extras:
hit = next((s for s in sections if abs(s["chainage_m"] - at) < 0.005), None)
if hit is None:
hit = {**copy.deepcopy(plain), "chainage_m": at, "kind": "irregular"}
sections.append(hit)
hit["structure"] = label
sections.sort(key=lambda s: s["chainage_m"])
attach_culvert_sets(root, sections)
relabel_pipe_stations(root, sections)
attach_wall_structures(root, sections)
attach_pavement_structures(root, sections)
attach_underdrains(root, sections)
return {
"station_py": [f"{label}@{at:.2f}" for at, label in extras],
"cards_py": {
f"{s['chainage_m']:.2f}": {"title": s.get("structure"), "shape": _shape_of(s)}
for s in sections
if s.get("structure") or _shape_of(s)
},
}
@pytest.fixture(scope="module")
def result() -> dict:
with tempfile.TemporaryDirectory() as folder:
return _run(Path(folder))
def _run(work: Path) -> dict:
definition = load_definition()
samples = _samples(definition)
plain = _plain_section()
payload = {
"types": with_visible().model_dump()["types"],
"definition": definition,
"samples": samples,
"grid": GRID,
"plain_section": plain,
}
(work / "input.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
out = subprocess.run(
["node", str(HERE / "helper_322_0_golden.cjs"), str(work / "input.json")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
check=True,
)
ts = json.loads(out.stdout)
rows: dict = {}
for index, sample in enumerate(samples):
key = f"{sample['type_id']}/{sample['case']}"
rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)}
return {"samples": rows, "pick": ts["pick"], "version": ts["version"]}
def test_type_cache_follows_definition_version(result: dict) -> None:
"""322-0 ③ — 화면 종류 캐시: 같은 판이면 그대로 · M02 저장으로 판이 바뀌면 탭이 다시 보일 때 버리고 다시 받음."""
assert result["version"] == {
"same": False,
"sameCalls": 0,
"reloads": 1,
"staleCalls": 1,
"removed": True,
}
def _cells(table: dict) -> dict[tuple[str, str, str], object]:
return {
(part, key, column): value
for part in ("samples", "pick")
for key, row in table[part].items()
for column, value in row.items()
}
def test_golden_matches(result: dict) -> None:
if os.environ.get("GOLDEN_322_RECORD") == "1":
old = json.loads(GOLDEN.read_text(encoding="utf-8")) if GOLDEN.exists() else {}
GOLDEN.write_text(
json.dumps(
{
"★ 메모": old.get("★ 메모", []),
"★": old.get("★", []),
"samples": result["samples"],
"pick": result["pick"],
},
ensure_ascii=False,
indent=1,
)
+ "\n",
encoding="utf-8",
)
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
star = {(s["part"], s["key"], s["column"]): s for s in golden["★"]}
want = _cells(golden)
got = _cells(result)
assert set(got) == set(want), "견본 · 칸 목록이 기록과 다름"
wrong = []
for cell, value in want.items():
mark = star.get(cell)
expect = mark["다음"] if mark and STAGE >= mark["단계"] else value
if got[cell] != expect:
wrong.append(f"{cell}: 기록 {expect!r} · 지금 {got[cell]!r}")
assert not wrong, "\n".join(wrong[:40])
def test_golden_covers_every_type(result: dict) -> None:
types = {item["type_id"] for item in load_definition()["types"]}
assert len(types) == 42
assert {key.split("/")[0] for key in result["samples"]} == types
assert set(result["pick"]) == types
def test_star_cells_exist() -> None:
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
cells = _cells(golden)
for mark in golden["★"]:
assert (mark["part"], mark["key"], mark["column"]) in cells, mark
+58
View File
@@ -0,0 +1,58 @@
"""PLAN 322-0 ③ — 판 확인: 서버 관 기본값 · 판 읽기 길이 정의 사본을 바로 따름(서버 다시 켜기 없음).
- `_registry_defaults` = 정의 파일이 바뀌면 다음 부름부터 새 기본값(옛 lru = 서버 다시 켤 때까지 옛 값).
- `GET /api/m02/structure-definition/version` = 지금 판 `{판}` 만(화면 종류 캐시가 판을 볼 때 · 한 벌 안 받음).
화면 캐시 쪽은 `test_322_0_golden.py::test_type_cache_follows_definition_version`.
"""
from __future__ import annotations
import json
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 B06_Section.B06_Section_Engine_Culvert import _registry_defaults
from common_util.common_util_auth import verify_session
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router
@pytest.fixture
def copy(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 _rewrite(path: Path, change) -> None:
doc = json.loads(path.read_text("utf-8"))
change(doc)
path.write_text(json.dumps(doc, ensure_ascii=False), "utf-8")
def test_registry_defaults_follow_definition_file(copy: Path) -> None:
before = _registry_defaults("pipe")["pipe_diameter_mm"]
def bump(doc: dict) -> None:
pipe = next(t for t in doc["types"] if t["type_id"] == "pipe")
option = next(o for o in pipe["options"] if o["key"] == "pipe_diameter_mm")
option["default"] = "1200" if before != "1200" else "800"
_rewrite(copy, bump)
assert _registry_defaults("pipe")["pipe_diameter_mm"] != before
def test_version_endpoint_reads_current_version(copy: Path) -> None:
app = FastAPI()
app.include_router(read_router)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "user_id": 2}
client = TestClient(app)
now = json.loads(copy.read_text("utf-8"))["판"]
assert client.get("/api/m02/structure-definition/version").json() == {"판": now}
_rewrite(copy, lambda doc: doc.update(판=now + 3))
assert client.get("/api/m02/structure-definition/version").json() == {"판": now + 3}