refactor(B05·B06): 설계 가게 한 곳 · 저장 한 줄기(PLAN 24-2a)
- A00_Common/design/design_store.ts — useDesignStore(projectId, routeId) get · set · subscribe
(structures · pipes · alignmentDraft · sectionDetail · selection) · 밑은 b_page_state 등록 키
(selection 등록 · alignmentDraft 는 옛 키 감쌈 — 브레인 판정 B)
- A00_Common/design/design_save.ts — saveDesign(ctx, {confirm}) 한 줄기:
관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · B05 [임시저장] · B06 [저장] · [확정]이 같은 차례
- 계획고 초안 가게 노선마다 한 벌(B05 Profile_Edit 주인 · B06 Grade_Edit 는 useProfileEditStore)
- 관 목록 주인 = 가게 pipes(Pipes_Draft 창구)
- 시험: test_design_store_save 추가 · save_channels · save_recompute · save_roundtrip 새 차례로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/* 설계 가게 · 저장 한 줄기(계획서 24-2a) 단위검증 헬퍼 —
|
||||
* `A00_Common/design/design_store.ts` · `design_save.ts` · `B05_Profile_UI_Profile_Edit.ts` 를
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌린다. 서버 창구는 가짜로 두고 부른 차례만 적는다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||
globalThis.sessionStorage = session;
|
||||
|
||||
const calls = [];
|
||||
const record = (name, fail) => async () => {
|
||||
calls.push(name);
|
||||
if (fail) throw new Error(`${name} 실패`);
|
||||
};
|
||||
let failStructures = false;
|
||||
const STUBS = {
|
||||
"@ui/ui_template_elements": {
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}`),
|
||||
showLoadingOverlay: () => calls.push("overlay:on"),
|
||||
hideLoadingOverlay: () => calls.push("overlay:off"),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
|
||||
flushPendingPipes: () => record("pipes")(),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": {
|
||||
flushPendingStructures: () => record("structures", failStructures)(),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Fetch": {
|
||||
flushUphillOverrides: () => record("uphill")(),
|
||||
saveProfileAlignment: async (_p, routeId, edits) =>
|
||||
calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`),
|
||||
},
|
||||
"../../B06_Section/B06_Section_Api_Culvert_Options": {
|
||||
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
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 { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
|
||||
const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
|
||||
const { saveDesign, DESIGN_SAVE_ORDER } = loadTs(
|
||||
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
|
||||
);
|
||||
|
||||
async function run() {
|
||||
const out = {};
|
||||
|
||||
// ① 가게 — 등록 키 밑 · 같은 칸이면 두 가게가 같은 값 · 듣는 쪽
|
||||
const a = useDesignStore("p1", 7);
|
||||
const b = useDesignStore("p1", 99); // 프로젝트 칸은 노선과 무관
|
||||
const heard = [];
|
||||
const off = b.subscribe("pipes", (value) => heard.push(value.length));
|
||||
a.set("pipes", [{ chainage_m: 10, source: "user" }]);
|
||||
out.pipesShared = b.get("pipes");
|
||||
out.pipesKey = [...Array(session.length).keys()].map((i) => session.key(i));
|
||||
off();
|
||||
a.set("pipes", [{ chainage_m: 10 }, { chainage_m: 20 }]);
|
||||
out.heard = heard;
|
||||
out.otherProject = useDesignStore("p2", 7).get("pipes");
|
||||
out.noRoute = useDesignStore("p1", null).get("alignmentDraft");
|
||||
a.set("selection", { chainageM: 12.5, structureId: "s-1" });
|
||||
out.selection = b.get("selection");
|
||||
a.set("pipes", null);
|
||||
a.set("selection", null);
|
||||
|
||||
// ② 계획고 가게 한 벌 — B05 패널 · B06 계획선 편집이 같은 벌
|
||||
const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } };
|
||||
const b05 = edit.createProfileEditStore(7, saved, () => undefined);
|
||||
const b06 = edit.useProfileEditStore(7, saved, () => undefined);
|
||||
b06.replace({ station_offsets: { "20.000": 0.5 }, curve_radii: saved.curve_radii });
|
||||
out.b05SeesB06 = b05.edits().station_offsets;
|
||||
out.b05Dirty = b05.dirty();
|
||||
// B05 가 자기 손에 쥔 값으로 이어 고쳐도 B06 편집이 안 사라짐(옛 두 가게 흠)
|
||||
b05.replace({
|
||||
station_offsets: { ...b05.edits().station_offsets, "60.000": -0.2 },
|
||||
curve_radii: b05.edits().curve_radii,
|
||||
});
|
||||
out.afterBoth = b06.edits().station_offsets;
|
||||
out.draft = useDesignStore(null, 7).get("alignmentDraft").station_offsets;
|
||||
out.pending = edit.pendingAlignment(7).station_offsets;
|
||||
// 되돌리기가 초안을 바꾼 뒤 B05 가 다시 세움 — B06 쪽 손잡이도 같은 벌을 봄
|
||||
useDesignStore(null, 7).set("alignmentDraft", {
|
||||
station_offsets: { "20.000": 0.1 },
|
||||
curve_radii: {},
|
||||
});
|
||||
edit.createProfileEditStore(7, saved, () => undefined);
|
||||
out.rebuiltSeenByB06 = b06.edits().station_offsets;
|
||||
|
||||
// ③ 저장 한 줄기 — 차례 · 기본 창구 · 보기 몫 · 계획고 저장됨 표시
|
||||
calls.length = 0;
|
||||
const ok = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
prepare: record("prepare"),
|
||||
steps: { pipes: record("pipes:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
done: async (confirm) => calls.push(`done:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.saveOk = ok;
|
||||
out.saveCalls = [...calls];
|
||||
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
|
||||
out.order = DESIGN_SAVE_ORDER;
|
||||
|
||||
// ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감
|
||||
calls.length = 0;
|
||||
failStructures = true;
|
||||
await saveDesign(
|
||||
{
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
steps: { uphill: record("uphill:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
},
|
||||
{ confirm: true },
|
||||
);
|
||||
failStructures = false;
|
||||
out.tolerant = [...calls];
|
||||
calls.length = 0;
|
||||
out.noRouteSave = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => null,
|
||||
cross: async () => calls.push("cross"),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.noRouteCalls = [...calls];
|
||||
|
||||
// ⑤ [초기화] — 초안 · 한 벌 모두 버림
|
||||
b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} });
|
||||
edit.clearAlignmentDrafts();
|
||||
out.cleared = {
|
||||
draft: useDesignStore(null, 7).get("alignmentDraft"),
|
||||
pending: edit.pendingAlignment(7),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
run().then(
|
||||
(out) => console.log(JSON.stringify(out)),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@@ -474,23 +474,25 @@ def _resave_unchanged(flow: Flow) -> None:
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 —
|
||||
# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a).
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
|
||||
"""B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
|
||||
"""B06 [저장] — 오르막 차례는 상단측만 확정."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
|
||||
@@ -63,14 +63,36 @@ def test_창구_넷은_초안이_비면_요청을_안_낸다():
|
||||
|
||||
|
||||
def test_저장이_창구를_다섯_다_거친다():
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다."""
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다.
|
||||
|
||||
차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) —
|
||||
B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다."""
|
||||
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"flushCulvertOptions()",
|
||||
"flushCulvertOptions(",
|
||||
"flushUphillOverrides(",
|
||||
"flushPendingPipes(",
|
||||
"flushPendingStructures(",
|
||||
):
|
||||
assert token in persist, f"{token} 가 저장 앞단에서 사라짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다.
|
||||
assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다.
|
||||
assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
|
||||
order = order[: order.index("]")]
|
||||
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), (
|
||||
"저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단"
|
||||
)
|
||||
|
||||
|
||||
def test_두_페이지가_저장_한_줄기를_탄다():
|
||||
"""B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a)."""
|
||||
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8")
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
for name, text in (("B05", actions), ("B06", persist)):
|
||||
assert "saveDesign(" in text, f"{name} 가 저장 한 줄기를 안 탐"
|
||||
for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("):
|
||||
assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림"
|
||||
assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서**
|
||||
`ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다.
|
||||
(계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의
|
||||
`prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +19,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PERSIST = ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts"
|
||||
PAGE = ROOT / "B06_Section" / "B06_Section_UI_Page.ts"
|
||||
SAVE = ROOT / "A00_Common" / "design" / "design_save.ts"
|
||||
|
||||
|
||||
def _body(source: str, marker: str) -> str:
|
||||
@@ -30,11 +33,17 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
|
||||
"export async function saveCurrentSections",
|
||||
"export async function confirmCurrentSections",
|
||||
):
|
||||
body = _body(text, marker)
|
||||
assert "ctx.reconcileDesigns()" in body, f"{marker} 가 재계산을 안 부른다"
|
||||
assert body.index("ctx.reconcileDesigns()") < body.index("collectSectionEdits(ctx)"), (
|
||||
f"{marker}: 재계산이 면적 수집보다 뒤에 있다 — 옛 면적이 실린다"
|
||||
assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], (
|
||||
f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다"
|
||||
)
|
||||
body = _body(text, "function sectionSaveContext")
|
||||
assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다"
|
||||
assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖"
|
||||
save = SAVE.read_text(encoding="utf-8")
|
||||
run = _body(save, "export async function saveDesign")
|
||||
assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), (
|
||||
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
|
||||
)
|
||||
|
||||
|
||||
def test_페이지가_재계산_통로를_이어_준다() -> None:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""설계 가게 한 곳 · 저장 한 줄기(계획서 24-2a) 회귀검증.
|
||||
|
||||
- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽.
|
||||
- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠).
|
||||
- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 ·
|
||||
계획고는 실패해도 다음 차례로.
|
||||
Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_design_store_save.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_가게는_등록_키_밑_같은_칸은_같은_값(out):
|
||||
assert out["pipesShared"] == [{"chainage_m": 10, "source": "user"}]
|
||||
assert out["pipesKey"] == ["aislo:draft:pipes:p1"]
|
||||
assert out["heard"] == [1], "끊은 뒤에는 안 불려야 함"
|
||||
assert out["otherProject"] is None
|
||||
assert out["noRoute"] is None
|
||||
assert out["selection"] == {"chainageM": 12.5, "structureId": "s-1"}
|
||||
|
||||
|
||||
def test_계획고_가게_한_벌(out):
|
||||
assert out["b05SeesB06"] == {"20.000": 0.5}
|
||||
assert out["b05Dirty"] is True
|
||||
assert out["afterBoth"] == {"20.000": 0.5, "60.000": -0.2}
|
||||
assert out["draft"] == out["afterBoth"] == out["pending"]
|
||||
assert out["rebuiltSeenByB06"] == {"20.000": 0.1}
|
||||
|
||||
|
||||
def test_저장_차례(out):
|
||||
assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert out["saveOk"] is True
|
||||
assert out["saveCalls"] == [
|
||||
"overlay:on",
|
||||
"prepare",
|
||||
"pipes",
|
||||
"pipes:screen",
|
||||
"structures",
|
||||
'alignment:7:{"20.000":0.1}',
|
||||
"culvert:aislo:draft:culvertopt:p1:7",
|
||||
"uphill",
|
||||
"cross:false",
|
||||
"done:false",
|
||||
"overlay:off",
|
||||
]
|
||||
assert out["afterSave"] == {"dirty": False, "draft": None}
|
||||
|
||||
|
||||
def test_구조물_실패는_알리고_넘어감_확정_노선없음(out):
|
||||
calls = out["tolerant"]
|
||||
assert "toast:error" in calls
|
||||
assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen")
|
||||
assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음"
|
||||
assert calls[-2:] == ["cross:true", "overlay:off"]
|
||||
assert "failed" not in calls
|
||||
assert out["noRouteSave"] is False and out["noRouteCalls"] == []
|
||||
|
||||
|
||||
def test_초기화는_초안과_한_벌을_버림(out):
|
||||
assert out["cleared"] == {"draft": None, "pending": None}
|
||||
Reference in New Issue
Block a user