refactor(B05·B06): 측점 · 구조물 고름을 selection 한 곳으로 · 관 찾는 거리 PIPE_MATCH_M 한 값 · 횡단 카드 누름이 옹벽 · 세월교 · BOX · 기슭막이 폼을 엶 (24-2d)

- 목록 · 종단 알약 · 3D · 횡단 카드 · 벽 어디서 골라도 selectDesign 한 길로 세션에 적음(구조물 id 포함) — B05 목록 구조물 고름도 B06 으로 이어짐
- 폼이 열 항목 풀이 resolveSelection 한 곳(관 먼저 · 구조물 다음 · 가장 가까운 것) — 공용 폼의 0.05 없앰
- B06 벽 · 구체 누름은 showAtChainage 하나를 탐(옛 관 전용 showPipeAt 없앰) · 벽 누름 중 폼이 카드를 먼저 고르는 이중 토글 막음
- B06 진입 때 구조물 id 로 카드를 찾고, 못 찾으면 다시 부르게 함 · B05 진입 때 구조물 id 가 있을 때만 구조물로 연다
- 시험 test_b05_b06_selection_single 21

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
This commit is contained in:
2026-09-29 21:02:55 +09:00
co-authored by Claude Sonnet 5.5
parent df0240eb2f
commit 8a9456579a
11 changed files with 597 additions and 141 deletions
+22 -3
View File
@@ -28,9 +28,28 @@ export function isNear(a: number, b: number): boolean {
return Math.abs(a - b) < PIPE_MATCH_M;
}
/** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도
* 자리가 맞는 쪽을 고른다. */
export function nearestWithin<T>(
items: ReadonlyArray<T>,
chainageM: number,
chainageOf: (item: T) => number,
): T | undefined {
let best: T | undefined;
let bestGap = PIPE_MATCH_M;
for (const item of items) {
const gap = Math.abs(chainageOf(item) - chainageM);
if (gap < bestGap) {
best = item;
bestGap = gap;
}
}
return best;
}
/** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */
export interface SelectableStructure {
structure_id: string;
structure_id?: string | null;
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
@@ -66,11 +85,11 @@ export function resolveSelection(
): ResolvedSelection {
if (selection.structureId) {
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
if (byId) return { pipeChainageM: null, structureId: byId.structure_id };
if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null };
}
const chainageM = selection.chainageM;
if (chainageM === null) return { pipeChainageM: null, structureId: null };
const pipe = pipes.find((entry) => isNear(entry.chainage_m, chainageM));
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
const hit = structures.find((entry) => coversChainage(entry, chainageM));
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
+14 -10
View File
@@ -28,11 +28,9 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { navigateTo } from "../A00_Common/router";
import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit";
import { createSelectionSync } from "./B05_Profile_UI_Selection";
import {
restoreStructurePick,
wireStructurePick,
writeStructurePick,
} from "./B05_Profile_UI_Structure_Pick_Session";
import { restoreStructurePick, wireStructurePick } from "./B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, nearestWithin, selectDesign } from "../A00_Common/design/design_selection";
import { structureAnchorM } from "./B05_Profile_Api_Structures";
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
@@ -98,6 +96,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
대피로: "refuge",
기타: "etc",
};
/** 고른 것을 selection 한 곳에 적는다 — B06 으로 이어지는 값이다(chainageM null = 해제). */
const rememberPick = (chainageM: number | null, structureId: string | null): void =>
selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, {
from: "b05",
});
const profilePanel = createRouteProfilePanel(
activeProjectId,
(stationId) => {
@@ -144,8 +147,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onStructureSelect: (structureId) => {
// 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만
// 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06).
writeStructurePick(activeProjectId, bridge.markChainage(structureId));
const chainage = bridge.pipeMarkChainage(structureId);
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
@@ -342,7 +345,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
profilePanel.drainage.updatePipeFacility(from, to, attributes),
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
onPipeFacilitySelect: (chainage) => {
writeStructurePick(activeProjectId, chainage); // 목록 선택도 B06으로 이어진다.
rememberPick(chainage, null); // 목록 선택도 B06으로 이어진다.
if (selectionSyncing) return;
selectionSyncing = true;
try {
@@ -350,9 +353,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const station =
chainage === null
? undefined
: bridge
.irregularStations()
.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05);
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
@@ -367,6 +368,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 3D·종단·유역도에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자).
onStructureReveal: () => layout.setOptionsOpen(true),
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 B06 으로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return;
selectionSyncing = true;
try {
+2 -4
View File
@@ -9,6 +9,7 @@
* 구조물(`origin: "pipe"`)일 때만 붙는다 — 다른 구조물에는 대응하는 유역이 없다.
* ========================================================================== */
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import {
irregularStationId,
isPipeStation,
@@ -48,9 +49,6 @@ export interface SelectionSync {
selectStationOfPipe: (chainageM: number | null) => void;
}
/** 같은 누가거리로 볼 여유(m). 관 지점과 구조물 측점은 소수점 둘째 자리까지 같은 값을 쓴다. */
const SAME_CHAINAGE_M = 0.51;
export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
const prefix = irregularStationId("");
@@ -94,7 +92,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
.stations()
.find(
(entry) =>
isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < SAME_CHAINAGE_M,
isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
const id = matched ? irregularStationId(matched.id) : null;
ports.setSyncing(true);
@@ -9,62 +9,35 @@
* 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import {
NO_SELECTION,
readSelection,
selectDesign,
type DesignSelection,
} from "../A00_Common/design/design_selection";
import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick";
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import type { StructureInstance } from "./B05_Profile_Api_Structures";
/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍과 같은 모양이다. */
/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍에 구조물 id 가 붙는다. */
export interface StructurePickHandoff {
at: number;
key?: string;
structureId?: string;
}
/* 넘김값도 ② 설계 초안이다 — 별도 임시 키를 두지 않고 등록표(`b_page_state`)를 쓴다
(2026-09-06 캐시·세션 일원화). */
/** 세션에 남긴다(선택 해제면 지운다). */
export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void {
if (!projectId) return;
if (!pick) return writeState("structure-pick", null, projectId);
const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key };
writeState("structure-pick", handoff, projectId);
}
/** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */
export function writeStructurePick(
projectId: string | null,
at: number | null,
key?: string,
): void {
if (!projectId) return;
if (at === null) return writeState("structure-pick", null, projectId);
// 부재키 없이 **측점만** 오는 길(사이드 목록·선택 동기화)이 3D 로 고른 부재키를 지웠다
// (2026-09-05 진단). 같은 측점이면 이미 적힌 부재키를 지킨다 — 3D 로 고른 직후 선택
// 동기화가 이 길을 타도 조정창이 그대로 열린다.
const kept = key ?? readStructurePick(projectId)?.key;
writeState("structure-pick", { at, key: kept }, projectId);
}
/* 넘김값은 `design_selection` 의 selection 한 곳이다 — 3D · 알약 · 목록 · 횡단 카드 어디서
골라도 그 함수 하나로 적고(`selectDesign`), 여기서는 읽기만 한다. 값은 ② 설계 초안이라
등록표(`b_page_state`)를 쓴다(2026-09-06 캐시·세션 일원화). */
/** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */
export function readStructurePick(projectId: string | null): StructurePickHandoff | null {
if (!projectId) return null;
try {
const value = readState<Partial<StructurePickHandoff>>("structure-pick", projectId);
if (!value) return null;
if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
return { at: value.at, key: typeof value.key === "string" ? value.key : undefined };
} catch {
return null;
}
}
/** 구간형 구조물이 그 누가거리를 덮는가(기준점형은 ±0.51m 안). */
function covers(entry: StructureInstance, chainageM: number): boolean {
const start = entry.start_m ?? entry.chainage_m;
const end = entry.end_m ?? entry.chainage_m;
if (start == null || end == null) return false;
return chainageM >= Math.min(start, end) - 0.51 && chainageM <= Math.max(start, end) + 0.51;
const value = readSelection(projectId);
if (!value || value.chainageM === null) return null;
return {
at: value.chainageM,
key: value.partKey,
structureId: value.structureId ?? undefined,
};
}
/**
@@ -72,7 +45,7 @@ function covers(entry: StructureInstance, chainageM: number): boolean {
*
* 배수관 세트(기슭막이·집수정·배관)는 `selectStation`(=기존 선택 동기화)이 좌측 폼·종단
* 그래프·3D 마커·유역도를 한 줄로 맞춘다. 그 줄에서 아무것도 안 잡히면 옛 정본 구조물로
* 보고 구조물 id로 연다.
* 보고 폼이 구조물 id 로 연다 — 관·구조물 찾는 거리와 순서는 `resolveSelection` 한 곳이다.
*/
export function wireStructurePick(
controls: StructurePickControls,
@@ -82,15 +55,16 @@ export function wireStructurePick(
selectStation: (chainageM: number | null) => void,
): void {
controls.onPick = (pick: StructurePick | null): void => {
rememberStructurePick(projectId, pick);
if (!pick) {
selectDesign(projectId, NO_SELECTION, { from: "b05" });
selectStation(null);
return;
}
const selection: DesignSelection = { chainageM: pick.chainageM, structureId: null };
const { structureId } = structures.resolveAt(selection);
selectDesign(projectId, { ...selection, structureId }, { partKey: pick.key, from: "b05" });
selectStation(pick.chainageM);
if (structures.hasSelection()) return;
const hit = structures.getStructures().find((entry) => covers(entry, pick.chainageM));
structures.selectById(hit?.structure_id ?? null);
if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId });
};
}
@@ -112,5 +86,10 @@ export function restoreStructurePick(
if (!handoff) return true; // 되살릴 것이 없으면 끝난 것으로 본다.
controls.select({ chainageM: handoff.at, kind: "", key: handoff.key });
selectStation(handoff.at);
// 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을
// 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다).
if (!structures.hasSelection() && handoff.structureId) {
structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId });
}
return structures.hasSelection();
}
+18 -5
View File
@@ -40,6 +40,7 @@ import {
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
@@ -408,7 +409,8 @@ export function createStructuresSection(
},
onSelectPipe: (pipe) => {
const isSame =
selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05;
selectedPipeChainage !== null &&
Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M;
if (isSame) {
resetForm();
} else {
@@ -540,9 +542,9 @@ export function createStructuresSection(
setPipeFacilities(pipes) {
pipeFacilities = pipes.map((pipe) => ({ ...pipe }));
if (tempPipeChainage !== null) {
// 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다(0.51m 기준).
// 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다.
const hit = pipeFacilities.find(
(pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.51,
(pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < PIPE_MATCH_M,
);
if (hit) {
tempPipeChainage = hit.chainage_m;
@@ -559,7 +561,9 @@ export function createStructuresSection(
}
if (
selectedPipeChainage !== null &&
!pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05)
!pipeFacilities.some(
(pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M,
)
) {
// 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다.
selectedPipeChainage = null;
@@ -578,10 +582,19 @@ export function createStructuresSection(
}
// 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든
// 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화).
const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05);
const pipe = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (!pipe) return;
loadPipeForm(pipe);
},
resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures),
selectAt(selection) {
const hit = resolveSelection(selection, pipeFacilities, structures);
if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM);
else this.selectById(hit.structureId);
return hit;
},
selectById(structureId) {
if (structureId !== null) callbacks.onReveal?.();
if (structureId === null) {
@@ -18,6 +18,7 @@ import {
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
import type { SummaryItem } from "../common_util/common_util_summary_item";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
@@ -93,6 +94,11 @@ export interface StructuresSection {
hasSelection: () => boolean;
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
selectById: (structureId: string | null) => void;
/** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */
resolveAt: (selection: DesignSelection) => ResolvedSelection;
/** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다.
* 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */
selectAt: (selection: DesignSelection) => ResolvedSelection;
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
addAt: (chainageM: number, typeId: string) => void;
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
+33 -18
View File
@@ -32,10 +32,8 @@ import {
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, selectDesign } from "../A00_Common/design/design_selection";
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
import { createSectionView } from "./B06_Section_UI_Section_View";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
@@ -196,7 +194,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
},
stationInterval: () => stationInterval ?? 20,
focusChainage: focusStationAt,
// 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저
// 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다.
focusChainage: (chainageM) => {
if (!isWallSelecting()) focusStationAt(chainageM);
},
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
@@ -377,7 +379,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
() => sectionDetail,
structuresPanel,
// 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼).
(chainageM, key) => writeStructurePick(projectId, chainageM, key),
(chainageM, key) =>
selectDesign(projectId, structuresPanel.selectionAt(chainageM), {
partKey: key,
from: "b06",
}),
);
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
@@ -387,13 +393,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
: null;
if (!target) {
structuresPanel.showAtChainage(null);
writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다.
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 세션 선택도 비운다.
return;
}
const owner = stationControls.structureSpan.ownerOf(target);
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
// 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m);
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
structureSelection.syncInletStructure();
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
@@ -462,16 +468,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
function restoreStructurePick(): void {
if (!sectionDetail) return;
const handoff = readStructurePick(projectId);
const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null;
const signature = handoff
? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}`
: null;
if (signature === null || signature === appliedPick) return;
appliedPick = signature;
applyStructurePick(handoff, sectionDetail, {
focusStation: (stationId) => sectionView.focusStation(stationId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
});
// 카드를 못 찾았으면(구조물 목록이 아직 안 실림) 적용한 것으로 치지 않는다 — 목록이 실린 뒤
// `load().then(restoreStructurePick)` 가 다시 부른다.
const applied = applyStructurePick(
handoff,
sectionDetail,
{
focusStation: (stationId) => sectionView.focusStation(stationId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role),
},
structuresPanel.getStructures(),
);
if (applied) appliedPick = signature;
}
function renderSectionDetail(): void {
@@ -16,6 +16,11 @@
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversChainage,
isNear,
type SelectableStructure,
} from "../A00_Common/design/design_selection";
/** 기슭막이 조정창이 받는 부재키 — 집수정·배관·본체는 여기서 빠진다. */
const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+)$/;
@@ -30,20 +35,30 @@ interface PickTargets {
/**
* 넘김값을 화면에 적용한다. 값이 없거나 그 측점이 이 노선에 없으면 아무것도 안 한다.
* 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다.
* 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다. 카드를 찾아 적용했으면 true —
* 못 찾았으면(구조물 목록이 아직 안 실린 경우 등) false 라 부르는 쪽이 나중에 다시 부른다.
*/
export function applyStructurePick(
handoff: StructurePickHandoff | null,
detail: SectionDetailResponse | null,
targets: PickTargets,
): void {
if (!handoff) return;
// 허용오차는 같은 화면의 다른 대조와 같은 0.05m 다 — 0.01m 로는 3D 가 준 누가거리가
/** 구조물 정본 — 카드 위치가 아닌 곳(구간형 옹벽 한가운데 등)을 골랐을 때 그 카드를 찾는 데 쓴다. */
structures: ReadonlyArray<SelectableStructure> = [],
): boolean {
if (!handoff) return false;
// 허용오차는 관·구조물 찾는 거리 한 값이다 — 0.01m 로는 3D 가 준 누가거리가
// 소수점 아래에서 조금만 달라도 그 측점을 못 찾고 조용히 끝났다(2026-09-05 진단).
const target = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - handoff.at) < 0.05,
);
if (!target) return;
const sections = detail?.cross_sections ?? [];
// 구조물 id 가 있으면 그 구조물의 카드가 먼저다 — 같은 측점 근처에 다른 카드(관)가 있어도.
const byId = handoff.structureId
? structures.find((entry) => entry.structure_id === handoff.structureId)
: undefined;
const covering = structures.find((entry) => coversChainage(entry, handoff.at));
const target =
(byId && cardOfStructure(sections, byId, handoff.at)) ||
sections.find((section) => isNear(section.chainage_m, handoff.at)) ||
(covering && cardOfStructure(sections, covering, handoff.at));
if (!target) return false;
// 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다.
const key = handoff.key?.split(":")[0];
requestAnimationFrame(() => {
@@ -61,4 +76,28 @@ export function applyStructurePick(
}
targets.refreshCard(target.chainage_m);
});
return true;
}
type Sections = SectionDetailResponse["cross_sections"];
/** 구조물이 자리한 카드 — 구간형이면 그 안의 첫 카드, 점형이면 가장 가까운 카드. 옹벽 · 기슭막이는
* 횡단 제원이라 시작 측점 카드가 주인이다(관은 `isNear` 로 카드를 바로 찾는다). */
function cardOfStructure(
sections: Sections,
structure: SelectableStructure,
at: number,
): Sections[number] | undefined {
if (!sections.length) return undefined;
const start = structure.start_m ?? structure.chainage_m;
const end = structure.end_m ?? structure.chainage_m;
if (start != null && end != null) {
const inside = sections.find(
(section) => section.chainage_m >= start - 0.01 && section.chainage_m <= end + 0.01,
);
if (inside) return inside;
}
return sections.reduce((best, entry) =>
Math.abs(entry.chainage_m - at) < Math.abs(best.chainage_m - at) ? entry : best,
);
}
@@ -27,10 +27,14 @@ import {
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { readSummaryItem } from "../common_util/common_util_summary_item";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
NO_SELECTION,
PIPE_MATCH_M,
resolveSelection,
selectDesign,
type DesignSelection,
} from "../A00_Common/design/design_selection";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
@@ -92,14 +96,20 @@ export interface B06StructuresPanel {
listRoot: HTMLElement;
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
load: () => Promise<void>;
/** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */
showPipeAt: (chainageM: number | null) => void;
/** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인
* 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */
overrideOptions: (chainageM: number, patch: Record<string, string | number>) => void;
/** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고,
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */
showAtChainage: (chainageM: number | null) => void;
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). 횡단도의 카드 ·
* 벽 · 구체 어디를 눌러도 이 길 하나다. 폼이 연 것을 고름 값으로 돌려준다. */
showAtChainage: (chainageM: number | null) => DesignSelection;
/** 횡단도에서 벽·구체 선택을 풀 때 — 관 계열 폼만 비운다. 구조물 폼은 그대로 둔다(카드를
* 갈아 선택하며 옛 카드의 벽 선택이 풀리는 순간 새 카드의 폼을 지우면 안 된다). */
releasePipe: () => void;
/** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */
selectionAt: (chainageM: number | null) => DesignSelection;
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
getStructures: () => StructureInstance[];
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
currentChainage: () => number | null;
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
@@ -116,17 +126,6 @@ export interface B06StructuresPanel {
};
}
/** 관 매칭 허용 오차(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다(B05와 같은 기준). */
const PIPE_MATCH_M = 0.51;
/** 이 구조물이 그 측점을 덮는가 — 점형은 근사 일치, 구간형은 시작~종료 안. */
function coversChainage(structure: StructureInstance, chainageM: number): boolean {
if (structure.placement === "interval" && structure.start_m != null && structure.end_m != null) {
return chainageM >= structure.start_m - 0.01 && chainageM <= structure.end_m + 0.01;
}
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
@@ -376,7 +375,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
selectDesign(
deps.projectId,
chainageM === null ? NO_SELECTION : { chainageM, structureId: null },
{ from: "b06" },
);
if (chainageM !== null) deps.focusChainage(chainageM);
},
},
@@ -456,8 +459,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
// 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다
// (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도
// 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다.
const picked = currentChainageM ?? readStructurePick(deps.projectId)?.at ?? null;
if (picked !== null) section.selectPipeByChainage(picked);
const pick = readStructurePick(deps.projectId);
const picked = currentChainageM ?? pick?.at ?? null;
// 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다.
if (picked !== null) {
section.selectAt({
chainageM: picked,
structureId: currentChainageM === null ? (pick?.structureId ?? null) : null,
});
}
} catch (error) {
showToast(
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
@@ -576,30 +586,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
// 폼을 그 값으로 다시 세운다 — 조정창이 한계를 알려 주던 것과 같은 자리다.
section.selectPipeByChainage(hit.chainage_m);
},
showPipeAt: (chainageM) => {
currentChainageM = chainageM;
if (chainageM !== null) refreshSpecDefaults(chainageM);
section.selectPipeByChainage(chainageM);
getStructures: () => structures,
releasePipe: () => {
currentChainageM = null;
section.selectPipeByChainage(null);
},
selectionAt: (chainageM) => {
const selection = { chainageM, structureId: null };
return { chainageM, structureId: section.resolveAt(selection).structureId };
},
showAtChainage: (chainageM) => {
currentChainageM = null;
if (chainageM === null) {
section.selectById(null);
return;
}
const selection: DesignSelection = { chainageM, structureId: null };
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
// 그려지는 것은 이쪽이다.
const pipe = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
);
if (pipe) {
currentChainageM = pipe.chainage_m;
refreshSpecDefaults(pipe.chainage_m);
section.selectPipeByChainage(pipe.chainage_m);
return;
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
const hit = resolveSelection(selection, pipeFacilities, structures);
if (hit.pipeChainageM !== null) {
currentChainageM = hit.pipeChainageM;
refreshSpecDefaults(hit.pipeChainageM);
}
const hit = structures.find((entry) => coversChainage(entry, chainageM));
section.selectById(hit?.structure_id ?? null);
section.selectAt(selection);
return { chainageM, structureId: hit.structureId };
},
};
}
@@ -661,7 +668,8 @@ export function wireStructureSelection(
markWallSelecting();
origRevet(chainageM, key);
if (key) remember?.(ownerChainageOf(chainageM), key);
panel.showPipeAt(key ? ownerChainageOf(chainageM) : null);
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
else panel.releasePipe();
if (key) syncInletStructure();
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
refreshAdjustSlots();
@@ -672,7 +680,8 @@ export function wireStructureSelection(
markWallSelecting();
origFord(chainageM, role);
if (role) remember?.(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
if (role) panel.showAtChainage(chainageM);
else panel.releasePipe();
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
refreshAdjustSlots();
@@ -682,7 +691,8 @@ export function wireStructureSelection(
markWallSelecting();
origBox(chainageM, role);
if (role) remember?.(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
if (role) panel.showAtChainage(chainageM);
else panel.releasePipe();
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
refreshAdjustSlots();
};
@@ -0,0 +1,178 @@
/* 측점 · 구조물 고름 한 곳 단위검증 헬퍼(PLAN 24-2d) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소와 즉시 실행 프레임만 흉내).
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const session = new Map();
const fakeStorage = {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
globalThis.sessionStorage = fakeStorage;
globalThis.localStorage = fakeStorage;
globalThis.requestAnimationFrame = (fn) => fn();
function loadTs(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 localRequire = (request) =>
request.startsWith(".")
? loadTs(path.join(path.dirname(full), `${request}.ts`))
: require(request);
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
const pickPage = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts"));
const pickSession = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"),
);
const out = { matchM: sel.PIPE_MATCH_M };
const P = "proj-1";
// ── 풀기: 관 → 구조물 순 · 한 거리 ───────────────────────────────────────
// 옹벽(구간 200~210) · 독립 기슭막이(구간 80~90) · BOX/세월교/배수관은 관 정본
const structures = [
{ structure_id: "wall", placement: "interval", start_m: 200, end_m: 210, chainage_m: 205 },
{ structure_id: "revet", placement: "interval", start_m: 80, end_m: 90, chainage_m: 85 },
];
const pipes = [{ chainage_m: 173.09 }, { chainage_m: 205.2 }, { chainage_m: 300 }];
const at = (chainageM, structureId = null) =>
sel.resolveSelection({ chainageM, structureId }, pipes, structures);
out.card = {
// 횡단 카드 측점이 관 측점에서 0.05 ~ 0.51 m 어긋나도 그 관을 찾는다(옛 B05 폼은 0.05 라 놓쳤다)
pipeNear: at(173.4),
pipeFar: at(173.09 + 0.6),
wallStart: at(200),
wallMiddle: at(207),
wallEdge: at(210.4),
revet: at(85),
none: at(250),
cleared: at(null),
// 같은 측점에 관과 구조물이 겹치면 관이 먼저(횡단도에 그려지는 것)
pipeBeatsStructure: at(205.0),
// 구조물 id 가 주어지고 목록에 있으면 측점보다 id 가 이긴다 · 없는 id 는 측점으로 되돌아간다
byId: at(300, "revet"),
staleId: at(200, "gone"),
};
// ── 세션 값 + 알림 ─────────────────────────────────────────────────────
const heard = [];
const off = sel.subscribeSelection(P, (s, from) => heard.push({ ...s, from }));
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", from: "b06" });
out.store1 = sel.readSelection(P);
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" });
out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음
sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" });
out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙)
sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" });
out.cleared = sel.readSelection(P);
out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]);
off();
sel.selectDesign(P, { chainageM: 1, structureId: null });
out.afterOff = heard.length;
out.noProject = sel.readSelection(null);
// 알림 도중 다시 낸 값은 적기만 한다(되울림 방지)
let echoes = 0;
const off2 = sel.subscribeSelection(P, () => {
echoes += 1;
sel.selectDesign(P, { chainageM: 2, structureId: null });
});
sel.selectDesign(P, { chainageM: 3, structureId: null });
out.echoes = echoes;
out.lastWritten = sel.readSelection(P)?.chainageM;
off2();
sel.selectDesign(P, sel.NO_SELECTION);
// ── 넘김값 읽기 ────────────────────────────────────────────────────────
sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "right" });
out.handoff = pickSession.readStructurePick(P);
sel.selectDesign(P, sel.NO_SELECTION);
out.handoffNone = pickSession.readStructurePick(P);
// ── B06 진입 때 카드 찾기 ──────────────────────────────────────────────
const cards = [80, 173.09, 200, 205.2, 220].map((c) => ({
chainage_m: c,
station_id: `s${c}`,
ford: null,
box: null,
}));
const detail = { cross_sections: cards };
const run = (handoff, list) => {
const calls = [];
calls.applied = pickPage.applyStructurePick(
handoff,
detail,
{
focusStation: (id) => calls.push(["focus", id]),
refreshCard: (c) => calls.push(["refresh", c]),
revetSelect: (c, k) => calls.push(["revet", c, k]),
fordSelect: (c, k) => calls.push(["ford", c, k]),
boxSelect: (c, k) => calls.push(["box", c, k]),
},
list,
);
return calls;
};
const midWall = { at: 207, structureId: "wall" }; // 구간 한가운데 — 카드 위치가 아님
out.retry = {
// 구조물 목록이 아직 안 실렸으면 카드를 못 찾고 false → 부르는 쪽이 나중에 다시 부른다
before: run({ at: 260 }, []).applied,
// 목록이 실린 뒤 같은 넘김값은 카드를 찾는다
after: run(midWall, structures).applied,
notFound: run({ at: 260 }, structures).applied,
emptyHandoff: run(null, structures).applied,
};
out.apply = {
pipeOffsetCard: run({ at: 173.4 }, structures),
wallByAnchor: run({ at: 205, structureId: "wall" }, structures),
wallNoId: run({ at: 207 }, structures),
revetCardFirst: run({ at: 85, structureId: "revet", key: "own" }, structures),
unknown: run({ at: 260 }, structures),
none: run(null, structures),
};
// ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ─────────────
const restoreRun = (pickValue) => {
sel.selectDesign(P, sel.NO_SELECTION);
if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key });
const calls = [];
let opened = false;
const section = {
hasSelection: () => opened,
selectAt: (s) => {
calls.push(["selectAt", s.chainageM, s.structureId]);
opened = true;
},
};
const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) };
const done = pickSession.restoreStructurePick(controls, P, section, (c) =>
calls.push(["station", c]),
);
return { calls, done };
};
out.restore = {
pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }),
structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }),
nothing: restoreRun(null),
};
sel.selectDesign(P, sel.NO_SELECTION);
console.log(JSON.stringify(out));
@@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
"""측점 · 구조물 고름 한 곳 회귀검증 — PLAN 24-2d (2026-09-29).
• 관 찾는 거리 한 값(`PIPE_MATCH_M = 0.51`) — 정의는 한 곳, 옛 0.05 는 없음
• 횡단 카드 · 종단 알약 · 목록 · 3D 표지 어디서 골라도 같은 순서(관 → 구조물) · 같은 거리로 폼이 열 항목을 풂
• 세션 값(`structure-pick`) 읽기 · 쓰기 · 알림 — 부재키 지킴 · 같은 값 조용 · 되울림 막음
• B06 진입 때 옹벽 · 기슭막이 카드를 구조물 id 로 찾음
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b05_b06_selection.cjs`).
실제 화면 조작은 ORCA(자체 확인)로 따로 본다.
"""
import json
import os
import re
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_b06_selection.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
# ── 거리 한 값 ─────────────────────────────────────────────────────────────
def test_거리_값은_0_51_하나(out):
assert out["matchM"] == 0.51
def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다():
definitions = []
for folder in ("A00_Common", "B05_Profile", "B06_Section"):
for current, _dirs, names in os.walk(os.path.join(ROOT, folder)):
if "node_modules" in current:
continue
for name in names:
if not name.endswith(".ts"):
continue
text = _read(os.path.relpath(os.path.join(current, name), ROOT))
if re.search(r"\b(const|let)\s+PIPE_MATCH_M\b", text):
definitions.append(name)
assert definitions == ["design_selection.ts"]
for name, folder in (
("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"),
("B05_Profile_UI_Page.ts", "B05_Profile"),
("B05_Profile_UI_Selection.ts", "B05_Profile"),
("B06_Section_UI_Page_Structures_Panel.ts", "B06_Section"),
("B06_Section_UI_Page_Structure_Pick.ts", "B06_Section"),
("B05_Profile_UI_Structure_Pick_Session.ts", "B05_Profile"),
):
text = _read(folder, name)
assert not re.search(r"<\s*0\.05\b", text), name
assert not re.search(r"[-+]\s*0\.51\b", text), name
# ── 카드 누름 → 폼이 열 항목 ────────────────────────────────────────────
def test_카드_측점이_관에서_0_05보다_어긋나도_관을_찾는다(out):
assert out["card"]["pipeNear"] == {"pipeChainageM": 173.09, "structureId": None}
assert out["card"]["pipeFar"]["pipeChainageM"] is None # 0.51 m 밖은 다른 자리
def test_옹벽_기슭막이_카드는_구조물_폼을_연다(out):
for key in ("wallStart", "wallMiddle", "wallEdge"):
assert out["card"][key] == {"pipeChainageM": None, "structureId": "wall"}, key
assert out["card"]["revet"]["structureId"] == "revet"
def test_같은_측점에_관과_구조물이_겹치면_관이_먼저(out):
assert out["card"]["pipeBeatsStructure"]["pipeChainageM"] == 205.2
def test_구조물_id가_있으면_id가_이기고_없는_id는_측점으로_되돌아간다(out):
assert out["card"]["byId"]["structureId"] == "revet"
assert out["card"]["staleId"]["structureId"] == "wall"
def test_고를_것이_없으면_해제(out):
assert out["card"]["none"] == {"pipeChainageM": None, "structureId": None}
assert out["card"]["cleared"] == {"pipeChainageM": None, "structureId": None}
# ── 세션 값 + 알림 ─────────────────────────────────────────────────────
def test_세션에_적고_읽는다(out):
assert out["store1"] == {"chainageM": 205, "structureId": "wall", "partKey": "own"}
assert out["handoff"] == {"at": 205, "key": "right", "structureId": "wall"}
assert out["handoffNone"] is None
assert out["noProject"] is None
def test_부재키를_안_주면_지키고_해제하면_비운다(out):
assert out["keyKept"] == "own"
assert out["cleared"] is None
def test_같은_값은_알리지_않고_출처를_함께_알린다(out):
assert out["sameQuiet"] == 1
assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]]
assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림
def test_알림_도중_다시_낸_값은_적기만_한다(out):
assert out["echoes"] == 1
assert out["lastWritten"] == 2
# ── B06 진입 때 카드 찾기 ──────────────────────────────────────────────
def test_관_카드는_거리_안에서_바로_찾는다(out):
assert out["apply"]["pipeOffsetCard"] == [["focus", "s173.09"]]
def test_옹벽은_구조물_id로_시작_카드를_찾는다(out):
assert out["apply"]["wallByAnchor"] == [["focus", "s200"]]
assert out["apply"]["wallNoId"] == [["focus", "s200"]]
def test_기슭막이_부재키는_그_카드의_조정창을_연다(out):
assert out["apply"]["revetCardFirst"] == [
["focus", "s80"],
["revet", 80, "own"],
["refresh", 80],
]
def test_카드를_못_찾으면_false로_알려_나중에_다시_부르게_한다(out):
assert out["retry"] == {
"before": False,
"after": True,
"notFound": False,
"emptyHandoff": False,
}
def test_모르는_자리_또는_값_없음은_아무것도_안_한다(out):
assert out["apply"]["unknown"] == []
assert out["apply"]["none"] == []
# ── B05 진입 때 되살리기 ───────────────────────────────────────────────
def test_B05_되살리기_관은_측점만으로_구조물을_짐작하지_않는다(out):
pipe = out["restore"]["pipeOnly"]
assert pipe["calls"] == [["3d", 85.05, None], ["station", 85.05]]
assert pipe["done"] is False # 관 목록이 실리면 다시 불린다
def test_B05_되살리기_구조물_id가_있으면_그_구조물을_연다(out):
structure = out["restore"]["structure"]
assert structure["calls"] == [
["3d", 205, "own"],
["station", 205],
["selectAt", 205, "wall"],
]
assert structure["done"] is True
def test_B05_되살리기_적힌_것이_없으면_끝난_것(out):
assert out["restore"]["nothing"] == {"calls": [], "done": True}
# ── 배선 ───────────────────────────────────────────────────────────────
def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나():
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "showPipeAt" not in panel # 관 전용 길을 없앰
assert panel.count("panel.showAtChainage(") == 3 # 기슭막이 · 세월교 · BOX
assert panel.count("panel.releasePipe()") == 3 # 벽 선택을 풀 때는 관 폼만 비움
assert "section.selectAt(" in panel
page = _read("B06_Section", "B06_Section_UI_Page.ts")
assert "writeStructurePick" not in page
assert "selectDesign(" in page
def test_고름은_selectDesign_한_길로_적는다():
for folder, name in (
("B05_Profile", "B05_Profile_UI_Page.ts"),
("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"),
("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"),
):
text = _read(folder, name)
assert "writeStructurePick" not in text, name
assert "rememberStructurePick" not in text, name
assert "selectDesign(" in _read("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts")