diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index fb714dea..b1690721 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -66,6 +66,8 @@ export interface RoutePlanResponse { violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ edited: boolean; + /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ + current_initial?: { logic: string; weights: Record | null }; } export interface RouteReplanResponse { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 46fb5d87..dde74057 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index c98242a0..96605545 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -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("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 { + async function run( + logic: RouteInitialLogic, + allowFallback = false, + useWeights: RouteInitialWeights | null = null, + ): Promise { 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), }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index feeac8bd..0a8635e0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -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; + 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; + 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]; diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index c2d578ea..d45ec375 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -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, }), ); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index 42a03411..5b44155d 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -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"