Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into sub_laptop_5

This commit is contained in:
2026-10-17 17:47:20 +09:00
24 changed files with 459 additions and 169 deletions
+13 -7
View File
@@ -207,12 +207,8 @@ export const STATE_REGISTRY = {
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
"alignment-draft": {
bucket: "draft",
scope: "route",
legacy: (_p, r) => `b05-profile-alignment-draft:${r}`,
},
* 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */
"alignment-draft": { bucket: "draft", scope: "route" },
/** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`).
* 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */
"route-draft": { bucket: "draft", scope: "project" },
@@ -552,11 +548,21 @@ export function routeStateKeys(name: StateName, projectId: string): string[] {
if (entry?.scope !== "route") return [];
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
return sessionKeys((key) => key.startsWith(prefix));
}
/** 한 통 · 한 프로젝트의 세션 키 전부(노선 칸 포함) — 되돌리기 사진(지금 프로젝트만 · 387-1). */
export function projectStateKeys(bucket: StateBucket, projectId: string): string[] {
const head = `aislo:${bucket}:`;
return sessionKeys((key) => key.startsWith(head) && key.split(":")[3] === projectId);
}
function sessionKeys(keep: (key: string) => boolean): string[] {
const keys: string[] = [];
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
if (key && keep(key)) keys.push(key);
}
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
+2 -1
View File
@@ -66,7 +66,8 @@
position: absolute;
top: 4px;
left: var(--spacing-8);
z-index: 4;
/* 고정 Y축(z5 · 불투명) 위 — 아래면 축 띠에 가려짐 */
z-index: 6;
}
/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */
+1 -16
View File
@@ -17,11 +17,7 @@
import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
import { chainageKey, emptyEdits, hasEdits } from "./logic/B05_Profile_UI_Profile_Alignment";
import {
ALIGNMENT_DRAFT_KEY_PREFIX,
draftProjectId,
useDesignStore,
} from "../A00_Common/design/design_store";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
import { clearState } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
@@ -111,22 +107,11 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul
* 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에
* 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측:
* 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남).
* 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다.
*/
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) {
const key = sessionStorage.key(index);
if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key);
}
keys.forEach((key) => sessionStorage.removeItem(key));
} catch {
// 세션 저장소를 못 쓰는 환경이면 남길 초안도 없다.
}
}
/**
+29 -51
View File
@@ -11,7 +11,8 @@
* 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라
* 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`).
*
* 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4).
* 대상 = 지금 프로젝트(사진첩을 세울 때 연 것)의 초안 통(`aislo:draft:` — 노선 초안 · 계획고 · 관 · 구조물 · 횡단 · 346-4).
* 다른 프로젝트 칸은 찍지도 지우지도 않음 · 세션은 등록부(`b_page_state`) 창구로만(387-1).
* 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번.
* 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남.
*
@@ -26,46 +27,30 @@
* 씨앗 값(364-1) — 비었을 때만 채우는 값(표준 횡단 · 횡단 보기 첫 열기)은 `seedPageHistory` 로 모든 사진에 넣음.
* ========================================================================== */
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
const DRAFT_PREFIX = "aislo:draft:";
const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
import { projectStateKeys, readByKey, writeByKey } from "../A00_Common/b_page_state";
import { draftProjectId } from "../A00_Common/design/design_store";
/** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */
const NOT_EDIT =
/^aislo:draft:(selection|pipe-ids|structure-type-cache|structure-pick|b08-[^:]*|b09-[^:]*):/;
/** 놓은 뒤 · 칸 바꾼 뒤 기다렸다 찍는 시간(ms) — 서버 다시 셈 뒤 초안 쓰기까지 한 걸음에. */
const SETTLE_MS = 400;
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
function isLayoutKey(key: string): boolean {
return (
key.includes("collapsed") ||
key.includes("width") ||
key.includes("height") ||
key.includes("open") ||
key.includes("visible")
);
}
function isDataKey(key: string): boolean {
if (key.startsWith(DRAFT_PREFIX)) return !NOT_EDIT.test(key);
return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
}
/** 한 프로젝트의 사진 칸 키 — B05·B06 은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
const editKeys = (projectId: string): string[] =>
projectStateKeys("draft", projectId).filter((key) => !NOT_EDIT.test(key));
const isEditKey = (key: string, projectId: string): boolean =>
key.startsWith("aislo:draft:") && key.split(":")[3] === projectId && !NOT_EDIT.test(key);
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
export type HistorySnapshot = Record<string, string>;
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */
function takeSnapshot(previous?: HistorySnapshot): HistorySnapshot {
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */
function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot {
const snapshot: HistorySnapshot = {};
try {
for (let index = 0; index < sessionStorage.length; index += 1) {
const key = sessionStorage.key(index);
if (!key || !isDataKey(key)) continue;
const value = sessionStorage.getItem(key);
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
}
} catch {
// 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다.
for (const key of editKeys(projectId)) {
const value = readByKey(key);
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
}
return snapshot;
}
@@ -163,21 +148,12 @@ function watchPointer(): void {
);
}
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */
function restoreSnapshot(snapshot: HistorySnapshot): void {
try {
const existing: string[] = [];
for (let index = 0; index < sessionStorage.length; index += 1) {
const key = sessionStorage.key(index);
if (key && isDataKey(key)) existing.push(key);
}
existing.forEach((key) => {
if (!(key in snapshot)) sessionStorage.removeItem(key);
});
Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value));
} catch {
// 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다.
}
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 그 프로젝트 조작 키는 지운다. */
function restoreSnapshot(projectId: string, snapshot: HistorySnapshot): void {
editKeys(projectId).forEach((key) => {
if (!(key in snapshot)) writeByKey(key, null);
});
Object.entries(snapshot).forEach(([key, value]) => writeByKey(key, value));
}
export interface ProfileHistory {
@@ -207,7 +183,9 @@ export function createProfileHistory(
): ProfileHistory {
watchPointer();
sync();
const stack: HistorySnapshot[] = [takeSnapshot()];
/** 사진첩을 세울 때 연 프로젝트 — 그 칸만 찍고 되씀. */
const projectId = draftProjectId();
const stack: HistorySnapshot[] = [takeSnapshot(projectId)];
let cursor = 0;
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
let restoring = false;
@@ -223,12 +201,12 @@ export function createProfileHistory(
const kind: PageHistoryEvent = index < cursor ? "undo" : "redo";
cursor = index;
restoring = replaying = true;
restoreSnapshot(stack[cursor]);
restoreSnapshot(projectId, stack[cursor]);
try {
onRestore();
hooks.forEach((entry) => entry.restore?.(kind));
// 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦.
stack[cursor] = takeSnapshot(stack[cursor]);
stack[cursor] = takeSnapshot(projectId, stack[cursor]);
} finally {
restoring = replaying = false;
}
@@ -246,7 +224,7 @@ export function createProfileHistory(
}
sync();
restAt = gestures;
const snapshot = takeSnapshot(stack[cursor]);
const snapshot = takeSnapshot(projectId, stack[cursor]);
if (sameSnapshot(snapshot, stack[cursor])) return;
if (gestures === openedAt) {
stack[0] = snapshot; // 열 때 — 첫 사진만 다시 뜸
@@ -274,10 +252,10 @@ export function createProfileHistory(
rebaseActive = () => {
if (restoring || paused || pointerHeld || settling || gestures !== restAt) return;
sync();
stack[cursor] = takeSnapshot(stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습
stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습
};
seedActive = (key) => {
const value = isDataKey(key) ? sessionStorage.getItem(key) : null;
const value = isEditKey(key, projectId) ? readByKey(key) : null;
if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value));
};
active = history;
@@ -36,13 +36,11 @@ import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free";
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { stepStructureStation } from "../common_util/common_util_structure_station";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
const KEY_STEP_M = 0.1;
/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */
const PIPE_KEY = "b05:pipes";
export interface PanelToolsContext {
/** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */
root: HTMLElement;
@@ -87,31 +85,23 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
/** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */
let initialPipes: number[] | null = null;
/** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */
/** 관 자리는 가게 관 초안(`pipes` · 사진에 듦)이 정본 — 찍기 직전엔 처음 본 목록만 잡아 둔다. */
function syncPipes(): void {
const chainages = ctx.drainage.pipeChainages();
if (initialPipes === null) initialPipes = chainages;
sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages));
if (initialPipes === null) initialPipes = ctx.drainage.pipeChainages();
}
/** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */
/** 스냅샷을 되돌린 뒤 관 위치도 그 시점 초안으로 맞춘다(초안 없는 걸음 = 처음 목록). 같은 목록이면 조용히 끝난다. */
function restorePipes(): void {
let chainages = initialPipes;
const raw = sessionStorage.getItem(PIPE_KEY);
if (raw) {
try {
chainages = JSON.parse(raw) as number[];
} catch {
// 손상된 값은 최초 목록으로 되돌린다.
}
}
const draft = useDesignStore(draftProjectId(), null).get("pipes");
const chainages = draft
? draft.map((pipe) => Math.round(pipe.chainage_m * 100) / 100)
: initialPipes;
if (chainages) ctx.drainage.setPipeChainages(chainages);
}
// 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부).
/** 찍기 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다(세울 때 · 쉬는 동안 온 값 덮을 때도).
* 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전**
* 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */
/** 찍기 직전(세울 때 · 쉬는 동안 온 값 덮을 때 · 연속 조작 멈출 때) 처음 본 관 목록을 잡는다 —
* 그래야 그 목록이 조작 **전** 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측). */
const inner = createProfileHistory(
() => {
ctx.restore();
+64 -18
View File
@@ -6,8 +6,8 @@
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
* 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다.
*
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 ·
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
* 그림 = B06 횡단 카드 읽기 전용(393-2 · `_Cross_Card` — 구조물 · 포장 · 면적 · 축) · 상세가 아직 없으면
* 원지반 · 계획 횡단선 · 성토사면 길이만(`_Cross_Draw`).
*
* 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계
* (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 ·
@@ -40,10 +40,15 @@ import {
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { readByKey, stateKey } from "../A00_Common/b_page_state";
import { readByKey, routeStateKeys, stateKey } from "../A00_Common/b_page_state";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint";
import {
routeCrossCard,
routeCrossSource,
type RouteCrossSource,
} from "./B05_Profile_UI_RouteEdit_Cross_Card";
export interface CrossPreviewParams {
projectId: string;
@@ -90,8 +95,12 @@ export interface CrossPreviewWindow {
interface CrossPane {
root: HTMLElement;
/** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */
show: (preview: CrossPreviewResponse | null, intervalM: number) => void;
/** 셈해 온 횡단을 그린다(단면 사본이 있으면 B06 카드 · 393-2). `null` 이면 빈 화면으로 되돌린다. */
show: (
preview: CrossPreviewResponse | null,
intervalM: number,
source?: RouteCrossSource | null,
) => void;
/** 기다리는 중임을 알린다. */
wait: (text: string) => void;
}
@@ -105,15 +114,11 @@ interface CrossPane {
function appliedHalfWidth(projectId: string): number | undefined {
const prefix = stateKey("cross-display", projectId, "");
if (!prefix) return undefined;
try {
const latest = Object.keys(window.sessionStorage)
.filter((key) => key.startsWith(prefix))
.sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0];
const value = Number(latest ? readByKey(latest) : NaN);
return Number.isFinite(value) && value > 0 ? value : undefined;
} catch {
return undefined;
}
const latest = routeStateKeys("cross-display", projectId).sort(
(a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)),
)[0];
const value = Number(latest ? readByKey(latest) : NaN);
return Number.isFinite(value) && value > 0 ? value : undefined;
}
function createPane(title: string, empty: string): CrossPane {
@@ -133,15 +138,40 @@ function createPane(title: string, empty: string): CrossPane {
let drawn: CrossPreviewResponse | null = null;
let size = { width: 0, height: 0 };
let zoom: ZoomPanState | undefined;
/** B06 카드 재료(393-2) · 카드 머리 · 발 높이(그림 높이 = 판 − 이것) · 측점 간격. */
let source: RouteCrossSource | null = null;
let chrome = 0;
let interval = 20;
function drawCard(preview: CrossPreviewResponse, from: RouteCrossSource): void {
const plotHeight = (): number => Math.max(120, (size.height || 150) - chrome);
const build = (): HTMLElement => {
const card = routeCrossCard(
from,
interval,
preview.cross_half_width_m,
size.width || 240,
plotHeight(),
);
box.replaceChildren(card);
return card;
};
const card = build();
const extra = card.offsetHeight - plotHeight();
if (size.height && extra > 0 && extra !== chrome) {
chrome = extra;
build();
}
}
function draw(preview: CrossPreviewResponse): void {
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
const rect = box.getBoundingClientRect();
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
if (source) return drawCard(preview, source);
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
box.replaceChildren(plot.svg);
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
@@ -166,9 +196,11 @@ function createPane(title: string, empty: string): CrossPane {
});
return {
root,
show(preview, intervalM) {
show(preview, intervalM, from = null) {
box.replaceChildren();
drawn = preview;
source = preview ? from : null;
interval = intervalM;
zoom = undefined;
if (!preview) {
station.textContent = "";
@@ -184,6 +216,7 @@ function createPane(title: string, empty: string): CrossPane {
wait(text) {
box.replaceChildren();
drawn = null;
source = null;
foot.textContent = text;
},
};
@@ -226,6 +259,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
let watching: { key: string | null; chainageM: number; structure: boolean } | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
let shown: CrossPreviewResponse | null = null;
let shownSource: RouteCrossSource | null = null;
let drawnRoute: unknown = null;
const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? [];
@@ -245,11 +279,23 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
function redraw(): void {
if (watching === null || watching.structure) return;
const route = pipe?.input("route") ?? null;
if (route !== drawnRoute && shown) previous.show(shown, interval());
if (route !== drawnRoute && shown)
previous.show(
shown,
interval(),
shownSource && {
...shownSource,
section: {
...shownSource.section,
station_id: shownSource.section.station_id.replace("b05-cur-", "b05-prev-"),
},
},
);
drawnRoute = route;
const at = located()?.chainageM ?? watching.chainageM;
shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId));
if (shown) current.show(shown, interval());
shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur");
if (shown) current.show(shown, interval(), shownSource);
else current.wait("계산 중…");
params.onShown?.();
}
@@ -0,0 +1,131 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross_Card.ts
* 평면 「횡단」 · 「이전 횡단」 판 그림 = **B06 횡단 카드 읽기 전용**(393-2) — 구조물 · 포장 · 면적 · 축 ·
* 종단 계획고 · 횡단 설계가 B06 카드와 같은 그림.
*
* · 자료 = 새 노선 상세(계산 묶음) · 없으면 받아 둔 저장 상세 · 노선 초안 중 새 상세가 아직 없으면 없음
* (판은 옛 선 그림 `_Cross_Draw` 로 · 종단 임시 횡단 창도 그것 그대로)
* · 단면 사본(station_id 앞머리 · design 사본) — B06 고름 · 줌 기억 · 면적 원본을 안 건드림
* · 구조물 자리 = 정본만 읽는 제어기(`Structure_Layouts` · 세월교 · 박스 조정값) · 누름 막음(줌 단추만)
* ========================================================================== */
import "../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
import { pipelineDetail } from "../A00_Common/design/design_pipeline_page";
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
import { nearestWithin } from "../A00_Common/design/design_selection";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import {
extraWalls,
inletStructure,
revetOffset,
} from "../B06_Section/B06_Section_Structure_Layouts";
import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box_Geom";
import type { BoxControl } from "../B06_Section/B06_Section_UI_Cross_Box_Panel";
import { culvertLinkFor, type CulvertLink } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import { fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford_Geom";
import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel";
import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View";
import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common";
/** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */
export interface RouteCrossSource {
section: CrossSection;
designZ: number | undefined;
link: CulvertLink | undefined;
}
const none = (): void => undefined;
/** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */
export function routeCrossSource(
projectId: string,
pipe: DesignPipeline | null,
chainageM: number,
pane: string,
): RouteCrossSource | null {
const detail =
pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId));
const sections = detail?.cross_sections ?? [];
const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m);
if (!detail || !found) return null;
const profiles = detail.longitudinal.design_profiles;
const planZ = designElevationAt(profiles, found.chainage_m);
return {
section: {
...found,
station_id: `b05-${pane}-${found.station_id}`,
design: found.design && { ...found.design },
},
designZ: planZ === undefined ? undefined : planZ - (found.design?.surface_drop_m ?? 0),
link: found.culvert
? undefined
: culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null),
};
}
/** B06 횡단 카드 읽기 전용 — 판 폭 · 그림 높이(px)로. */
export function routeCrossCard(
source: RouteCrossSource,
intervalM: number,
halfWidthM: number | undefined,
widthPx: number,
plotHeightPx: number,
): HTMLElement {
const { section } = source;
const design = section.design;
const ford: FordControl = {
adjustFor: () => fordAdjustOf(design?.ford_adjust),
update: none,
reset: none,
setPipe: none,
setWing: none,
selectedFor: () => null,
select: none,
};
const box: BoxControl = {
adjustFor: () => ({
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design?.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design?.box_adjust?.right ?? {}) },
}),
update: none,
reset: none,
setBody: none,
setWing: none,
selectedFor: () => null,
select: none,
};
const card = createCrossSectionCard(
section,
false,
1,
none,
intervalM,
halfWidthM,
widthPx,
plotHeightPx,
source.designZ,
undefined,
undefined,
null,
undefined,
undefined,
revetOffset,
inletStructure,
extraWalls,
source.link,
undefined,
undefined,
ford,
box,
);
// 읽기 전용 — 벽 · 선 · 면적 고름을 막음(줌 단추만 통과)
card.addEventListener(
"click",
(event) => {
if (!(event.target as Element).closest("button")) event.stopPropagation();
},
true,
);
return card;
}
@@ -254,6 +254,14 @@
background: var(--color-surface);
}
/* B06 횡단 카드 읽기 전용(393-2) — 판 상자가 테두리 · 고름 강조 · 손가락 없음. */
.b05-routeedit__cross-box > .b06-cross-card {
border: 0;
border-radius: 0;
box-shadow: none;
cursor: default;
}
.b05-routeedit__cross-svg {
display: block;
width: 100%;
+11
View File
@@ -258,6 +258,17 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo
}
}
/** 받아 둔 상세 중 노선 번호가 가장 큰 것(최신) — 노선 번호를 모르는 쪽(B05 평면 횡단 판 · 393-2). */
export function peekSectionDetail(projectId: string): SectionDetailResponse | null {
const prefix = `${projectId}:`;
let best: { routeId: number; detail: SectionDetailResponse } | null = null;
for (const [key, detail] of cache) {
const routeId = Number(key.slice(prefix.length));
if (key.startsWith(prefix) && (!best || routeId > best.routeId)) best = { routeId, detail };
}
return best?.detail ?? null;
}
/**
* 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름).
*
+4 -4
View File
@@ -42,8 +42,8 @@ function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...ZERO_ADJUST };
}
/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다. */
const revetOffset: RevetOffsetControl = {
/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다(B05 평면 횡단 판도 씀 · 393-2). */
export const revetOffset: RevetOffsetControl = {
adjustFor: (section, role) => storedWallAdjust(section, role),
storedAdjustFor: (section, role) =>
section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null,
@@ -55,7 +55,7 @@ const revetOffset: RevetOffsetControl = {
reset: () => undefined,
};
const extraWalls: ExtraWallControl = {
export const extraWalls: ExtraWallControl = {
countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0,
setCount: () => undefined,
equalize: () => undefined,
@@ -63,7 +63,7 @@ const extraWalls: ExtraWallControl = {
syncCount: () => undefined,
};
const inletStructure: InletStructureControl = {
export const inletStructure: InletStructureControl = {
valueFor: (section) => section.design?.inlet_structure ?? "auto",
adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }),
set: () => undefined,
@@ -21,6 +21,7 @@ import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxPick, BoxSideAdjust } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
/** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */
type WingPatch = Partial<{
@@ -136,10 +137,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
const key = fordSessionKey();
if (!key) return;
try {
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
string,
FordAdjust
>;
const parsed = JSON.parse(readByKey(key) ?? "{}") as Record<string, FordAdjust>;
Object.entries(parsed).forEach(([chainage, value]) =>
fordAdjustments.set(chainage, fordAdjustOf(value)),
);
@@ -150,8 +148,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
function persistFordAdjustments(): void {
const key = fordSessionKey();
if (key)
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
if (key) writeByKey(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
}
// 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`)
@@ -337,10 +334,7 @@ export function createBoxControls(deps: FordControlDeps): {
const key = deps.sessionKey();
if (!key) return;
try {
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
string,
BoxAdjust
>;
const parsed = JSON.parse(readByKey(key) ?? "{}") as Record<string, BoxAdjust>;
Object.entries(parsed).forEach(([chainage, value]) =>
adjustments.set(chainage, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left },
@@ -354,7 +348,7 @@ export function createBoxControls(deps: FordControlDeps): {
function persist(): void {
const key = deps.sessionKey();
if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments)));
if (key) writeByKey(key, JSON.stringify(Object.fromEntries(adjustments)));
}
const adjustAt = (chainageM: number): BoxAdjust => {
@@ -28,6 +28,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc";
import { renameChainageKeys } from "./logic/B06_Section_Station_Swap";
import { onAnySwap } from "./B06_Section_Swap_Bus";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
/** 세션에 담기는 측점별 조작값 한 겹 맵. */
export interface SessionMap<V> {
@@ -81,7 +82,7 @@ export function createSessionMap<V>(
const key = sessionKey();
if (!key) return;
try {
const raw = window.sessionStorage.getItem(key);
const raw = readByKey(key);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, unknown>;
Object.entries(parsed).forEach(([entry, value]) => {
@@ -96,7 +97,7 @@ export function createSessionMap<V>(
const key = sessionKey();
if (!key) return;
try {
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(values)));
writeByKey(key, JSON.stringify(Object.fromEntries(values)));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
@@ -125,7 +126,7 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag
const key = sessionKey();
if (!key) return;
try {
const raw = window.sessionStorage.getItem(key);
const raw = readByKey(key);
if (!raw) return;
const parsed = JSON.parse(raw) as {
detached?: Record<string, boolean>;
@@ -140,7 +141,7 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag
persist(): void {
const key = sessionKey();
if (!key) return;
window.sessionStorage.setItem(
writeByKey(
key,
JSON.stringify({
detached: Object.fromEntries(detached),
@@ -20,6 +20,8 @@ globalThis.sessionStorage = {
const win = new EventTarget();
win.setTimeout = setTimeout;
win.clearTimeout = clearTimeout;
win.sessionStorage = globalThis.sessionStorage;
win.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) }; // 연 프로젝트(387-1)
globalThis.window = win;
const cache = new Map();
+7 -2
View File
@@ -20,6 +20,11 @@ globalThis.sessionStorage = {
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
// 사진첩은 등록부 창구 · 연 프로젝트(p) 칸만(387-1)
globalThis.window = {
sessionStorage: globalThis.sessionStorage,
localStorage: { getItem: (k) => (k === "frd_current_project_id" ? "p" : null) },
};
const toasts = [];
let pipe = null;
@@ -168,13 +173,13 @@ pipe = {
);
state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림
sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned));
sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits));
sessionStorage.setItem("aislo:draft:alignment-draft:p:0", JSON.stringify(state.edits));
generation += 1;
emit();
},
grade(edits) {
state.edits = edits;
sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits));
sessionStorage.setItem("aislo:draft:alignment-draft:p:0", JSON.stringify(edits));
generation += 1;
emit();
},
@@ -181,6 +181,11 @@ const store = load("A00_Common", "design", "design_store.ts");
STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined };
STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" };
STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖
// B06 카드 그림(393-2) — 화면 밖 · 판 값 고르기는 진짜
STUBS["B05_Profile_UI_RouteEdit_Cross_Card"] = {
routeCrossSource: () => null,
routeCrossCard: () => null,
};
const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts");
const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null);
@@ -67,7 +67,14 @@ let stations = [
let footprint = null;
let route = { v: 1 };
// 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내
const panels = { now: null, current: () => panels.now, open: (id) => (panels.now = id) };
// 393-1 — 측점 눈금이 연 판은 기억 안 함(remember 거짓)
const panels = {
now: null,
current: () => panels.now,
open: (id, remember = true) => ((panels.now = id), (panels.remember = remember)),
};
// 393-2 — 판 그림 = B06 카드(가짜) · 그린 단면 id 를 적음
const cards = [];
const side = { append() {} };
const STUBS = {
@@ -79,6 +86,12 @@ const STUBS = {
summarizeCross: () => "",
},
"./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new WeakMap([[side, panels]]) },
"./B05_Profile_UI_RouteEdit_Cross_Card": {
routeCrossSource: (projectId, pipe, chainageM, pane) => ({
section: { station_id: `b05-${pane}-s${chainageM}` },
}),
routeCrossCard: (source) => (cards.push(source.section.station_id), fakeEl()),
},
"../A00_Common/design/design_pipeline_page": {
pagePipeline: () => ({
output: (name) => (name === "ground" ? { stations } : null),
@@ -171,8 +184,10 @@ async function run() {
];
footprint = { records: [record("grid:5", 102, 9, -4, false, true)] };
route = { v: 2 };
cards.length = 0;
await cross.refresh();
out.afterEdit = cross.watched();
out.cardsAfterEdit = cards.slice();
// 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄
const heard = [];
@@ -185,6 +200,7 @@ async function run() {
selection: pick.readSelection("p-1"),
heard: heard.slice(),
panel: panels.now,
remember: panels.remember,
watched: cross.watched(),
};
@@ -49,7 +49,7 @@ const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "desig
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_History.ts"));
const out = {};
// ① 옛 키에 남은 초안 — 처음 읽을 때 새 칸으로 옮겨 받고 옛 키는 지운다.
// ① 옛 키(프로젝트 없음)에 남은 초안 — 안 받음(387-1 · 다른 프로젝트 초안일 수 있음).
local.setItem("frd_current_project_id", "p-1");
session.setItem(
"b05-profile-alignment-draft:7",
+25
View File
@@ -0,0 +1,25 @@
/* 진짜 TS 를 그 자리에서 CommonJS 로 트랜스파일해 부름 — 상대 경로는 진짜 파일(.ts)로 따라감.
* ESM 드라이버는 `createRequire(import.meta.url)(이 파일)` 로 받음(test_346_4_profile_history · test_364_1).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
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 near = (request) =>
request.startsWith(".")
? loadTs(path.join(path.dirname(full), `${request}.ts`))
: require(request);
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
}
module.exports = { loadTs };
+15 -15
View File
@@ -1,4 +1,4 @@
"""346-4 되돌리기 앞일 — 사진 대상 = aislo:draft: 전부 · 누름 중간은 안 찍고 놓을 때 한 번 · 크기.
"""346-4 되돌리기 앞일 — 사진 대상 = 연 프로젝트 aislo:draft: 전부(387-1 · 옛 b05 키 · 다른 프로젝트 밖) · 누름 중간은 안 찍고 놓을 때 한 번 · 크기.
349 — 열기만(손길 없음) = 걸음 0 · 열 때 쓰는 초안은 첫 사진 · 조작 한 번 = 걸음 하나.
@@ -15,10 +15,11 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs"
DRIVER = r"""
import { writeFileSync } from "node:fs";
import { createRequire } from "node:module";
// 세션 흉내 — getItem 은 진짜처럼 매번 새 글을 돌려줌(사진끼리 글을 같이 쓰는지 재려고).
const data = new Map();
globalThis.sessionStorage = {
@@ -30,11 +31,14 @@ globalThis.sessionStorage = {
};
globalThis.window = new EventTarget();
window.setTimeout = setTimeout;
const { createProfileHistory } = await import("./B05_Profile_UI_Profile_History.js");
window.sessionStorage = sessionStorage;
window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) };
const { loadTs } = createRequire(import.meta.url)(process.argv[3]);
const { createProfileHistory } = loadTs(process.argv[4]);
const tick = () => new Promise((r) => setTimeout(r, 5));
const out = {};
// 1. 대상 — 초안 통 전부 + 옛 키 · 보기 설정 · pref · result 는 뺌
// 1. 대상 — 연 프로젝트(p1) 초안 통 전부 · 옛 키 · 보기 설정 · pref · result · 다른 프로젝트는 뺌
const K = {
route: "aislo:draft:route-draft:p1",
cross: "aislo:draft:crossdesign:p1:7",
@@ -43,6 +47,7 @@ const K = {
layout: "b05-panel-collapsed",
pref: "aislo:pref:table-open",
result: "aislo:result:latest:p1",
other: "aislo:draft:crossdesign:p2:7",
};
for (const k of Object.values(K)) sessionStorage.setItem(k, "0");
let restored = 0;
@@ -124,15 +129,9 @@ writeFileSync(process.argv[2], JSON.stringify(out));
@pytest.fixture(scope="module")
def result(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("ts_history")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck",
"--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json")],
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "--expose-gc", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
@@ -142,9 +141,10 @@ def result(tmp_path_factory: pytest.TempPathFactory) -> dict:
def test_초안_통_전부를_되돌림(result) -> None:
after = result["after"]
assert result["undo"] and result["restored"] == 1
assert after["route"] == after["cross"] == after["pipes"] == after["legacy"] == "0"
# 보기 설정 · pref · result 는 사진 밖 — 되돌리기가 안 건드림
assert after["layout"] == after["pref"] == after["result"] == "1"
assert after["route"] == after["cross"] == after["pipes"] == "0"
# 옛 키 · 보기 설정 · pref · result · 다른 프로젝트는 사진 밖 — 되돌리기가 안 건드림
assert after["legacy"] == after["layout"] == after["pref"] == after["result"] == "1"
assert after["other"] == "1"
def test_누름_중간은_안_찍고_놓을_때_한_번(result) -> None:
@@ -137,6 +137,6 @@ def test_창_분할_판_좌상단():
assert "massOverlay.append(button);" in popout
assert "home: () => massHandle.before(massOverlay)," in popout
page = read("A00_Common/design/design_page.css")
assert "position: absolute;" in css_block(
page, ".b05-profile__masshaul-overlay > .b05-subpanel__popout"
)
button = css_block(page, ".b05-profile__masshaul-overlay > .b05-subpanel__popout")
# 고정 Y축(z5) 위 — 아래면 단추가 축 띠에 가려짐
assert "position: absolute;" in button and "z-index: 6;" in button
+10 -12
View File
@@ -17,10 +17,11 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_Profile_History.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs"
DRIVER = r"""
import { writeFileSync } from "node:fs";
import { createRequire } from "node:module";
const data = new Map();
globalThis.sessionStorage = {
get length() { return data.size; },
@@ -31,7 +32,10 @@ globalThis.sessionStorage = {
};
globalThis.window = new EventTarget();
window.setTimeout = setTimeout;
const { createProfileHistory, seedPageHistory } = await import("./B05_Profile_UI_Profile_History.js");
window.sessionStorage = sessionStorage;
window.localStorage = { getItem: (k) => (k === "frd_current_project_id" ? "p1" : null) };
const { loadTs } = createRequire(import.meta.url)(process.argv[3]);
const { createProfileHistory, seedPageHistory } = loadTs(process.argv[4]);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const fire = (type) => window.dispatchEvent(new Event(type));
/** 누르고 놓음 — 보기 손길(세션 안 씀) 또는 편집(누른 동안 write). */
@@ -47,11 +51,11 @@ const K = {
std: "aislo:draft:std-cross:p1",
structures: "aislo:draft:structures:p1",
cross: "aislo:draft:crossdesign:p1:7",
pipes: "b05:pipes",
pipes: "aislo:draft:pipes:p1",
};
const out = {};
// 새 페이지 — 관 자리는 찍을 때만 옮겨 적음(Panel_Tools syncPipes 꼴)
// 새 페이지 — 관 초안이 손길 없이 뒤에서 옴(찍기 직전에 쓰는 흉내)
let pipes = [];
sessionStorage.setItem(K.route, "r0");
const h = createProfileHistory(() => {}, 100, () => sessionStorage.setItem(K.pipes, JSON.stringify(pipes)));
@@ -129,15 +133,9 @@ writeFileSync(process.argv[2], JSON.stringify(out));
@pytest.fixture(scope="module")
def result(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("ts_364_1")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck",
"--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", str(out / "driver.mjs"), str(out / "out.json")],
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(out / "driver.mjs"), str(out / "out.json"), str(LOADER), str(TS_FILE)],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
@@ -0,0 +1,77 @@
"""387-1(384 단계 1) 세션 열쇠 — 설계 화면은 세션을 등록부(`b_page_state`)로만 · 프로젝트 없는 열쇠 0.
· design · B05/B06 UI_* 파일 코드(주석 뺌)에 직접 sessionStorage 0
· 등록부 초안 · 결과 칸 옛 키는 모두 프로젝트를 품음 · `b05:pipes` 글 0
· `projectStateKeys` = 그 통 · 그 프로젝트 키만(노선 칸 포함 · 다른 프로젝트 · 다른 통 · 옛 키 밖)
./venv/Scripts/python.exe -m pytest resources/tester/test_384_1_session_keys.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
LOADER = Path(__file__).resolve().parent / "helper_ts_cjs_load.cjs"
def _code(path: Path) -> str:
text = path.read_text(encoding="utf-8")
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
return re.sub(r"(?m)^\s*//.*$|\s//\s.*$", "", text)
def _screen_files() -> list[Path]:
files = list((ROOT / "A00_Common" / "design").rglob("*.ts"))
files += (ROOT / "B05_Profile").glob("B05_Profile_UI_*.ts")
files += (ROOT / "B06_Section").glob("B06_Section_UI_*.ts")
return sorted(files)
def test_화면_파일에_직접_세션_0() -> None:
files = _screen_files()
assert len(files) > 100
hits = [str(p.relative_to(ROOT)) for p in files if "sessionStorage" in _code(p)]
assert hits == []
def test_프로젝트_없는_열쇠_0() -> None:
hits = [str(p.relative_to(ROOT)) for p in _screen_files() if '"b05:pipes"' in _code(p)]
assert hits == []
DRIVER = r"""
const data = new Map();
const fake = {
get length() { return data.size; },
key: (i) => [...data.keys()][i] ?? null,
getItem: (k) => (data.has(k) ? data.get(k) : null),
setItem: (k, v) => data.set(k, String(v)),
removeItem: (k) => data.delete(k),
};
globalThis.window = { sessionStorage: fake, localStorage: fake };
const { loadTs } = require(process.argv[1]);
const s = loadTs(process.argv[2]);
const legacy = Object.entries(s.STATE_REGISTRY)
.filter(([, e]) => e.bucket !== "pref" && e.legacy)
.map(([name, e]) => [name, e.legacy("PX", "RX")]);
for (const k of [
"aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1", "aislo:draft:pipes:p10",
"aislo:draft:crossw:p2:7", "aislo:result:latest:p1", "b05:pipes", "b06:crossw:p1:7",
]) fake.setItem(k, "1");
process.stdout.write(JSON.stringify({ legacy, keys: s.projectStateKeys("draft", "p1").sort() }));
"""
def test_등록부_옛_키는_프로젝트_품음_사진_칸은_그_프로젝트만() -> None:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", DRIVER, str(LOADER), str(ROOT / "A00_Common" / "b_page_state.ts")],
capture_output=True, text=True, encoding="utf-8", cwd=str(ROOT),
) # fmt: skip
assert run.returncode == 0, run.stderr
out = json.loads(run.stdout)
assert out["legacy"] and all("PX" in key for _, key in out["legacy"])
assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"]
@@ -92,3 +92,13 @@ def test_관_구조물_고름은_마름모만(results):
def test_고름을_풀면_법선도_없다(results):
assert results["cleared"] == {"watched": None, "redrawn": True}
def test_393_판_그림은_B06_카드_사본_노선_고치면_전후(results):
# 노선 고친 차례 — 아래 판 = 고치기 전 측점(이전 사본 id) · 위 판 = 새 측점 · B06 카드 id 와 안 겹침
assert results["cardsAfterEdit"] == ["b05-prev-s100", "b05-cur-s102"]
def test_393_1_눈금이_연_판은_기억_안_함(results):
assert results["planClick"]["panel"] == "cross"
assert results["planClick"]["remember"] is False
@@ -72,9 +72,10 @@ def test_크기_접기_키는_하나():
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"]
def test_초안은_등록부_칸_프로젝트_없는_옛_키는_안_받음(results):
# 387-1 — 옛 키(프로젝트 없음)는 다른 프로젝트 초안일 수 있어 안 받음 · 손대지 않음
assert results["adopted"] is None
assert results["keysAfterAdopt"] == ["b05-profile-alignment-draft:7"]
assert results["sameSlot"]["station_offsets"] == {"40.000": 0.1}