Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -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 로 바로 — 노선 캐시 없음 = 서버에서 한 번 읽고 같은 본문
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)"), (
|
||||
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user