feat(B05): 계획노선 편집 창 — 열 때 서버가 적어 둔 현재 초기값(current_initial)을 「현재 초기값」에

- /route/plan 의 current_initial {logic, weights} 를 읽어 이름 · 비중 슬라이더에 보임(없으면 예상노선 추종)
- 캐시가 서버와 다른 갈래 · 비중이면 캐시를 버림(서버가 정본 — [확인] 때만 적힘)
- 노드 없이 이름만 아는 초기값의 [초기화]는 그 갈래를 서버에 물어 노드를 실음
- 시험 test_b05_routeedit_initial.py — 서버 초기값 펴기 · 캐시 견주기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
This commit is contained in:
2026-09-29 10:08:44 +09:00
co-authored by Claude Opus 5.5
parent 2615bf78d2
commit 43572ccccc
6 changed files with 109 additions and 13 deletions
+2
View File
@@ -66,6 +66,8 @@ export interface RoutePlanResponse {
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
edited: boolean;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
}
export interface RouteReplanResponse {
+1 -1
View File
@@ -664,7 +664,7 @@ export async function openRouteEditModal(
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
history = createRouteEditHistory(snapshotNow());
historyControls.sync();
initial.ready(snapshotNow(), plan.edited);
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
+34 -12
View File
@@ -26,8 +26,11 @@ import {
normalizeRouteInitial,
readInitialChoice,
rebalanceWeights,
sameBase,
serverBase,
WEIGHT_KEYS,
writeInitialChoice,
type InitialBase,
type InitialChoice,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
@@ -48,8 +51,8 @@ export interface InitialPanelParams {
}
export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */
ready: (opening: RouteEditSnapshot, edited: boolean) => void;
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
/** [확인]에 실어 보낼 지금 초기값. */
current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null };
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
@@ -99,16 +102,19 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
const openingChoice = readInitialChoice(projectId);
let openingChoice = readInitialChoice(projectId);
let choice: InitialChoice | null = openingChoice;
/** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */
let base: InitialBase = { logic: "follow", weights: null };
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
function render(): void {
const logic = choice?.logic ?? "follow";
nowLine.textContent = `현재 초기값: ${choice ? choiceLabel(logic, choice.weights) : choiceLabel("follow", null)}`;
const now = choice ?? base;
const logic = now.logic;
nowLine.textContent = `현재 초기값: ${choiceLabel(logic, now.weights)}`;
metricsEl.textContent =
choice && !choice.metrics
? "비교값 — 서버 답에 없습니다."
@@ -128,10 +134,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
}
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
async function run(logic: RouteInitialLogic, allowFallback = false): Promise<void> {
async function run(
logic: RouteInitialLogic,
allowFallback = false,
useWeights: RouteInitialWeights | null = null,
): Promise<void> {
if (busy || !opening) return;
busy = true;
const sent = logic === "weighted" ? { ...weights } : null;
const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null;
render();
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
try {
@@ -149,7 +159,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
} catch (error) {
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
// 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다.
if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited) {
if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited && logic === "follow") {
params.apply(
copy(opening),
"초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
@@ -176,10 +186,10 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
render();
});
});
// [초기화] — 마지막에 적용한 초기값으로. 아직 없으면 예상노선 추종을 서버에 묻는다.
// [초기화] — 마지막에 적용한 초기값으로. 노드가 없으면(서버가 이름만 앎) 그 갈래를 서버에 묻는다.
resetButton.addEventListener("click", () => {
if (!choice) {
void run("follow", true);
void run(base.logic, true, base.weights);
return;
}
params.apply(
@@ -190,12 +200,24 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
render();
return {
ready(snapshot, wasEdited) {
ready(snapshot, wasEdited, server) {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 초기값」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다. 캐시는 같은 초기값의 노드를 들고 있을 때만 [초기화]가 쓴다.
if (server) {
base = serverBase(server);
if (choice && !sameBase(choice, base)) choice = null;
if (base.weights) weights = { ...base.weights };
openingChoice = choice;
writeInitialChoice(projectId, choice);
}
render();
},
current: () => ({ mode: choice?.logic ?? "follow", weights: choice?.weights ?? null }),
current: () => {
const now = choice ?? base;
return { mode: now.logic, weights: now.weights };
},
cancel: () => writeInitialChoice(projectId, openingChoice),
keep: () => writeInitialChoice(projectId, choice),
};
@@ -134,6 +134,38 @@ export function normalizeRouteInitial(
};
}
/** 이름만 아는 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 `current_initial` 의 꼴. */
export interface InitialBase {
logic: RouteInitialLogic;
/** 비중 계산일 때만. 나머지 갈래는 null. */
weights: RouteInitialWeights | null;
}
/** 서버 `current_initial {logic, weights}` 를 편다. 모르는 값이면 예상노선 추종. */
export function serverBase(server: unknown): InitialBase {
const body = (server ?? {}) as Record<string, unknown>;
const logic =
typeof body.logic === "string" && body.logic in LOGIC_LABEL
? (body.logic as RouteInitialLogic)
: "follow";
if (logic !== "weighted") return { logic, weights: null };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
earthwork: Math.round(Number(raw.earthwork)),
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS } };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중인가. */
export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
if (choice.logic !== base.logic) return false;
if (base.logic !== "weighted") return true;
return WEIGHT_KEYS.every((key) => choice.weights?.[key] === base.weights?.[key]);
}
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string {
if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic];
@@ -145,6 +145,26 @@ const labels = {
).criteria.confirmed,
};
// ⑤ 서버가 적어 둔 현재 초기값(`current_initial`) — 펴기와 캐시 견주기.
const bases = {
follow: model.serverBase({ logic: "follow", weights: { expected: 100, earthwork: 0, grade: 0 } }),
weighted: model.serverBase({
logic: "weighted",
weights: { expected: 70.0, earthwork: 15.0, grade: 15.0 },
}),
unknown: model.serverBase({ logic: "nope" }),
none: model.serverBase(null),
};
const sameAsServer = {
earthVsFollow: model.sameBase(plain, bases.follow),
earthVsEarth: model.sameBase(plain, model.serverBase({ logic: "earthwork" })),
weightedSame: model.sameBase(
planShape,
model.serverBase({ logic: "weighted", weights: { expected: 60, earthwork: 20, grade: 20 } }),
),
weightedOther: model.sameBase(planShape, bases.weighted),
};
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
model.writeInitialChoice("p-1", plain);
const readBack = model.readInitialChoice("p-1");
@@ -178,5 +198,7 @@ process.stdout.write(
keys,
badLogic,
cleared,
bases,
sameAsServer,
}),
);
@@ -90,6 +90,24 @@ def test_비교값과_기준값_글(results):
)
def test_서버가_적어_둔_현재_초기값(results):
bases = results["bases"]
assert bases["follow"] == {"logic": "follow", "weights": None}
assert bases["weighted"] == {
"logic": "weighted",
"weights": {"expected": 70, "earthwork": 15, "grade": 15},
}
assert bases["unknown"] == {"logic": "follow", "weights": None}
assert bases["none"] == {"logic": "follow", "weights": None}
same = results["sameAsServer"]
assert same == {
"earthVsFollow": False,
"earthVsEarth": True,
"weightedSame": True,
"weightedOther": False,
}
def test_캐시는_결과_통_프로젝트_칸(results):
assert results["keys"] == ["aislo:result:routeedit-initial:p-1"]
assert results["readBack"]["logic"] == "earthwork"