Merge remote-tracking branches 'origin/sub_desktop_1' and 'origin/sub_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-12 15:52:04 +09:00
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]