feat(B05): 계획노선 편집 창 고정 표시 · 시점 · 종점 고정(PLAN 22-4 · 22-5)

- 고정 한 벌 — 시점 · 종점(기본 고정) · 교각점을 한 칸 줄로 봄 · 저장 · 읽기는 _RouteEdit_Lock.ts(readRouteLocks = 초기노선 계산에 넘길 한 곳)
- 곧은 선은 양 끝 · 호는 교각점과 앞뒤 점이 모두 고정일 때 고정 색(--map-route-locked · 밝음 #db2777 · 어두움 #f472b6) · 고정 점도 같은 색
- 시점 · 종점을 누르면 고정 단추만 뜸 · 풀면 끌어 옮김 · 고정이면 끌기 · 지우기 막힘
- 되돌리기 · [취소] · 다시 열기 · [확인] 뒤에도 고정 유지(pref 통 · 좌표로 되짚음) · [확인] 몸통은 그대로
- 시험 test_b05_routeedit_lock 5건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-29 19:46:46 +09:00
co-authored by Claude Opus 5.5
parent 5b41cfaf01
commit ea4cb043cd
10 changed files with 387 additions and 20 deletions
@@ -0,0 +1,106 @@
/* 계획노선 편집 창 **고정 한 벌**(계획서 22-4 · 22-5) 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Lock.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();
// 브라우저 저장소 흉내 — `b_page_state` 가 읽고 쓰는 자리.
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 local = memoryStorage();
globalThis.window = { localStorage: local, sessionStorage: memoryStorage() };
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 lock = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Lock.ts"));
const planned = [
[0, 0],
[100, 0],
[150, 80],
[260, 90],
];
// ① 저장된 것이 없으면 시점 · 종점만 고정.
const fresh = lock.readRouteLocks("p-1");
const freshColumn = lock.lockColumn(planned, fresh);
// ② 시점을 풀고 교각점 둘을 고정해 적는다 → 같은 프로젝트로 다시 읽는다.
const edited = [false, true, true, true];
lock.writeRouteLocks("p-1", planned, edited);
const readBack = lock.readRouteLocks("p-1");
const otherProject = lock.readRouteLocks("p-2");
const storedKeys = [...local._map.keys()];
// ③ 서버가 끝자리만 조금 다른 좌표로 돌려줘도 같은 교각점 · 멀리 옮긴 점은 풀림.
const nudged = [
[0, 0],
[100.03, -0.02],
[151, 80],
[260, 90],
];
const nudgedColumn = lock.lockColumn(nudged, readBack);
// ④ 선 · 호 고정 판정 — 곧은 선은 양 끝, 호는 교각점과 앞뒤.
const straight = [0, 1, 2].map((index) => lock.straightLocked(edited, index));
const arc = [1, 2].map((index) => lock.arcLocked(edited, index));
const arcAll = [1, 2].map((index) => lock.arcLocked([true, true, true, true], index));
// ⑤ 노선을 못 읽은(노드 1개 이하) 채 닫으면 적어 둔 고정을 안 지운다 · 깨진 값은 기본값.
lock.writeRouteLocks("p-1", [], []);
const keptAfterEmpty = lock.readRouteLocks("p-1");
local.setItem("aislo:pref:routeedit-locks:p-3", "{\"start\":false,\"apex\":[[1,\"x\"],[2,3]]}");
const broken = lock.readRouteLocks("p-3");
process.stdout.write(
JSON.stringify({
fresh,
freshColumn,
readBack,
otherProject,
storedKeys,
nudgedColumn,
straight,
arc,
arcAll,
keptAfterEmpty,
broken,
}),
);
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **고정 한 벌**(계획서 22-4 · 22-5) 회귀검증.
시점 · 종점은 기본 고정 · 교각점 고정은 좌표로 다시 짚음 · 곧은 선은 양 끝 · 호는 교각점과
앞뒤 점이 모두 고정일 때 고정. 자리는 화면 상태 보관소 pref 통의 프로젝트 칸.
Node 헬퍼(`helper_b05_routeedit_lock.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_lock.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_저장된_것이_없으면_시점_종점만_고정(results):
assert results["fresh"] == {"start": True, "end": True, "apex": []}
assert results["freshColumn"] == [True, False, False, True]
def test_저장소_왕복은_프로젝트마다(results):
assert results["readBack"] == {"start": False, "end": True, "apex": [[100, 0], [150, 80]]}
assert results["otherProject"] == {"start": True, "end": True, "apex": []}
assert results["storedKeys"] == ["aislo:pref:routeedit-locks:p-1"]
def test_교각점은_좌표로_다시_짚음(results):
# 0.05m 안은 같은 점 · 1m 옮긴 점은 풀림 · 시점 · 종점은 자리와 무관하게 역할로.
assert results["nudgedColumn"] == [False, True, False, True]
def test_선과_호_고정_판정(results):
assert results["straight"] == [False, True, True]
assert results["arc"] == [False, True]
assert results["arcAll"] == [True, True]
def test_빈_노선은_안_지우고_깨진_값은_기본값(results):
assert results["keptAfterEmpty"] == results["readBack"]
assert results["broken"] == {"start": False, "end": True, "apex": [[2, 3]]}