fix(종횡단): 342-1 계획선 한 벌 · 남은 낡음 표시 · 흙 배분 열쇠

- 횡단 다시 셈이 화면이 쥔 선(측점 한 벌 · 관 지점 · 옮긴 꼭짓점)을 받음 — 종단도가 선을 맡기고 B06 진입 정합 · 3D [업데이트] 앞 다시 셈도 같은 선 · 낡음 판정도 그 선 기준(staleAgainstScreen)
- 다시 셈 뒤에도 낡은 측점이 남으면 유토곡선은 그리고 그 측점만 표시 · 콘솔 경고 한 줄 · 흙 배분도 건너뛰지 않음(09-03 「새 값만 보여주기」 바꿈 · 사용자 확정 10-16)
- 흙 배분: 실패한 열쇠 다시 보냄 · 받은 열쇠(누가토량 결과 전체)는 다시 안 물음 · 서버도 같은 열쇠 기억 · 묶음 실패는 500
- 옛 시험 셋 새 동작으로 맞춤 · 서버 열쇠 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 11:15:22 +09:00
co-authored by Claude Opus 5.5
parent 2879f9bea9
commit 48ddca36a2
13 changed files with 290 additions and 108 deletions
+4 -4
View File
@@ -342,9 +342,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
history.record(); history.record();
} }
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리 /** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물 * 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
function listed(): AlignmentBase | null { function listed(): AlignmentBase | null {
if (!storedBase || !detail) return storedBase; if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations; const server = detail.longitudinal.stations;
@@ -393,10 +392,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
detail: () => detail, detail: () => detail,
routeId: () => routeId, routeId: () => routeId,
edits: () => store.edits(), edits: () => store.edits(),
alignment: () => alignment,
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS), debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
onApplied: (updated) => { onApplied: (updated) => {
draw(); draw();
callbacks?.onCrossDesignsUpdated?.(updated); if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated);
}, },
}); });
const scheduleCrossPreview = (): void => crossPreview.schedule(); const scheduleCrossPreview = (): void => crossPreview.schedule();
+24 -10
View File
@@ -21,6 +21,8 @@ export type HaulPlanPayload = HaulPlan | null;
const SETTLE_MS = 400; const SETTLE_MS = 400;
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */ /** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
const TIMEOUT_MS = 20000; const TIMEOUT_MS = 20000;
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
const KEPT_PLANS = 8;
export interface HaulPlanPrefetch { export interface HaulPlanPrefetch {
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
@@ -48,23 +50,28 @@ export function createHaulPlanPrefetch(
let sentKey: string | null = null; let sentKey: string | null = null;
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
let applying = false; let applying = false;
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
const kept = new Map<string, HaulPlanPayload>();
/** /**
* 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
* 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다. * 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
*/ */
function keyOf(result: unknown): string { function keyOf(result: unknown): string {
const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points; const points = (result as { points?: unknown[] })?.points;
if (!Array.isArray(points) || !points.length) return "empty"; if (!Array.isArray(points) || !points.length) return "empty";
const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0; return JSON.stringify(points);
const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0;
return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`;
} }
async function send(result: unknown, seq: number): Promise<void> { /** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */
function forget(seq: number): void {
if (seq === sequence) sentKey = null;
}
async function send(result: unknown, seq: number, key: string): Promise<void> {
const route = routeId(); const route = routeId();
if (!route) return; if (!route) return forget(seq);
const controller = new AbortController(); const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
try { try {
@@ -78,11 +85,14 @@ export function createHaulPlanPrefetch(
signal: controller.signal, signal: controller.signal,
}, },
); );
if (!response.ok) return; if (!response.ok) return forget(seq);
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return; if (seq !== sequence) return;
plan = payload.haul_plan ?? null; plan = payload.haul_plan ?? null;
kept.delete(key);
kept.set(key, plan);
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
@@ -94,6 +104,7 @@ export function createHaulPlanPrefetch(
} }
} catch { } catch {
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
forget(seq);
} finally { } finally {
window.clearTimeout(abort); window.clearTimeout(abort);
} }
@@ -109,7 +120,10 @@ export function createHaulPlanPrefetch(
sequence += 1; sequence += 1;
const seq = sequence; const seq = sequence;
window.clearTimeout(timer); window.clearTimeout(timer);
timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS); // 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다.
const known = kept.get(key);
if (known !== undefined) return void (plan = known);
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
}, },
current: () => plan, current: () => plan,
dispose() { dispose() {
+11 -1
View File
@@ -141,6 +141,8 @@ export interface RouteMassHaulDrawParams {
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다. * 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
*/ */
pendingRecalc?: boolean; pendingRecalc?: boolean;
/** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */
staleChainages?: number[];
} }
export interface RouteMassHaulPanel { export interface RouteMassHaulPanel {
@@ -379,11 +381,19 @@ export function createRouteMassHaulPanel(
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
if (params.pendingRecalc) { if (params.pendingRecalc) {
pendingChip.textContent = "다시 계산 중…";
if (drawnOnce) pendingChip.hidden = false; if (drawnOnce) pendingChip.hidden = false;
else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
return; return;
} }
pendingChip.hidden = true; const stale = params.staleChainages ?? [];
pendingChip.hidden = !stale.length;
pendingChip.textContent = stale.length
? `낡은 측점 ${stale.length}곳 · ${stale
.slice(0, 5)
.map((chainage) => `${chainage.toFixed(2)} m`)
.join(" · ")}${stale.length > 5 ? " …" : ""}`
: "다시 계산 중…";
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
+35 -6
View File
@@ -9,8 +9,9 @@
* 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다.
* ========================================================================== */ * ========================================================================== */
import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import { holdScreenAlignment, refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
import { staleChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
export interface CrossPreviewContext { export interface CrossPreviewContext {
@@ -18,6 +19,8 @@ export interface CrossPreviewContext {
detail: () => SectionDetailResponse | null; detail: () => SectionDetailResponse | null;
routeId: () => number | null; routeId: () => number | null;
edits: () => AlignmentEdits; edits: () => AlignmentEdits;
/** 화면이 쥔 계획선(측점 한 벌) — 다시 셈 · B06 · 3D 가 같은 선을 받음(342-1). */
alignment: () => ProfileAlignment | null;
/** 부를 때마다 읽음 — 보기마다 다름. */ /** 부를 때마다 읽음 — 보기마다 다름. */
debounceMs: () => number; debounceMs: () => number;
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */ /** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
@@ -27,8 +30,12 @@ export interface CrossPreviewContext {
export interface CrossPreview { export interface CrossPreview {
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */
schedule: () => void; schedule: () => void;
/** 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1). */ /**
scheduleIfStale: (key: string) => void; * 그리기가 잰 낡음 지문 — 새 지문일 때만 `schedule`(같은 낡음 되풀이 · 서버 폴백 반복 막음 · 317-1).
* 돌려주는 값 = 다시 셈을 기다리는 중인가. 다시 셈 뒤에도 남은 낡음은 false — 곡선은 그리고
* 그 측점만 표시(342-1 · 09-03 「새 값만 보여주기」를 바꿈 · 사용자 확정 10-16).
*/
scheduleIfStale: (key: string) => boolean;
/** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */
dispose: () => void; dispose: () => void;
} }
@@ -37,19 +44,34 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
let timer = 0; let timer = 0;
let seq = 0; let seq = 0;
let lastStale = ""; let lastStale = "";
/** 다시 셈 예약 · 진행 중 — 이 동안의 낡음은 「다시 계산 중」. */
let busy = false;
let warned = "";
const release = holdScreenAlignment((routeId) =>
routeId === ctx.routeId() ? ctx.alignment() : null,
);
const preview: CrossPreview = { const preview: CrossPreview = {
scheduleIfStale(key) { scheduleIfStale(key) {
if (key && key !== lastStale) preview.schedule(); if (key && key !== lastStale) preview.schedule();
else if (key && !busy && key !== warned) {
warned = key;
console.warn(
`[횡단] 다시 셈 뒤에도 낡은 측점 ${staleChainages(key).length}곳 — 곡선은 그리고 그 측점만 표시`,
staleChainages(key),
);
}
lastStale = key; lastStale = key;
return key !== "" && busy;
}, },
schedule() { schedule() {
if (!ctx.detail() || ctx.routeId() === null) return; if (!ctx.detail() || ctx.routeId() === null) return;
busy = true;
window.clearTimeout(timer); window.clearTimeout(timer);
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
const detail = ctx.detail(); const detail = ctx.detail();
const routeId = ctx.routeId(); const routeId = ctx.routeId();
if (!detail || routeId === null) return; if (!detail || routeId === null) return void (busy = false);
const current = (seq += 1); const current = (seq += 1);
// 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가
// 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화).
@@ -60,19 +82,26 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
routeId, routeId,
detail, detail,
edits: ctx.edits(), edits: ctx.edits(),
alignment: ctx.alignment(),
// 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다.
shouldApply: isCurrent, shouldApply: isCurrent,
}) })
.then((updated) => { .then((updated) => {
if (updated.length) ctx.onApplied(updated); if (current === seq) busy = false;
// 남은 낡음이 있으면 바뀐 것이 없어도 다시 그림 — 「다시 계산 중」에 멈추지 않게(342-1)
if (updated.length || lastStale) ctx.onApplied(updated);
}) })
.catch(() => { .catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
if (current !== seq) return;
busy = false;
if (lastStale) ctx.onApplied([]);
}); });
}, ctx.debounceMs()); }, ctx.debounceMs());
}, },
dispose() { dispose() {
window.clearTimeout(timer); window.clearTimeout(timer);
release();
}, },
}; };
return preview; return preview;
+7 -4
View File
@@ -15,6 +15,7 @@ import {
} from "../B06_Section/B06_Section_UI_Longitudinal"; } from "../B06_Section/B06_Section_UI_Longitudinal";
import { import {
LONG_PAD, LONG_PAD,
staleChainages,
staleKey, staleKey,
windowElevationRange, windowElevationRange,
} from "../B06_Section/B06_Section_UI_Section_Common"; } from "../B06_Section/B06_Section_UI_Section_Common";
@@ -59,8 +60,9 @@ import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"
/** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */ /** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */
export interface ProfileRenderContext { export interface ProfileRenderContext {
body: HTMLElement; body: HTMLElement;
/** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1). */ /** 그릴 때 잰 낡은 횡단 지문("" = 모두 맞음) — 패널이 새 지문일 때만 다시 셈(317-1).
onStaleDesigns?: (key: string) => void; * 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
onStaleDesigns?: (key: string) => boolean;
callbacks: RouteProfilePanelCallbacks | undefined; callbacks: RouteProfilePanelCallbacks | undefined;
massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
tableOverlay: { tableOverlay: {
@@ -230,7 +232,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
longitudinal: { design_profiles: designProfiles }, longitudinal: { design_profiles: designProfiles },
cross_sections: detail.cross_sections, cross_sections: detail.cross_sections,
}); });
ctx.onStaleDesigns?.(stale); const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
const graphData = normalizedLongitudinal(longitudinal); const graphData = normalizedLongitudinal(longitudinal);
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m // 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
@@ -424,7 +426,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
pendingRecalc: stale !== "", pendingRecalc: pending,
staleChainages: pending ? [] : staleChainages(stale),
axis: { axis: {
maxChainageM: maxChainageOf(longitudinal), maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
+105 -72
View File
@@ -25,7 +25,7 @@
* ========================================================================== */ * ========================================================================== */
import { computeCrossDesign } from "@util/common_util_cross_design"; import { computeCrossDesign } from "@util/common_util_cross_design";
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
@@ -46,6 +46,7 @@ import {
planElevationAt, planElevationAt,
toAlignmentBase, toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import { readState } from "../A00_Common/b_page_state"; import { readState } from "../A00_Common/b_page_state";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
@@ -64,6 +65,8 @@ export interface CrossRefreshInput {
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
detail: SectionDetailResponse; detail: SectionDetailResponse;
edits: CrossRefreshEdits; edits: CrossRefreshEdits;
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
alignment?: ProfileAlignment | null;
/** /**
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나 * 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다. * 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
@@ -149,10 +152,33 @@ function preserveUserFields(
} }
/** /**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. * 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. * 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). * (유토곡선 「다시 계산하는 중」 머묾 · 341 부록 2 4장). B05 · B06 · 3D 가 모두 이 선을 받는다.
*/ */
type ScreenLine = (routeId: number) => ProfileAlignment | null;
let screenLine: ScreenLine | null = null;
/** 종단도가 자기 선을 맡김 — 돌려준 함수로 거둠(자기 것일 때만). */
export function holdScreenAlignment(line: ScreenLine): () => void {
screenLine = line;
return () => {
if (screenLine === line) screenLine = null;
};
}
/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */
export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean {
const screen = screenLine?.(routeId);
if (!screen) return hasStaleDesigns(detail);
return hasStaleDesigns({
longitudinal: {
design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])],
},
cross_sections: detail.cross_sections,
});
}
/** /**
* 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1). * 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1).
* 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙). * 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙).
@@ -161,7 +187,7 @@ export async function refreshStaleDesigns(
input: Omit<CrossRefreshInput, "edits" | "shouldApply">, input: Omit<CrossRefreshInput, "edits" | "shouldApply">,
): Promise<number[]> { ): Promise<number[]> {
const draft = readAlignmentDraft(input.routeId); const draft = readAlignmentDraft(input.routeId);
if (!draft && !hasStaleDesigns(input.detail)) return []; if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return [];
const saved = ( const saved = (
input.detail.longitudinal.profile_alignment as input.detail.longitudinal.profile_alignment as
| { | {
@@ -179,6 +205,11 @@ export async function refreshStaleDesigns(
return refreshCrossDesigns({ ...input, edits }); return refreshCrossDesigns({ ...input, edits });
} }
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> { export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const sections = input.detail.cross_sections; const sections = input.detail.cross_sections;
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
@@ -290,14 +321,19 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map<number, num
function refreshLocally(input: CrossRefreshInput): number[] | null { function refreshLocally(input: CrossRefreshInput): number[] | null {
const { projectId, detail, edits } = input; const { projectId, detail, edits } = input;
const stored = readAlignment(detail.longitudinal); const stored = readAlignment(detail.longitudinal);
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다. // 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1
const alignment =
input.alignment ??
screenLine?.(input.routeId) ??
(stored
? buildAlignment(toAlignmentBase(stored), {
station_offsets: edits.station_offsets ?? {},
curve_radii: edits.curve_radii ?? {},
})
: null);
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로. if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
const alignment = buildAlignment(toAlignmentBase(stored), {
station_offsets: edits.station_offsets ?? {},
curve_radii: edits.curve_radii ?? {},
});
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
@@ -367,69 +403,66 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
const choice = choiceAt(section.chainage_m); const choice = choiceAt(section.chainage_m);
const extendLeft = extendAt(section.chainage_m, design, "left"); const extendLeft = extendAt(section.chainage_m, design, "left");
const extendRight = extendAt(section.chainage_m, design, "right"); const extendRight = extendAt(section.chainage_m, design, "right");
const elevation = planElevationAt(alignment, section.chainage_m);
const options: CrossDesignOptions = {
groundType:
choice?.ground_type ??
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
sectionMode:
choice?.section_mode ??
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
ditchSide:
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: pavedBySpecs(section),
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
standard,
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
ditchChoice: choice
? (choice.ditch_choice ?? null)
: typeof design.ditch_choice === "boolean"
? design.ditch_choice
: null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
surfaceDropM:
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
planRadiusM: section.plan_radius_m ?? null,
curveOuterSide:
section.curve_outer_side === "left" || section.curve_outer_side === "right"
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
spoilFill:
typeof design.spoil_fill_width_m === "number" &&
design.spoil_fill_width_m > 0 &&
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
? {
side: design.spoil_fill_side,
widthM: design.spoil_fill_width_m,
slopeRatioN: null,
}
: null,
};
let next; let next;
try { try {
next = computeCrossDesign( next = computeCrossDesign(section.samples ?? [], elevation, options);
section.samples ?? [],
planElevationAt(alignment, section.chainage_m),
{
groundType:
choice?.ground_type ??
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
sectionMode:
choice?.section_mode ??
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
ditchSide:
choice?.ditch_side ??
(typeof design.ditch_side === "string" ? design.ditch_side : null),
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: pavedBySpecs(section),
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
standard,
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
ditchChoice: choice
? (choice.ditch_choice ?? null)
: typeof design.ditch_choice === "boolean"
? design.ditch_choice
: null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
surfaceDropM:
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
planRadiusM: section.plan_radius_m ?? null,
curveOuterSide:
section.curve_outer_side === "left" || section.curve_outer_side === "right"
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
spoilFill:
typeof design.spoil_fill_width_m === "number" &&
design.spoil_fill_width_m > 0 &&
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
? {
side: design.spoil_fill_side,
widthM: design.spoil_fill_width_m,
slopeRatioN: null,
}
: null,
},
);
} catch { } catch {
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
} }
+29 -5
View File
@@ -15,7 +15,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import json
import logging import logging
from collections import OrderedDict
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -37,6 +40,16 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
_NPM_SCRIPT = "build:server-calc" _NPM_SCRIPT = "build:server-calc"
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다. # 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
_MAX_POINTS = 5000 _MAX_POINTS = 5000
# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움
# (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는
# 다시 셈(_derived 저장본은 343 틀 뒤).
_KEPT: OrderedDict[str, Any] = OrderedDict()
_KEPT_MAX = 32
def _plan_key(result: dict[str, Any], context: Any) -> str:
body = json.dumps({"result": result, "context": context}, sort_keys=True, default=str)
return hashlib.sha1(body.encode("utf-8")).hexdigest()
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None) @router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
@@ -69,15 +82,17 @@ async def compute_haul_plan(
factors = await conversion_factors_for(project_id) factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다. # 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id) limits = await haul_limits_for(project_id)
context = _mass_haul_context(haul_inputs, factors, limits)
key = _plan_key(result, context)
if key in _KEPT:
_KEPT.move_to_end(key)
return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]})
try: try:
output = await asyncio.to_thread( output = await asyncio.to_thread(
run_bundle_json, run_bundle_json,
BUNDLE, BUNDLE,
_NPM_SCRIPT, _NPM_SCRIPT,
{ {"haul_plan_for": result, "context": context},
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs, factors, limits),
},
) )
except Exception: except Exception:
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
@@ -85,5 +100,14 @@ async def compute_haul_plan(
status_code=500, status_code=500,
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
) )
plan = output.get("haul_plan") if isinstance(output, dict) else None # 묶음 실패(None)는 실패로 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었다(342-1).
if not isinstance(output, dict):
return JSONResponse(
status_code=500,
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
)
plan = output.get("haul_plan")
_KEPT[key] = plan
if len(_KEPT) > _KEPT_MAX:
_KEPT.popitem(last=False)
return JSONResponse(content={"status": "success", "haul_plan": plan}) return JSONResponse(content={"status": "success", "haul_plan": plan})
@@ -15,14 +15,13 @@ import {
writeShoulderExtend, writeShoulderExtend,
} from "./B06_Section_Cross_Design_Session"; } from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh";
import { import {
bermSpansFromStructures, bermSpansFromStructures,
readBermSpans, readBermSpans,
writeBermSpans, writeBermSpans,
} from "./B06_Section_UI_Page_Persist"; } from "./B06_Section_UI_Page_Persist";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { L } from "./B06_Section_UI_Page_Common"; import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
@@ -167,7 +166,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; // 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1).
if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장). // (CLAUDE.md 5장).
try { try {
@@ -133,6 +133,11 @@ export function staleKey(
.join("|"); .join("|");
} }
/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */
export function staleChainages(key: string): number[] {
return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : [];
}
interface StaleCandidate { interface StaleCandidate {
chainage_m: number; chainage_m: number;
// 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314) // 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314)
@@ -15,6 +15,7 @@ const STUBS = {
refreshCalls += 1; refreshCalls += 1;
return []; return [];
}, },
holdScreenAlignment: () => () => {}, // 화면 선 맡김(342-1) — 이 시험 몫 아님
}, },
}; };
globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} }; globalThis.window = { setTimeout: (fn) => (fn(), 1), clearTimeout: () => {} };
@@ -63,6 +64,7 @@ const fordFresh = { ...fordStale, design: { ...fordStale.design, tilted: true }
detail: () => detailOf([fordStale]), detail: () => detailOf([fordStale]),
routeId: () => 1, routeId: () => 1,
edits: () => ({ station_offsets: {}, curve_radii: {} }), edits: () => ({ station_offsets: {}, curve_radii: {} }),
alignment: () => null,
debounceMs: () => 0, debounceMs: () => 0,
onApplied: () => {}, onApplied: () => {},
}); });
+3 -2
View File
@@ -56,8 +56,9 @@ def test_draw_and_button_wiring() -> None:
panel = _read("A00_Common", "design", "design_profile.ts") panel = _read("A00_Common", "design", "design_profile.ts")
assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
assert "ctx.onStaleDesigns?.(stale);" in render # 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1)
assert 'pendingRecalc: stale !== ""' in render assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render
assert "pendingRecalc: pending," in render
corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts") corridor = _read("B05_Profile", "B05_Profile_UI_Corridor.ts")
body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0] body = corridor.split("export async function refreshCorridor", 1)[1].split("\n}\n", 1)[0]
assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body assert "await refreshStaleDesigns({ projectId, routeId, detail })" in body
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""342-1 흙 배분 서버 — 같은 열쇠(누가토량 결과 + 계수 · 장비 경계)면 Node 를 다시 안 띄움.
묶음 실패는 500 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었음.
"""
import asyncio
import json
import sys
from pathlib import Path
from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section import B06_Section_Router_HaulPlan as router # noqa: E402
def _patch(monkeypatch, outputs: list) -> list:
calls: list = []
async def _none(_project_id):
return {}
def _run(_bundle, _script, payload):
calls.append(payload)
return outputs.pop(0)
monkeypatch.setattr(router, "haul_inputs_for", _none)
monkeypatch.setattr(router, "conversion_factors_for", _none)
monkeypatch.setattr(router, "haul_limits_for", _none)
monkeypatch.setattr(router, "_mass_haul_context", lambda *_: {"limits": []})
monkeypatch.setattr(router, "run_bundle_json", _run)
router._KEPT.clear()
return calls
def _post(result: dict):
response = asyncio.run(router.compute_haul_plan(uuid4(), 7, {"result": result}))
return response.status_code, json.loads(response.body)
def test_같은_열쇠는_Node_한_번(monkeypatch):
calls = _patch(monkeypatch, [{"haul_plan": {"tag": "a"}}, {"haul_plan": {"tag": "b"}}])
a = {"points": [{"cumulative_volume_m3": 1.0}]}
b = {"points": [{"cumulative_volume_m3": 2.0}]}
assert _post(a) == (200, {"status": "success", "haul_plan": {"tag": "a"}})
assert _post(a)[1]["haul_plan"] == {"tag": "a"}
assert len(calls) == 1
assert _post(b)[1]["haul_plan"] == {"tag": "b"}
assert len(calls) == 2
def test_묶음_실패는_500_기억_안_함(monkeypatch):
calls = _patch(monkeypatch, [None, {"haul_plan": None}])
a = {"points": [{"cumulative_volume_m3": 1.0}]}
assert _post(a)[0] == 500
assert _post(a) == (200, {"status": "success", "haul_plan": None})
assert len(calls) == 2
@@ -99,6 +99,7 @@ def test_source_shared_rule_and_b05_entry_recompute():
assert "scheduleCrossPreview();" in B05_PANEL assert "scheduleCrossPreview();" in B05_PANEL
# B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다 # B06: 같은 규칙 하나만 쓴다 — 옛 암 2단계 필드 누락 조건도 공용 유틸로 옮겼다
# (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다). # (2026-09-03 일원화: 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다).
assert "hasStaleDesigns(sectionDetail)" in B06_PAGE # 판정은 종단도가 쥔 화면 선 기준(342-1 · 공용 규칙 hasStaleDesigns 를 그 선으로 부름)
assert "staleAgainstScreen(sectionDetail, currentRouteId)" in B06_PAGE
assert "two_stage_slope === undefined" in COMMON assert "two_stage_slope === undefined" in COMMON
assert "two_stage_slope === undefined" not in B06_PAGE assert "two_stage_slope === undefined" not in B06_PAGE