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