diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts
index 1fc2a3709..79203c190 100644
--- a/A00_Common/design/design_structure_status.ts
+++ b/A00_Common/design/design_structure_status.ts
@@ -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
(
@@ -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[number];
+ const layout = (pipes: ReadonlyArray | 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());
+}
diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts
index 0dae09d4a..dba4f7730 100644
--- a/A00_Common/design/design_view_cross.ts
+++ b/A00_Common/design/design_view_cross.ts
@@ -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 ㉯). 만드는 것은 사용자가 누를 때만.
diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts
index 8f1e945cd..ea2106c23 100644
--- a/B05_Profile/B05_Profile_Api_Structures.ts
+++ b/B05_Profile/B05_Profile_Api_Structures.ts
@@ -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("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);
}
/**
diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts
index 77aebce06..4b0209d90 100644
--- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts
+++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts
@@ -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 = {
diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts
index 48b66ddc8..c39fc5dbc 100644
--- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts
+++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts
@@ -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 | 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);
- }
-}
diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts
index 390a81de5..7a6ebe3c7 100644
--- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts
+++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts
@@ -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) => {
diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts
index ea213c922..435cc2c02 100644
--- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts
+++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts
@@ -108,9 +108,6 @@ export interface B06StructuresPanelDeps {
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
applyPipeOptions?: (chainageM: number, patch: Record) => 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");
}
diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts
index 9c0dfccd3..d416affff 100644
--- a/common_util/common_util_structure_catalog.ts
+++ b/common_util/common_util_structure_catalog.ts
@@ -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 })["칸 이름"]?.[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;
diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs
index d59153035..4242bf606 100644
--- a/resources/tester/helper_319_kind_swap.cjs
+++ b/resources/tester/helper_319_kind_swap.cjs
@@ -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) ──────
diff --git a/resources/tester/helper_323_6_change.cjs b/resources/tester/helper_323_6_change.cjs
new file mode 100644
index 000000000..df09a044a
--- /dev/null
+++ b/resources/tester/helper_323_6_change.cjs
@@ -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(">"))]),
+ }),
+);
diff --git a/resources/tester/helper_b06_draft_pipe_replay.cjs b/resources/tester/helper_b06_draft_pipe_replay.cjs
index 50718cd1b..df2316c72 100644
--- a/resources/tester/helper_b06_draft_pipe_replay.cjs
+++ b/resources/tester/helper_b06_draft_pipe_replay.cjs
@@ -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,
}),
);
diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py
index 0429a2dd3..8fcfe4b8b 100644
--- a/resources/tester/test_298_add_reanalyze.py
+++ b/resources/tester/test_298_add_reanalyze.py
@@ -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);"
diff --git a/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py
index 4e5285c56..dcc7f15cd 100644
--- a/resources/tester/test_313_clear_all.py
+++ b/resources/tester/test_313_clear_all.py
@@ -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
diff --git a/resources/tester/test_319_kind_swap.py b/resources/tester/test_319_kind_swap.py
index 903fd7334..dc9fbdee1 100644
--- a/resources/tester/test_319_kind_swap.py
+++ b/resources/tester/test_319_kind_swap.py
@@ -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"]
diff --git a/resources/tester/test_323_6_change.py b/resources/tester/test_323_6_change.py
new file mode 100644
index 000000000..233ecc636
--- /dev/null
+++ b/resources/tester/test_323_6_change.py
@@ -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"
+ )
diff --git a/resources/tester/test_b06_draft_pipe_replay.py b/resources/tester/test_b06_draft_pipe_replay.py
index 349195287..4b034f681 100644
--- a/resources/tester/test_b06_draft_pipe_replay.py
+++ b/resources/tester/test_b06_draft_pipe_replay.py
@@ -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")