feat(b06): 종단 그래프 우클릭으로 구조물·계곡 통과 시설을 넣고 빼게 함

B05 와 B06 은 한 페이지인데 구조물을 넣고 빼는 자리가 B05 뿐이라 화면을 오가야 했음.
종단 그래프 렌더러·알약 레인·좌측 배치 폼은 이미 공용이었으므로, 빠져 있던 조작층만
B05 와 같은 부품(mountStructureMenu)으로 B06 에도 붙임.

관 추가·삭제는 이동과 같은 예약 경로를 씀 — 세션에 쌓고 [저장]·[확정]에서 정본
(pipe_points.json)으로 나가며, 세부 배수유역 재분할은 그때 서버가 처리함. 넣었다 바로
빼거나 뺐다 다시 넣으면 예약만 걷어 내 정본이 흔들리지 않음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
This commit is contained in:
2026-09-12 15:50:02 +09:00
co-authored by Claude Opus 5
parent c94d8c849a
commit 11161659f5
8 changed files with 417 additions and 7 deletions
+2
View File
@@ -107,6 +107,8 @@ export const STATE_REGISTRY = {
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */
culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` },
/** 배수관 추가·삭제 예약(2026-09-12) — B06 종단·목록에서 넣고 뺀 관. */
culvertedit: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertedit:${p}:${r}` },
/** 암 경계선 오프셋(측점별). */
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
/** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07).
+91 -4
View File
@@ -31,6 +31,11 @@ export interface CulvertOptionWriter {
/** 기준 측점 이동을 예약한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다).
* 세부 배수유역 재분할은 저장 시 서버가 관 목록을 다시 받아 처리한다. */
queueMove: (fromChainageM: number, toChainageM: number) => void;
/** 관(계곡 통과 시설) 추가를 예약한다 — B05·B06 은 한 페이지라 넣는 자리도 같아야 한다
* (2026-09-12 사용자). 이동과 같은 규칙으로 세션에만 쌓고 [저장]·[확정]에서 나간다. */
queueAdd: (chainageM: number, facility?: string) => void;
/** 관 삭제를 예약한다. 추가해 둔 것을 다시 지우면 예약만 걷어 낸다. */
queueRemove: (chainageM: number) => void;
/** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */
flush: () => Promise<void>;
}
@@ -75,16 +80,59 @@ function readMoves(sessionKey: string | null): PendingMoves {
}
}
/** 예약된 추가·삭제 — 이동과 같은 세션 칸에 함께 담는다(키 하나로 읽고 쓴다). */
interface PendingEdits {
/** 넣을 관 — 누가거리(m)와 시설 종류(기본 배관). */
adds: Array<{ chainage_m: number; facility?: string }>;
/** 지울 관 — 측점 키(누가거리 2자리). */
removes: string[];
}
const EMPTY_EDITS: PendingEdits = { adds: [], removes: [] };
function readEdits(sessionKey: string | null): PendingEdits {
if (!sessionKey) return { ...EMPTY_EDITS, adds: [], removes: [] };
try {
const raw = window.sessionStorage.getItem(sessionKey);
const parsed = raw ? (JSON.parse(raw) as Partial<PendingEdits>) : null;
return {
adds: Array.isArray(parsed?.adds) ? parsed.adds : [],
removes: Array.isArray(parsed?.removes) ? parsed.removes : [],
};
} catch {
return { adds: [], removes: [] };
}
}
function writeEdits(sessionKey: string | null, edits: PendingEdits): void {
if (!sessionKey) return;
try {
if (!edits.adds.length && !edits.removes.length) window.sessionStorage.removeItem(sessionKey);
else window.sessionStorage.setItem(sessionKey, JSON.stringify(edits));
} catch {
/* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */
}
}
export async function flushCulvertOptions(
projectId: string | null,
sessionKey: string | null,
moveKey: string | null = null,
editKey: string | null = null,
): Promise<void> {
const pending = readPending(sessionKey);
const moves = readMoves(moveKey);
if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return;
const edits = readEdits(editKey);
if (
!projectId ||
(Object.keys(pending).length === 0 &&
Object.keys(moves).length === 0 &&
!edits.adds.length &&
!edits.removes.length)
)
return;
const current = await fetchDetailPipePoints(projectId);
let touched = false;
let touched = edits.adds.length > 0 || edits.removes.length > 0;
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
const { lonlat: _lonlat, ...input } = point;
@@ -100,9 +148,23 @@ export async function flushCulvertOptions(
if (movedTo !== undefined) next.chainage_m = movedTo;
return next;
});
if (touched) await saveDetailPipePoints(projectId, points);
// 지울 것을 걷어 내고 넣을 것을 붙인다 — 서버는 이 목록을 그대로 정본으로 삼는다.
const removed = new Set(edits.removes);
const next = points.filter((point) => !removed.has(keyOf(point.chainage_m)));
for (const add of edits.adds) {
if (next.some((point) => Math.abs(point.chainage_m - add.chainage_m) < 0.005)) continue;
next.push({
chainage_m: add.chainage_m,
// 사람이 넣은 자리 — 세류 교차·간격 규칙이 만든 것과 구분한다.
source: "user",
...(add.facility ? { facility: add.facility as DetailPipeInput["facility"] } : {}),
});
}
next.sort((left, right) => left.chainage_m - right.chainage_m);
if (touched) await saveDetailPipePoints(projectId, next);
writePending(sessionKey, {});
writePending(moveKey, {});
writeEdits(editKey, { adds: [], removes: [] });
}
/**
@@ -115,6 +177,8 @@ export function createCulvertOptionWriter(
onError?: (message: string) => void,
/** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */
moveKey: () => string | null = () => null,
/** 추가·삭제 예약 세션 키 — 없으면 추가·삭제를 받지 않는다. */
editKey: () => string | null = () => null,
): CulvertOptionWriter {
return {
queue(chainageM, patch) {
@@ -134,9 +198,32 @@ export function createCulvertOptionWriter(
moves[origin] = toChainageM;
window.sessionStorage.setItem(key, JSON.stringify(moves));
},
queueAdd(chainageM, facility) {
const key = editKey();
if (!key) return;
const edits = readEdits(key);
const at = Number(chainageM.toFixed(2));
// 같은 자리를 지웠다가 다시 넣으면 삭제 예약만 걷어 낸다(정본은 그대로).
const removedAt = edits.removes.indexOf(keyOf(at));
if (removedAt >= 0) edits.removes.splice(removedAt, 1);
else if (!edits.adds.some((entry) => Math.abs(entry.chainage_m - at) < 0.005))
edits.adds.push({ chainage_m: at, ...(facility ? { facility } : {}) });
writeEdits(key, edits);
},
queueRemove(chainageM) {
const key = editKey();
if (!key) return;
const edits = readEdits(key);
const at = Number(chainageM.toFixed(2));
// 아직 정본에 없는(이번에 넣은) 관이면 추가 예약만 거둔다.
const addedAt = edits.adds.findIndex((entry) => Math.abs(entry.chainage_m - at) < 0.005);
if (addedAt >= 0) edits.adds.splice(addedAt, 1);
else if (!edits.removes.includes(keyOf(at))) edits.removes.push(keyOf(at));
writeEdits(key, edits);
},
async flush() {
try {
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
await flushCulvertOptions(projectId(), sessionKey(), moveKey(), editKey());
} catch (error) {
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
throw error;
+13
View File
@@ -203,6 +203,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
movePipe: (fromChainageM, toChainageM) =>
stationControls.queueCulvertMove(fromChainageM, toChainageM),
addPipe: (chainageM, facility) => stationControls.queueCulvertAdd(chainageM, facility),
removePipe: (chainageM) => stationControls.queueCulvertRemove(chainageM),
});
const dockDivider = document.createElement("hr");
@@ -483,6 +485,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
sectionView.setStructureEdit({
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => {
structuresPanel.removeStructureById(structureId);
},
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
detail: () => sectionDetail,
@@ -45,6 +45,7 @@ export interface StationControlDeps {
| "extraspan"
| "culvertopt"
| "culvertmove"
| "culvertedit"
| "revetlink"
| "fordadjust"
| "boxadjust",
@@ -96,6 +97,10 @@ export interface StationControls {
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => void;
/** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */
queueCulvertMove: (fromChainageM: number, toChainageM: number) => void;
/** 관 추가를 예약한다 — B06 종단 우클릭·목록이 쓴다(2026-09-12 B05·B06 일원화). */
queueCulvertAdd: (chainageM: number, facility?: string) => void;
/** 관 삭제를 예약한다. */
queueCulvertRemove: (chainageM: number) => void;
load: () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
@@ -432,6 +437,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
() => deps.sessionKey("culvertopt"),
deps.onSaveError,
() => deps.sessionKey("culvertmove"),
() => deps.sessionKey("culvertedit"),
);
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
const linkDetached = linkSession.detached;
@@ -619,6 +625,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
queueCulvertMove: (fromChainageM, toChainageM) =>
culvertOptions.queueMove(fromChainageM, toChainageM),
queueCulvertAdd: (chainageM, facility) => culvertOptions.queueAdd(chainageM, facility),
queueCulvertRemove: (chainageM) => culvertOptions.queueRemove(chainageM),
load: () => {
loadStationWidths();
loadRevetShifts();
@@ -39,8 +39,13 @@ import {
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다.";
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
const PIPE_ADD_NOTICE =
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
const PIPE_REMOVE_NOTICE =
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
const PIPE_MOVE_NOTICE =
@@ -75,6 +80,10 @@ export interface B06StructuresPanelDeps {
/** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가
* 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */
movePipe?: (fromChainageM: number, toChainageM: number) => void;
/** 관(계곡 통과 시설)을 넣는다 — 이동과 같은 예약 경로(2026-09-12 B05·B06 일원화). */
addPipe?: (chainageM: number, facility?: string) => void;
/** 관을 뺀다 — 같은 예약 경로. */
removePipe?: (chainageM: number) => void;
/** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로**
* 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */
onStructuresChanged?: () => void;
@@ -97,6 +106,11 @@ export interface B06StructuresPanel {
showAtChainage: (chainageM: number | null) => void;
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
currentChainage: () => number | null;
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
addStructureAt: (chainageM: number, typeId: string) => void;
removeStructureById: (structureId: string) => boolean;
addPipeAt: (chainageM: number) => void;
removePipeAt: (chainageM: number) => void;
/** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */
facility: {
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
@@ -248,7 +262,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
},
getInterval: deps.stationInterval,
onReveal: () => deps.reveal?.(),
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
onPipeAdd: (chainageM, attributes) => {
if (!deps.addPipe) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) {
showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error");
return;
}
const facility = attributes.facility ?? "pipe";
deps.addPipe(at, facility);
pipeFacilities.push({ chainage_m: at, facility, options: attributes.options ?? {} });
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_ADD_NOTICE, "success");
},
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
@@ -289,7 +323,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
section.setPipeFacilities(pipeFacilities);
},
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
@@ -400,11 +434,35 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
section.setPipeFacilities(pipeFacilities);
}
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
function removePipeAt(chainageM: number): void {
if (!deps.removePipe) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
deps.removePipe(at);
pipeFacilities = pipeFacilities.filter(
(entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M,
);
if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M)
currentChainageM = null;
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_REMOVE_NOTICE, "success");
}
return {
root: section.root,
listRoot: section.listRoot,
load,
currentChainage: () => currentChainageM,
// 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다.
addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId),
removeStructureById: (structureId) => section.removeById(structureId),
addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"),
removePipeAt: (chainageM) => removePipeAt(chainageM),
facility: {
setInletStructure: (value) => section.facility.setInletStructure(value),
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
@@ -50,6 +50,10 @@ import {
buildStructureLane,
STRUCTURE_LANE_HEIGHT_PX,
} from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import {
mountSectionStructureMenu,
type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu";
import {
structureAnchorM,
type StructureInstance,
@@ -131,6 +135,9 @@ export interface SectionViewController {
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다
* (2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
dispose: () => void;
}
@@ -163,6 +170,7 @@ export function createSectionView(
let currentNaturalSpoilSlope: number | undefined;
let markStructures: ReadonlyArray<StructureInstance> = [];
let markTypes: ReadonlyArray<StructureType> = [];
let structureEdit: SectionStructureEdit | null = null;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
@@ -554,6 +562,18 @@ export function createSectionView(
}
chartWrap.replaceChildren(...nodes);
// 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면
// 구조물군 → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
if (structureEdit) {
mountSectionStructureMenu(chartWrap, {
structures: markStructures,
types: markTypes,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
edit: structureEdit,
});
}
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
@@ -753,6 +773,10 @@ export function createSectionView(
setStationSelectListener(listener) {
stationSelectListener = listener;
},
setStructureEdit(edit) {
structureEdit = edit;
drawPanel();
},
setStructureMarks(structures, types) {
markStructures = structures;
markTypes = types;
@@ -0,0 +1,83 @@
/* =============================================================================
* B06_Section_UI_Section_View_Menu.ts
* B06 ** ** B05 (`mountStructureMenu`) .
*
* (2026-09-12 ) B05·B06 .
* .
*
* ·· ** ** []·[]
* (`pipe_points.json`) (CLAUDE.md 5).
* .
* ========================================================================== */
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
export interface SectionStructureEdit {
/** 관(계곡 통과 시설)을 그 자리에 넣는다. */
addPipe: (chainageM: number) => void;
/** 관을 뺀다. */
removePipe: (chainageM: number) => void;
/** 레지스트리 종류를 골라 구조물을 넣는다(좌측 「구조물 배치」와 같은 2단 메뉴). */
addStructureType: (chainageM: number, typeId: string) => void;
/** 구조물(관 아님)을 뺀다. */
removeStructure: (structureId: string) => void;
}
/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */
export function isPipeMark(structure: StructureInstance): boolean {
return String(structure.structure_id ?? "").startsWith("pipe-");
}
/**
* .
* (B05 ).
*/
export function mountSectionStructureMenu(
host: HTMLElement,
input: {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
x: (chainageM: number) => number;
chainageAt: (px: number) => number;
maxChainageM: number;
edit: SectionStructureEdit;
},
): void {
// 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다.
const stations: IrregularStation[] = input.structures.map((structure) => {
const chainage = structureAnchorM(structure);
const pipe = isPipeMark(structure);
return {
id: String(structure.structure_id),
station: 0,
remainder: 0,
chainage_m: chainage,
structure:
input.types.find((type) => type.type_id === structure.type_id)?.name ??
String(structure.type_id),
origin: pipe ? "pipe" : "user",
} as IrregularStation;
});
mountStructureMenu(host, {
stations,
x: input.x,
chainageAt: input.chainageAt,
maxChainageM: input.maxChainageM,
onRemove: (station) => {
if (station.origin === "pipe") input.edit.removePipe(station.chainage_m);
else input.edit.removeStructure(station.id);
},
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
structureTypes: input.types.map((type) => ({
type_id: type.type_id,
group: type.group,
name: type.name,
})),
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
});
}
@@ -0,0 +1,135 @@
"""B06 종단에서 넣고 뺀 **계곡 통과 시설**이 세션에 제대로 쌓이는지(2026-09-12 사용자 지시).
B05·B06 페이지이므로 추가·삭제도 양쪽에서 되어야 한다. 다만 정본에 바로 쓰지
않고 **세션에 예약**했다가 [저장]·[확정]에서 번에 내보낸다(CLAUDE.md 5). 넣었다 바로
빼면 예약만 걷어 내야 하고, 뺐다 다시 넣어도 마찬가지다 그러지 않으면 저장 있지도
않은 관을 지우거나 같은 자리에 둘이 선다.
실제 화면 코드를 그대로 컴파일해 Node 돌린다(다단 하한 시험과 같은 방식).
"""
import json
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts"
_RUNNER = """
const { writeFileSync } = require("node:fs");
const Module = require("node:module");
// 화면 코드가 번들러 별칭(`@config/`) 쓴다 Node 에는 없으니 껍데기로 돌려준다.
// 시험이 보는 것은 세션 예약 로직뿐이라 값은 쓴다.
const load = Module._load;
Module._load = function (request, parent, isMain) {
if (request.startsWith("@config/") || request.startsWith("@util/") || request.startsWith("@ui/"))
return new Proxy({}, { get: () => () => undefined });
return load.call(this, request, parent, isMain);
};
// 세션 저장소 흉내 화면 코드는 window.sessionStorage 쓴다.
const store = new Map();
global.window = {
sessionStorage: {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
},
};
const { createCulvertOptionWriter } = require(process.argv[3]);
const EDIT_KEY = "edit";
const writer = createCulvertOptionWriter(
() => "p1",
() => "opt",
undefined,
() => "move",
() => EDIT_KEY,
);
const read = () => JSON.parse(store.get(EDIT_KEY) ?? '{"adds":[],"removes":[]}');
const steps = {};
writer.queueAdd(120, "pipe");
writer.queueAdd(140);
steps.afterTwoAdds = read();
// 방금 넣은 자리를 다시 빼면 **추가 예약만** 사라진다(정본에는 아직 없다).
writer.queueRemove(140);
steps.afterUndoAdd = read();
// 정본에 있던 관을 빼면 삭제 예약으로 남는다.
writer.queueRemove(200);
steps.afterRemove = read();
// 자리를 다시 넣으면 삭제 예약만 걷어 낸다(새로 만들지 않는다).
writer.queueAdd(200);
steps.afterUndoRemove = read();
// 같은 자리를 넣어도 하나만 남는다.
writer.queueAdd(120);
steps.afterDuplicateAdd = read();
writeFileSync(process.argv[2], JSON.stringify(steps));
"""
def _run(tmp_path: Path) -> dict:
out = tmp_path / "out"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
"--ignoreConfig",
"--target",
"es2022",
"--module",
"commonjs",
"--skipLibCheck",
"--outDir",
str(out),
str(MODULE),
],
cwd=str(PROJECT_ROOT),
check=False,
capture_output=True,
)
compiled = next(out.rglob("B06_Section_Api_Culvert_Options.js"), None)
assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패"
(out / "runner.cjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "result.json"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(out / "runner.cjs"), str(result), str(compiled)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_추가와_삭제_예약이_서로를_걷어_낸다(tmp_path: Path) -> None:
steps = _run(tmp_path)
# 넣은 둘이 그대로 쌓인다.
assert [entry["chainage_m"] for entry in steps["afterTwoAdds"]["adds"]] == [120.0, 140.0]
assert steps["afterTwoAdds"]["adds"][0]["facility"] == "pipe"
assert steps["afterTwoAdds"]["removes"] == []
# 방금 넣은 자리를 빼면 추가 예약만 사라지고 삭제 예약은 안 생긴다.
assert [entry["chainage_m"] for entry in steps["afterUndoAdd"]["adds"]] == [120.0]
assert steps["afterUndoAdd"]["removes"] == []
# 정본에 있던 관은 삭제 예약으로 남는다.
assert steps["afterRemove"]["removes"] == ["200.00"]
# 뺀 자리를 다시 넣으면 삭제 예약만 걷힌다 — 새 관을 만들지 않는다.
assert steps["afterUndoRemove"]["removes"] == []
assert [entry["chainage_m"] for entry in steps["afterUndoRemove"]["adds"]] == [120.0]
# 같은 자리를 두 번 넣어도 하나만 남는다.
assert [entry["chainage_m"] for entry in steps["afterDuplicateAdd"]["adds"]] == [120.0]