From 8be42a302f895bf31a5192ee85251a1ac3b96ad6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:04:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=84=A4=EA=B3=84=EB=AC=B6=EC=9D=8C):=203?= =?UTF-8?q?44-4=20=EA=B3=84=EC=82=B0=20=EB=AC=B6=EC=9D=8C=20=EB=B3=B8?= =?UTF-8?q?=EC=B2=B4=20=C2=B7=20=EA=B0=80=EA=B2=8C=20=EC=B9=B8=20=E2=80=94?= =?UTF-8?q?=20=EC=B0=A8=EB=A1=80=20=EB=B2=88=ED=98=B8=20=C2=B7=20=EC=98=9B?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=EB=B2=84=EB=A6=BC=20=C2=B7=20=EC=98=9B?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=81=8A=EA=B8=B0=20=C2=B7=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EB=B2=94=EC=9C=84=20=C2=B7=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EA=B8=B0=EB=A1=9D=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=B9=B8=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_pipeline.ts 새 — 단계 주입 · deps 판 같으면 건너뜀 · 단계마다 범위 쌓기(못 끝낸 단계는 합침) · follow 만 입력 한 번 고침 · 단계 · 알림 안 입력 쓰기 막음 · window.__aisloPipeline 20 번 - design_store — routeDraft(세션 즉시) · stations · profile · footprint(메모리 우선 · 멈춘 뒤 1 s 한 번) · generation(메모리만) - b_page_state — onStateRemoved(키 지움 알림 · clearResults 때 메모리도 버림) - 시험 test_344_4_design_pipeline 10 건 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/b_page_state.ts | 9 + A00_Common/design/design_pipeline.ts | 354 ++++++++++++++++ A00_Common/design/design_pipeline_types.ts | 2 + A00_Common/design/design_store.ts | 87 +++- .../tester/helper_344_4_design_pipeline.cjs | 379 ++++++++++++++++++ .../tester/test_344_4_design_pipeline.py | 165 ++++++++ 6 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 A00_Common/design/design_pipeline.ts create mode 100644 resources/tester/helper_344_4_design_pipeline.cjs create mode 100644 resources/tester/test_344_4_design_pipeline.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 18b32061b..4c697c6e6 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -421,7 +421,16 @@ function readRaw(key: string): string | null { } } +const removedListeners = new Set<(key: string) => void>(); + +/** 키가 지워질 때 부름(`clearDrafts` · `clearResults` 포함) — 메모리 우선 칸(설계 가게)이 함께 버림. */ +export function onStateRemoved(fn: (key: string) => void): () => void { + removedListeners.add(fn); + return () => removedListeners.delete(fn); +} + function writeRaw(key: string, value: string | null): void { + if (value === null) removedListeners.forEach((fn) => fn(key)); try { const store = storageFor(key); if (value === null) store.removeItem(key); diff --git a/A00_Common/design/design_pipeline.ts b/A00_Common/design/design_pipeline.ts new file mode 100644 index 000000000..b43310c52 --- /dev/null +++ b/A00_Common/design/design_pipeline.ts @@ -0,0 +1,354 @@ +/* ============================================================================= + * A00_Common/design/design_pipeline.ts + * 실시간 계산 묶음 하나(PLAN 344-4) — 조작마다 차례 번호(generation) +1 · 단계 사이마다 번호 + * 확인 · 옛 번호 결과 버림 · 뒤 단계 요청은 AbortController 로 끊음. 계약(키 모양 · 단계 · deps) + * = `design_pipeline_types.ts` 머리 주석. + * + * const pipe = createDesignPipeline({ stages: { route, stations, ... } }); + * pipe.subscribe(publishToStore(useDesignStore(projectId, null))); + * pipe.start(inputs); // 처음 · 전 구간 + * pipe.update("grade", edits, { fromM: 40, toM: 60 }); // 조작 · 바뀐 범위 + * + * 단계 시간 = `window.__aisloPipeline.runs`(마지막 RUN_LOG_SIZE 번 · 단계별 ms · 개발용). + * 화면 · fetch · 세션은 단계 함수(주입) 몫 — 이 파일은 차례 · 버림 · 범위 · 기록만. + * ========================================================================== */ + +import type { DesignStore } from "./design_store"; +import type { + ChangeRange, + ChangeSource, + FollowPatch, + InputKey, + PipelineInputs, + PipelineOutputs, + PipelineRunRecord, + PipelineStages, + RangePart, + StageContext, + StageName, +} from "./design_pipeline_types"; +import { SOURCE_INPUT } from "./design_pipeline_types"; + +/** 브라우저 즉시 — 차례대로 기다림. */ +export const BROWSER_STAGES: StageName[] = [ + "route", + "follow", + "stations", + "ground", + "planline", + "profile", + "cross", + "footprint", + "masshaul", +]; +/** 뒤 — 브라우저 단계가 끝나면 함께 띄우고 기다리지 않음. */ +export const AFTER_STAGES: StageName[] = ["haul", "drainage", "corridor"]; + +type Dep = StageName | `input.${InputKey}`; + +/** 단계가 기대는 값 — 이 판들이 그대로면 다시 안 셈(계약 표와 같음). */ +export const STAGE_DEPS: Record = { + route: ["input.route"], + follow: ["route"], + stations: ["route", "input.pipes", "input.structures"], + ground: ["stations"], + planline: ["ground"], + profile: ["planline", "input.alignment"], + cross: ["profile", "ground", "input.cross", "input.pipes", "input.structures"], + footprint: ["cross", "ground"], + masshaul: ["cross"], + haul: ["masshaul"], + drainage: ["route", "input.pipes"], + corridor: ["profile", "cross"], +}; + +/** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */ +const FOLLOW_KEYS: ReadonlyArray = ["pipes", "structures", "alignment"]; +/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단 · 유토곡선이 그다음. */ +const YIELD_AFTER = new Set(["profile"]); +export const RUN_LOG_SIZE = 20; + +export type PipelineEvent = + | { kind: "generation"; generation: number } + | { kind: "stage"; stage: StageName; value: unknown; generation: number }; + +export interface DesignPipeline { + /** 처음 · 전 구간 — 입력 다섯을 한 번에. */ + start(inputs: PipelineInputs): Promise; + /** 조작 하나 — 값이 같으면 안 셈(null). part 없으면 전 구간. */ + update( + source: ChangeSource, + value: unknown, + part?: RangePart | null, + ): Promise | null; + subscribe(fn: (event: PipelineEvent) => void): () => void; + output(name: K): PipelineOutputs[K] | null; + readonly generation: number; + /** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */ + readonly blockedWrites: number; + /** 떠 있는 뒤 단계가 다 끝날 때까지. */ + settled(): Promise; +} + +type Outs = { [S in StageName]: PipelineOutputs[S] | null }; + +function emptyOuts(): Outs { + const out = {} as Record; + [...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => (out[name] = null)); + return out as Outs; +} + +/** 두 범위 합침 — null(전 구간)이 이김 · 거리는 앞 ~ 뒤. */ +export function mergeRange( + a: ChangeRange | null | undefined, + b: ChangeRange | null, +): ChangeRange | null { + if (a === undefined) return b; + if (a === null || b === null) return null; + // ponytail: 떨어진 두 범위도 앞 ~ 뒤 하나로 — 사이까지 셈. 구간 목록은 느릴 때. + return { + sources: [...new Set([...a.sources, ...b.sources])], + fromM: Math.min(a.fromM, b.fromM), + toM: Math.max(a.toM, b.toM), + keys: [...new Set([...a.keys, ...b.keys])], + }; +} + +const now = (): number => (typeof performance !== "undefined" ? performance.now() : Date.now()); + +function runLog(): { runs: PipelineRunRecord[] } { + const root = globalThis as { __aisloPipeline?: { runs: PipelineRunRecord[] } }; + root.__aisloPipeline ??= { runs: [] }; + return root.__aisloPipeline; +} + +export function createDesignPipeline(options: { stages: PipelineStages }): DesignPipeline { + const stages = options.stages; + const inputs: PipelineInputs = { + route: null, + alignment: null, + cross: null, + pipes: null, + structures: null, + }; + const inputVer: Record = { + route: 0, + alignment: 0, + cross: 0, + pipes: 0, + structures: 0, + }; + const out = emptyOuts(); + const outVer = {} as Record; + const lastSig = {} as Partial>; + /** 마지막으로 끝난 뒤 쌓인 범위 — 없음(undefined) · 전 구간(null) · 범위. */ + const pending = {} as Partial>; + [...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => { + outVer[name] = 0; + pending[name] = null; + }); + const listeners = new Set<(event: PipelineEvent) => void>(); + const inFlight = new Set>(); + let generation = 0; + let controller = new AbortController(); + /** 단계 함수 · 알림을 부르는 동안(동기) — 이때 들어온 입력 쓰기는 고리라 막음. */ + let busy = false; + let blockedWrites = 0; + + const emit = (event: PipelineEvent): void => { + busy = true; + try { + listeners.forEach((fn) => fn(event)); + } finally { + busy = false; + } + }; + + const sigOf = (name: StageName): string => + STAGE_DEPS[name] + .map((dep) => + dep.startsWith("input.") ? inputVer[dep.slice(6) as InputKey] : outVer[dep as StageName], + ) + .join(","); + + const markAll = (range: ChangeRange | null): void => { + [...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => { + pending[name] = mergeRange(pending[name], range); + }); + }; + + const applyFollow = (patch: FollowPatch | null): void => { + if (!patch) return; + for (const key of Object.keys(patch) as Array) { + if (!FOLLOW_KEYS.includes(key)) { + console.warn(`[pipeline] follow 가 고칠 수 없는 입력 — 버림: ${key}`); + continue; + } + inputs[key] = patch[key] as never; + inputVer[key] += 1; + } + }; + + /** 한 단계 — 같은 판이면 건너뜀 · 옛 번호면 버림 · 아니면 출력 내보냄. */ + async function step( + name: StageName, + gen: number, + signal: AbortSignal, + record: PipelineRunRecord, + before: Outs, + ): Promise<"ok" | "dropped" | "error"> { + const sig = sigOf(name); + const fn = stages[name] as ((ctx: StageContext) => unknown) | undefined; + if (!fn || sig === lastSig[name]) { + record.skipped.push(name); + if (fn) delete pending[name]; + return "ok"; + } + const ctx: StageContext = { + generation: gen, + range: pending[name] ?? null, + signal, + inputs: { ...inputs }, + out: { ...out }, + before, + prev: out[name], + }; + const t0 = now(); + let value: unknown; + try { + busy = true; + let pendingValue: unknown; + try { + pendingValue = fn(ctx); + } finally { + busy = false; + } + value = await pendingValue; + } catch (error) { + if (gen !== generation) return "dropped"; + console.error(`[pipeline] ${name} 단계 실패`, error); + return "error"; + } + if (gen !== generation) return "dropped"; + record.stages[name] = Math.round((now() - t0) * 100) / 100; + if (name === "follow") applyFollow(value as FollowPatch | null); + (out as Record)[name] = value; + outVer[name] += 1; + lastSig[name] = sig; + delete pending[name]; + emit({ kind: "stage", stage: name, value, generation: gen }); + return "ok"; + } + + async function run(): Promise { + const gen = generation; + const signal = controller.signal; + const record: PipelineRunRecord = { + generation: gen, + sources: [], + stages: {}, + skipped: [], + totalMs: 0, + outcome: "running", + }; + // 같은 틱의 조작은 마지막 하나만 셈. + await Promise.resolve(); + if (gen !== generation) { + record.outcome = "dropped"; + return record; + } + record.sources = [...new Set(Object.values(pending).flatMap((range) => range?.sources ?? []))]; + const log = runLog(); + log.runs.push(record); + if (log.runs.length > RUN_LOG_SIZE) log.runs.splice(0, log.runs.length - RUN_LOG_SIZE); + const before = { ...out }; + const t0 = now(); + for (const name of BROWSER_STAGES) { + const result = await step(name, gen, signal, record, before); + if (result !== "ok") { + record.outcome = result; + return record; + } + if (YIELD_AFTER.has(name) && record.stages[name] !== undefined) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (gen !== generation) { + record.outcome = "dropped"; + return record; + } + } + } + record.totalMs = Math.round((now() - t0) * 100) / 100; + record.outcome = "done"; + for (const name of AFTER_STAGES) { + const task = step(name, gen, signal, record, before); + inFlight.add(task); + void task.finally(() => inFlight.delete(task)); + } + return record; + } + + function begin(range: ChangeRange | null): Promise { + generation += 1; + controller.abort(); + controller = new AbortController(); + markAll(range); + emit({ kind: "generation", generation }); + return run(); + } + + return { + start(next) { + (Object.keys(inputs) as InputKey[]).forEach((key) => { + inputs[key] = next[key] as never; + inputVer[key] += 1; + }); + return begin(null); + }, + update(source, value, part = null) { + if (busy) { + // 파생(단계 · 알림)이 입력에 쓰는 길 — 고리라 막음. 예외는 follow 반환값뿐. + blockedWrites += 1; + console.warn(`[pipeline] 계산 · 알림 안에서 입력 쓰기 막음: ${source}`); + return null; + } + const key = SOURCE_INPUT[source]; + if (JSON.stringify(inputs[key]) === JSON.stringify(value)) return null; + inputs[key] = value as never; + inputVer[key] += 1; + const range = part + ? { sources: [source], fromM: part.fromM, toM: part.toM, keys: part.keys ?? [] } + : { sources: [source], fromM: 0, toM: Infinity, keys: [] }; + return begin(range); + }, + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + output(name) { + return out[name] as never; + }, + get generation() { + return generation; + }, + get blockedWrites() { + return blockedWrites; + }, + async settled() { + while (inFlight.size) await Promise.allSettled([...inFlight]); + }, + }; +} + +/** 가게 잇기 — 측점(ground 단계 · 지반 채움) · 종단 · 평면 발자국 · 차례 번호를 가게 키로. */ +export function publishToStore(store: DesignStore): (event: PipelineEvent) => void { + return (event) => { + if (event.kind === "generation") return store.set("generation", event.generation); + if (event.stage === "ground") { + store.set("stations", (event.value as PipelineOutputs["ground"] | null)?.stations ?? null); + } else if (event.stage === "profile") { + store.set("profile", event.value as PipelineOutputs["profile"] | null); + } else if (event.stage === "footprint") { + store.set("footprint", event.value as PipelineOutputs["footprint"] | null); + } + }; +} diff --git a/A00_Common/design/design_pipeline_types.ts b/A00_Common/design/design_pipeline_types.ts index dcc3c9fa8..1693c24cb 100644 --- a/A00_Common/design/design_pipeline_types.ts +++ b/A00_Common/design/design_pipeline_types.ts @@ -225,6 +225,8 @@ export interface StageContext { inputs: Readonly; /** 앞 단계 출력(이번 계산 것 · 없으면 null · 읽기만). */ out: Readonly<{ [S in StageName]: PipelineOutputs[S] | null }>; + /** 이번 계산 시작 때 출력 — follow 가 옛 노선(`before.route`)과 새 노선을 견줌. */ + before: Readonly<{ [S in StageName]: PipelineOutputs[S] | null }>; /** 이 단계 앞 출력 — 범위 밖을 재사용. */ prev: PipelineOutputs[K] | null; } diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 909a33868..3b0720983 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -13,14 +13,21 @@ * `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`) * 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c). * 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. + * + * 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) · + * `stations` · `profile` · `footprint` 는 **메모리 우선**(결과가 커서 끌기마다 세션에 쓰지 않음 · + * 멈춘 뒤 `PERSIST_DELAY_MS` 한 번 · 키가 지워지면 메모리도 버림) · `generation` 은 메모리만. * ========================================================================== */ -import { readState, writeState } from "../b_page_state"; +import { onStateRemoved, readState, stateKey, writeState } from "../b_page_state"; +import type { StateName } 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"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import type { ProfileAlignment } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import type { PipelineStation, PlanFootprint, RouteDraft } from "./design_pipeline_types"; /** 한 페이지의 두 보기(계획서 24-1). */ export type DesignView = "profile" | "cross"; @@ -43,6 +50,13 @@ export interface DesignStoreValues { sectionDetail: SectionDetailResponse | null; /** 고른 측점 · 구조물. */ selection: DesignSelection | null; + /** 노선 초안(노드 · 곡선 · 반경 · 스냅 · 지형 · rev) — 노선 편집기가 놓을 때 씀. */ + routeDraft: RouteDraft | null; + /** 계산 묶음 결과 — 측점(지반 채움) · 종단 · 평면 발자국 · 지금 차례 번호. */ + stations: PipelineStation[] | null; + profile: ProfileAlignment | null; + footprint: PlanFootprint | null; + generation: number | null; } export type DesignStoreKey = keyof DesignStoreValues; @@ -100,6 +114,7 @@ function slotOf( write: (value) => writeState("alignment-draft", value, project, routeId), }; } + if (key in MEMORY_SLOTS) return memorySlot(key as MemoryKey, projectId); // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. const name = ( { @@ -107,8 +122,9 @@ function slotOf( pipes: "pipes", sectionDetail: "section-detail", selection: "selection", + routeDraft: "route-draft", } as const - )[key as Exclude]; + )[key as Exclude]; const routeScoped = name === "section-detail"; if (!projectId || (routeScoped && routeId === null)) { return { id: null, read: () => null, write: () => undefined }; @@ -121,6 +137,73 @@ function slotOf( }; } +/** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */ +const MEMORY_SLOTS = { + stations: "design-stations", + profile: "design-profile", + footprint: "design-footprint", + generation: null, +} as const satisfies Partial>; +type MemoryKey = keyof typeof MEMORY_SLOTS; + +/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */ +export const PERSIST_DELAY_MS = 1000; +const memory = new Map(); +const persistTimers = new Map< + string, + { timer: ReturnType; write: () => void } +>(); + +function cancelPersist(storageKey: string): void { + const pending = persistTimers.get(storageKey); + if (pending) clearTimeout(pending.timer); + persistTimers.delete(storageKey); +} + +onStateRemoved((storageKey) => { + memory.delete(storageKey); + cancelPersist(storageKey); +}); + +/** 미뤄 둔 세션 쓰기를 지금 — [저장] 앞 · 시험. */ +export function flushDesignStore(): void { + const writes = [...persistTimers.values()]; + persistTimers.forEach((pending) => clearTimeout(pending.timer)); + persistTimers.clear(); + writes.forEach((pending) => pending.write()); +} + +function memorySlot(key: MemoryKey, projectId: string | null): Slot { + const name = MEMORY_SLOTS[key]; + const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`; + if (!projectId || !storageKey) return { id: null, read: () => null, write: () => undefined }; + return { + id: storageKey, + read: () => { + if (memory.has(storageKey)) return memory.get(storageKey) as T; + const stored = name ? readState(name, projectId) : null; + if (stored !== null) memory.set(storageKey, stored); + return stored; + }, + write: (value) => { + if (value === null) { + memory.delete(storageKey); + cancelPersist(storageKey); + if (name) writeState(name, null, projectId); + return; + } + memory.set(storageKey, value); + if (!name) return; + cancelPersist(storageKey); + const write = (): void => { + persistTimers.delete(storageKey); + writeState(name, memory.get(storageKey) ?? null, projectId); + }; + persistTimers.set(storageKey, { timer: setTimeout(write, PERSIST_DELAY_MS), write }); + }, + }; +} + const listeners = new Map void>>(); /** diff --git a/resources/tester/helper_344_4_design_pipeline.cjs b/resources/tester/helper_344_4_design_pipeline.cjs new file mode 100644 index 000000000..368fba33c --- /dev/null +++ b/resources/tester/helper_344_4_design_pipeline.cjs @@ -0,0 +1,379 @@ +/* PLAN 344-4 단위검증 헬퍼 — 계산 묶음(`A00_Common/design/design_pipeline.ts`) · 가게 칸 + * (`design_store.ts`) · 등록표(`b_page_state.ts`)를 그 자리에서 트랜스파일해 Node 로 돌림. + * 단계 함수는 가짜 — 부른 수 · 받은 범위 · 차례 번호만 적음. 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_344_4_design_pipeline.py`. */ +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; + }, + }; +} +const session = memoryStorage(); +globalThis.window = { sessionStorage: session, localStorage: memoryStorage() }; +globalThis.fetch = async () => ({ ok: false }); + +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) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const DESIGN = path.join(ROOT, "A00_Common", "design"); +const pipeMod = loadTs(path.join(DESIGN, "design_pipeline.ts")); +const storeMod = loadTs(path.join(DESIGN, "design_store.ts")); +const page = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const plain = (range) => + range && { ...range, toM: Number.isFinite(range.toM) ? range.toM : "Infinity" }; + +/** 가짜 단계 — 부른 수 · 범위 · 번호를 적고 입력 지문을 돌려줌. hold[name] 이 있으면 그 약속을 기다림. */ +function fakeStages(log, hold = {}) { + const stages = {}; + for (const name of [...pipeMod.BROWSER_STAGES, ...pipeMod.AFTER_STAGES]) { + stages[name] = (ctx) => { + log.push({ + name, + gen: ctx.generation, + range: plain(ctx.range), + aborted: () => ctx.signal.aborted, + }); + const value = + name === "follow" + ? null + : { name, gen: ctx.generation, inputs: JSON.stringify(ctx.inputs) }; + if (name === "ground") value.stations = [{ key: "0", chainageM: 0 }]; + return hold[name] ? hold[name](ctx).then(() => value) : value; + }; + } + return stages; +} +const calls = (log, name) => log.filter((entry) => entry.name === name).length; +const BASE = { + route: { rev: 1 }, + alignment: { station_offsets: {} }, + cross: {}, + pipes: [], + structures: [], +}; + +async function run() { + const out = {}; + + // ① 처음 · 같은 입력 다시 안 셈 · 범위 전달 + { + const log = []; + const pipe = pipeMod.createDesignPipeline({ stages: fakeStages(log) }); + const events = []; + pipe.subscribe((event) => + events.push(event.kind === "stage" ? event.stage : `gen${event.generation}`), + ); + const first = await pipe.start(BASE); + await pipe.settled(); + const allOnce = Object.fromEntries( + [...pipeMod.BROWSER_STAGES, ...pipeMod.AFTER_STAGES].map((n) => [n, calls(log, n)]), + ); + const firstRange = log.find((e) => e.name === "route").range; + const same = pipe.update("grade", { station_offsets: {} }); + const genAfterSame = pipe.generation; + log.length = 0; + const cross = await pipe.update( + "cross", + { "0+040": { mode: "both_fill" } }, + { fromM: 40, toM: 40, keys: ["0+040"] }, + ); + await pipe.settled(); + const crossRan = log.map((e) => e.name); + const crossRange = log.find((e) => e.name === "cross").range; + log.length = 0; + await pipe.update("grade", { station_offsets: { "100.000": 0.5 } }, { fromM: 80, toM: 120 }); + await pipe.settled(); + const gradeRan = log.map((e) => e.name); + const gradeRange = log.find((e) => e.name === "profile").range; + log.length = 0; + await pipe.update("route", { rev: 2 }, { fromM: 120, toM: Infinity }); + await pipe.settled(); + const routeRan = log.map((e) => e.name); + const routeRange = log.find((e) => e.name === "stations").range; + out.basic = { + first: { outcome: first.outcome, skipped: first.skipped, allOnce, firstRange }, + same: { result: same, generation: genAfterSame }, + crossRan, + crossRange, + crossSkipped: cross.skipped, + gradeRan, + gradeRange, + routeRan, + routeRange, + events: events.slice(0, 3), + }; + } + + // ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(haul 늦음 · 끊김) + { + const log = []; + let releaseGround = null; + let holdGround = true; + const hold = { + ground: () => + holdGround ? new Promise((resolve) => (releaseGround = resolve)) : Promise.resolve(), + }; + const pipe = pipeMod.createDesignPipeline({ stages: fakeStages(log, hold) }); + const commits = []; + pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`)); + const runA = pipe.start(BASE); + await tick(5); + const release1 = releaseGround; + holdGround = false; + const runB = pipe.update("route", { rev: 2 }, { fromM: 0, toM: Infinity }); + const recB = await runB; + release1(); + const recA = await runA; + await pipe.settled(); + out.lateBrowser = { + a: recA.outcome, + b: recB.outcome, + groundGen: pipe.output("ground").gen, + commits, + }; + + // 뒤 단계 — haul 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림 + let releaseHaul = []; + const hold2 = { haul: () => new Promise((resolve) => releaseHaul.push(resolve)) }; + const log2 = []; + const pipe2 = pipeMod.createDesignPipeline({ stages: fakeStages(log2, hold2) }); + const commits2 = []; + pipe2.subscribe((e) => e.kind === "stage" && e.stage === "haul" && commits2.push(e.generation)); + await pipe2.start(BASE); + const haul1 = log2.find((e) => e.name === "haul"); + await pipe2.update("cross", { x: 1 }, { fromM: 0, toM: 0 }); + const haul1Aborted = haul1.aborted(); + releaseHaul[0](); + await tick(1); + releaseHaul[1](); + await pipe2.settled(); + out.lateAfter = { + haul1Aborted, + commits: commits2, + haulGen: pipe2.output("haul").gen, + generation: pipe2.generation, + }; + } + + // ③ 범위 합침 — cross 가 끝나기 전 새 조작 → 다음 cross 는 두 범위를 함께 셈 + { + const log = []; + let release = null; + let holdCross = false; + const hold = { + cross: () => (holdCross ? new Promise((r) => (release = r)) : Promise.resolve()), + }; + const pipe = pipeMod.createDesignPipeline({ stages: fakeStages(log, hold) }); + await pipe.start(BASE); + await pipe.settled(); + log.length = 0; + holdCross = true; + const first = pipe.update( + "grade", + { station_offsets: { "50.000": 1 } }, + { fromM: 40, toM: 60 }, + ); + await tick(10); + holdCross = false; + const second = pipe.update( + "grade", + { station_offsets: { "210.000": 1 } }, + { fromM: 200, toM: 220 }, + ); + release(); + const rec1 = await first; + await second; + await pipe.settled(); + const profiles = log.filter((e) => e.name === "profile").map((e) => e.range); + const crosses = log.filter((e) => e.name === "cross").map((e) => e.range); + out.merge = { first: rec1.outcome, profiles, crosses }; + out.mergeFn = plain( + pipeMod.mergeRange( + { sources: ["grade"], fromM: 40, toM: 60, keys: [] }, + { sources: ["cross"], fromM: 10, toM: 20, keys: ["a"] }, + ), + ); + } + + // ④ 고리 없음 — 알림 · 단계 안 입력 쓰기 막음 · follow 만 한 번 · 노선 · 횡단은 못 고침 + { + const log = []; + const stages = fakeStages(log); + let stageWrite = "unset"; + let pipe = null; + stages.planline = (ctx) => { + stageWrite = pipe.update("pipe", [{ id: "p-loop" }]); + return { gen: ctx.generation }; + }; + stages.follow = (ctx) => { + log.push({ name: "follow", gen: ctx.generation }); + return { + pipes: [{ id: "p1", followed: ctx.inputs.route.rev }], + route: { rev: 99 }, + cross: { bad: 1 }, + }; + }; + pipe = pipeMod.createDesignPipeline({ stages }); + let notifyWrite = "unset"; + pipe.subscribe((e) => { + if (e.kind === "stage" && e.stage === "profile") + notifyWrite = pipe.update("grade", { station_offsets: { loop: 1 } }); + }); + const warn = console.warn; + const warnings = []; + console.warn = (...args) => warnings.push(String(args[0])); + await pipe.start(BASE); + await pipe.settled(); + const afterStart = { + generation: pipe.generation, + follow: calls(log, "follow"), + stations: calls(log, "stations"), + }; + const stationsInputs = JSON.parse(pipe.output("stations").inputs); + log.length = 0; + await pipe.update("grade", { station_offsets: { "10.000": 1 } }, { fromM: 0, toM: 20 }); + await pipe.settled(); + const gradeRan = log.map((e) => e.name); + console.warn = warn; + out.loop = { + stageWrite, + notifyWrite, + blocked: pipe.blockedWrites, + afterStart, + stationsPipes: stationsInputs.pipes, + stationsRoute: stationsInputs.route, + stationsCross: stationsInputs.cross, + gradeRan, + warnings: warnings.length, + }; + } + + // ⑤ 단계 시간 기록 — 마지막 RUN_LOG_SIZE 번 · 단계별 ms + { + const pipe = pipeMod.createDesignPipeline({ stages: fakeStages([]) }); + await pipe.start(BASE); + for (let i = 0; i < 25; i += 1) + await pipe.update("grade", { station_offsets: { i } }, { fromM: i, toM: i + 1 }); + await pipe.settled(); + const runs = globalThis.__aisloPipeline.runs; + const last = runs[runs.length - 1]; + out.timing = { + size: runs.length, + limit: pipeMod.RUN_LOG_SIZE, + last: { + outcome: last.outcome, + sources: last.sources, + stageNames: Object.keys(last.stages), + totalIsNumber: typeof last.totalMs === "number", + }, + }; + } + + // ⑥ 가게 — 결과 칸 메모리 우선 · 멈춘 뒤 한 번 · 지우면 메모리도 · 노선 초안 즉시 · clearDrafts + { + const P = "proj-344"; + const store = storeMod.useDesignStore(P, null); + const key = (n, b = "result") => `aislo:${b}:${n}:${P}`; + const stations = [{ key: "0", chainageM: 0 }]; + store.set("stations", stations); + const beforeFlush = session.getItem(key("design-stations")); + const memoryRead = store.get("stations") === stations; + await tick(storeMod.PERSIST_DELAY_MS + 50); + const afterDelay = JSON.parse(session.getItem(key("design-stations"))); + store.set("profile", { schema_version: 1 }); + storeMod.flushDesignStore(); + const flushed = JSON.parse(session.getItem(key("design-profile"))); + store.set("generation", 7); + const generationSession = [...Array(session.length).keys()] + .map((i) => session.key(i)) + .filter((k) => k.includes("generation")); + page.clearResults(P); + const cleared = { stations: store.get("stations"), profile: store.get("profile") }; + store.set("footprint", { roadLeft: [] }); + page.clearResults(P); + await tick(storeMod.PERSIST_DELAY_MS + 50); + const footprintRevived = session.getItem(key("design-footprint")); + store.set("routeDraft", { + rev: 3, + nodes: [ + [0, 0], + [10, 0], + ], + }); + const draftNow = JSON.parse(session.getItem(key("route-draft", "draft"))); + const hasDraft = page.hasDrafts(P); + page.clearDrafts(P); + const draftCleared = { + session: session.getItem(key("route-draft", "draft")), + store: store.get("routeDraft"), + }; + const pipe = pipeMod.createDesignPipeline({ stages: fakeStages([]) }); + pipe.subscribe(pipeMod.publishToStore(store)); + await pipe.start(BASE); + await pipe.settled(); + out.store = { + beforeFlush, + memoryRead, + afterDelay, + flushed, + generation: store.get("generation"), + generationSession, + cleared, + footprintRevived, + draftNow, + hasDraft, + draftCleared, + published: { + generation: store.get("generation"), + stations: store.get("stations"), + profileGen: store.get("profile").gen, + }, + registry: { + draft: page.STATE_REGISTRY["route-draft"], + stations: page.STATE_REGISTRY["design-stations"], + profile: page.STATE_REGISTRY["design-profile"], + footprint: page.STATE_REGISTRY["design-footprint"], + }, + }; + } + return out; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_344_4_design_pipeline.py b/resources/tester/test_344_4_design_pipeline.py new file mode 100644 index 000000000..261e5b7cf --- /dev/null +++ b/resources/tester/test_344_4_design_pipeline.py @@ -0,0 +1,165 @@ +"""PLAN 344-4 — 실시간 계산 묶음(`A00_Common/design/design_pipeline.ts`) · 가게 칸 · 노선 초안 캐시 칸. + +· 늦은 응답 버림(브라우저 단계 · 뒤 단계 끊김) · 같은 입력 다시 안 셈 · 범위 전달 · 범위 합침 +· 고리 없음(단계 · 알림 안 입력 쓰기 막음 · follow 만 한 번 · 노선 · 횡단은 못 고침) +· 단계 시간 기록(window.__aisloPipeline) · 결과 칸 메모리 우선 · 노선 초안 즉시 · clearDrafts +짝: `helper_344_4_design_pipeline.cjs`(실제 TS 를 Node 로 돌림). +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + +BROWSER = [ + "route", + "follow", + "stations", + "ground", + "planline", + "profile", + "cross", + "footprint", + "masshaul", +] +AFTER = ["haul", "drainage", "corridor"] + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_344_4_design_pipeline.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _range(sources, from_m, to_m, keys=()): + return {"sources": list(sources), "fromM": from_m, "toM": to_m, "keys": list(keys)} + + +def test_처음은_전_단계_한_번_전_구간(r): + first = r["basic"]["first"] + assert first["outcome"] == "done" and first["skipped"] == [] + assert first["allOnce"] == {name: 1 for name in BROWSER + AFTER} + assert first["firstRange"] is None + assert r["basic"]["events"] == ["gen1", "route", "follow"] + + +def test_같은_입력_다시_안_셈(r): + b = r["basic"] + assert b["same"] == {"result": None, "generation": 1} # 같은 값 = 차례 번호도 안 오름 + # 횡단만 바뀌면 노선 ~ 종단 · 배수유역은 건너뜀 + assert b["crossRan"] == ["cross", "footprint", "masshaul", "haul", "corridor"] + assert b["crossSkipped"] == [ + "route", + "follow", + "stations", + "ground", + "planline", + "profile", + "drainage", + ] + # 계획고만 바뀌면 측점 · 지반 · 첫 계획선 · 배수유역 건너뜀 + assert b["gradeRan"] == ["profile", "cross", "footprint", "masshaul", "haul", "corridor"] + # 노선이 바뀌면 전부 + assert b["routeRan"] == BROWSER + AFTER + + +def test_범위_전달(r): + b = r["basic"] + assert b["crossRange"] == _range(["cross"], 40, 40, ["0+040"]) # 횡단 = 그 측점 + assert b["gradeRange"] == _range(["grade"], 80, 120) # 계획고 = 이웃 꼭짓점 사이 + assert b["routeRange"] == _range(["route"], 120, "Infinity") # 노선 = 바뀐 접선점부터 끝 + + +def test_늦은_응답_버림_브라우저_단계(r): + late = r["lateBrowser"] + assert late["a"] == "dropped" and late["b"] == "done" + assert late["groundGen"] == 2 + assert not [ + c + for c in late["commits"] + if c.endswith("@1") and not c.startswith(("route", "follow", "stations")) + ] + + +def test_늦은_응답_버림_뒤_단계_끊김(r): + late = r["lateAfter"] + assert late["haul1Aborted"] is True # 새 조작 = 옛 요청 끊음 + assert late["commits"] == [2] and late["haulGen"] == 2 and late["generation"] == 2 + + +def test_못_끝낸_단계는_범위를_합쳐_셈(r): + m = r["merge"] + assert m["first"] == "dropped" + assert m["profiles"] == [_range(["grade"], 40, 60), _range(["grade"], 200, 220)] + assert m["crosses"] == [_range(["grade"], 40, 60), _range(["grade"], 40, 220)] + assert r["mergeFn"] == _range(["grade", "cross"], 10, 60, ["a"]) + + +def test_고리_없음(r): + loop = r["loop"] + assert loop["stageWrite"] is None and loop["notifyWrite"] is None + assert loop["blocked"] == 3 # 단계 안 1 · 알림 안 2(처음 · 계획고) + assert loop["afterStart"] == {"generation": 1, "follow": 1, "stations": 1} + # follow 만 입력을 한 번 고침 — 관은 따라감 · 노선 · 횡단 고침은 버림 + assert loop["stationsPipes"] == [{"id": "p1", "followed": 1}] + assert loop["stationsRoute"] == {"rev": 1} and loop["stationsCross"] == {} + assert "follow" not in loop["gradeRan"] and "stations" not in loop["gradeRan"] + assert loop["warnings"] == 5 + + +def test_단계_시간_기록(r): + t = r["timing"] + assert t["size"] == t["limit"] == 20 + assert t["last"]["outcome"] == "done" and t["last"]["sources"] == ["grade"] + assert t["last"]["stageNames"] == [ + "profile", + "cross", + "footprint", + "masshaul", + "haul", + "corridor", + ] + assert t["last"]["totalIsNumber"] is True + + +def test_가게_결과_칸_메모리_우선(r): + s = r["store"] + assert s["beforeFlush"] is None and s["memoryRead"] is True # 끌기마다 세션에 안 씀 + assert s["afterDelay"] == [{"key": "0", "chainageM": 0}] # 멈춘 뒤 한 번 + assert s["flushed"] == {"schema_version": 1} + assert s["generationSession"] == [] # 차례 번호는 메모리만 + assert s["cleared"] == {"stations": None, "profile": None} # clearResults = 메모리도 버림 + assert s["footprintRevived"] is None # 지운 뒤 미뤄 둔 쓰기가 되살리지 않음 + assert s["published"] == { + "generation": 1, + "stations": [{"key": "0", "chainageM": 0}], + "profileGen": 1, + } + + +def test_노선_초안_칸(r): + s = r["store"] + assert s["draftNow"] == {"rev": 3, "nodes": [[0, 0], [10, 0]]} # 초안은 세션 즉시 + assert s["hasDraft"] is True + assert s["draftCleared"] == {"session": None, "store": None} # clearDrafts 가 함께 비움 + assert s["registry"]["draft"] == {"bucket": "draft", "scope": "project"} + for name in ("stations", "profile", "footprint"): + assert s["registry"][name] == {"bucket": "result", "scope": "project"}