fix(B05·B06 한 페이지): 시설 옮김 = 횡단 편집 열쇠째 · [저장] 중 먹이기 멈춤 · 새 시설 측점 미리보기 걷음 · 되돌리기 뒤 다시 견줌 (415-1 · 415-2)

- H1: 관 · 구조물 밖 측점이 움직인 때만 「노선 바뀜」 넘김 규칙 · 시설만 옮기면 열쇠째 옮김
  (사면 쉬프트 · 소단 깊이 · 단면 유형 · 관 측점 고침 그대로)
- H1 덧: 초안 없을 때도 저장분 계획으로 열쇠 주인(관 = 저장 id · 계산 묶음 열쇠와 같음 · 첫 옮김도 열쇠째)
  · 묶음이 세운 열쇠 목록을 같은 측점이면 주인 규칙으로 다시 세지 않음 · 갈아끼운 옛 상세 맞춤 끝냄
- H2: [저장] 도중 관 · 구조물 먹이기 · 맞춤 고리 쉼 · 끝나면 저장 목록 다시 읽고 맞춤(design_pipeline_facilities)
- M4: 새 시설 측점 서버 미리보기(preview-stations) 걷음 · 새 자리 = 계산 묶음 갈아끼움 · 카드 이름만 얹음
  · 종류 바뀐 자리 = 구조물 없는 사본
