feat(설계묶음): 344-4 계산 묶음 본체 · 가게 칸 — 차례 번호 · 옛 결과 버림 · 옛 요청 끊기 · 바뀐 범위 · 단계 시간 기록 · 결과 칸 메모리 우선

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 11:04:38 +09:00
co-authored by Claude Opus 5.5
parent 5e8df73763
commit 8be42a302f
6 changed files with 994 additions and 2 deletions
+9
View File
@@ -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);
+354
View File
@@ -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<StageName, Dep[]> = {
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<keyof FollowPatch> = ["pipes", "structures", "alignment"];
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단 · 유토곡선이 그다음. */
const YIELD_AFTER = new Set<StageName>(["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<PipelineRunRecord>;
/** 조작 하나 — 값이 같으면 안 셈(null). part 없으면 전 구간. */
update(
source: ChangeSource,
value: unknown,
part?: RangePart | null,
): Promise<PipelineRunRecord> | null;
subscribe(fn: (event: PipelineEvent) => void): () => void;
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
readonly generation: number;
/** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */
readonly blockedWrites: number;
/** 떠 있는 뒤 단계가 다 끝날 때까지. */
settled(): Promise<void>;
}
type Outs = { [S in StageName]: PipelineOutputs[S] | null };
function emptyOuts(): Outs {
const out = {} as Record<StageName, null>;
[...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<InputKey, number> = {
route: 0,
alignment: 0,
cross: 0,
pipes: 0,
structures: 0,
};
const out = emptyOuts();
const outVer = {} as Record<StageName, number>;
const lastSig = {} as Partial<Record<StageName, string>>;
/** 마지막으로 끝난 뒤 쌓인 범위 — 없음(undefined) · 전 구간(null) · 범위. */
const pending = {} as Partial<Record<StageName, ChangeRange | null>>;
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => {
outVer[name] = 0;
pending[name] = null;
});
const listeners = new Set<(event: PipelineEvent) => void>();
const inFlight = new Set<Promise<unknown>>();
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<keyof FollowPatch>) {
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<StageName>) => unknown) | undefined;
if (!fn || sig === lastSig[name]) {
record.skipped.push(name);
if (fn) delete pending[name];
return "ok";
}
const ctx: StageContext<StageName> = {
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<StageName, unknown>)[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<PipelineRunRecord> {
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<PipelineRunRecord> {
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);
}
};
}
@@ -225,6 +225,8 @@ export interface StageContext<K extends StageName> {
inputs: Readonly<PipelineInputs>;
/** 앞 단계 출력(이번 계산 것 · 없으면 null · 읽기만). */
out: Readonly<{ [S in StageName]: PipelineOutputs[S] | null }>;
/** 이번 계산 시작 때 출력 — follow 가 옛 노선(`before.route`)과 새 노선을 견줌. */
before: Readonly<{ [S in StageName]: PipelineOutputs[S] | null }>;
/** 이 단계 앞 출력 — 범위 밖을 재사용. */
prev: PipelineOutputs[K] | null;
}
+85 -2
View File
@@ -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<K extends DesignStoreKey>(
write: (value) => writeState("alignment-draft", value, project, routeId),
};
}
if (key in MEMORY_SLOTS) return memorySlot<V>(key as MemoryKey, projectId);
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
{
@@ -107,8 +122,9 @@ function slotOf<K extends DesignStoreKey>(
pipes: "pipes",
sectionDetail: "section-detail",
selection: "selection",
routeDraft: "route-draft",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
)[key as Exclude<DesignStoreKey, "alignmentDraft" | MemoryKey>];
const routeScoped = name === "section-detail";
if (!projectId || (routeScoped && routeId === null)) {
return { id: null, read: () => null, write: () => undefined };
@@ -121,6 +137,73 @@ function slotOf<K extends DesignStoreKey>(
};
}
/** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */
const MEMORY_SLOTS = {
stations: "design-stations",
profile: "design-profile",
footprint: "design-footprint",
generation: null,
} as const satisfies Partial<Record<DesignStoreKey, StateName | null>>;
type MemoryKey = keyof typeof MEMORY_SLOTS;
/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */
export const PERSIST_DELAY_MS = 1000;
const memory = new Map<string, unknown>();
const persistTimers = new Map<
string,
{ timer: ReturnType<typeof setTimeout>; 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<T>(key: MemoryKey, projectId: string | null): Slot<T> {
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<T>(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<string, Set<(value: unknown) => void>>();
/**
@@ -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);
});
@@ -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"}