Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-19 10:52:12 +09:00
34 changed files with 1334 additions and 165 deletions
+7 -5
View File
@@ -31,6 +31,7 @@ globalThis.window = {
const REAL = new Set(
[
"A00_Common/design/design_save.ts",
"A00_Common/design/design_save_api.ts",
"A00_Common/design/design_page_route.ts",
"A00_Common/b_page_state.ts",
"B05_Profile/B05_Profile_Api_Fetch.ts",
@@ -86,6 +87,7 @@ globalThis.fetch = async (url, init) => {
if (url.includes("/surface/confirmed")) reply = { model_id: 7 };
else if (url.includes("/route/latest")) reply = LATEST;
else if (url.includes("/route/confirm")) reply = { failed_parts: [] };
else if (url.includes("/design/save")) reply = { status: "success", steps: { uphill: {} } };
return { ok: true, status: 200, json: async () => reply };
};
@@ -96,14 +98,14 @@ function seed(withLatest) {
state.writeState("uphill", { "40.000": "right" }, P);
}
async function run(steps) {
async function run(parts) {
lines = [];
const failed = [];
const ok = await save.saveDesign({
projectId: P,
routeId: () => 3,
steps,
cross: async () => undefined,
parts,
crossPart: async () => null,
failed: (error) => failed.push(String(error?.message ?? error)),
});
return { ok, failed, lines, uphillAfter: state.readState("uphill", P) };
@@ -125,10 +127,10 @@ async function run(steps) {
latest: () => LATEST,
confirmedSurface: () => ({ model_id: 7 }),
routeReady: () => true,
bridge: () => ({ saveStructuresIfDirty: async () => undefined }),
bridge: () => ({ structuresSavePart: async () => null }),
uphillOverrides: memory,
});
out.open = await run({ uphill: parts.steps.uphill });
out.open = await run({ uphill: parts.parts.uphill });
out.openMemoryAfter = memory.size;
// ③ 새로고침해 B06 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문
+39 -12
View File
@@ -41,6 +41,28 @@ const STUBS = {
fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()),
saveSections: async () => calls.push("cross:save"),
confirmSections: async () => calls.push("cross:confirm"),
sectionConfirmBody: () => ({}),
warnSectionSave: () => undefined,
},
};
// 472-6 한 본문 창구 — 서버가 차례대로 정본을 쓰는 동안 가게가 바뀜(바뀜 신호 = 저장 도중) → 횡단 저장
let duringSave = async () => undefined;
STUBS[path.join(ROOT, "A00_Common", "design", "design_save_api.ts")] = {
SAVE_PART_KEYS: [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
],
DesignSaveError: class extends Error {},
postDesignSave: async (_p, _r, confirm, parts) => {
await duringSave();
if (parts.cross?.body) calls.push(confirm ? "cross:confirm" : "cross:save");
return { waited: false, steps: {} };
},
};
@@ -74,7 +96,11 @@ STUBS["@util/common_util_station_key"] = loadTs(
);
// 상단측 차례 본문(463 · 지표 조건 · 확정 지표면 GET · 확정 POST) — 이 시험은 횡단 뒤처리만 봄
const B05_FETCH = path.join(ROOT, "B05_Profile", "B05_Profile_Api_Fetch.ts");
STUBS[B05_FETCH] = { ...loadTs(B05_FETCH), flushRouteParts: async () => undefined };
STUBS[B05_FETCH] = {
...loadTs(B05_FETCH),
flushRouteParts: async () => undefined,
routePartsBody: async () => ({}),
};
const { saveDesign, savingDesign } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
);
@@ -117,21 +143,22 @@ const { invalidateSectionDetail, loadSectionDetail } = loadTs(
const parts = crossSaveParts(ctx);
calls.length = 0;
const savingSeen = [];
// 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중)
duringSave = async () => {
server = "새 자리 4+8 · 47+0";
savingSeen.push(savingDesign(P));
await refresh();
};
const ok = await saveDesign({
projectId: P,
routeId: () => R,
steps: {
// 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중)
structures: async () => {
server = "새 자리 4+8 · 47+0";
savingSeen.push(savingDesign(P));
await refresh();
},
// 추가 측점(368-2) — 초안 없음 · 이 시험은 안 봄(진짜는 서버 GET)
plusStations: async () => undefined,
...parts.steps,
parts: {
// 구조물 · 추가 측점 — 초안 없음 · 이 시험은 안 봄(진짜는 서버 GET)
structures: () => null,
plusStations: () => null,
...parts.parts,
},
cross: (confirm) => parts.cross(confirm),
crossPart: (confirm) => parts.crossPart(confirm),
// 뒤처리 = 한 벌(`design_shared` saveSharedDesign done) 차례 그대로 — 공용 캐시를 한 번 비운 뒤 보기 몫(416 M1 ·
// 횡단 보기 done 은 비우지 않고 다시 그리기만)
done: async () => {
+48 -4
View File
@@ -71,12 +71,17 @@ STUBS["B04_PreProcess_Api_Fetch"] = {
};
STUBS["B05_Profile_Api_Structures"] = {
flushPendingStructures: async () => failing("structures"),
structuresPart: async () => null,
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459) 안 돎
};
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
STUBS["B05_Profile_Api_PlusStations"] = {
flushPlusStations: async () => undefined,
plusStationsPart: () => ({ body: null }),
};
STUBS["B05_Profile_Api_Fetch"] = {
flushUphillOverrides: async () => undefined,
flushRouteParts: async () => undefined, // 상단측 차례(463)
routePartsBody: async () => ({}),
saveProfileAlignment: async (_projectId, routeId, draft) => {
calls.push(["ALIGN", routeId, draft]);
return {};
@@ -87,7 +92,38 @@ STUBS["B05_Profile_UI_Profile_Edit"] = {
markAlignmentSaved: (routeId) => alignment.delete(routeId),
writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits),
};
STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined };
STUBS["B06_Section_Api_Culvert_Options"] = {
flushCulvertOptions: async () => undefined,
culvertOptionsPart: () => null,
};
// 472-6 한 본문 창구 — 관 칸 · 계획고 칸만 적음(옛 PUT 줄과 같은 꼴)
STUBS["design_save_api"] = {
SAVE_PART_KEYS: [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
],
DesignSaveError: class extends Error {},
postDesignSave: async (_projectId, routeId, confirm, parts) => {
const pipes = parts.pipes?.body;
calls.push([
"SAVE",
pipes === undefined || pipes === null ? "none" : (pipes.points ?? null),
confirm,
]);
const align = parts.alignment?.body;
if (align) {
const { route_id: _id, ...draft } = align;
calls.push(["ALIGN", routeId, draft]);
}
return { waited: false, steps: {} };
},
};
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
pendingRouteSave: async () => routeBody,
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
@@ -114,8 +150,16 @@ async function once({ route = false, draft = null, confirm = false, fail = null,
projectId: P,
routeId: () => routeId,
// 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음
steps: { pipes: async () => void calls.push(["RELOAD"]) },
cross: async () => void calls.push(["CROSS"]),
parts: {
pipes: (base) => ({
body: base?.body ?? null,
after: async (response) => {
await base?.after?.(response);
calls.push(["RELOAD"]);
},
}),
},
crossPart: async () => ({ body: {} }),
failed: (error) => (failure = String(error && error.message ? error.message : error)),
},
{ confirm },
+58 -5
View File
@@ -44,6 +44,33 @@ const routeStub = {
routeSaveSummary: (saved, outside) => (calls.push(`summary:${saved.route_id}:${outside}`), ""),
rebuildAfterRouteSave: async () => calls.push("rebuild"),
};
class DesignSaveError extends Error {
constructor(message, step, restored) {
super(message);
this.step = step;
this.restored = restored;
}
}
const SAVE_PART_KEYS = [
"settings",
"pipes",
"structures",
"plusStations",
"alignment",
"culvertOptions",
"uphill",
"cross",
];
const saveApiStub = {
SAVE_PART_KEYS,
DesignSaveError,
postDesignSave: async (_p, routeId, confirm, parts) => {
const sent = SAVE_PART_KEYS.filter((key) => parts[key] && parts[key].body != null);
calls.push(`post:${routeId}:${confirm}:${sent.join(",")}`);
if (failStructures) throw new DesignSaveError("structures 실패", "structures", true);
return { waited: false, steps: { alignment: { dropped_edit_chainages: [] } } };
},
};
const STUBS = {
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": routeStub,
"@ui/ui_template_elements": {
@@ -53,6 +80,7 @@ const STUBS = {
},
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
flushPendingPipes: () => record("pipes")(),
pipesPart: () => ({ body: { points: [] }, after: () => calls.push("pipes:after") }),
readPendingPipes: () => null, // 관 초안 없음 — 노선 저장 뒤 얹기(349-0u)는 안 돎
writePendingPipes: () => undefined,
},
@@ -65,24 +93,39 @@ const STUBS = {
},
"../../B05_Profile/B05_Profile_Api_Structures": {
flushPendingStructures: () => record("structures", failStructures)(),
structuresPart: async () => ({
body: { base_revision: 0, structures: [] },
after: () => calls.push("structures:after"),
}),
readPendingStructures: () => null, // 구조물 초안 없음 — 노선 저장 뒤 얹기(459)는 안 돎
writePendingStructures: () => undefined,
fetchStructures: async () => ({ structures: [] }),
},
"../../B05_Profile/B05_Profile_Api_PlusStations": {
flushPlusStations: () => record("plusStations")(),
plusStationsPart: () => ({ body: null, after: () => calls.push("plusStations:after") }),
},
"../../B05_Profile/B05_Profile_Api_Fetch": {
flushUphillOverrides: () => record("uphill")(),
flushRouteParts: () => record("uphill")(), // 상단측 차례 = 종단 보기 몫과 같은 본문(463)
routePartsBody: async () => ({ irregular_stations: [] }),
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}`),
culvertOptionsPart: (key) => ({
body: { "10.00": {} },
after: () => calls.push(`culvert:after:${key}`),
}),
},
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
"../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined },
"../../B05_Profile/B05_Profile_Api_PageSettings": {
flushPageSettings: async () => undefined,
pageSettingsPart: () => null,
},
// 472-6 한 본문 창구 — 보낸 칸만 적음 · 구조물 실패 = 서버가 그 차례에서 되돌림
"./design_save_api": saveApiStub,
};
const cache = new Map();
@@ -160,8 +203,19 @@ async function run() {
projectId: "p1",
routeId: () => 7,
prepare: record("prepare"),
steps: { pipes: record("pipes:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
parts: {
pipes: (base) => ({
body: base.body,
after: async (response) => {
await base.after(response);
calls.push("pipes:screen");
},
}),
},
crossPart: async (confirm) => ({
body: { confirm },
after: () => calls.push(`cross:after:${confirm}`),
}),
done: async (confirm) => calls.push(`done:${confirm}`),
failed: () => calls.push("failed"),
});
@@ -177,8 +231,7 @@ async function run() {
{
projectId: "p1",
routeId: () => 7,
steps: { uphill: record("uphill:screen") },
cross: async (confirm) => calls.push(`cross:${confirm}`),
crossPart: async (confirm) => ({ body: { confirm } }),
failed: (error) => (calls.push("failed"), (out.tolerantError = error.message)),
},
{ confirm: true },
@@ -30,8 +30,11 @@ def test_save_path_and_merge_kept():
# 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로
page = _read("A00_Common", "design", "design_view_profile.ts")
assert "persistUphillOverrides" in page
# 상단측 본문 = 종단 보기 몫 · 기본 창구 한 함수(463)
assert "flushRouteParts(projectId)" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
# 상단측 본문 = 기본 조각 한 벌(463 · 472-6 종단 보기 몫은 그 본문 그대로)
assert "body: base.body," in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
assert "body: await routePartsBody(projectId)," in _read(
"A00_Common", "design", "design_save.ts"
)
assert "uphill_overrides: uphillOverridesOf(projectId)," in _read(
"B05_Profile", "B05_Profile_Api_Fetch.ts"
)
+7 -4
View File
@@ -41,9 +41,10 @@ def got() -> dict:
def _confirm_body(lines: list[str]) -> dict:
posts = [line for line in lines if line.startswith("POST ") and "/route/confirm" in line]
"""상단측 차례 본문 — 472-6 부터 한 본문 창구(`/design/save`)의 `uphill` 칸."""
posts = [line for line in lines if line.startswith("POST ") and "/design/save" in line]
assert len(posts) == 1, lines
return json.loads(posts[0].split(" ", 2)[2])
return json.loads(posts[0].split(" ", 2)[2])["uphill"]
def test_종단_보기를_열든_안_열든_같은_요청(got):
@@ -79,6 +80,8 @@ def test_서버는_그_본문이면_비정규_측점_횡단을_맞춤(got):
def test_종단_보기_몫은_기본_창구_함수를_부름():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8")
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
assert "await flushRouteParts(projectId);" in actions
assert "saveRouteParts(" not in actions
# 472-6 — 종단 보기 몫은 기본 조각 본문 그대로(메모리만 더 비움) · 노선 바뀐 저장은 옛 창구
assert "uphill: (base) =>" in actions and "body: base.body," in actions
assert "saveRouteParts(" not in actions and "flushRouteParts" not in actions
assert "body: await routePartsBody(projectId)," in save
assert "return () => flushRouteParts(projectId);" in save
+1 -1
View File
@@ -243,7 +243,7 @@ def test_wiring_sources() -> None:
assert "forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id" in events[live:]
persist = _src("B06_Section", "B06_Section_UI_Page_Persist.ts")
assert "patchFor(at).structure_reset = true" in persist
assert 'clearState("kindreset", ctx.projectId)' in persist
assert 'clearState("kindreset", projectId)' in persist # 472-6 횡단 조각 저장 뒤
store = _src("B06_Section", "B06_Section_Section_Store.ts")
assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store
view = _src("A00_Common", "design", "design_view_cross.ts")
+4 -3
View File
@@ -28,13 +28,14 @@ def test_저장도_횡단_보기를_세움():
body = _save_body()
assert 'await shared.ensureView("cross");' in body
assert 'if (confirm) await shared.ensureView("cross")' not in body
assert "cross: crossBody," in body
# 472-6 — 횡단 본문 = 횡단 보기 조각(`crossPart`) · 한 본문 창구에 실림
assert " crossPart," in body
assert "profileSave?.cross" not in body
def test_못_세우면_실패_알림():
body = _save_body()
assert "if (!crossBody) {" in body
assert "if (!crossPart) {" in body
assert '"B06_Profile_Save_Failed"' in body and '"B06_Profile_Confirm_Failed"' in body
@@ -43,4 +44,4 @@ def test_종단_보기_몫엔_횡단_본문_없음():
start = actions.index("export function profileSaveParts(")
parts = actions[start:]
assert "saveCachedCrossPatches" not in actions
assert "\n cross: " not in parts
assert "\n cross: " not in parts and "crossPart" not in parts
+2 -2
View File
@@ -72,8 +72,8 @@ def test_확정_하위_실패면_던짐(out):
def test_종단_보기_상단측_차례도_같은_창구():
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text("utf-8")
# 기본 창구와 같은 함수(463) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄)
assert "await flushRouteParts(projectId);" in actions
# 기본 창구와 같은 본문(463 · 472-6 기본 조각) — 상단측은 세션에서 읽음(메모리 옛 값 안 보냄)
assert "body: base.body," in actions and "flushRouteParts" not in actions
fetch = (ROOT / "B05_Profile" / "B05_Profile_Api_Fetch.ts").read_text("utf-8")
assert "uphill_overrides: uphillOverridesOf(projectId)," in fetch
assert "confirmRoute(" not in actions
+3 -1
View File
@@ -243,7 +243,9 @@ def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김():
for needle in ("crossDesignChoices(", "readRockBoundarySession(", "readCutSlopeSession("):
assert needle in overlay, needle
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "cachedCrossPatches(ctx.projectId, routeId, edits.crossPatches)" in persist
assert (
"cachedCrossPatches(projectId, routeId, edits.crossPatches)" in persist
) # 472-6 횡단 조각
keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8")
carry = keys[keys.index("export function carryChainageStates") :]
assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry
+9 -8
View File
@@ -40,31 +40,32 @@ def out() -> dict:
return json.loads(proc.stdout)
def _puts(case: dict) -> list:
return [call for call in case["calls"] if call[0] == "PUT"]
def _saves(case: dict) -> list:
"""472-6 — 노선 안 바뀐 저장 = 한 본문 창구 한 번 · ["SAVE", 관 칸(points · "none"), 확정]."""
return [call for call in case["calls"] if call[0] == "SAVE"]
def test_관_PUT_은_초안_한_번(out):
case = out["draft"]
assert case["ok"] and _puts(case) == [
["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
assert case["ok"] and _saves(case) == [
["SAVE", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
]
assert case["calls"].index(["RELOAD"]) > case["calls"].index(
_puts(case)[0]
_saves(case)[0]
) # 화면은 뒤에 받기만
assert case["left"] is None
def test_초안_없으면_PUT_없음(out):
assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == []
assert out["noDraft"]["ok"] and _saves(out["noDraft"]) == [["SAVE", "none", False]]
def test_확정은_칸_없이_다시_셈(out):
assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]]
assert _saves(out["confirmNoDraft"]) == [["SAVE", None, True]]
def test_빈_초안은_다_지움(out):
assert _puts(out["emptyDraft"]) == [["PUT", [], False]]
assert _saves(out["emptyDraft"]) == [["SAVE", [], False]]
def test_노선_저장_뒤_실패_초안_새_노선(out):
+254
View File
@@ -0,0 +1,254 @@
"""PLAN 472-6 6-A — [저장] · [확정] 한 본문 창구 · 전부 아니면 없음.
① storage 떠 두기 · 되돌림 — 바뀐 · 새로 생긴 · 지운 파일만 되씀(안 바뀐 파일 그대로).
② 실제 창구(검증_sub8 · DB · storage) — 중간 차례 실패 → DB 7 표 행 · storage 해시 시작 그대로 ·
차례 이름 · 까닭 · restored. 같은 본문 정상 = 옛 차례(안쪽 창구 차례로 부름)와 행 · 파일 같음.
검증 프로젝트가 없는 자리(다른 PC · DB 없음)는 ② 건너뜀.
./venv/Scripts/python.exe -m pytest resources/tester/test_472_6_design_save.py -q
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import sys
from pathlib import Path
from uuid import UUID
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT))
from common_util import common_util_save_guard as guard # noqa: E402
PROJECT = "9872e9a7-e483-427b-a6b2-72cdbc063bc3" # 검증_sub8(OWNERS.md)
VOLATILE = ("created_at", "updated_at", "confirmed_at", "started_at", "completed_at")
def test_storage_되돌림은_바뀐_것만(tmp_path) -> None:
(tmp_path / "B06_Section" / "a").mkdir(parents=True)
keep = tmp_path / "B06_Section" / "a" / "keep.json"
edit = tmp_path / "B06_Section" / "edit.json"
gone = tmp_path / "workflow.json"
for path, text in ((keep, "k"), (edit, "e"), (gone, "w")):
path.write_text(text, encoding="utf-8")
outside = tmp_path / "B03_FileInput.txt"
outside.write_text("o", encoding="utf-8")
snap = guard.snapshot_storage(tmp_path, ("B06_Section", "workflow.json"))
kept_stamp = keep.stat().st_mtime_ns
edit.write_text("changed", encoding="utf-8")
gone.unlink()
(tmp_path / "B06_Section" / "new.json").write_text("n", encoding="utf-8")
outside.write_text("o2", encoding="utf-8") # 범위 밖은 안 건드림
assert snap.changed() == ["B06_Section/edit.json", "B06_Section/new.json", "workflow.json"]
assert snap.restore() == 3
assert edit.read_text(encoding="utf-8") == "e" and gone.read_text(encoding="utf-8") == "w"
assert not (tmp_path / "B06_Section" / "new.json").exists()
assert keep.stat().st_mtime_ns == kept_stamp and outside.read_text(encoding="utf-8") == "o2"
snap.discard()
assert not snap.copy_dir.exists()
# ── 실제 창구 ──────────────────────────────────────────────────────────────
def _db_ready() -> Path | None:
try:
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import close_db_pool, init_db_pool
async def find():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await get_project_storage_relative_path(connection, UUID(PROJECT))
finally:
await close_db_pool()
root = Path(resolve_stored_project_path(asyncio.run(find())))
return root if (root / "B06_Section" / "longitudinal").is_dir() else None
except Exception:
return None
ROOT_DIR = _db_ready()
live = pytest.mark.skipif(ROOT_DIR is None, reason="검증_sub8 · DB 없음")
def _state(root: Path) -> tuple[dict, dict]:
"""(행 — 시각 칸 뺌, 파일 내용 해시) — 저장 범위 전부."""
from config.config_db import close_db_pool, init_db_pool
async def rows():
pool = await init_db_pool()
try:
async with pool.acquire() as connection:
return await guard.snapshot_rows(connection, PROJECT)
finally:
await close_db_pool()
raw = asyncio.run(rows())
clean = {
table: [{k: str(v) for k, v in row.items() if k not in VOLATILE} for row in values]
for table, values in raw.items()
}
scope = guard.STORAGE_SCOPE + guard.PIPE_SCOPE
files = {
relative: hashlib.sha1(path.read_bytes()).hexdigest()
for relative, path in guard._files(root, scope).items()
if not relative.startswith("_derived/")
}
return clean, files
def _request(**extra):
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest
body = {
"route_id": 380,
"settings": {"design_speed_kph": 20, "terrain_type": "special", "max_grade_pct": 13.0},
"plus_stations": {"chainages": [500.0]},
"cross": {},
**extra,
}
return DesignSaveRequest.model_validate(body)
def _save(request, monkeypatch=None):
from B06_Section.B06_Section_Router_DesignSave import save_design
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
return await save_design(UUID(PROJECT), request, None)
finally:
await close_db_pool()
response = asyncio.run(run())
return response.status_code, json.loads(bytes(response.body).decode("utf-8"))
@live
def test_중간_차례_실패면_DB_storage_그대로(monkeypatch) -> None:
from B06_Section import B06_Section_Router_Stations as stations
before = _state(ROOT_DIR)
async def broken(project_id, request):
(ROOT_DIR / "B06_Section" / "half_written.json").write_text("{}", encoding="utf-8")
raise RuntimeError("일부러 실패")
monkeypatch.setattr(stations, "put_plus_stations", broken)
status, body = _save(_request())
assert status == 409 and body["step"] == "plusStations" and body["restored"] is True
assert "일부러 실패" in body["message"]
after = _state(ROOT_DIR)
assert after[0] == before[0] # 앞 차례(페이지 설정)가 쓴 단계 params 도 되돌림
assert after[1] == before[1]
@live
def test_정상_한_본문은_옛_차례와_같음() -> None:
from B05_Profile.B05_Profile_Router_Lifecycle import save_page_settings
from B06_Section.B06_Section_Router_Confirm import save_sections
from B06_Section.B06_Section_Router_Stations import put_plus_stations
from config.config_db import close_db_pool, get_db_pool, init_db_pool
start = _state(ROOT_DIR)
request = _request()
async def old_way():
await init_db_pool()
try:
async with get_db_pool().acquire() as connection:
rows = await guard.snapshot_rows(connection, PROJECT)
files = guard.snapshot_storage(ROOT_DIR, guard.STORAGE_SCOPE)
await save_page_settings(UUID(PROJECT), request.settings)
await put_plus_stations(UUID(PROJECT), request.plus_stations)
await save_sections(UUID(PROJECT), 380, request.cross)
return rows, files
finally:
await close_db_pool()
rows, files = asyncio.run(old_way())
old = _state(ROOT_DIR)
async def undo():
await init_db_pool()
try:
files.restore()
async with get_db_pool().acquire() as connection:
await guard.restore_rows(connection, PROJECT, rows)
finally:
await close_db_pool()
asyncio.run(undo())
assert _state(ROOT_DIR) == start
try:
status, body = _save(request)
assert status == 200 and body["status"] == "success", body
assert set(body["steps"]) == {"settings", "plusStations", "cross"}
new = _state(ROOT_DIR)
assert new[0] == old[0]
assert new[1] == old[1]
finally:
asyncio.run(undo())
files.discard()
assert _state(ROOT_DIR) == start
@live
def test_되돌림마저_실패면_복구_확인_필요(monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
async def broken(project_id, request):
raise RuntimeError("설정 실패")
def no_restore(self):
raise OSError("디스크 잠김")
monkeypatch.setattr(lifecycle, "save_page_settings", broken)
monkeypatch.setattr(guard.StorageSnapshot, "restore", no_restore)
status, body = _save(_request())
assert status == 500 and body["restored"] is False and body["step"] == "settings"
assert "복구 확인 필요" in body["message"]
@live
def test_같은_프로젝트_저장이_겹치면_뒤_요청이_기다림() -> None:
from B06_Section.B06_Section_Router_DesignSave import DesignSaveRequest, save_design
from config.config_db import close_db_pool, init_db_pool
async def run():
await init_db_pool()
try:
lock = guard.project_lock(PROJECT)
await lock.acquire()
task = asyncio.create_task(
save_design(UUID(PROJECT), DesignSaveRequest(route_id=380), None)
)
await asyncio.sleep(0.3)
assert not task.done() # 앞 저장(잠금)이 끝날 때까지 기다림
lock.release()
return await task
finally:
await close_db_pool()
response = asyncio.run(run())
body = json.loads(bytes(response.body).decode("utf-8"))
assert body["status"] == "success" and body["waited"] is True and body["steps"] == {}
@live
def test_시험_갈래_환경_변수면_그_차례_뒤_실패_되돌림(monkeypatch) -> None:
before = _state(ROOT_DIR)
monkeypatch.setenv("AISLO_DESIGN_SAVE_FAIL_AFTER", "settings")
status, body = _save(_request())
assert status == 409 and body["step"] == "settings" and body["restored"] is True
assert _state(ROOT_DIR) == before
+7 -3
View File
@@ -75,10 +75,14 @@ def test_저장이_창구를_다섯_다_거친다():
"flushPendingStructures(",
):
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 조정창 구간값이 저장에서 빠짐"
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨ · 뒤 차례가 옛 정본 위에 얹히지 않게).
# 472-6 — 노선 안 바뀐 저장은 한 본문 조각(조정창 구간값 = 세션 `culvertopt`) · 노선 바뀐
# 저장은 위 창구 차례 그대로
assert 'culvertOptionsPart(stateKey("culvertopt", projectId, routeId))' in save, (
"B06 조정창 구간값이 한 본문 저장에서 빠짐"
)
# 차례가 실패하면 그 차례에서 멈추고 이름째 알림(429 ⑨) · 한 본문은 서버가 차례 이름째 되돌림
assert "throw stepFailed(step, error)" in save, "실패한 차례에서 멈춤이 빠짐"
assert "STEP_LABEL[error.step]" in save, "한 본문 실패 차례 이름이 빠짐"
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
order = order[: order.index("]")]
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
+6 -3
View File
@@ -32,12 +32,15 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
text = PERSIST.read_text(encoding="utf-8")
body = _body(text, "export function crossSaveParts")
assert "prepare: () => ctx.reconcileDesigns()" in body, "횡단 보기 몫이 재계산을 안 넘긴다"
assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖"
assert "collectSectionEdits(ctx)" in body[body.index("crossPart:") :], (
"면적 수집이 횡단 조각 밖"
)
shared = _body(SHARED.read_text(encoding="utf-8"), "export async function saveSharedDesign")
assert "prepare: crossSave?.prepare" in shared, "한 벌이 재계산을 저장 한 줄기에 안 넘긴다"
save = SAVE.read_text(encoding="utf-8")
run = _body(save, "export async function saveDesign", 4000)
assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), (
# 472-6 — 노선 안 바뀐 저장 = 한 본문(`saveOneBody`) · 재계산 뒤에 횡단 조각을 모음
run = _body(save, "async function saveOneBody", 4000)
assert run.index("ctx.prepare?.()") < run.index("ctx.crossPart(confirm)"), (
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
)
+16 -10
View File
@@ -60,17 +60,18 @@ def test_저장_차례(out):
"cross",
] # 346-3 노선 먼저
assert out["saveOk"] is True
# 472-6 — 노선 초안 없으면 조각을 모아 한 요청(서버 한 창구 · 전부 아니면 없음) ·
# 저장 뒤 조각 정리는 차례대로 · 본문 없는 조각(추가 측점 초안 없음)도 정리는 돎
assert out["saveCalls"] == [
"overlay:on",
"prepare",
"pipes",
"post:7:false:pipes,structures,alignment,culvertOptions,uphill,cross",
"pipes:after",
"pipes:screen",
"structures",
"plusStations",
'alignment:7:{"20.000":0.1}',
"culvert:aislo:draft:culvertopt:p1:7",
"uphill",
"cross:false",
"structures:after",
"plusStations:after",
"culvert:after:aislo:draft:culvertopt:p1:7",
"cross:after:false",
"done:false",
"overlay:off",
]
@@ -78,10 +79,15 @@ def test_저장_차례(out):
def test_구조물_실패는_멈추고_알림_노선없음(out):
"""429 ⑨ — 차례가 실패하면 뒤 차례(상단측 · 횡단)를 안 보냄 · 차례 이름째 알림."""
assert out["tolerant"] == ["overlay:on", "pipes", "structures", "failed", "overlay:off"]
"""429 ⑨ · 472-6 — 서버가 구조물 차례에서 실패 · 되돌림 → 정리 · done 없음 · 차례 이름째."""
assert out["tolerant"] == [
"overlay:on",
"post:7:true:pipes,structures,culvertOptions,uphill,cross",
"failed",
"overlay:off",
]
assert out["tolerantOk"] is False
assert out["tolerantError"].startswith("구조물 차례에서 멈췄습니다")
assert out["tolerantError"].startswith("구조물 차례에서 실패 — 아무것도 저장하지 않았습니다")
assert "structures 실패" in out["tolerantError"] # 까닭 그대로
assert out["noRouteSave"] is False and out["noRouteCalls"] == []