refactor(B05·B06): 종단도 한 벌 마무리 — 배수유역도 판(createDrainageSide)을 두 보기에 끼움 · 켬끔 단추 · 흙덮기 강제 값 한 칸 · 계획고 초안 키를 등록부(alignment-draft)로

- 두 보기 같은 오른쪽 판 · 같은 켬끔 값(drainage-visible) · 단추는 도구줄 오른쪽(Profile_SideTool)
- B06 배수유역도 노선 평면선 = 최신 노선 캐시(없으면 한 번 읽기)
- 최소 흙덮기 강제 여부를 두 보기가 한 칸(profile-enforce-min-cover)에서 봄 — B06 막기 동작
- 계획고 초안 b05-profile-alignment-draft:{노선} → aislo:draft:alignment-draft:{프로젝트}:{노선}(옛 값 옮겨 받음) · 되돌리기 사진 · [초기화] 쓸기 포함
- 세로 창 고정을 _Profile_Drag 로(design_profile 684줄) · 시험 test_design_profile_single 6건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-29 20:47:40 +09:00
co-authored by Claude Opus 5.5
parent adb822e0b2
commit acd30ba32c
12 changed files with 335 additions and 59 deletions
+11
View File
@@ -57,6 +57,8 @@ export const STATE_REGISTRY = {
legacy: () => "b05-route-drainage-collapsed",
},
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
@@ -156,10 +158,19 @@ export const STATE_REGISTRY = {
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
crossdesign: { bucket: "draft", scope: "route" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
"alignment-draft": {
bucket: "draft",
scope: "route",
legacy: (_p, r) => `b05-profile-alignment-draft:${r}`,
},
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
/** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */
"profile-enforce-min-cover": { bucket: "result", scope: "project" },
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
* 노선이 바뀌면 `latest` 와 함께 버린다. */
+19 -30
View File
@@ -12,6 +12,7 @@
* ========================================================================== */
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
@@ -73,6 +74,7 @@ import { createProfileZoom } from "../../B05_Profile/B05_Profile_UI_Profile_Zoom
import {
adoptLegacyProfileKeys,
bindProfileCollapse,
createElevationHold,
createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import {
@@ -105,6 +107,8 @@ export interface DesignProfileContext {
view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory;
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
sideTool?: (board: DesignSideBoard) => HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
@@ -194,8 +198,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 페이지 설정 체크박스를 따라온다(기본 해제). */
let enforceMinCover = false;
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
* 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */
let enforceMinCover = readState<boolean>("profile-enforce-min-cover", projectId) === true;
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): void => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
@@ -282,7 +287,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
minCoverViolations,
balanceBar,
tools: tools.render(),
trailing: profileZoom.bar,
trailing,
alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
edited: store.edited(),
@@ -299,32 +304,15 @@ export function createDesignProfile(ctx: DesignProfileContext) {
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
const profileZoom = createProfileZoom(() => draw());
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
let elevationWindow: { min: number; max: number } | undefined;
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */
let heightEditing = false;
const holdElevationRange = (
next: { min: number; max: number } | null,
): { min: number; max: number } | undefined => {
if (heightEditing) return elevationWindow;
elevationWindow = next ?? undefined;
return elevationWindow;
};
// 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다.
body.addEventListener("pointerdown", (event) => {
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
heightEditing = true;
const release = (): void => {
heightEditing = false;
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
draw();
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick } = createPanelTools({
@@ -461,7 +449,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
applyEdits,
handleToolPick,
zoom: profileZoom.state,
holdElevationRange,
holdElevationRange: elevationHold.hold,
toolActive: () => tools.mode() !== "none",
selectedRuns: () => tools.selectedRuns(),
stationIdAtStructure,
@@ -481,12 +469,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
body.addEventListener("scroll", () => {
if (heightEditing) return;
if (elevationHold.editing()) return;
const fitted = updateElevationWindow?.() ?? null;
window.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
draw();
}, SCROLL_SETTLE_MS);
@@ -592,6 +580,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
writeState("profile-enforce-min-cover", enforce, projectId);
},
/** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */
setMinCoverPipes(pipes: ReadonlyArray<MinCoverPipe>) {
+27 -21
View File
@@ -10,12 +10,13 @@
* 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다
* (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것).
*
* ⚠ `alignmentDraft` 만 등록부 밖 옛 키(`b05-profile-alignment-draft:{노선}`)를 감싼다 —
* B05 되돌리기(`_Profile_History`)가 `b05-` 접두어 키만 사진 찍기 때문이다. 등록부 이관은
* 24-2c 가 되돌리기와 함께 한다(브레인 판정 9-29).
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
* 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
* ========================================================================== */
import { readByKey, readState, writeByKey, writeState } from "../b_page_state";
import { readState, writeState } from "../b_page_state";
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
@@ -56,9 +57,20 @@ export interface DesignStore {
): () => void;
}
/** 옛 계획고 초안 키 — 위 머리 주석 ⚠ 참조. */
/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-`
* (노선 번호는 프로젝트를 건너 겹치지 않는다). */
export function draftProjectId(projectId: string | null = null): string {
if (projectId) return projectId;
try {
return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-";
} catch {
return "-";
}
}
interface Slot<T> {
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
id: string | null;
@@ -73,25 +85,19 @@ function slotOf<K extends DesignStoreKey>(
): Slot<DesignStoreValues[K]> {
type V = DesignStoreValues[K];
if (key === "alignmentDraft") {
const raw = routeId === null ? null : `${ALIGNMENT_DRAFT_KEY_PREFIX}:${routeId}`;
const project = draftProjectId(projectId);
if (routeId === null) return { id: null, read: () => null, write: () => undefined };
return {
id: raw,
id: `alignment-draft:${project}:${routeId}`,
read: () => {
const text = raw ? readByKey(raw) : null;
if (!text) return null;
try {
const parsed = JSON.parse(text) as Partial<AlignmentEdits>;
return {
station_offsets: parsed.station_offsets ?? {},
curve_radii: parsed.curve_radii ?? {},
} as V;
} catch {
return null;
}
},
write: (value) => {
if (raw) writeByKey(raw, value === null ? null : JSON.stringify(value));
const parsed = readState<Partial<AlignmentEdits>>("alignment-draft", project, routeId);
if (!parsed) return null;
return {
station_offsets: parsed.station_offsets ?? {},
curve_radii: parsed.curve_radii ?? {},
} as V;
},
write: (value) => writeState("alignment-draft", value, project, routeId),
};
}
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
@@ -57,6 +57,42 @@ export function bindProfileCollapse(
setCollapsed(readStateRaw("profile-collapsed") === "true");
}
/**
* 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다 — 끌어 올리는 동안 축까지 따라
* 움직이면 조작 감각이 깨진다. 손을 뗀 뒤 한 번만 다시 맞춘다(`redraw`).
*/
export function createElevationHold(
body: HTMLElement,
redraw: () => void,
): {
hold: (next: { min: number; max: number } | null) => { min: number; max: number } | undefined;
editing: () => boolean;
} {
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
let elevationWindow: { min: number; max: number } | undefined;
let heightEditing = false;
body.addEventListener("pointerdown", (event) => {
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
heightEditing = true;
const release = (): void => {
heightEditing = false;
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
redraw();
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
return {
hold(next) {
if (heightEditing) return elevationWindow;
elevationWindow = next ?? undefined;
return elevationWindow;
},
editing: () => heightEditing,
};
}
/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */
interface SubPanel {
overlay: HTMLElement;
+8 -1
View File
@@ -17,7 +17,12 @@
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment";
import { ALIGNMENT_DRAFT_KEY_PREFIX, useDesignStore } from "../A00_Common/design/design_store";
import {
ALIGNMENT_DRAFT_KEY_PREFIX,
draftProjectId,
useDesignStore,
} from "../A00_Common/design/design_store";
import { clearState } from "../A00_Common/b_page_state";
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
const BUTTON_CLEARANCE_PX = 23;
@@ -103,6 +108,8 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul
*/
export function clearAlignmentDrafts(): void {
shared.clear();
// 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다.
clearState("alignment-draft", draftProjectId());
try {
const keys: string[] = [];
for (let index = 0; index < sessionStorage.length; index += 1) {
@@ -13,7 +13,7 @@
* ========================================================================== */
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-", "aislo:draft:alignment-draft:"];
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
function isLayoutKey(key: string): boolean {
+3 -2
View File
@@ -9,7 +9,7 @@
import { createDesignProfile } from "../A00_Common/design/design_profile";
import type { DesignSideBoard } from "../A00_Common/design/design_profile";
import { createDrainageSide } from "./B05_Profile_UI_Drainage_Side";
import { drainageSide, drainageSideTool } from "./B05_Profile_UI_Profile_SideTool";
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
/** 콜백 타입은 `_Panel_Types` 에 있다 — 옛 임포트 경로가 그대로 동작하도록 다시 내보낸다. */
@@ -26,7 +26,8 @@ export function createRouteProfilePanel(
const profile = createDesignProfile({
projectId,
view: "profile",
side: createDrainageSide,
side: drainageSide,
sideTool: drainageSideTool,
onSelectStation,
onResetAll,
callbacks,
@@ -0,0 +1,24 @@
/* =============================================================================
* B05_Profile_UI_Profile_SideTool.ts
* 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양 · 같은 켬끔 값**으로 끼운다
* (계획서 24-2c · 24-2e). 판 본체는 `_Drainage_Side`(sub7), 켬 · 끔 값은 `drainage-visible`.
* ========================================================================== */
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type { DesignSideBoard, DesignSideFactory } from "../A00_Common/design/design_profile";
import {
createDrainageSide,
createDrainageToggleButton,
type DrainageSideBoard,
} from "./B05_Profile_UI_Drainage_Side";
/** 오른쪽 판 — 켬 · 끔은 두 보기가 같은 값을 쓴다(기본 켬). */
export const drainageSide: DesignSideFactory = (host) =>
createDrainageSide(host, { visible: readStateRaw("drainage-visible") !== "false" });
/** 켬 · 끔 단추 — 누를 때마다 값을 적는다. */
export function drainageSideTool(board: DesignSideBoard): HTMLElement {
const side = board as DrainageSideBoard;
side.onVisibleChange((visible) => writeStateRaw("drainage-visible", String(visible)));
return createDrainageToggleButton(side);
}
@@ -492,6 +492,20 @@
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
/* 도구줄 오른쪽 묶음 — 가로 배율 조작구 + 오른쪽 판 켬끔 단추(두 보기 같은 자리, 계획서 24-2c). */
.b05-route-profile__trailing {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
.b05-route-profile__trailing > .b05-drainage__toggle {
padding: 1px var(--spacing-8);
font-size: var(--text-caption);
}
.b05-profile__zoom-btn {
width: 22px;
height: 22px;
@@ -9,6 +9,9 @@
* ========================================================================== */
import { createDesignProfile, type DesignProfile } from "../A00_Common/design/design_profile";
import { readState } from "../A00_Common/b_page_state";
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
import { drainageSide, drainageSideTool } from "../B05_Profile/B05_Profile_UI_Profile_SideTool";
import type {
CrossSection,
EarthworkConversion,
@@ -93,6 +96,31 @@ function pipeStations(
});
}
/**
* 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고,
* 없으면 한 번 읽는다(읽기만 · 계산 없음).
*/
function loadRoute(projectId: string, profile: DesignProfile): void {
// 최소 흙덮기 강제 여부 — 종단 보기를 거치지 않고 바로 들어왔으면 저장된 노선 설정값.
const enforceKnown = readState<boolean>("profile-enforce-min-cover", projectId) !== null;
const apply = (latest: RouteLatestResponse): void => {
profile.setRoutePolyline(latest.route_points ?? []);
if (!enforceKnown) {
profile.setEnforceMinCover(latest.route_params?.enforce_pipe_clearance === true);
}
};
const cached = readState<RouteLatestResponse>("latest", projectId);
if (cached?.route_points?.length) {
apply(cached);
return;
}
void fetchLatestRoute(projectId)
.then(apply)
.catch(() => {
/* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */
});
}
export function createSectionProfile(host: SectionProfileHost): SectionProfile {
let profile: DesignProfile | null = null;
let projectId: string | null = null;
@@ -140,6 +168,9 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile {
createDesignProfile({
projectId: id,
view: "cross",
// 오른쪽 판(배수유역도) — 종단 보기와 같은 판 · 같은 켬끔 값(계획서 24-2e).
side: drainageSide,
sideTool: drainageSideTool,
onSelectStation: (stationId) => host.selectCard(cardOf(stationId)),
callbacks: {
onCrossDesignsUpdated: (updated) => host.refreshCards(updated),
@@ -184,6 +215,7 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile {
if (!profile) {
projectId = id;
profile = build(id);
loadRoute(id, profile);
syncMarks();
}
detail = input.detail;
@@ -194,10 +226,6 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile {
input.stationInterval,
Number.isFinite(routeId) ? routeId : undefined,
);
// 최소 흙덮기 강제 여부는 종단 보기 「페이지 설정」 값 — 계획선 정책에 실려 온다.
const policy = input.detail.longitudinal.profile_alignment as
{ policy?: { enforce_pipe_clearance?: boolean } } | undefined;
profile.setEnforceMinCover(policy?.policy?.enforce_pipe_clearance === true);
if (input.conversion) {
profile.setEarthworkContext({
conversion: input.conversion,
@@ -0,0 +1,79 @@
/* 종단도 한 벌(계획서 24-2c) 단위검증 헬퍼 — 계획고 초안이 등록부 칸(`alignment-draft`)으로
* 옮겨졌는지 · 옛 키 값을 옮겨 받는지 · 되돌리기 사진이 새 칸을 찍는지를 Node 로 돌린다.
* `A00_Common/design/design_store.ts` · `B05_Profile_UI_Profile_History.ts` 를 그 자리에서
* 트랜스파일한다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
keys: () => [...map.keys()],
};
}
const session = memoryStorage();
const local = memoryStorage();
globalThis.window = { sessionStorage: session, localStorage: local };
globalThis.sessionStorage = session;
const cache = new Map();
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) => {
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_History.ts"));
const out = {};
// ① 옛 키에 남은 초안 — 처음 읽을 때 새 칸으로 옮겨 받고 옛 키는 지운다.
local.setItem("frd_current_project_id", "p-1");
session.setItem(
"b05-profile-alignment-draft:7",
JSON.stringify({ station_offsets: { "20.000": 0.3 }, curve_radii: {} }),
);
out.adopted = useDesignStore(null, 7).get("alignmentDraft");
out.keysAfterAdopt = session.keys();
// ② 쓰기는 새 칸으로 — 프로젝트를 준 가게와 안 준 가게가 같은 칸.
useDesignStore("p-1", 7).set("alignmentDraft", {
station_offsets: { "40.000": 0.1 },
curve_radii: {},
});
out.sameSlot = useDesignStore(null, 7).get("alignmentDraft");
// ③ 되돌리기가 새 칸을 사진 찍는다 — 편집 한 걸음을 쌓고 되돌리면 앞 값이 돌아온다.
const undoStack = history.createProfileHistory(() => undefined);
useDesignStore("p-1", 7).set("alignmentDraft", {
station_offsets: { "40.000": 0.2 },
curve_radii: {},
});
undoStack.record();
out.beforeUndo = useDesignStore("p-1", 7).get("alignmentDraft");
out.undone = undoStack.undo();
out.afterUndo = useDesignStore("p-1", 7).get("alignmentDraft");
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""종단도 한 벌(계획서 24-2c) 회귀검증.
두 보기(B05 종단 · B06 횡단)가 **같은 종단도**(`A00_Common/design/design_profile.ts`)를 쓰고
같은 오른쪽 판(배수유역도)을 끼우는지, 옛 B06 위쪽 붙박이 종단이 남지 않았는지, 계획고 초안이
등록부 칸(`alignment-draft`)으로 옮겨져 되돌리기가 그 칸을 찍는지 본다.
Node 헬퍼(`helper_design_profile_single.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_design_profile_single.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_두_보기가_같은_종단도와_같은_판():
b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts")
b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts")
for source in (b05, b06):
assert "createDesignProfile(" in source
assert "side: drainageSide" in source
assert "sideTool: drainageSideTool" in source
assert 'view: "profile"' in b05 and 'view: "cross"' in b06
def test_옛_B06_위쪽_종단이_남지_않음():
view = _read("B06_Section/B06_Section_UI_Section_View.ts")
assert "createSectionProfile(" in view
assert "drawLongitudinalPanel" not in view
assert "b06-section__panel" not in view
for gone in ("Draw", "Chart", "Panel"):
assert not (ROOT / f"B06_Section/B06_Section_UI_Section_View_{gone}.ts").exists()
def test_막기_규칙은_종단도_한_곳():
profile = _read("A00_Common/design/design_profile.ts")
assert profile.count("blocksMinCover(") == 1
# 판이 없는 보기도 관 목록을 넘겨 최소 흙덮기를 판정한다.
assert "setMinCoverPipes(" in profile
assert "setMinCoverPipes(" in _read("B06_Section/B06_Section_UI_Section_View_Profile.ts")
def test_크기_접기_키는_하나():
drag = _read("B05_Profile/B05_Profile_UI_Profile_Drag.ts")
assert 'stateKey("profile-height")' in drag
assert '"profile-collapsed"' in drag
assert '["section-panel-height", "profile-height"]' in drag # 옛 값 옮겨 받음
def test_초안은_등록부_칸으로_옮겨_받음(results):
assert results["adopted"] == {"station_offsets": {"20.000": 0.3}, "curve_radii": {}}
assert results["keysAfterAdopt"] == ["aislo:draft:alignment-draft:p-1:7"]
assert results["sameSlot"]["station_offsets"] == {"40.000": 0.1}
def test_되돌리기가_새_칸을_찍음(results):
assert results["beforeUndo"]["station_offsets"] == {"40.000": 0.2}
assert results["undone"] is True
assert results["afterUndo"]["station_offsets"] == {"40.000": 0.1}