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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user