- 415-2: 되돌리기 · 다시하기(세션만 되씀) 뒤 관 · 구조물을 묶음 입력과 견줘 다시 넣음
- 시험: test_415_page_split 새로 · 미리보기 전제 시험(273-2 · 298 · 317 · 318 · 322-0 · 322-3)을 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-18 13:14:59 +09:00
co-authored by Claude Opus 5.5
parent 19ab7d54b9
commit d9fec937aa
19 changed files with 816 additions and 114 deletions
@@ -0,0 +1,72 @@
/* =============================================================================
* A00_Common/design/design_pipeline_facilities.ts
* 계산 묶음 관 · 구조물 먹이기(PLAN 415) — 가게 관 · 구조물 초안이 바뀌면 묶음에 넣음(비면 저장분).
* · [저장] 도중 = 안 넣음(H2) — 초안이 비는 사이 열 때 읽은 옛 저장분으로 돌지 않게 · 끝나면 `resync`
* (저장 목록 다시 읽기 · 입력 맞춤) 한 번.
* · 되돌리기 · 다시하기 = 세션만 되쓰고 가게 알림이 없음 → 묶음 입력과 견줘 다르면 넣음(415-2 · 노선 초안은
* 노선 길이 맡음).
* 저장 창구는 늦게 불러옴(묶음 시험이 화면 모듈을 안 끌게).
* ========================================================================== */
import { onPageHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import type { useDesignStore } from "./design_store";
import type { ChangeSource } from "./logic/design_pipeline_types";
type Key = "pipes" | "structures";
const FACILITIES: ReadonlyArray<{ source: ChangeSource; key: Key }> = [
{ source: "pipe", key: "pipes" },
{ source: "structure", key: "structures" },
];
export interface FacilityFeed {
saving(): boolean;
/** 저장이 끝나면 `resync` 한 번 — 기다리는 동안 다시 불러도 한 번. */
afterSave(): void;
stop(): void;
}
export function feedFacilities(o: {
projectId: string;
store: ReturnType<typeof useDesignStore>;
/** 묶음이 받을 때인가(열었음 · 묶음이 가게에 쓰는 메아리 아님). */
ready: () => boolean;
gone: () => boolean;
saved: (key: Key) => unknown;
input: (key: Key) => unknown;
/** 노선 초안이 걸렸나 — 되돌리기는 노선 길이 맡음. */
routed: () => boolean;
feed: (source: ChangeSource, value: unknown) => void;
resync: () => Promise<unknown>;
}): FacilityFeed {
let savingOf: ((id: string) => boolean) | null = null;
// 한 번 불러 봄 — 덜 선 모듈(시험 싣개의 고리)은 안 받음
void import("./design_save")
.then((m) => (m.savingDesign(o.projectId), (savingOf = m.savingDesign)))
.catch(() => undefined);
const saving = (): boolean => savingOf?.(o.projectId) ?? false;
let waiting: Promise<void> | null = null;
const afterSave = (): void => {
waiting ??= (async () => {
// ponytail: 100 ms 마다 물음 — 저장 끝 알림이 생기면 그것으로
while (saving() && !o.gone()) await new Promise((done) => setTimeout(done, 100));
if (!o.gone()) await o.resync();
})().finally(() => (waiting = null));
};
const put = (source: ChangeSource, value: unknown): void =>
saving() ? afterSave() : o.feed(source, value);
const stops = [
...FACILITIES.map(({ source, key }) =>
o.store.subscribe(key, (value) => o.ready() && put(source, value ?? o.saved(key))),
),
onPageHistory({
restore: () => {
if (!o.ready() || o.routed()) return;
for (const { source, key } of FACILITIES) {
const now = o.store.get(key) ?? o.saved(key);
if (JSON.stringify(now) !== JSON.stringify(o.input(key))) put(source, now);
}
},
}),
];
return { saving, afterSave, stop: () => stops.forEach((stop) => stop()) };
}
+16 -9
View File
@@ -66,8 +66,9 @@ import {
} from "../../B05_Profile/B05_Profile_Api_Replan";
import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import { pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
import { feedFacilities } from "./design_pipeline_facilities";
export type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
import {
groundStageDeps,
@@ -569,6 +570,7 @@ function createPagePipeline(
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
await pipe.settled();
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
if (facilities.saving()) return (facilities.afterSave(), round); // 저장 도중 = 옛 저장분 · 끝나면 다시
const now = inputsNow();
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
@@ -617,13 +619,18 @@ function createPagePipeline(
})
.catch(() => undefined);
const unsubscribe = [
store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)),
store.subscribe(
"structures",
(list) => started && !echo && void feed("structure", list ?? savedStructures),
),
];
/** 관 · 구조물 먹이기 — [저장] 도중 멈춤(끝나면 저장 목록 다시 읽고 맞춤) · 되돌리기 뒤 다시 견줌(415). */
const facilities = feedFacilities({
projectId,
store,
ready: () => started && !echo,
gone: () => disposed,
saved: (key) => (key === "pipes" ? savedPipes : savedStructures),
input: (key) => pipe.input(key),
routed: () => pipe.input("route") !== null || store.get("routeDraft") !== null,
feed: (source, value) => void feed(source, value),
resync: async () => (forgetPageData(projectId), await loadPlan(), reconcile()),
});
const bridge: RouteDraftBridge = {
saved: () => store.get("routeDraft"),
@@ -679,7 +686,7 @@ function createPagePipeline(
detail: () => shown,
dispose() {
disposed = true;
unsubscribe.forEach((stop) => stop());
facilities.stop();
pipe.dispose();
},
};
@@ -30,7 +30,6 @@ import {
type StateName,
} from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { forgetPreviewAdjusts } from "./B06_Section_Section_Drafts";
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
@@ -167,7 +166,6 @@ export function forgetStationAdjusts(
if (hit !== undefined) delete marks[hit];
if (next !== first) marks[chainageM.toFixed(2)] = first;
writeState("kindreset", Object.keys(marks).length ? marks : null, projectId);
forgetPreviewAdjusts(projectId, chainageM, STRUCTURE_ADJUST_KEYS);
reloadStationAdjusts?.();
}
+42 -65
View File
@@ -4,8 +4,7 @@
*
* · 옛 자리 구조물 측점(서버가 [저장] 때 다시 맞추기 전) = 숨김 — 쥐고 있다가 초안이 다시 그 자리를
* 심거나 초안이 없어지면 돌려 놓음.
* · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
* · 새 자리(서버 횡단 없음) = 계산 묶음 측점 갈아끼움이 얹음(브라우저 계산 줄 하나 · 415 M4) — 여기는 안 물음.
*
* 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) ·
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점
@@ -31,11 +30,7 @@ import {
type PlannedStation,
} from "../common_util/common_util_structure_station";
import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys";
import {
previewStructureSections,
type CrossSection,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { type CrossSection, type SectionDetailResponse } from "./B06_Section_Api_Fetch";
type Pipes = PendingPipes;
@@ -43,35 +38,19 @@ const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_STATION_M
const byChainage = (a: { chainage_m: number }, b: { chainage_m: number }) =>
a.chainage_m - b.chainage_m;
/** 미리 뜬 횡단(`프로젝트:노선:누가거리`) — 못 뜬 자리는 null(다시 안 물음) · 저장 뒤 비움. */
const previews = new Map<string, CrossSection | null>();
/** 초안이 빼 둔 옛 자리 측점. */
const parked = new WeakMap<SectionDetailResponse, CrossSection[]>();
/** 상세마다 마지막 맞춤 번호 — 늦게 끝난 옛 맞춤이 새 맞춤을 덮지 않게. */
const turns = new WeakMap<SectionDetailResponse, number>();
/** 저장 · 노선 바뀜 뒤 — 페이지 저장분(자료 줄 · 386-2) · 미리 뜬 횡단을 버림. */
export function forgetDraftStations(projectId: string): void {
forgetPageData(projectId);
for (const key of [...previews.keys()]) if (key.startsWith(`${projectId}:`)) previews.delete(key);
/** 상세가 갈아끼워짐 — 옛 상세에 돌던 맞춤은 측점 열쇠 목록을 옛 측점으로 덮지 않게 끝냄(415 H1). */
export function supersedeDraftStations(detail: SectionDetailResponse): void {
turns.set(detail, (turns.get(detail) ?? 0) + 1);
}
/** 종류 바꿈(318-4) — 그 자리 미리 뜬 횡단 설계에 얹혔던 조정값 사본을 걷음(조작이 캐시에 쓴 것). */
export function forgetPreviewAdjusts(
projectId: string,
chainageM: number,
keys: readonly string[],
): void {
for (const [key, section] of previews) {
if (
!key.startsWith(`${projectId}:`) ||
!section?.design ||
!near(section.chainage_m, chainageM)
)
continue;
const design = section.design as unknown as Record<string, unknown>;
for (const name of keys) delete design[name];
}
/** 저장 · 노선 바뀜 뒤 — 페이지 저장분(자료 줄 · 386-2)을 버림. */
export function forgetDraftStations(projectId: string): void {
forgetPageData(projectId);
}
/** 저장된 관 · 구조물 — 한쪽 초안만 있을 때 심는 자리 셈에 씀(페이지 자료 줄 한 번 · 저장 뒤 다시). */
@@ -81,20 +60,6 @@ const savedOf = (projectId: string): Promise<[Pipes, StructureInstance[]]> =>
pageStructures(projectId).then((res) => res.structures),
]);
async function previewOf(
projectId: string,
routeId: number,
chainages: number[],
): Promise<CrossSection[]> {
const key = (at: number) => `${projectId}:${routeId}:${at.toFixed(3)}`;
const ask = chainages.filter((at) => !previews.has(key(at)));
if (ask.length) {
const got = await previewStructureSections(projectId, ask);
ask.forEach((at) => previews.set(key(at), got.find((s) => near(s.chainage_m, at)) ?? null));
}
return chainages.flatMap((at) => previews.get(key(at)) ?? []);
}
/** 일반 측점 자리(grid · 272)의 「구조물 없는 꼴」(217) — 저장분에서 시설 · 이름만 뺀 사본(한 벌을 다시 씀 ·
* 지형 쪽 고친 값은 그 측점 것 그대로) · 새 종류는 그 위에 얹음(`decorate`) · 측점은 남으므로 숨기지 않음. */
const bares = new WeakMap<CrossSection, CrossSection>();
@@ -109,7 +74,7 @@ function bareOf(section: CrossSection): CrossSection {
return copy;
}
/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */
/** 구조물 없는 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */
function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void {
for (const section of fresh) {
section.preview = true;
@@ -156,9 +121,8 @@ export async function withDraftShapes(
}
/**
* 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 새 자리 미리 뜬 횡단 얹음. 초안이 없으면
* 서버 목록 그대로(빼 둔 것 돌려 놓음). 미리 뜨기를 못 하면(샘플링 조건 없음 · 끊김) 새 자리는
* 종전처럼 [저장] 뒤에 섬.
* 초안에 맞춰 상세의 횡단 목록을 고친다 — 옛 자리 숨김 · 종류 바뀐 자리 구조물 없는 사본. 초안이 없으면
* 서버 목록 그대로(빼 둔 것 돌려 놓음). 새 자리는 계산 묶음 측점 갈아끼움이 얹음(415 M4).
*/
export async function withDraftStations(
detail: SectionDetailResponse,
@@ -175,9 +139,19 @@ export async function withDraftStations(
].sort(byChainage);
let live = all;
let stale: CrossSection[] = [];
let fresh: CrossSection[] = [];
// 추가 측점 초안(368-2)도 측점 계획을 바꿈 — 계획은 그 초안을 스스로 읽음
if (pipesDraft || structuresDraft || readPlusDraft(projectId)) {
if (!(pipesDraft || structuresDraft || readPlusDraft(projectId))) {
// 초안 없음 = 저장분 계획으로 열쇠 주인만 — 관 = 저장 id(계산 묶음 열쇠와 같음 · 첫 옮김도 열쇠째 · 415 H1)
const inputs = await statusOf(projectId, null, null);
if (inputs) {
const [pipes, structures, types] = inputs;
useStationOwners(
projectId,
planStations(pipes, structures, types, registryDefaults(types)),
pipes,
);
}
} else {
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
if (inputs) {
const [pipes, structures, types] = inputs;
@@ -185,7 +159,7 @@ export async function withDraftStations(
useStationOwners(projectId, planned, pipes); // 측점 열쇠 = 덮는 구조물 id(344-3) · 관 = pipe:
const planted = planned.map((station) => station.chainage_m);
// 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고
// 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
// 구조물 없는 사본을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
const [oldPipes, oldStructures] = await savedOf(projectId).catch(
(): [Pipes, StructureInstance[]] => [[], []],
);
@@ -221,25 +195,28 @@ export async function withDraftStations(
planned.some((station) => near(station.chainage_m, section.chainage_m)),
);
stale = [...all.filter(isStale), ...retitled];
const bare = [...all.filter(isStale).filter((section) => section.grid), ...retitled].map(
bareOf,
);
const bare = [
// 종류만 바뀐 자리(측점이 남음) — 시설이 떠난 자리는 숨김만
...all.filter(
(section) =>
isStale(section) &&
replaced(section) &&
(section.grid || planted.some((at) => near(at, section.chainage_m))),
),
...retitled,
].map(bareOf);
live = [...all.filter((section) => !stale.includes(section)), ...bare];
const missing = planted.filter(
(at, index) =>
planted.findIndex((other) => near(other, at)) === index &&
live.every((section) => !near(section.chainage_m, at)),
);
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
if (fresh.length || bare.length) decorate([...fresh, ...bare], planned);
if (bare.length) decorate(bare, planned);
// 계산 묶음 갈아끼움이 얹은 새 자리 = 카드 이름만(415 M4)
for (const section of live)
if (section.kind === "irregular" && !section.structure) {
const hit = planned.find((station) => near(station.chainage_m, section.chainage_m));
if (hit) section.structure = hit.title;
}
}
}
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
detail.cross_sections.splice(
0,
detail.cross_sections.length,
...[...live, ...fresh].sort(byChainage),
);
detail.cross_sections.splice(0, detail.cross_sections.length, ...live.sort(byChainage));
parked.set(detail, stale);
useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3)
}
+2
View File
@@ -23,6 +23,7 @@ import { setGeneralStations, setPlusStations } from "../common_util/common_util_
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
import {
forgetDraftStations,
supersedeDraftStations,
withDraftShapes,
withDraftStations,
} from "./B06_Section_Section_Drafts";
@@ -170,6 +171,7 @@ export async function swapSectionStations(
else useStationKeyList(projectId, routeId, nextKeys);
// 메모리만 — 세션 상세 = 저장 정본만(387-7 · 새로고침 · B06 바로 열기 = 묶음이 다시 갈아끼움 · 365-1)
cache.set(key, swap.detail);
supersedeDraftStations(current); // 옛 상세에 돌던 맞춤이 새 열쇠 목록을 덮지 않게(415 H1)
await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4)
withStructureAreas(swap.detail);
if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음
+14 -2
View File
@@ -150,16 +150,28 @@ export function stationIntervalOf(detail: KeyedDetail): number {
);
}
/** 그대로 세운 목록(계산 묶음 열쇠)이 있는 노선 — 같은 측점이면 주인 규칙으로 다시 세지 않음(415 H1). */
const given = new Set<string>();
/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */
export function useStationKeys(projectId: string, routeId: number, detail: KeyedDetail): void {
const list = stationKeysOf(projectId, detail.cross_sections, stationIntervalOf(detail));
lists.set(scopeOf(projectId, routeId), list);
const scope = scopeOf(projectId, routeId);
const now = lists.get(scope);
const sections = detail.cross_sections;
current = { projectId, routeId };
// 옮긴 관의 주인 자리가 아직 옛 자리여도 묶음 열쇠(pipe:<저장 id>)를 세션 id 로 덮지 않음
const same =
now?.length === sections.length &&
sections.every((section, i) => Math.abs(now[i].chainageM - section.chainage_m) < NEAR_M);
if (given.has(scope) && same) return;
given.delete(scope);
lists.set(scope, stationKeysOf(projectId, sections, stationIntervalOf(detail)));
}
/** 열쇠 목록을 그대로 세움 — 계산 묶음 측점(`PipelineStation.key`)이 정한 열쇠(346-2 4파). */
export function useStationKeyList(projectId: string, routeId: number, list: Keyed[]): void {
lists.set(scopeOf(projectId, routeId), list);
given.add(scopeOf(projectId, routeId));
current = { projectId, routeId };
}
@@ -102,7 +102,7 @@ export interface StationSwap {
removed: string[];
/** 이어 받은 측점 중 누가거리가 바뀐 것 — 옛 0.01 m 글 → 새 글(조작값 맵 옮김). */
moves: Map<string, string>;
/** 이어 받은 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */
/** 이어 받은 관 · 구조물 밖 측점 중 좌표가 0.01 m 넘게 움직인 것이 있나 — 노선이 바뀐 차례(「노선 밖 고침」 셈). */
moved: boolean;
}
@@ -137,7 +137,13 @@ export function swapCrossSections(
const prior = priorOf(station);
if (prior) used.add(prior);
else added.push(station.key);
if (prior && Math.hypot(prior.center_x - station.x, prior.center_y - station.y) > 0.01)
// 노선이 바뀜 = 관 · 구조물 밖 측점이 움직임 — 시설만 옮기면 고침을 열쇠째 옮김(415 H1)
if (
prior &&
station.kind !== "pipe" &&
station.kind !== "structure" &&
Math.hypot(prior.center_x - station.x, prior.center_y - station.y) > 0.01
)
moved = true;
if (prior && text(prior.chainage_m) !== text(station.chainageM))
moves.set(text(prior.chainage_m), text(station.chainageM));
@@ -111,14 +111,18 @@ const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m);
draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95
await drafts.withDraftStations(detail, "P", 1);
await drafts.withDraftShapes(detail, "P"); // 그림 자료(323-4 · `Section_Store` 차례)
const preview = detail.cross_sections.find((s) => s.chainage_m === 95);
out.moved = {
out.moved = { chainages: chainages(detail), asked: asked.splice(0) };
// 새 자리 = 계산 묶음 갈아끼움이 얹은 측점(415 M4) — 다음 맞춤이 그대로 둠 · 관 그림
detail.cross_sections.push({ chainage_m: 95, kind: "irregular", station_id: "s95", samples: [] });
detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m);
await drafts.withDraftStations(detail, "P", 1);
await drafts.withDraftShapes(detail, "P");
const swapped = detail.cross_sections.find((s) => s.chainage_m === 95);
out.again = {
chainages: chainages(detail),
asked: asked.splice(0),
preview: { preview: preview.preview, structure: preview.structure, culvert: preview.culvert },
swapped: { preview: !!swapped?.preview, culvert: !!swapped?.culvert },
};
await drafts.withDraftStations(detail, "P", 1);
out.again = { chainages: chainages(detail), asked: asked.splice(0) };
draftPipes = null;
await drafts.withDraftStations(detail, "P", 1);
out.cleared = { chainages: chainages(detail) };
+9 -6
View File
@@ -472,6 +472,8 @@ const out = {};
// ── 5. 미리 뜬 횡단 사본 걷음(318-4) — 그 자리 조정값만 · 다른 칸 그대로 ─────────
const state = { draftPipes: null, savedPipes: [] };
/** 서버 미리보기 요청(415 M4 — 0 이어야 함). */
const asked = [];
const loadDrafts = makeLoader(
[
B06("B06_Section_Section_Drafts"),
@@ -499,7 +501,7 @@ const loadDrafts = makeLoader(
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes },
"./B06_Section_Api_Fetch": {
previewStructureSections: async (_p, chainages) =>
chainages.map((at) => ({
(asked.push(chainages), chainages).map((at) => ({
chainage_m: at,
kind: "irregular",
design: { ground_type: "soil", ford_adjust: { w: 1 }, revet_adjust: { inlet: { x: 1 } } },
@@ -524,15 +526,16 @@ const loadStore = makeLoader(
);
(async () => {
// 415 M4 — 새 자리 서버 미리보기 걷음 · 조정값 사본도 없음(새 자리는 계산 묶음 갈아끼움이 얹음)
const { withDraftStations, forgetPreviewAdjusts } = loadDrafts(B06("B06_Section_Section_Drafts"));
const detail = { cross_sections: [{ chainage_m: 0, kind: "regular", design: {} }] };
state.draftPipes = [{ chainage_m: 50, facility: "box_culvert" }];
await withDraftStations(detail, "P", 7);
const at50 = () => detail.cross_sections.find((s) => Math.abs(s.chainage_m - 50) < 0.01);
const had = Object.keys(at50()?.design ?? {}).sort();
forgetPreviewAdjusts("P", 50, ["ford_adjust", "revet_adjust"]);
forgetPreviewAdjusts("OTHER", 0, ["ground_type"]);
out.preview = { had, after: Object.keys(at50()?.design ?? {}).sort() };
out.preview = {
asked: asked.splice(0),
chainages: detail.cross_sections.map((s) => s.chainage_m),
forget: typeof forgetPreviewAdjusts,
};
const { crossPatchesFromCache } = loadStore(B06("B06_Section_Section_Store"));
out.cache_patches = crossPatchesFromCache(
+22 -1
View File
@@ -235,6 +235,26 @@ const { pickGroups } = loadTs(B05("B05_Profile_UI_Structures_Panel_Types"));
const { structureDisplayName } = loadTs(B06("B06_Section_UI_Section_Common"));
const { listPick } = loadTs(file("common_util", "common_util_structure_catalog.ts"));
/** 415 M4 — 새 자리 = 계산 묶음 측점 갈아끼움 흉내(측점 계획 자리 중 상세에 없는 것 · 서버 안 물음). */
function swapIn(detail, types) {
const plan = loadTs(file("common_util", "common_util_structure_station.ts"));
const { registryDefaults } = loadTs(file("common_util", "common_util_culvert_sets.ts"));
const structures = JSON.parse(
window.sessionStorage.getItem(`aislo:draft:structures:${PROJECT}`) ?? "null",
);
const planned = plan.planStations(
pendingPipes ?? [],
structures ?? [],
types,
registryDefaults(types),
);
const near = (a, b) => Math.abs(a - b) < plan.SAME_STATION_M;
for (const { chainage_m: at } of planned)
if (detail.cross_sections.every((s) => !near(s.chainage_m, at)))
detail.cross_sections.push({ ...plainAt(at), kind: "irregular" });
detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m);
}
async function main() {
const types = await fetchStructureTypes(true);
const byId = new Map(types.map((type) => [type.type_id, type]));
@@ -267,7 +287,8 @@ async function main() {
forgetDraftStations(PROJECT);
pendingPipes = sample.pipe ? [clone(sample.pipe)] : null;
writePendingStructures(PROJECT, sample.structure ? [clone(sample.structure)] : null);
const detail = await loadSectionDetail(PROJECT, route, { force: true });
swapIn(await loadSectionDetail(PROJECT, route, { force: true }), types);
const detail = await loadSectionDetail(PROJECT, route); // 갈아끼움 뒤 다시 그림 = 캐시 다시 맞춤
const touched = detail.cross_sections.filter(
(section) => section.structure || Object.keys(shapeOf(section)).length,
);
+19
View File
@@ -128,10 +128,29 @@ async function run(setup, detail) {
drafts.forgetDraftStations("P");
route += 1;
await drafts.withDraftStations(detail, "P", route);
swapIn(detail);
await drafts.withDraftStations(detail, "P", route); // 갈아끼움 뒤 다시 그림 = 다시 맞춤
await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4)
return summary(detail);
}
/** 415 M4 — 새 자리 = 계산 묶음 측점 갈아끼움 흉내(측점 계획 자리 중 상세에 없는 것 · 기본 설계 · 서버 안 물음). */
function swapIn(detail) {
const plan = load(file("common_util", "common_util_structure_station.ts"));
const { registryDefaults } = load(file("common_util", "common_util_culvert_sets.ts"));
const planned = plan.planStations(
state.draftPipes ?? state.savedPipes,
state.draftStructures ?? state.savedStructures,
TYPES,
registryDefaults(TYPES),
);
const near = (a, b) => Math.abs(a - b) < plan.SAME_STATION_M;
for (const { chainage_m: at } of planned)
if (detail.cross_sections.every((s) => !near(s.chainage_m, at)))
detail.cross_sections.push({ chainage_m: at, kind: "irregular", station_id: `p${at}` });
detail.cross_sections.sort((a, b) => a.chainage_m - b.chainage_m);
}
(async () => {
const out = {};
out.place = await run({ draftStructures: [wall("retaining_wall")] }, { cross_sections: grid() });
+278
View File
@@ -0,0 +1,278 @@
/* 415 H2 헬퍼 — [저장] 도중 계산 묶음 먹이기 멈춤 · 끝나면 저장 목록 다시 읽음. 진짜 TS(`design_pipeline_page` ·
* 단계 파일 전부)를 트랜스파일해 Node 로 돌림 · 서버 창구 · 저장 중 표시만 가짜(틀 = `helper_365_page_pipeline.cjs`).
* 입력 argv[2] JSON · argv[3] 격자 이진 · 결과 = 표준출력 JSON. 짝 = `test_415_page_split.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
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;
},
};
}
globalThis.window = {
sessionStorage: memoryStorage(),
localStorage: memoryStorage(),
setTimeout,
clearTimeout,
};
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림
const haul = { calls: 0, aborted: 0, answered: 0 };
globalThis.fetch = (url, init) =>
new Promise((resolve, reject) => {
haul.calls += 1;
const timer = setTimeout(() => {
haul.answered += 1;
resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) });
}, job.haulDelayMs);
init.signal.addEventListener("abort", () => {
clearTimeout(timer);
haul.aborted += 1;
reject(new Error("aborted"));
});
});
const STUBS = {};
const cache = new Map();
function loadTs(full) {
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 (CATALOG.isCatalogRequest(request)) return CATALOG;
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
if (key) return STUBS[key];
if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" };
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
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);
return box.exports;
}
const load = (...parts) => loadTs(path.join(ROOT, ...parts));
const sampler = load("common_util", "common_util_surface_sampler.ts");
const bytes = fs.readFileSync(process.argv[3]);
const grid = sampler.parseDtmGrid(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length),
);
const local = { grid, design: "las" };
STUBS["B05_Profile_Api_Replan"] = {
analysisTerrain: () => "las",
fetchRoutePlan: async () => ({
planned: job.line,
curves: [],
min_radius_m: job.minRadius,
station_snap: false,
}),
};
STUBS["B05_Profile_Api_Structures"] = {
fetchStructures: async () => ({ structures: [] }),
fetchStructureTypes: async () => job.types,
};
STUBS["B05_Profile_Api_TerrainGrid"] = {
loadDesignTerrainGrid: async () => local,
groundStageDeps: () => ({ grid: () => local, remote: async () => [] }),
};
/** 서버 관 저장분 — [저장] 뒤 바뀜 · 읽은 횟수(저장 뒤 다시 읽음). */
let serverPipes = job.pipes;
let pipeReads = 0;
STUBS["B04_PreProcess_Api_Fetch"] = {
fetchDetailPipePoints: async () => {
pipeReads += 1;
return { pipe_points: serverPipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })) };
},
};
/** [저장] 도중 — 묶음이 늦게 불러오는 저장 창구 가짜(415 H2). */
let saving = false;
STUBS["design_save"] = { savingDesign: () => saving };
STUBS["B06_Section_UI_Standard_Panel"] = {
effectiveStandardCross: () => job.standard,
readRockBoundaryDefault: () => -0.5,
};
const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts");
const pageMod = load("A00_Common", "design", "design_pipeline_page.ts");
const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts");
const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts");
const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts");
const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts");
const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts");
const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts");
const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts");
const page = load("A00_Common", "b_page_state.ts");
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const runs = () => globalThis.__aisloPipeline?.runs ?? [];
async function settle(count) {
for (let i = 0; i < 400; i += 1) {
const last = runs()[runs().length - 1];
if (runs().length >= count && last && last.outcome !== "running") return last;
await tick(5);
}
throw new Error("묶음이 끝나지 않음");
}
const n = job.nodes.length;
const draftOf = (nodes) => ({
nodes,
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
stationSnap: false,
});
const shift = (index, dx, dy) =>
job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node));
/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */
async function savedBaseOf() {
const seed = pipeMod.createDesignPipeline({
stages: {
route: routeGround.createRouteStage({
minRadiusM: () => job.minRadius,
savedLine: () => ({ line: job.line, curves: [] }),
}),
stations: stationsMod.createStationsStage({
intervalM: job.interval,
types: () => job.types,
}),
ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }),
},
});
await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] });
const ground = seed.output("ground");
seed.dispose();
const samples = ground.longitudinal.chainageM.map((c, i) => ({
chainage_m: c,
elevation_m: ground.longitudinal.z[i],
}));
const profile = gradeGround.groundProfile(samples);
return {
policy: job.policy,
onlyExplicitCurves: false,
basePvi: gradeGround.groundFollowingBasePvi({
samples,
stations: ground.stations.map((s) => s.chainageM),
policy: job.policy,
}),
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((s) => ({
station_id: grade.stationIdOf(s.chainageM),
chainage_m: s.chainageM,
})),
};
}
const store = load("A00_Common", "design", "design_store.ts");
const pipeAt = () =>
pageMod
.pagePipeline(P)
.input("pipes")
.map((pipe) => pipe.chainage_m);
const stationAt = () =>
(shop.get("stations") ?? []).filter((s) => s.kind === "pipe").map((s) => s.chainageM);
const P = "proj-415";
const shop = store.useDesignStore(P, null);
async function run() {
window.localStorage.setItem("frd_current_project_id", P);
const savedBase = await savedBaseOf();
const link = {
pipelineSource: () => ({
base: savedBase,
edits: alignment.emptyEdits(),
detail: job.detail,
routeId: 7,
}),
adoptPipeline: () => undefined,
redraw: () => undefined,
};
pageMod.routeDraftBridge(P, link, { stationIntervalM: job.interval });
pageMod.pipelineEarthwork(P, { conversion: job.conversion });
await settle(runs().length + 1);
const out = { open: pipeAt() };
// ① 관 5 m 옮김(저장 전 초안) — 묶음이 새 자리로
const moved = job.pipes.map((pipe) => ({ ...pipe, chainage_m: pipe.chainage_m + 5 }));
shop.set("pipes", moved);
await settle(runs().length + 1);
out.moved = { input: pipeAt(), stations: stationAt() };
// ② [저장] 도중 — 관 차례가 서버에 쓰고 초안을 비움 · 맞춤 고리도 불림
saving = true;
serverPipes = moved;
const mark = runs().length;
const reads = pipeReads;
shop.set("pipes", null);
const rounds = await pageMod.reconcilePagePipeline(P);
await tick(150);
out.during = {
runs: runs().length - mark,
rounds,
input: pipeAt(),
stations: stationAt(),
reads: pipeReads - reads,
};
// ③ 저장 끝 — 저장 목록 다시 읽고 맞춤(같은 자리 = 다시 안 셈)
saving = false;
await tick(400);
await pageMod.pagePipeline(P).settled();
out.after = {
runs: runs().length - mark,
input: pipeAt(),
stations: stationAt(),
reads: pipeReads - reads,
sources: runs()
.slice(mark)
.map((r) => r.sources),
};
// ④ 저장 뒤 맞춤 고리 — 저장분(새 자리) 대 묶음 입력 같음 = 0 바퀴
out.reconcile = await pageMod.reconcilePagePipeline(P);
// ⑤ 관 옮김 → 되돌리기(415-2) — 세션만 되쓰는 사진첩 · 묶음 입력 · 측점이 원래로 · 다시하기 = 옮긴 자리
const history = load("B05_Profile", "B05_Profile_UI_Profile_History.ts");
const book = history.createProfileHistory(() => undefined);
const again = moved.map((pipe) => ({ ...pipe, chainage_m: pipe.chainage_m + 5 }));
shop.set("pipes", again);
await settle(runs().length + 1);
book.record();
const movedAgain = { input: pipeAt(), stations: stationAt() };
book.undo();
await tick(50);
await pageMod.pagePipeline(P).settled();
const undone = { input: pipeAt(), stations: stationAt(), draft: shop.get("pipes") };
book.redo();
await tick(50);
await pageMod.pagePipeline(P).settled();
out.undo = { movedAgain, undone, redone: { input: pipeAt(), stations: stationAt() } };
pageMod.disposePagePipeline(P);
return out;
}
run()
.then((out) => process.stdout.write(JSON.stringify(out)))
.catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
+149
View File
@@ -0,0 +1,149 @@
/* 415 H1 헬퍼 — 관만 옮김 = 횡단 고침을 측점 열쇠째 옮김 · 노선이 바뀜 = 「노선 바뀜」 넘김 규칙.
* node helper_415_swap.mjs <묶음.mjs>
* 묶음 = 346-2 진입점(`helper_346_2_swap_entry.ts` · vite ssr 진짜 모듈) · 표준출력 JSON 한 벌. 짝 = test_415_page_split.py.
*/
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
globalThis.__toasts = [];
const m = await import(pathToFileURL(process.argv[2]).href);
const P = "p415";
const R = 3;
const out = {};
/* 옛 목록 — 시점 · 격자 넷 · 관 하나(45.054) · 종점(간격 20 m) */
const section = (chainage_m, kind, extra = {}) => ({
station_id: `station_${String(Math.round(chainage_m * 1000)).padStart(12, "0")}`,
chainage_m,
label: "",
kind,
center_z: 100,
azimuth_deg: 90,
center_x: chainage_m,
center_y: 0,
frame: { left_xy: [0, 1] },
samples: [{ offset_m: 0, elevation_m: 100, valid: true }],
design: { design_elevation_m: 101 },
...extra,
});
const detail = () => ({
longitudinal: {
length_m: 100,
stations: [],
profile_alignment: { policy: { station_interval_m: 20 } },
},
cross_sections: [
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }),
section(60, "regular"),
section(80, "regular"),
section(100, "ep"),
],
});
const station = (key, chainageM, kind, id = null, dy = 0) => ({
key,
chainageM,
x: chainageM,
y: dy,
azimuthDeg: 90,
left: [0, 1],
kind,
id,
ground: { centerZ: 99, left: [{ offsetM: 0.5, z: 99.1 }], right: [{ offsetM: 0.5, z: 98.9 }] },
planRadiusM: null,
curveOuterSide: null,
curveWideningM: null,
});
/** 관 자리 `pipeAt` · 노선 옆 밀림 `dy`(0 = 노선 그대로) · 종점 `endAt`. */
const list = (pipeAt, dy = 0, endAt = 100) =>
[
station("end:BP", 0, "end", null, dy),
station("grid:1", 20, "grid", null, dy),
station("grid:2", 40, "grid", null, dy),
station("pipe:pipe-s1", pipeAt, "pipe", "pipe-s1", dy),
station("grid:3", 60, "grid", null, dy),
station("grid:4", 80, "grid", null, dy),
station("end:EP", endAt, "end", null, dy),
].sort((a, b) => a.chainageM - b.chainageM);
const choice = (mode) => ({ ground_type: "soil", section_mode: mode, ditch_side: "right" });
const snap = () => ({
choices: [...m.crossDesignChoices(P, R).entries()]
.sort((a, b) => a[0] - b[0])
.map(([at, c]) => [at, c.ground_type ?? null, c.section_mode ?? null]),
shoulder: Object.entries(m.readState("shoulderext", P, R) ?? {})
.filter(([k]) => k !== "@at")
.map(([k, v]) => [k, v]),
berm: Object.entries(m.readState("bermdepth", P, R) ?? {})
.filter(([k]) => k !== "@at")
.map(([k, v]) => [k, v]),
});
/* 1. 순수 — 관만 옮김 = moved 아님 · 노선이 옆으로 밀림 = moved */
{
const keys = ["end:BP", "grid:1", "grid:2", "pipe:pipe-s1", "grid:3", "grid:4", "end:EP"];
out.pure = {
pipeOnly: m.swapCrossSections(detail(), list(50), keys, 20).moved,
route: m.swapCrossSections(detail(), list(50, 0.5, 95), keys, 20).moved,
};
}
/* 2. 가게 측점 → 상세 갈아끼움 · 세션 고침 */
{
m.writeState("section-detail", detail(), P, R);
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45.054 }] }, P);
await m.loadSectionDetail(P, R); // 세션 한 벌 → 캐시 · 측점 열쇠 세움
m.writeCrossDesignChoice(P, R, 40, choice("right_cut")); // 단면 유형
m.writeCrossDesignChoice(P, R, 45.054, choice("left_cut")); // 관 측점 고침
m.writeState("shoulderext", { "@at": { "grid:1": 20 }, "grid:1": { left: 0.7 } }, P, R); // 사면 쉬프트
m.writeState("bermdepth", { "@at": { "grid:3": 60 }, "grid:3": { right: 0.4 } }, P, R); // 소단 깊이
out.before = snap();
const stop = m.followSwaps(
P,
() => R,
{ visible: () => true },
async () => undefined,
);
// 관 5 m 옮김(노선 그대로) — 관 자리 장부도 새 자리
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 50 }] }, P);
m.useDesignStore(P, null).set("stations", list(50));
await new Promise((resolve) => setTimeout(resolve, 30));
out.pipeMoved = { ...snap(), toasts: globalThis.__toasts.length };
// 관 한 번 더(10 m) — 둘째 옮김도 그대로(첫 옮김 뒤 관 id 를 앎)
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 55 }] }, P);
m.useDesignStore(P, null).set("stations", list(55));
await new Promise((resolve) => setTimeout(resolve, 30));
out.pipeMovedAgain = snap();
// 노선이 바뀜(옆으로 0.5 m · 종점 95 m) = 넘김 규칙 — 단면 유형 · 사면 쉬프트 · 소단 깊이 버림 · 지반 유형 넘김
m.useDesignStore(P, null).set("stations", list(55, 0.5, 95));
await new Promise((resolve) => setTimeout(resolve, 30));
out.routeMoved = snap();
stop();
}
process.stdout.write(JSON.stringify(out));
+10 -11
View File
@@ -3,8 +3,8 @@
- 미리 뜨기(`sample_structure_sections`)는 파일을 안 쓰고, [저장] 때 정본 쓰기
(`generate_irregular_sections`)와 같은 횡단을 낸다(같은 길 · 화면이 본 값 = 저장 값).
- 기본 설계(`compute_default_designs`)는 파일 대신 받은 횡단으로도 셈.
- 화면 쪽: 옛 자리 숨김 · 새 자리 얹음 · 이름 · 관 그림 · 늦게 끝난 옛 맞춤이 새 것을 안 덮음
(`helper_273_2_preview_stations.cjs`).
- 화면 쪽: 옛 자리 숨김 · 새 자리는 안 물음(415 M4 · 계산 묶음 갈아끼움이 얹음) · 관 그림 · 늦게 끝난 옛
맞춤이 새 것을 안 덮음(`helper_273_2_preview_stations.cjs`).
"""
from __future__ import annotations
@@ -111,22 +111,21 @@ def ts() -> dict:
return json.loads(proc.stdout)
def test_화면_옛_자리_숨기고_새_자리_얹음(ts) -> None:
assert ts["moved"]["chainages"] == [0, 20, 40, 95, 100] # 옛 95.38 숨김 · 새 95 미리 뜬 것
assert ts["moved"]["asked"] == [[95]]
card = ts["moved"]["preview"]
assert card["preview"] is True and card["structure"] == "파형강관 D800"
assert card["culvert"] is True # 관 그림(세트)도 얹힘
def test_화면_옛_자리_숨기고_새_자리_안_물음(ts) -> None:
# 415 M4 — 새 자리 서버 미리보기 걷음(브라우저 계산 줄 하나) · 옛 95.38 숨김 · 미리보기 요청 0
assert ts["moved"]["chainages"] == [0, 20, 40, 100]
assert ts["moved"]["asked"] == []
def test_한_번_뜬_자리는_다시_안_물음(ts) -> None:
def test_묶음이_얹은_새_자리는_그대로_관_그림(ts) -> None:
assert ts["again"]["asked"] == []
assert ts["again"]["chainages"] == [0, 20, 40, 95, 100]
assert ts["again"]["swapped"] == {"preview": False, "culvert": True}
def test_초안이_없으면_서버_목록_그대로(ts) -> None:
assert ts["cleared"]["chainages"] == [0, 20, 40, 95.38, 100]
assert ts["cleared"]["chainages"] == [0, 20, 40, 95, 95.38, 100]
def test_늦게_끝난_옛_맞춤이_새_것을_안_덮음(ts) -> None:
assert ts["race"]["chainages"] == [0, 20, 40, 100, 120]
assert ts["race"]["chainages"] == [0, 20, 40, 100]
+1 -1
View File
@@ -159,7 +159,7 @@ def test_추가_뒤_새_측점_횡단_미리_뜸():
drafts, "export async function withDraftStations(", "parked.set(detail, stale);"
)
assert "readPendingPipes(projectId)" in stations
assert "previewOf(projectId, routeId, missing)" in stations
assert "previewStructureSections" not in drafts # 415 M4 — 새 자리는 계산 묶음 갈아끼움이 얹음
def test_B06_알림_글_뒤에서_다시_나눔():
@@ -1,7 +1,7 @@
"""PLAN 317 — 구조물 측점에서 종류를 바꾸면 횡단을 「구조물 없는 상태」부터 새로(요구 213).
- 저장분과 같은 종류 = 저장분 횡단 그대로
- 다른 종류로 바꿈 = 저장분 횡단은 빼 두고 새 자리처럼 미리 뜬 횡단(옛 구조물 흔적 없음)
- 다른 종류로 바꿈 = 저장분 횡단은 빼 두고 구조물 없는 사본(옛 구조물 흔적 없음 · 415 M4 = 서버 미리보기 안 물음)
- 저장분 종류로 되돌림 = 빼 둔 저장분 횡단이 돌아옴
실제 TS(`withDraftStations`)를 `helper_317_kind_change_station.cjs` 가 Node 로 돌린다(서버 부르는 곳 흉내).
@@ -42,7 +42,8 @@ def test_same_kind_keeps_saved_section(result: dict) -> None:
def test_changed_kind_starts_from_fresh_section(result: dict) -> None:
assert result["changed_kind"] == {"count": 2, "fresh": True, "old": False, "preview": True}
# 415 M4 — 서버 미리보기(fresh) 대신 구조물 없는 사본 · 설계는 브라우저 다시 셈이 맡음
assert result["changed_kind"] == {"count": 2, "fresh": False, "old": True, "preview": True}
def test_back_to_saved_kind_restores_saved_section(result: dict) -> None:
+3 -3
View File
@@ -183,7 +183,7 @@ def test_forget_session_adjusts_at_station(result: dict) -> None:
assert forget["basinadjust"] == {} and forget["inletstruct"] == {}
assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로
assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음
assert forget["preview_calls"][0] == ["P", 100, list(STRUCTURE_ADJUST_KEYS)]
assert forget["preview_calls"] == [] # 415 M4 — 미리 뜬 횡단 사본 없음
def test_kind_reset_marks(result: dict) -> None:
@@ -196,8 +196,8 @@ def test_kind_reset_marks(result: dict) -> None:
def test_preview_copy_stripped(result: dict) -> None:
assert result["preview"]["had"] == ["ford_adjust", "ground_type", "revet_adjust"]
assert result["preview"]["after"] == ["ground_type"]
# 415 M4 — 새 자리 서버 미리보기 요청 0 · 사본 없음
assert result["preview"] == {"asked": [], "chainages": [0], "forget": "undefined"}
def test_b05_save_path_patches(result: dict) -> None:
+7 -5
View File
@@ -39,10 +39,11 @@ def _marked(rows: list) -> list:
def test_place(flows: dict) -> None:
# 415 M4 — 새 자리 = 계산 묶음 갈아끼움(미리 뜬 카드 아님 · 서버 안 물음) · 이름 그대로
assert _marked(flows["place"]) == [
[93, "옹벽", True, []],
[101, "옹벽", True, []],
[113, "옹벽", True, []],
[93, "옹벽", False, []],
[101, "옹벽", False, []],
[113, "옹벽", False, []],
]
@@ -56,7 +57,7 @@ def test_place_on_general_station_gets_name_and_shape(flows: dict) -> None:
def test_move_hides_old_and_adds_new(flows: dict) -> None:
assert _marked(flows["move"]) == [[75, "파형강관 D1000", True, ["culvert"]]]
assert _marked(flows["move"]) == [[75, "파형강관 D1000", False, ["culvert"]]] # 415 M4
assert all(row[0] != 61 for row in flows["move"])
@@ -108,6 +109,7 @@ def test_saved_decimal_stations_follow_plan(flows: dict) -> None:
# 소수 자리 저장분 = B05 를 먼저 연 것과 같음 — 소수 카드는 빼 두고 정수 자리(격자 자리는 사본)
rows = flows["snapSavedDec"]
assert [row[0] for row in _marked(rows)] == SNAPPED
assert all(row[2] for row in _marked(rows))
# 격자 자리 = 구조물 없는 사본(미리 뜬 카드) · 나머지 = 계산 묶음 갈아끼움(415 M4)
assert all(row[2] == (row[0] % 20 == 0) for row in _marked(rows))
assert all(float(row[0]).is_integer() for row in rows)
assert len(rows) == 58
+152
View File
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
"""PLAN 415 — 한 페이지 끊김 ① (분석 412 3장 H1 · H2 · M4).
못박는 것
- H1 관 · 구조물만 옮김 = 「노선 바뀜」 넘김 규칙 안 탐 — 측점 열쇠째 옮김(단면 유형 · 사면 쉬프트 ·
소단 깊이 · 관 측점 고침 그대로 · 두 번 옮겨도) · 노선 입력이 바뀐 때만 넘김 규칙(대조).
- H2 [저장] 도중 계산 줄 먹이기 멈춤 — 관 초안이 비어도 열 때 읽은 옛 관 목록으로 안 돎 · 맞춤 고리도 쉼 ·
저장 끝 = 저장 목록 다시 읽음 · 새 자리 그대로(다시 셈 0).
- 415-2 관 옮김 → 되돌리기 = 묶음 입력 · 공용 측점도 원래 · 다시하기 = 옮긴 자리.
- M4 새 시설 측점 서버 미리보기 걷음 — 미리보기 요청 0(`test_273_2` · `test_318` 이 맡음) · 여기는 코드 자리.
짝: `helper_415_swap.mjs`(346-2 진입점 묶음 · 진짜 상세 가게) · `helper_415_h2.cjs`(진짜 계산 묶음 · 365 틀).
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
import test_365_page_pipeline as t365 # noqa: E402
pytestmark = t365.pytestmark
@pytest.fixture(scope="module")
def h1(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("b415")
build = subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_346_2_swap_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_346_2_swap_entry.js", bundle)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_415_swap.mjs"), str(bundle)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def h2() -> dict:
with tempfile.TemporaryDirectory() as folder:
path, grid = t365._job(Path(folder))
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_415_h2.cjs"), str(path), str(grid)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=180,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_H1_순수_관만_옮김은_노선_바뀜_아님(h1):
assert h1["pure"] == {"pipeOnly": False, "route": True}
def test_H1_관_옮김_뒤_고침_그대로(h1):
before = h1["before"]
assert before["choices"] == [[40, "soil", "right_cut"], [45.05, "soil", "left_cut"]]
assert before["shoulder"] == [["grid:1", {"left": 0.7}]]
assert before["berm"] == [["grid:3", {"right": 0.4}]]
moved = h1["pipeMoved"]
# 관 측점 고침은 새 자리(50 m)로 · 격자 고침 그대로 · 「노선 밖 고침」 알림 없음
assert moved["choices"] == [[40, "soil", "right_cut"], [50, "soil", "left_cut"]]
assert moved["shoulder"] == before["shoulder"] and moved["berm"] == before["berm"]
assert moved["toasts"] == 0
again = h1["pipeMovedAgain"]
assert again["choices"] == [[40, "soil", "right_cut"], [55, "soil", "left_cut"]]
assert again["shoulder"] == before["shoulder"] and again["berm"] == before["berm"]
def test_H1_노선이_바뀌면_넘김_규칙(h1):
routed = h1["routeMoved"]
# 대조 — 노선 입력이 바뀐 때 = 지반 유형만 넘기고 단면 유형 · 사면 쉬프트 · 소단 깊이 버림
assert routed["choices"] == [[40, "soil", None], [55, "soil", None]]
assert routed["shoulder"] == [] and routed["berm"] == []
def test_H2_저장_도중_먹이기_멈춤(h2):
opened = h2["open"]
assert h2["moved"]["input"] == [c + 5 for c in opened]
during = h2["during"]
assert during["runs"] == 0 and during["rounds"] == 0 # 옛 관 목록으로 안 돎 · 고리도 쉼
assert during["input"] == h2["moved"]["input"]
assert during["stations"] == h2["moved"]["stations"]
assert during["reads"] == 0
def test_H2_저장_끝_저장_목록_다시_읽음(h2):
after = h2["after"]
assert after["reads"] >= 1 # 저장 목록 다시 읽음
assert after["input"] == h2["moved"]["input"] # 새 자리 그대로
assert after["stations"] == h2["moved"]["stations"]
# 관 · 구조물 다시 먹이기 없음(옛 목록으로 안 돎) — 저장 측점 기준이 새 자리라 횡단만 한 번
assert all(source == ["cross"] for source in after["sources"]) and after["runs"] <= 1
assert h2["reconcile"] == 0
def test_M4_새_시설_측점_미리보기_걷음():
drafts = (ROOT / "B06_Section/B06_Section_Section_Drafts.ts").read_text(encoding="utf-8")
assert "previewStructureSections" not in drafts
page = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8")
guard = (ROOT / "A00_Common/design/design_pipeline_facilities.ts").read_text(encoding="utf-8")
# 저장 창구는 늦게 불러옴 — 묶음(시험 · 화면)이 저장 모듈을 맨 위에서 안 끎
assert 'from "./design_save"' not in page and 'import("./design_save")' in guard
def test_415_2_관_옮김_되돌리기_측점_원래(h2):
undo = h2["undo"]
base = h2["moved"]["input"] # 저장 뒤 저장분 = 옮긴 자리
assert undo["movedAgain"]["input"] == [c + 5 for c in base]
# 되돌리기 = 세션만 되씀(가게 알림 없음) → 묶음 입력 · 공용 측점도 원래
assert undo["undone"]["draft"] is None
assert undo["undone"]["input"] == base and undo["undone"]["stations"] == base
assert undo["redone"]["input"] == undo["movedAgain"]["input"]
assert undo["redone"]["stations"] == undo["movedAgain"]["stations"]