diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 4013830b..b7bbeb64 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -174,6 +174,9 @@ export const STATE_REGISTRY = { scope: "project", legacy: (p) => `b06:rock-boundary-default:${p}`, }, + /** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값). + * 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */ + "routeedit-initial": { bucket: "result", scope: "project" }, } as const satisfies Record; export type StateName = keyof typeof STATE_REGISTRY; diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index ad414804..fb714dea 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -76,7 +76,11 @@ export interface RouteReplanResponse { vertex_count: number; } -async function requestJson(path: string, init: RequestInit, timeoutMs: number): Promise { +export async function requestJson( + path: string, + init: RequestInit, + timeoutMs: number, +): Promise { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); try { @@ -121,13 +125,15 @@ export interface RouteReplanVertex { export async function replanRoute( projectId: string, vertices: Array<[number, number]> | RouteReplanVertex[], + /** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */ + extra: Record = {}, ): Promise { const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) => Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex, ); return requestJson( `/projects/${projectId}/route/replan`, - { method: "POST", body: JSON.stringify({ vertices: payload }) }, + { method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) }, REPLAN_TIMEOUT_MS, ); } diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts new file mode 100644 index 00000000..1584a7f1 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -0,0 +1,54 @@ +/* ============================================================================= + * B05_Profile_Api_RouteInitial.ts + * 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다. + * + * POST /projects/{id}/route/initial { logic, weights? } → 노드 · 비교값 · 공통 제약 기준값 + * + * 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고, + * 받은 비교값·기준값을 보이기만 한다. 서버 길은 sub3 몫이라 모양이 오면 `normalizeRouteInitial` + * (`_RouteEdit_Initial_Model`) 한 곳만 맞춘다. + * ========================================================================== */ + +import { requestJson } from "./B05_Profile_Api_Replan"; + +/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */ +const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; + +/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */ +export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; + +/** 비중 계산의 세 비중(%) — 합 100. */ +export interface RouteInitialWeights { + expected: number; + earthwork: number; + slope: number; +} + +export interface RouteInitialRequest { + logic: RouteInitialLogic; + weights?: RouteInitialWeights; +} + +/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */ +export const ROUTE_INITIAL_MISSING = + "초기노선 갈래 서버 길이 아직 없습니다(계획서 20 · sub3 대기)."; + +/** 로직 하나로 초기노선을 묻는다. 답의 모양은 `normalizeRouteInitial` 이 편다. */ +export async function fetchRouteInitial( + projectId: string, + request: RouteInitialRequest, +): Promise { + try { + return await requestJson( + `/projects/${projectId}/route/initial`, + { method: "POST", body: JSON.stringify(request) }, + INITIAL_TIMEOUT_MS, + ); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (/HTTP 40[45]|Not Found|Method Not Allowed/.test(message)) { + throw new Error(ROUTE_INITIAL_MISSING); + } + throw error; + } +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3020e7e8..46fb5d87 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -7,7 +7,7 @@ * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. * * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). - * [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산). + * [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`). * * 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은 * 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다. @@ -39,6 +39,7 @@ import { } from "./B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; import { + bindNodeSplice, bindRouteEditNavigation, handleAtScreen, nodeAtScreen, @@ -59,6 +60,7 @@ import { viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; +import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; import { applyArcLocks, applyCurveLimits, @@ -93,7 +95,7 @@ const CONTOUR_LEVEL_BUDGET = 1200; type Vertex = [number, number]; -/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ +/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ export interface RouteEditOptions { /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ stationIntervalM?: number; @@ -227,8 +229,16 @@ export async function openRouteEditModal( curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. overlay.remove(); }; - overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); - overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close); + /** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */ + const initial = createInitialPanel({ + overlay, + projectId, + planMinRadiusM: () => minRadiusM, + apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), + }); + const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다. + overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel); + overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel); // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. function resize(): void { @@ -412,19 +422,16 @@ export async function openRouteEditModal( return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked }; } + /** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */ + function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void { + ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); + applyEdit(message, record); + } + const historyControls = bindHistoryControls({ overlay, getHistory: () => history, - restore: (snapshot, message) => { - planned = snapshot.planned; - curveOn = snapshot.curveOn; - curveRadius = snapshot.curveRadius; - curveLock = snapshot.curveLock; - curveArc = snapshot.curveArc; - apexLock = snapshot.apexLock; - picked = snapshot.picked; - applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. - }, + restore: (snapshot, message) => restoreSnapshot(snapshot, message, false), }); // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── @@ -592,28 +599,22 @@ export async function openRouteEditModal( canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); - canvas.addEventListener("dblclick", (event) => { - const rect = canvas.getBoundingClientRect(); - const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - const segment = segmentAt(px, py); - if (segment < 0) return; - spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. - picked = segment + 1; - applyEdit("새 노드를 넣었습니다(직선 추가)."); - }); - - canvas.addEventListener("contextmenu", (event) => { - event.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top)); - if (index < 0) return; - if (planned.length <= 2) { - showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); - return; - } - spliceNode(snapshotNow(), index); - picked = -1; - applyEdit("노드를 지웠습니다(직선 삭제)."); + bindNodeSplice({ + canvas, + unrotate: rotation.unrotate, + segmentAt, + nodeAt, + insert: (segment, px, py) => { + spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. + picked = segment + 1; + applyEdit("새 노드를 넣었습니다(직선 추가)."); + }, + remove: (index) => { + if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + spliceNode(snapshotNow(), index); + picked = -1; + applyEdit("노드를 지웠습니다(직선 삭제)."); + }, }); // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. @@ -634,6 +635,8 @@ export async function openRouteEditModal( busy, projectId, nodes: () => ({ planned, curveOn, curveRadius }), + changed: () => history?.isDirty() ?? false, + initial, close, onApplied, }); @@ -655,18 +658,13 @@ export async function openRouteEditModal( limitArcM = plan.limit_curve_length_m ?? 0; // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); - planned = flat.planned; - nodeInfo = flat.nodes; - curveInfo = flat.curves; - curveOn = flat.curveOn; - curveRadius = flat.curveRadius; - curveLock = flat.curveLock; - curveArc = flat.curveArc; - apexLock = flat.apexLock; + ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat); + ({ nodes: nodeInfo, curves: curveInfo } = flat); picked = -1; - // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. + // 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로. history = createRouteEditHistory(snapshotNow()); historyControls.sync(); + initial.ready(snapshotNow(), plan.edited); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 853cb56d..63c1e3fe 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -1,6 +1,9 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Apply.ts - * 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시. + * 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시. + * + * 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면 + * 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). * * 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는 * 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이 @@ -9,7 +12,7 @@ import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; import { showToast } from "@ui/ui_template_elements"; -import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; +import { replanRoute } from "./B05_Profile_Api_Replan"; type Vertex = [number, number]; @@ -20,12 +23,16 @@ export interface RouteApplyParams { projectId: string; /** 지금 편집값 — 누른 순간에 읽는다. */ nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array }; + /** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */ + changed: () => boolean; + /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ + initial: { current: () => unknown; keep: () => void }; /** 성공하면 모달을 닫고 화면을 다시 읽는다. */ close: () => void; onApplied: () => void | Promise; } -/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */ +/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */ export function bindRouteApply(params: RouteApplyParams): void { const { overlay, busy, projectId } = params; @@ -46,6 +53,7 @@ export function bindRouteApply(params: RouteApplyParams): void { // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). clearDrafts(projectId); clearResults(projectId); + params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다. showToast("노선을 다시 계산했습니다.", "success"); params.close(); await params.onApplied(); @@ -63,6 +71,11 @@ export function bindRouteApply(params: RouteApplyParams): void { showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } + if (!params.changed()) { + showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info"); + params.close(); + return; + } void runHeavy("계획노선 반영", () => replanRoute( projectId, @@ -72,10 +85,8 @@ export function bindRouteApply(params: RouteApplyParams): void { curve: curveOn[index] !== false, radius_m: curveRadius[index] ?? null, })), + { initial: params.initial.current() }, ), ); }); - overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { - void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); - }); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 0e1d1d71..c830b3bb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -52,8 +52,9 @@ export function createRouteEditChrome(): RouteEditChrome { - + title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>연 상태로 + diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts new file mode 100644 index 00000000..4b344139 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -0,0 +1,202 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Initial.ts + * 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화] + * · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값. + * + * 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에 + * 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고, + * [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시 + * 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`). + * 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { + fetchRouteInitial, + ROUTE_INITIAL_MISSING, + type RouteInitialLogic, + type RouteInitialWeights, +} from "./B05_Profile_Api_RouteInitial"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import { + choiceLabel, + criteriaLine, + DEFAULT_WEIGHTS, + metricsLine, + normalizeRouteInitial, + readInitialChoice, + rebalanceWeights, + WEIGHT_KEYS, + writeInitialChoice, + type InitialChoice, + type WeightKey, +} from "./B05_Profile_UI_RouteEdit_Initial_Model"; + +const WEIGHT_NAME: Record = { + expected: "예상", + earthwork: "토공", + slope: "기울기", +}; + +export interface InitialPanelParams { + overlay: HTMLElement; + projectId: string; + /** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */ + planMinRadiusM: () => number; + /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ + apply: (snapshot: RouteEditSnapshot, message: string) => void; +} + +export interface InitialPanel { + /** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */ + ready: (opening: RouteEditSnapshot, edited: boolean) => void; + /** [확인]에 실어 보낼 지금 초기값. */ + current: () => { logic: RouteInitialLogic; weights: RouteInitialWeights | null }; + /** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */ + cancel: () => void; + /** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */ + keep: () => void; +} + +const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot => + JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot; + +export function createInitialPanel(params: InitialPanelParams): InitialPanel { + const { overlay, projectId } = params; + const box = document.createElement("div"); + box.className = "b05-routeedit__initial"; + box.innerHTML = ` +
+ + + +
+ +
+
+
`; + overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box); + + const nowLine = box.querySelector(".b05-routeedit__initial-now")!; + const metricsEl = box.querySelector('[data-line="metrics"]')!; + const criteriaEl = box.querySelector('[data-line="criteria"]')!; + const weightsBox = box.querySelector(".b05-routeedit__weights")!; + const openWeights = box.querySelector('[data-act="weights-open"]')!; + const resetButton = overlay.querySelector('[data-act="reset"]')!; + const buttons = [...box.querySelectorAll("button"), resetButton]; + + /** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */ + const openingChoice = readInitialChoice(projectId); + let choice: InitialChoice | null = openingChoice; + 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)}`; + metricsEl.textContent = + choice && !choice.metrics + ? "비교값 — 서버 답에 없습니다." + : metricsLine(choice?.metrics ?? null); + criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM()); + box.dataset.logic = logic; + for (const key of WEIGHT_KEYS) { + box.querySelector(`[data-weight="${key}"]`)!.value = String(weights[key]); + box.querySelector(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`; + } + box.querySelector("[data-weight-sum]")!.textContent = + `합 ${weights.expected + weights.earthwork + weights.slope}`; + buttons.forEach((button) => { + button.disabled = busy || !opening; + }); + resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`; + } + + /** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */ + async function run(logic: RouteInitialLogic, allowFallback = false): Promise { + if (busy || !opening) return; + busy = true; + const sent = logic === "weighted" ? { ...weights } : null; + render(); + nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`; + try { + const response = await fetchRouteInitial(projectId, { + logic, + ...(sent ? { weights: sent } : {}), + }); + if (!box.isConnected) return; // 그 사이 창이 닫혔다. + choice = normalizeRouteInitial(response, logic, sent); + writeInitialChoice(projectId, choice); + params.apply( + copy(choice.snapshot), + `초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다."; + // 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다. + if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited) { + params.apply( + copy(opening), + "초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.", + ); + } else { + showToast(message, "error"); + } + } finally { + busy = false; + if (box.isConnected) render(); + } + } + + box.querySelectorAll("[data-logic]").forEach((button) => { + button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic)); + }); + openWeights.addEventListener("click", () => { + weightsBox.hidden = !weightsBox.hidden; + openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden)); + }); + box.querySelectorAll("[data-weight]").forEach((input) => { + input.addEventListener("input", () => { + weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value)); + render(); + }); + }); + // [초기화] — 마지막에 적용한 초기값으로. 아직 없으면 예상노선 추종을 서버에 묻는다. + resetButton.addEventListener("click", () => { + if (!choice) { + void run("follow", true); + return; + } + params.apply( + copy(choice.snapshot), + `초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`, + ); + }); + render(); + + return { + ready(snapshot, wasEdited) { + opening = copy(snapshot); + edited = wasEdited; + render(); + }, + current: () => ({ logic: choice?.logic ?? "follow", weights: choice?.weights ?? null }), + 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 new file mode 100644 index 00000000..bdf220cc --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -0,0 +1,191 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Initial_Model.ts + * 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시. + * 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다). + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial"; +import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; + +type Vertex = [number, number]; + +export const LOGIC_LABEL: Record = { + follow: "예상노선 추종", + earthwork: "토공 최소", + gentle: "기울기 순한 노선", + weighted: "비중 계산", +}; + +/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */ +export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 }; +export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] as const; +export type WeightKey = (typeof WEIGHT_KEYS)[number]; + +/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */ +export function rebalanceWeights( + weights: RouteInitialWeights, + key: WeightKey, + value: number, +): RouteInitialWeights { + const fixed = Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))); + const [first, second] = WEIGHT_KEYS.filter((name) => name !== key); + const rest = 100 - fixed; + const pool = weights[first] + weights[second]; + const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2); + const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare }; + return { expected: out.expected, earthwork: out.earthwork, slope: out.slope }; +} + +/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */ +export interface InitialMetrics { + lengthM: number | null; + maxGradePct: number | null; + meanGradePct: number | null; + cutM3: number | null; + fillM3: number | null; + overLimitCount: number | null; + offsetM: number | null; +} + +/** 공통 제약 기준값 — 서버가 준 값. `confirmed` 가 거짓이면 지식DB 후보(사용자 확정 대기). */ +export interface InitialCriteria { + maxGradePct: number | null; + minRadiusM: number | null; + streamOffsetM: number | null; + confirmed: boolean; +} + +/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */ +export interface InitialChoice { + logic: RouteInitialLogic; + weights: RouteInitialWeights | null; + snapshot: RouteEditSnapshot; + metrics: InitialMetrics | null; + criteria: InitialCriteria | null; +} + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */ +function pick(source: Record, ...names: string[]): number | null { + for (const name of names) { + const value = num(source[name]); + if (value !== null) return value; + } + return null; +} + +/** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */ +function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot { + if (Array.isArray(curves) && curves.length) { + const flat = flattenServerPlan(nodes as never, curves as never); + return { ...flat, picked: -1 }; + } + const rows = nodes as Array | Vertex>; + const planned = rows.map((row): Vertex => + Array.isArray(row) ? [row[0], row[1]] : [Number(row.x), Number(row.y)], + ); + return { + planned, + curveOn: rows.map((row) => (Array.isArray(row) ? true : row.curve !== false)), + curveRadius: rows.map((row) => (Array.isArray(row) ? null : num(row.radius_m))), + curveLock: planned.map(() => null), + curveArc: planned.map(() => null), + apexLock: planned.map(() => false), + picked: -1, + }; +} + +/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */ +export function normalizeRouteInitial( + response: unknown, + logic: RouteInitialLogic, + weights: RouteInitialWeights | null, +): InitialChoice { + const body = (response ?? {}) as Record; + const nodes = Array.isArray(body.nodes) + ? body.nodes + : Array.isArray(body.vertices) + ? body.vertices + : []; + const snapshot = snapshotOf(nodes, body.curves); + if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) { + throw new Error("서버가 낸 초기노선에 노드가 모자랍니다."); + } + const m = (body.metrics ?? {}) as Record; + const c = (body.criteria ?? {}) as Record; + return { + logic, + weights, + snapshot, + metrics: body.metrics + ? { + lengthM: pick(m, "length_m", "total_length_m"), + maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"), + meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"), + cutM3: pick(m, "cut_m3", "cut_volume_m3"), + fillM3: pick(m, "fill_m3", "fill_volume_m3"), + overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"), + offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"), + } + : null, + criteria: body.criteria + ? { + maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"), + minRadiusM: pick(c, "min_radius_m"), + streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"), + confirmed: c.confirmed === true, + } + : null, + }; +} + +/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */ +export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string { + if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic]; + return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.slope}`; +} + +const fixed = (value: number | null, digits: number, unit: string): string => + value === null + ? "—" + : `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`; + +/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */ +export function metricsLine(metrics: InitialMetrics | null): string { + if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다."; + return [ + `연장 ${fixed(metrics.lengthM, 1, "m")}`, + `기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`, + `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + `기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`, + `이격 ${fixed(metrics.offsetM, 1, "m")}`, + ].join(" · "); +} + +/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */ +export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string { + const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null); + const parts = [ + `종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`, + `최소 R ${fixed(radius, 0, "m")}`, + `계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`, + ]; + const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)"; + return `공통 제약 ${parts.join(" · ")}${tail}`; +} + +/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */ +export function readInitialChoice(projectId: string): InitialChoice | null { + const saved = readState("routeedit-initial", projectId); + if (!saved || !saved.snapshot || !Array.isArray(saved.snapshot.planned)) return null; + if (!(saved.logic in LOGIC_LABEL) || saved.snapshot.planned.length < 2) return null; + return saved; +} + +export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void { + writeState("routeedit-initial", choice, projectId); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index e4ab7f24..8f16a823 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -104,6 +104,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void canvas.addEventListener("pointercancel", stop); } +export interface NodeSpliceParams { + canvas: HTMLCanvasElement; + /** 돌린 지도 보정 — 화면 px 를 돌리기 전 그림 px 로. */ + unrotate: (px: number, py: number) => [number, number]; + segmentAt: (px: number, py: number) => number; + nodeAt: (px: number, py: number) => number; + /** 선(`segment` 번째 구간) 위 그림 px 자리에 노드를 끼운다. */ + insert: (segment: number, px: number, py: number) => void; + /** `index` 번째 노드를 뺀다. */ + remove: (index: number) => void; +} + +/** 선 두 번 클릭 = 노드 추가 · 노드 오른쪽 클릭 = 삭제. 리스너는 캔버스와 수명이 같다. */ +export function bindNodeSplice(params: NodeSpliceParams): void { + const { canvas } = params; + const at = (event: MouseEvent): [number, number] => { + const rect = canvas.getBoundingClientRect(); + return params.unrotate(event.clientX - rect.left, event.clientY - rect.top); + }; + canvas.addEventListener("dblclick", (event) => { + const [px, py] = at(event); + const segment = params.segmentAt(px, py); + if (segment >= 0) params.insert(segment, px, py); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + const index = params.nodeAt(...at(event)); + if (index >= 0) params.remove(index); + }); +} + /* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */ export type ScreenOf = (point: [number, number]) => [number, number]; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index b620c00a..33b79f1c 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -125,6 +125,78 @@ line-height: 1; } +/* 초기노선 갈래(계획서 20) — 회전 단추 아래 오른쪽. 로직 단추 · 비중 · 현재 초기값 · 비교값. */ +.b05-routeedit__initial { + position: absolute; + top: calc(var(--spacing-12) * 2 + 34px); + right: var(--spacing-12); + z-index: 1; + display: flex; + flex-direction: column; + gap: 4px; + max-width: min(420px, 44%); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__initial-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.b05-routeedit__initial-row .ui-btn { + padding: 4px var(--spacing-8); + font-size: var(--text-caption); +} + +.b05-routeedit__initial-row [aria-expanded="true"] { + border-color: var(--color-primary); +} + +.b05-routeedit__weights { + display: grid; + gap: 2px; +} + +.b05-routeedit__weights label { + display: grid; + grid-template-columns: 3.5em 1fr 2.5em; + align-items: center; + gap: var(--spacing-8); +} + +.b05-routeedit__weights output { + text-align: right; + color: var(--color-text-body); +} + +.b05-routeedit__weights-foot { + display: flex; + align-items: center; + justify-content: space-between; +} + +.b05-routeedit__weights-foot .ui-btn { + padding: 4px var(--spacing-12); + font-size: var(--text-caption); +} + +.b05-routeedit__initial-now { + color: var(--color-text-body); + font-weight: 600; +} + +.b05-routeedit__initial-line { + line-height: 1.45; +} + .b05-routeedit__legend { display: inline-flex; align-items: center; diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs new file mode 100644 index 00000000..50c3545d --- /dev/null +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -0,0 +1,162 @@ +/* 계획노선 편집 창 **초기노선 갈래**(계획서 20) 셈 단위검증 헬퍼 — + * `B05_Profile_UI_RouteEdit_Initial_Model.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다. + * 브라우저 전용인 저장소는 가짜로 채운다 — 셈만 본다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +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; + }, + _map: map, + }; +} +const session = memoryStorage(); +globalThis.window = { localStorage: memoryStorage(), sessionStorage: session }; +globalThis.fetch = () => Promise.resolve({ ok: false }); + +const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } }; + +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) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts")); +const { DEFAULT_WEIGHTS } = model; + +// ① 비중 맞추기 — 하나를 옮기면 나머지 둘이 비율대로 나눠 합 100. +const moves = [ + model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70), + model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40), + model.rebalanceWeights({ expected: 100, earthwork: 0, slope: 0 }, "expected", 60), + model.rebalanceWeights({ expected: 20, earthwork: 30, slope: 50 }, "slope", 100), + model.rebalanceWeights({ expected: 33, earthwork: 33, slope: 34 }, "earthwork", 33.4), + model.rebalanceWeights(DEFAULT_WEIGHTS, "slope", -5), +]; + +// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다. +const plain = model.normalizeRouteInitial( + { + nodes: [ + { x: 0, y: 0 }, + { x: 50, y: 10, curve: true, radius_m: 30 }, + { x: 100, y: 0, curve: false }, + ], + metrics: { + length_m: 1234.56, + max_grade_pct: 12.34, + mean_grade_pct: 6.1, + cut_m3: 1500.4, + fill_m3: 900, + over_limit_count: 2, + offset_mean_m: 3.25, + }, + criteria: { max_grade_pct: 14, min_radius_m: 12, stream_offset_m: 5 }, + }, + "earthwork", + null, +); +const planShape = model.normalizeRouteInitial( + { + nodes: [ + { x: 0, y: 0, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] }, + { x: 40, y: 30, radius_m: 25, inner_angle_deg: 140, tangent_m: 9, violations: [] }, + { x: 90, y: 30, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] }, + ], + curves: [ + { + apex: [40, 30], + radius_m: 25, + tangent_m: 9, + inner_angle_deg: 140, + start: [33, 25], + end: [49, 30], + node_first: 1, + node_last: 1, + violations: [], + }, + ], + }, + "weighted", + { expected: 60, earthwork: 20, slope: 20 }, +); +let tooShort = ""; +try { + model.normalizeRouteInitial({ nodes: [{ x: 1, y: 1 }] }, "gentle", null); +} catch (error) { + tooShort = error.message; +} + +// ③ 글 — 현재 초기값 · 비교값 · 기준값. +const labels = { + follow: model.choiceLabel("follow", null), + weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, slope: 20 }), + metrics: model.metricsLine(plain.metrics), + metricsNone: model.metricsLine(null), + criteria: model.criteriaLine(plain.criteria, 0), + criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0), + criteriaFallback: model.criteriaLine(null, 15), +}; + +// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. +model.writeInitialChoice("p-1", plain); +const readBack = model.readInitialChoice("p-1"); +const other = model.readInitialChoice("p-2"); +const keys = [...session._map.keys()]; +session._map.set( + keys[0], + JSON.stringify({ + logic: "nope", + snapshot: { + planned: [ + [0, 0], + [1, 1], + ], + }, + }), +); +const badLogic = model.readInitialChoice("p-1"); +model.writeInitialChoice("p-1", null); +const cleared = model.readInitialChoice("p-1"); + +process.stdout.write( + JSON.stringify({ + moves, + plain, + planShape, + tooShort, + labels, + readBack, + other, + keys, + badLogic, + cleared, + }), +); diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py new file mode 100644 index 00000000..f7a7f254 --- /dev/null +++ b/resources/tester/test_b05_routeedit_initial.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증. + +비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과 +`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만 +보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸. + +Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_비중은_늘_합_100_정수(results): + for weights in results["moves"]: + assert list(weights) == ["expected", "earthwork", "slope"] + assert sum(weights.values()) == 100 + assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values()) + + +def test_비중은_나머지를_지금_비율대로_나눔(results): + moves = results["moves"] + assert moves[0] == {"expected": 70, "earthwork": 15, "slope": 15} + assert moves[1] == {"expected": 40, "earthwork": 40, "slope": 20} + # 나머지가 모두 0 이면 반씩. + assert moves[2] == {"expected": 60, "earthwork": 20, "slope": 20} + assert moves[3] == {"expected": 0, "earthwork": 0, "slope": 100} + assert moves[5]["slope"] == 0 + + +def test_노드_목록_꼴을_편집값으로(results): + snap = results["plain"]["snapshot"] + assert snap["planned"] == [[0, 0], [50, 10], [100, 0]] + assert snap["curveOn"] == [True, True, False] + assert snap["curveRadius"] == [None, 30, None] + assert snap["curveLock"] == [None, None, None] + assert snap["apexLock"] == [False, False, False] + assert snap["picked"] == -1 + + +def test_노선계획_꼴도_같은_규칙으로(results): + shape = results["planShape"] + snap = shape["snapshot"] + assert snap["planned"] == [[0, 0], [40, 30], [90, 30]] + assert snap["curveOn"] == [False, True, False] + assert snap["curveRadius"] == [None, 25, None] + assert shape["weights"] == {"expected": 60, "earthwork": 20, "slope": 20} + assert shape["metrics"] is None and shape["criteria"] is None + + +def test_노드가_모자라면_싣지_않음(results): + assert "모자랍니다" in results["tooShort"] + + +def test_비교값과_기준값_글(results): + labels = results["labels"] + assert labels["follow"] == "예상노선 추종" + assert labels["weighted"] == "비중 계산 60/20/20" + assert labels["metrics"] == ( + "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥" + " · 기준 초과 2구간 · 이격 3.3m" + ) + assert "로직을 누르면" in labels["metricsNone"] + assert labels["criteria"] == ( + "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)" + ) + assert labels["criteriaConfirmed"].endswith("5.0m") + assert labels["criteriaFallback"] == ( + "공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)" + ) + + +def test_캐시는_결과_통_프로젝트_칸(results): + assert results["keys"] == ["aislo:result:routeedit-initial:p-1"] + assert results["readBack"]["logic"] == "earthwork" + assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]] + assert results["other"] is None + assert results["badLogic"] is None + assert results["cleared"] is None