feat(구조물표): 323-6 바꿈 한 길 — 바뀜 신호 하나(구조물 초안도 설계 가게 · watchDrafts) · 종류 바꿈 표 규칙(swapsInPlace · E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로) · 조용한 되돌림 없앰(알림) · 그리기가 조정값 안 만듦 · replayDraftPipeOptions 걷음

- 구조물 초안 쓰기 = useDesignStore().set("structures")(칸 그대로) → 관 초안과 같은 구독 · 횡단 보기 watchDrafts(구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만 · 옵션만은 폼 길이 그 카드만 · 카드 줌 · 팬 지킴) · 옛 되부름 onStructuresChanged 걷음
- 종류 바꿈 = 표(catalog) swapsInPlace — 정본 다름 · 구조물 자리꼴 다름(E · F) = 지우고 같은 기준 측점에 새로(구간 = 기본 길이) · 같으면 종류 키만 · 막히면 옛 기록 · 옛 종류 폼 + 알림(전엔 [수정] 조기 반환 → 조용히 되돌림 · 드롭다운만 새 종류)
- syncApplied = 사용자가 조작한 세션이 있을 때만 깎음(카드 그리기가 자동 배치를 조정값으로 굳혀 [저장]으로 내보내던 것)
- replayDraftPipeOptions 걷음 — 그림 자료는 형상 얹기(초안 현황) · 다단 조정값은 조정 세션이 정본
- 시험: test_323_6_change(신호 · 거르기 · 표 규칙 · 그리기 · 걷음) · test_319 점형 ↔ 구간형 · 막힘 알림 · 되얹기 시험 = 형상 얹기로 · 옛 기대 글 고침

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 18:21:41 +09:00
co-authored by Claude Opus 5.5
parent cb4bab7bf2
commit 529baca3ff
16 changed files with 352 additions and 96 deletions
@@ -9,6 +9,7 @@
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import type { PlannedStation } from "../../common_util/common_util_structure_station";
import { useDesignStore, type DesignStoreValues } from "./design_store";
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
export function structureStatus<P, S>(
@@ -42,3 +43,35 @@ export function plannedIrregular(
};
});
}
/**
* 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
* 돌려주는 함수로 끊음.
*/
export function watchDrafts(projectId: string | null, onChange: () => void): () => void {
const shop = useDesignStore(projectId, null);
type Pipe = NonNullable<DesignStoreValues["pipes"]>[number];
const layout = (pipes: ReadonlyArray<Pipe> | null): string =>
JSON.stringify(
(pipes ?? []).map((p) => [
p.chainage_m,
p.facility ?? "pipe",
p.start_m ?? null,
p.end_m ?? null,
p.summary_item ?? null,
p.group_items ?? null,
]),
);
let seen = layout(shop.get("pipes"));
const offs = [
shop.subscribe("structures", onChange),
shop.subscribe("pipes", (pipes) => {
const key = layout(pipes);
if (key === seen) return;
seen = key;
onChange();
}),
];
return () => offs.forEach((off) => off());
}
+5 -10
View File
@@ -37,14 +37,13 @@ import {
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { paintMapPick } from "./design_pick_profile";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { watchDrafts } from "./design_structure_status";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
replayDraftPipeOptions,
type PipeOptionsContext,
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
import {
@@ -145,7 +144,6 @@ export async function mountCrossView(
syncBermSpans(structures);
},
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
onStructuresChanged: () => void refreshDetailForStructures(),
onPipesWritten: () => {
window.clearTimeout(pipeReloadTimer);
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
@@ -565,11 +563,10 @@ export async function mountCrossView(
void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
host.onDispose(
onStaleStructureTypes(() => {
if (host.visible()) void structuresPanel.load().then(restoreStructurePick);
}),
);
const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick);
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
@@ -669,8 +666,6 @@ export async function mountCrossView(
bindPipeOptions(shared, (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
);
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
+4 -3
View File
@@ -11,11 +11,11 @@
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { useCatalogDefinition } from "../common_util/common_util_structure_catalog";
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { useDesignStore } from "../A00_Common/design/design_store";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import {
type DefinedRule,
@@ -362,11 +362,12 @@ export function defaultOptions(type: StructureType): Record<string, string | num
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
export function readPendingStructures(projectId: string): StructureInstance[] | null {
return readState<StructureInstance[]>("structures", projectId);
return useDesignStore(projectId, null).get("structures");
}
/** 설계 가게로 씀 — 관 초안과 같은 바뀜 신호(구독 · 323-6). 칸은 그대로(`structures`). */
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
writeState("structures", next, projectId);
useDesignStore(projectId, null).set("structures", next);
}
/**
@@ -18,7 +18,8 @@ import { asRoad } from "./B05_Profile_UI_Structures_Define";
import { readState, writeState } from "../A00_Common/b_page_state";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection";
import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { swapsInPlace } from "../common_util/common_util_structure_catalog";
import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options";
/** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */
@@ -89,9 +90,10 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
: ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M);
const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? ""));
if (!next || !prev) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기
// 정본이 다른 종류(관 지점 ↔ 구조물 — 맹암거 · 개거 등)로 = 옛 것을 지워 「없음」을 거친 뒤 같은
// 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 기본값 · 요구 217) · 옛 조정값은 318-4 대로 버림
if (!!next.managed_by !== !!prev.managed_by) {
// 표 규칙(323-6 · `swapsInPlace`) — 정본이 다르거나(관 지점 ↔ 구조물) 구조물 자리꼴이 다르면(E · F 점형 ↔
// 구간형) 옛 것을 지워 「없음」을 거친 뒤 같은 기준 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류
// 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림
if (!swapsInPlace(prev.type_id, next.type_id)) {
const at = record ? structureAnchorM(record) : pipeItem!.chainage_m;
if (record) {
ctx.structures.splice(index, 1);
@@ -135,8 +137,12 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
// 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4)
if (pipeItem)
forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id);
// 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림
if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record;
// 반영이 막히면(측점 · 필수 칸 등) 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰 · 323-6)
if (record && draft && ctx.structures[index] === draft) {
ctx.structures[index] = record;
ctx.loadForm(record);
showToast(`${next.name}(으)로 못 바꿈 — 폼 칸(측점 · 필수 칸)을 확인하세요.`, "error");
}
}
type TypeValues = {
@@ -7,7 +7,6 @@
* ========================================================================== */
import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
@@ -276,22 +275,3 @@ function applySoilTiers(
if (Object.keys(clipped).length)
queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped));
}
/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */
const ADJUST_OWNED = /_revet_(height_m|form)$/;
/**
* 관 초안 옵션을 **횡단 캐시에 다시 얹는다**(24-7a). 새로고침 뒤 캐시 상세는 저장분이라
* 저장 전에 고친 월류 폭·관경 등이 폼·카드에서 옛 값으로 돌아간다 — 초안이 관 옵션의 정본이다.
*/
export function replayDraftPipeOptions(
ctx: PipeOptionsContext,
pipes: ReadonlyArray<DetailPipeInput> | null,
): void {
for (const pipe of pipes ?? []) {
const patch = Object.fromEntries(
Object.entries(pipe.options ?? {}).filter(([key]) => !ADJUST_OWNED.test(key)),
);
if (Object.keys(patch).length) applyPipeOptionsToCache(ctx, pipe.chainage_m, patch, false);
}
}
@@ -280,10 +280,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
shapePicked: () => revetSelection?.shape === true,
syncApplied: (chainageM, role, applied) => {
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. 그리기는 조정값을
// 새로 만들지 않음(323-6) — 사용자가 조작한 세션이 있을 때만 그 값을 깎음(없으면 자동 그대로 · 저장 안 함)
const key = revetKey(chainageM, role);
const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST };
if (sameAdjust(stored, applied)) return;
const stored = revetShifts.get(key);
if (!stored || sameAdjust(stored, applied)) return;
storeAdjust(key, { ...applied });
},
update: (chainageM, role, patch) => {
@@ -108,9 +108,6 @@ export interface B06StructuresPanelDeps {
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로**
* 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */
onStructuresChanged?: () => void;
/** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */
onPipesWritten?: () => void;
}
@@ -215,7 +212,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
section.setStructures(structures);
pushMarks();
if (deps.projectId) writePendingStructures(deps.projectId, structures);
deps.onStructuresChanged?.();
// 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1)
const picked = readSelection(deps.projectId);
const moved = picked?.structureId
@@ -284,7 +280,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_ADD_NOTICE, "success");
},
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
@@ -305,7 +300,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
if (mine && JSON.stringify(linkFields(mine)) !== before) {
persistPipes();
section.setPipeFacilities(pipeFacilities);
deps.onStructuresChanged?.();
}
}
// 폼 값은 **폼 칸 전부**다 — 비운 칸은 지운 것(계획서 24-4a · B05 [저장]과 같은 규칙).
@@ -333,10 +327,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
}
persistPipes();
section.setPipeFacilities(pipeFacilities);
if (facility !== was) {
pushMarks();
deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`)
}
// 카드 · 그림 다시 = 초안 구독(323-6 · `watchDrafts` — 종류가 바뀌면 그 관 자리 그림이 새 종류)
if (facility !== was) pushMarks();
},
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
@@ -517,7 +509,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
if (plan.pipes.length) {
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
persistPipes();
deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로
}
snapNotice(plan.pipes.length, plan.skipped);
}
@@ -544,7 +535,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2)
showToast(PIPE_MOVE_NOTICE, "success");
}
@@ -563,7 +553,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_REMOVE_NOTICE, "success");
}
+13 -1
View File
@@ -170,11 +170,23 @@ export function cardTitle(
return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`;
}
/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */
/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */
export const fieldName = (key: string): string | undefined =>
(PARTS as unknown as { "칸 이름"?: Record<string, string> })["칸 이름"]?.[key];
/**
* 종류 바꿈 = 제자리(종류 키만 갈아 끼움)인가(323-6 · 표 규칙) — 정본이 같고, 구조물은 자리꼴도 같아야.
* 아니면(관 지점 ↔ 구조물 · E · F 점형 ↔ 구간형) 옛 것을 지우고 같은 기준 측점에 새로(구간 = 기본 길이).
* 관 지점 시설은 구간도 옵션(길이 · 전 · 후)이라 자리꼴로 안 가름.
*/
export function swapsInPlace(fromTypeId: string, toTypeId: string): boolean {
const from = catalogRow(fromTypeId);
const to = catalogRow(toTypeId);
if (!from || !to || from.owner !== to.owner) return false;
return from.owner === "pipe_points" || from.placement === to.placement;
}
/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */
export function shapeNeeds(typeId: string): readonly string[] {
const shape = catalogRow(typeId)?.shape ?? "none";
const box = (PARTS as unknown as Parts).parts.형상 as Record<string, { "필수 칸"?: string[] }>;
+64 -4
View File
@@ -95,10 +95,14 @@ const out = {};
],
{
"./B05_Profile_UI_Structures_Define": { asRoad: (record) => record },
"@ui/ui_template_elements": { showConfirmDialog: async () => true, showToast: () => {} },
"@ui/ui_template_elements": {
showConfirmDialog: async () => true,
showToast: (text, kind) => toasts.push([text, kind ?? null]),
},
"./B06_Section_Section_Drafts": { forgetPreviewAdjusts: () => {} },
},
);
const toasts = [];
const { bindStructuresEvents } = load(B05("B05_Profile_UI_Structures_Panel_Events"));
const typeMap = new Map(TYPES.map((type) => [type.type_id, type]));
const el = () => ({
@@ -109,8 +113,18 @@ const out = {};
},
});
const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } });
const run = ({ structures = [], pipes = [], editing = null, pipeAt = null, next }) => {
// commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림)
const run = ({
structures = [],
pipes = [],
editing = null,
pipeAt = null,
next,
commitOk = true,
}) => {
toasts.length = 0;
const calls = {
loaded: [],
removed: [],
addAt: [],
emit: 0,
@@ -164,14 +178,19 @@ const out = {};
syncPlacementFields: () => {},
renderOptionFields: () => {},
syncFacilityForm: () => {},
liveCommit: () => (calls.live += 1),
liveCommit: () => {
calls.live += 1;
const at = structures.findIndex((s) => s.structure_id === editingId);
if (commitOk && at >= 0) structures[at] = { ...structures[at] };
},
loadForm: (target) => calls.loaded.push(target?.type_id ?? null),
emit: () => (calls.emit += 1),
addAt: (at, typeId) => calls.addAt.push([at, typeId]),
interval: () => 20,
};
bindStructuresEvents(ctx);
ctx.typeSelect.listeners.change();
return { ...calls, left: structures.map((s) => s.type_id) };
return { ...calls, left: structures.map((s) => s.type_id), toasts: [...toasts] };
};
const store = window.sessionStorage;
const kindreset = () => JSON.parse(store.getItem("aislo:draft:kindreset:P") ?? "null");
@@ -219,6 +238,47 @@ const out = {};
next: "open_ditch",
});
out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null });
// 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리
out.point_to_interval = run({
structures: [{ structure_id: "m1", type_id: "mirror", placement: "point", chainage_m: 140 }],
editing: "m1",
next: "guardrail",
});
out.interval_to_point = run({
structures: [
{
structure_id: "g1",
type_id: "guardrail",
placement: "interval",
start_m: 100,
chainage_m: 105,
end_m: 120,
},
],
editing: "g1",
next: "mirror",
});
out.interval_same = run({
structures: [
{
structure_id: "r1",
type_id: "refuge",
placement: "interval",
start_m: 100,
chainage_m: 105,
end_m: 120,
},
],
editing: "r1",
next: "work_yard",
});
// 막힘 = 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰)
out.blocked = run({
structures: [{ structure_id: "b1", type_id: "mirror", placement: "point", chainage_m: 30 }],
editing: "b1",
next: "curb",
commitOk: false,
});
}
// ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ──────
+82
View File
@@ -0,0 +1,82 @@
/* PLAN 323-6 바꿈 한 길 헬퍼 — 진짜 `B05_Profile_Api_Structures.ts`(구조물 초안) · `design_store.ts`(바뀜 신호)를
* 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON. 짝 = `test_323_6_change.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 CATALOG = require("./helper_322_catalog_module.cjs");
const memory = new Map();
const store = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => memory.set(k, String(v)),
removeItem: (k) => memory.delete(k),
key: (i) => [...memory.keys()][i] ?? null,
get length() {
return memory.size;
},
};
globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} };
globalThis.sessionStorage = store;
globalThis.localStorage = store;
const cache = new Map();
function load(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const need = (spec) => {
if (CATALOG.isCatalogRequest(spec)) return CATALOG;
if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} });
if (spec.startsWith("@config/")) return load(path.join(ROOT, "config", `${spec.slice(8)}.ts`));
if (spec.startsWith(".")) return load(path.join(path.dirname(full), `${spec}.ts`));
return require(spec);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts"));
const { useDesignStore } = load(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const heard = { structures: [], pipes: [] };
const shop = useDesignStore("P", null);
for (const key of ["structures", "pipes"]) shop.subscribe(key, (value) => heard[key].push(value));
const wall = { structure_id: "w1", type_id: "retaining_wall", chainage_m: 10 };
api.writePendingStructures("P", [wall]);
api.writePendingStructures("P", null);
api.writePendingStructures("P", [wall]);
// 바뀜 신호 거르기 — 구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만(옵션만 = 폼 길이 그 카드)
const status = load(path.join(ROOT, "A00_Common", "design", "design_structure_status.ts"));
let fired = 0;
const off = status.watchDrafts("Q", () => (fired += 1));
const shopQ = useDesignStore("Q", null);
shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 1 } }]);
shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 2 } }]);
shopQ.set("pipes", [{ chainage_m: 12, source: "user", options: { a: 2 } }]);
shopQ.set("pipes", [{ chainage_m: 12, source: "user", facility: "box_culvert" }]);
shopQ.set("structures", []);
off();
shopQ.set("structures", null);
process.stdout.write(
JSON.stringify({
watch: fired,
heard: heard.structures.map((v) => (v ? v.length : null)),
pipesHeard: heard.pipes.length,
read: api.readPendingStructures("P"),
key: memory.has("aislo:draft:structures:P"),
swaps: [
"mirror>curb",
"mirror>guardrail",
"refuge>work_yard",
"pipe>box_culvert",
"pipe>revetment",
"cross_underdrain>pipe",
].map((pair) => [pair, CATALOG.swapsInPlace(...pair.split(">"))]),
}),
);
@@ -1,5 +1,6 @@
/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다.
/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a → 323-6) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다 — 옛 되얹기
* (`replayDraftPipeOptions`)는 걷고 형상 얹기 한 벌(`attachShapes` · 초안 현황)이 맡음(323-6).
* 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다.
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다.
@@ -47,50 +48,45 @@ function loadTs(file) {
return box.exports;
}
const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"));
const DEF = JSON.parse(
fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"),
);
const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t }));
const attach = loadTs(path.join(ROOT, "common_util", "common_util_structure_attach.ts"));
// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 옹벽 없음
// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 조정창 정본(기슭막이 조정값)
const adjust = { outlet: { x: 0.4, d: null, h: 2.0, m: null } };
const detail = {
cross_sections: [
{
chainage_m: 85.05,
culvert: { pipe_kind: "파형강관", diameter_m: 1.0, inlet: {}, outlet: {} },
design: { revet_adjust: adjust },
},
{
chainage_m: 173.09,
ford: { span_m: 10, diameter_m: 1.0, pipe_count: 1, wing_in: {}, wing_out: {} },
},
{ chainage_m: 258.12 },
],
};
const sent = [];
const ctx = {
detail: () => detail,
ford: {
setPipe: (chainageM, patch) => {
sent.push({ chainageM, patch });
if (patch.ford_width_m) detail.cross_sections[1].ford.span_m = patch.ford_width_m;
},
setWing: () => {},
},
box: {},
revetOffset: { update: () => sent.push({ revet: true }) },
overrideOptions: () => {},
refreshCard: () => {},
};
// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 되살리지 않음)
options.replayDraftPipeOptions(ctx, [
{ chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } },
{ chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } },
{ chainage_m: 258.12, options: {} },
]);
options.replayDraftPipeOptions(ctx, null);
// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 조정값은 안 건드림)
attach.attachShapes(
detail.cross_sections,
[
{ chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } },
{ chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } },
],
[],
TYPES,
);
process.stdout.write(
JSON.stringify({
fordSpan: detail.cross_sections[1].ford.span_m,
culvertDiameter: detail.cross_sections[0].culvert.diameter_m,
fordCalls: sent.filter((s) => s.chainageM === 173.09).length,
revetTouched: sent.some((s) => s.revet === true),
fordAt: detail.cross_sections.filter((s) => s.ford).map((s) => s.chainage_m),
revetTouched: detail.cross_sections[0].design.revet_adjust !== adjust,
}),
);
+6 -4
View File
@@ -65,8 +65,7 @@ def test_B06_추가_순간_기다림_적고_초안_씀():
body.index("pipeFacilities.push({"),
body.index("if (waitsFordHeight(attributes.facility, attributes.options))"),
body.index("fordWaitsOf(deps.projectId).add(at);"),
body.index("persistPipes();"),
body.index("deps.onStructuresChanged?.();"),
body.index("persistPipes();"), # 초안 씀 = 카드 다시(바뀜 신호 하나 · 323-6)
]
assert order == sorted(order)
@@ -145,8 +144,11 @@ def test_실패하면_토스트_한_줄(out):
def test_추가_뒤_새_측점_횡단_미리_뜸():
body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:")
assert "deps.onStructuresChanged?.();" in body
assert "onStructuresChanged: () => void refreshDetailForStructures()," in CROSS
assert "persistPipes();" in body # 초안 씀 → 구독(`watchDrafts`)이 카드 다시(323-6)
# 카드 다시 = 바뀜 신호 하나(323-6 · 관 · 구조물 초안 구독 · 옛 되부름 갈음)
assert (
"watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())" in CROSS
)
drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts")
stations = _body(
drafts, "export async function withDraftStations(", "parked.set(detail, stale);"
+1 -1
View File
@@ -83,7 +83,7 @@ def test_B05_B06_같은_목록_각자_관빼기():
assert "onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage)," in b05
# 공용 확인창 · 저장 길(초안 → [저장])은 안 고침
events = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts")
assert 'import { showConfirmDialog } from "@ui/ui_template_elements";' in events
assert 'import { showConfirmDialog, showToast } from "@ui/ui_template_elements";' in events
assert "ctx.emit();" in events
+16
View File
@@ -75,6 +75,22 @@ def test_same_owner_switch_unchanged(result: dict) -> None:
assert result["to_blank"]["reset"] == 1 and result["to_blank"]["addAt"] == []
def test_point_interval_switch_replaces_at_same_anchor(result: dict) -> None:
# 323-6 — 표 규칙(`swapsInPlace`) · 구간 = 기본 길이(`addAt`)
up = result["point_to_interval"]
assert up["left"] == [] and up["addAt"] == [[140, "guardrail"]] and up["live"] == 0
down = result["interval_to_point"]
assert down["left"] == [] and down["addAt"] == [[105, "mirror"]]
same = result["interval_same"]
assert same["addAt"] == [] and same["live"] == 1 and same["left"] == ["work_yard"]
def test_blocked_switch_restores_with_notice(result: dict) -> None:
blocked = result["blocked"]
assert blocked["left"] == ["mirror"] and blocked["loaded"] == ["mirror"]
assert len(blocked["toasts"]) == 1 and blocked["toasts"][0][1] == "error"
def test_grid_station_kind_change_uses_bare_copy(result: dict) -> None:
# 일반 측점 자리 = 측점이 남음 — 저장분에서 구조물만 뺀 사본에 새 종류(지형 쪽 고친 값 그대로)
grid = result["grid"]
+79
View File
@@ -0,0 +1,79 @@
"""PLAN 323-6 바꿈 한 길 — 바뀜 신호 하나 · 종류 바꿈 표 규칙 · 그리기가 조정값을 안 만듦 · 옛 되얹기 걷음.
- 구조물 초안도 설계 가게(`useDesignStore`)로 씀 = 관 초안과 같은 신호 · 칸은 그대로(`structures`) ·
횡단 보기가 두 초안을 구독해 카드 · 그림을 다시(옛 되부름 `onStructuresChanged` 갈음).
- 종류 바꿈 = 정본 같고 구조물은 자리꼴도 같아야 제자리(`swapsInPlace`) · 아니면 지우고 같은 기준 측점에 새로
(흐름 = test_319_kind_swap).
- 카드 그리기의 적용값 되받기는 사용자가 조작한 세션이 있을 때만(자동 배치를 조정값으로 안 굳힘).
TS 쪽 = `helper_323_6_change.cjs`(Node).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_323_6_change.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(run.stdout)
def _src(*parts: str) -> str:
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
def test_structure_draft_signals_through_store(out: dict) -> None:
assert out["heard"] == [1, None, 1] and out["pipesHeard"] == 0
assert out["read"] == [{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 10}]
assert out["key"] is True # 칸 그대로
cross = _src("A00_Common", "design", "design_view_cross.ts")
assert "watchDrafts(projectId," in cross and "onStructuresChanged" not in cross
panel = _src("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "onStructuresChanged" not in panel # 옛 되부름 걷음
def test_change_signal_filters_option_only_pipe_edits(out: dict) -> None:
# 관 놓기 · 옵션만 · 옮기기 · 종류 바꿈 · 구조물 · 끊은 뒤 = 1 · 0 · 1 · 1 · 1 · 0
assert out["watch"] == 4
def test_kind_swap_rule_from_table(out: dict) -> None:
assert dict(out["swaps"]) == {
"mirror>curb": True,
"mirror>guardrail": False,
"refuge>work_yard": True,
"pipe>box_culvert": True,
"pipe>revetment": True,
"cross_underdrain>pipe": False,
}
def test_drawing_does_not_create_adjusts() -> None:
controls = _src("B06_Section", "B06_Section_UI_Page_Station_Controls.ts")
body = controls.split("syncApplied: (chainageM, role, applied) => {", 1)[1].split("},", 1)[0]
assert "if (!stored || sameAdjust(stored, applied)) return;" in body
def test_old_replay_removed() -> None:
assert "replayDraftPipeOptions" not in _src(
"B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"
)
@@ -5,6 +5,7 @@
저장값(월류 폭 10 · 관경 1000)으로 돌아가 있었다. 초안(관 목록 세션 초안)이 정본이므로
횡단 보기가 서며 초안 관 옵션을 캐시에 다시 얹는다.
• 되얹기 — 세월교 월류 폭 3.5 · 배수관 관경 1200 이 캐시 스펙에 실림 · 조정창 정본(기슭막이 높이 · 형태)은 안 건드림
(323-6 — 옛 `replayDraftPipeOptions` 걷음 · 형상 얹기 한 벌이 초안 현황으로 다시 셈)
• 횡단 보기가 먼저 선 뒤 종단 보기가 서도 종단 보기가 배수유역도의 관 목록을 먼저 쥠(빈 목록으로 시작해 관을 지우지 않음)
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b06_draft_pipe_replay.cjs`).
"""
@@ -33,12 +34,15 @@ def test_draft_pipe_options_are_replayed_onto_the_cache():
out = json.loads(proc.stdout)
assert out["fordSpan"] == 3.5 # 월류 폭 초안이 캐시 스펙에 실림(저장분 10 아님)
assert out["culvertDiameter"] == 1.2 # 배수관 관경 초안 1200
assert out["fordCalls"] == 1 # 옵션 없는 관은 건드리지 않고 세월교는 한 번만
assert out["fordAt"] == [173.09] # 세월교는 제 측점에만
assert out["revetTouched"] is False # 기슭막이 높이 · 형태는 조정창이 정본
# 배선 — 횡단 보기가 서며 초안을 읽어 되얹고, 종단 보기는 마지막 관 목록을 먼저 싣는다.
# 배선 — 옛 되얹기는 걷음(323-6) · 초안 현황으로 상세를 읽을 때마다 형상 얹기(`withDraftShapes`) ·
# 종단 보기는 마지막 관 목록을 먼저 싣는다.
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId))" in cross
assert "replayDraftPipeOptions" not in cross
store = _read("B06_Section", "B06_Section_Section_Store.ts")
assert "await withDraftShapes(detail, projectId);" in store
shared = _read("A00_Common", "design", "design_shared.ts")
assert "shared.lastPipes = pipes" in shared
profile = _read("A00_Common", "design", "design_view_profile.ts")