Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -55,6 +55,8 @@ const SEED = {
|
||||
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
|
||||
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
|
||||
"aislo.otp-pending": "x", // 프로젝트와 무관
|
||||
[`d02_points_${A}`]: '{"base":1}', // D02 옛 키(401 · 등록표 옛 키 머리로 잡음)
|
||||
[`e03_prob_cache:${C}`]: "{}", // E 옛 키 · 빈 값 = 고침 없음
|
||||
};
|
||||
const seed = (extra = {}) => {
|
||||
global.window.sessionStorage = fakeStorage();
|
||||
|
||||
@@ -58,6 +58,8 @@ const { loadTs } = makeLoader({
|
||||
});
|
||||
|
||||
const drag = loadTs(path.join(ROOT, "ui_template", "ui_template_overlay_drag.ts"));
|
||||
// 공용 떠남 가드(401) — 라우터 흉내를 이어 둠(같은 한 벌을 design_leave 가 부름)
|
||||
loadTs(path.join(ROOT, "A00_Common", "b_project_switch.ts")).linkRouter(STUBS["../router"]);
|
||||
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
|
||||
|
||||
let settles = 0;
|
||||
|
||||
@@ -20,6 +20,7 @@ global.IDBKeyRange = {
|
||||
upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }),
|
||||
lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }),
|
||||
bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }),
|
||||
only: (v) => ({ test: (k) => k === v }),
|
||||
};
|
||||
|
||||
function makeIdb(options = {}) {
|
||||
@@ -163,7 +164,11 @@ const fresh = (options) => {
|
||||
};
|
||||
const text = (buffer) => new TextDecoder().decode(buffer);
|
||||
|
||||
// 401 시험이 가짜 IndexedDB 만 빌려 씀 — 그때는 아래 차례를 안 돌림
|
||||
module.exports = { makeIdb };
|
||||
|
||||
(async () => {
|
||||
if (require.main !== module) return;
|
||||
const out = {};
|
||||
|
||||
// 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐)
|
||||
|
||||
@@ -71,6 +71,10 @@ const { loadTs } = makeLoader({
|
||||
},
|
||||
});
|
||||
|
||||
// 공용 떠남 가드(401) — 라우터 흉내를 이어 둔 한 벌을 design_leave 에 줌
|
||||
const projectSwitch = loadTs(path.join(ROOT, "A00_Common", "b_project_switch.ts"));
|
||||
projectSwitch.linkRouter(STUBS["../router"]);
|
||||
STUBS["../b_project_switch"] = projectSwitch;
|
||||
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
|
||||
const P = "p1";
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
@@ -57,9 +57,10 @@ const sw = load("A00_Common/b_project_switch.ts", (id) => {
|
||||
if (id.includes("config_frontend")) return config;
|
||||
return { showConfirmDialog: async () => true };
|
||||
});
|
||||
const store = load("A00_Common/design/design_store.ts", (id) =>
|
||||
id.includes("b_page_state") ? pageState : config,
|
||||
);
|
||||
const store = load("A00_Common/design/design_store.ts", (id) => {
|
||||
if (id.includes("b_project_switch")) return sw; // 지금 프로젝트 = 탭 칸 먼저(401)
|
||||
return id.includes("b_page_state") ? pageState : config;
|
||||
});
|
||||
|
||||
const P = "aaaa1111";
|
||||
const seed = () => {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/* 401 프로젝트마다 따로 동작 — 진짜 TS(b_page_state · b_project_switch · b_asset_cache)를 가짜 저장소 ·
|
||||
* 가짜 IndexedDB 위에서 돌려 JSON 으로 냄. 탭 둘 = 세션 둘 · localStorage 하나 · 모듈 한 벌씩. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const { makeIdb } = require("./helper_343_3_asset_cache.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function fakeStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
setItem: (k, v) => map.set(k, String(v)),
|
||||
removeItem: (k) => map.delete(k),
|
||||
dump: () => Object.fromEntries(map),
|
||||
};
|
||||
}
|
||||
|
||||
const shared = { localStorage: fakeStorage(), idb: makeIdb() };
|
||||
const router = { route: "/b07-design-detail", guard: null, sets: 0 };
|
||||
let choice = "save";
|
||||
const asked = [];
|
||||
|
||||
/** 탭 하나 = 창 하나 · 모듈 한 벌(모듈 메모리 따로). */
|
||||
function openTab() {
|
||||
const win = new EventTarget();
|
||||
win.sessionStorage = fakeStorage();
|
||||
win.localStorage = shared.localStorage;
|
||||
win.indexedDB = shared.idb.indexedDB;
|
||||
win.setTimeout = setTimeout;
|
||||
win.reloads = 0;
|
||||
win.location = { reload: () => (win.reloads += 1) };
|
||||
const cache = new Map();
|
||||
const load = (rel) => {
|
||||
if (cache.has(rel)) return cache.get(rel);
|
||||
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
cache.set(rel, exports);
|
||||
new Function("exports", "require", "window", js)(exports, need, win);
|
||||
return exports;
|
||||
};
|
||||
const need = (id) => {
|
||||
if (id.includes("config_frontend"))
|
||||
return { CURRENT_PROJECT_ID_KEY: "frd_current_project_id", API_BASE_URL: "http://x/api" };
|
||||
if (id.endsWith("b_page_state")) return load("A00_Common/b_page_state.ts");
|
||||
if (id.endsWith("b_asset_cache")) return load("A00_Common/b_asset_cache.ts");
|
||||
if (id.endsWith("ui_template_locale")) return { t: (key) => key };
|
||||
if (id.endsWith("ui_template_leave_dialog"))
|
||||
return {
|
||||
leaveOrStay: async (message, hooks) => {
|
||||
asked.push(message);
|
||||
if (choice === "cancel") return "stay";
|
||||
if (choice === "discard") return (await hooks.discard(), "discarded");
|
||||
try {
|
||||
await hooks.save();
|
||||
} catch {
|
||||
return "stay";
|
||||
}
|
||||
return hooks.stillDirty() ? "stay" : "saved";
|
||||
},
|
||||
};
|
||||
if (id.endsWith("ui_template_elements")) return { showConfirmDialog: async () => true };
|
||||
return {}; // B04 · B05 받기(보관함이 부르지 않음)
|
||||
};
|
||||
const sw = load("A00_Common/b_project_switch.ts");
|
||||
sw.linkRouter({
|
||||
currentRoute: () => router.route,
|
||||
setLeaveGuard: (fn) => ((router.guard = fn), (router.sets += 1)),
|
||||
});
|
||||
return { win, sw };
|
||||
}
|
||||
|
||||
const X = "11111111-aaaa-4aaa-8aaa-000000000001";
|
||||
const Y = "22222222-bbbb-4bbb-8bbb-000000000002";
|
||||
/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */
|
||||
class BeforeUnload extends Event {
|
||||
returnValue = true;
|
||||
}
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const keys = (win) => Object.keys(win.sessionStorage.dump()).sort();
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
|
||||
// 1. 탭 둘 — B 가 Y 로 바꿔도 A 는 X · 새 탭은 마지막 값(Y)
|
||||
shared.localStorage.setItem("frd_current_project_id", X);
|
||||
const A = openTab();
|
||||
out.newTabStartsWithLast = A.sw.currentProjectId();
|
||||
await A.sw.enterProject(X, async (id) => id);
|
||||
const B = openTab();
|
||||
await B.sw.enterProject(Y, async (id) => id);
|
||||
out.tabs = {
|
||||
a: A.sw.currentProjectId(),
|
||||
b: B.sw.currentProjectId(),
|
||||
fresh: openTab().sw.currentProjectId(),
|
||||
last: shared.localStorage.getItem("frd_current_project_id"),
|
||||
};
|
||||
|
||||
// 2. 다른 프로젝트 화면을 그리려 하면 새로 엶 — 같은 프로젝트는 그대로
|
||||
out.reload = { first: A.sw.reloadIfOtherProject(), same: A.sw.reloadIfOtherProject() };
|
||||
A.sw.setCurrentProject(Y);
|
||||
out.reload.other = A.sw.reloadIfOtherProject();
|
||||
out.reload.count = A.win.reloads;
|
||||
A.sw.setCurrentProject(X);
|
||||
|
||||
// 3. 떠남 — 그 프로젝트 칸 · 준비 표식 · 보관함 몫만 · 밖 칸 · 취향 · CAD 복구본 그대로
|
||||
const seedX = [
|
||||
`aislo:draft:structures:${X}`,
|
||||
`aislo:draft:crossw:${X}:12`,
|
||||
`aislo:result:section-detail:${X}:12`,
|
||||
`b06:culvertopt:${X}:12`,
|
||||
`aislo:draft:d03-plan:${X}`,
|
||||
`aislo:draft:d04-assignees:${X}`,
|
||||
`aislo:draft:e05-report:${X}`,
|
||||
`d02_points_${X}`,
|
||||
`e02_ground_cache:${X}`,
|
||||
`e03_prob_cache:${X}`,
|
||||
`e04_settings:${X}`,
|
||||
`e06_rock_cache:${X}`,
|
||||
`e07_settings:${X}`,
|
||||
];
|
||||
const keep = [
|
||||
`aislo:draft:structures:${Y}`,
|
||||
"a06:otp-pending",
|
||||
"aislo.adminCompany",
|
||||
"aislo.provenance.tint",
|
||||
"m01_master_draft",
|
||||
"m02-hopyo-check:3",
|
||||
"aislo:entered-project",
|
||||
];
|
||||
seedX.forEach((k) => A.win.sessionStorage.setItem(k, '{"v":1}'));
|
||||
keep.slice(0, -1).forEach((k) => A.win.sessionStorage.setItem(k, "1"));
|
||||
A.win.sessionStorage.setItem("frd_preloaded_signature", `${X}|sig`);
|
||||
const localKeep = [`aislo:pref:routeedit-view:${X}`, `OPEN_WEB_CAD__RECOVERY__b07:${X}:1`];
|
||||
localKeep.forEach((k) => shared.localStorage.setItem(k, "1"));
|
||||
for (const [pid, url] of [
|
||||
[X, "u1"],
|
||||
[X, "u2"],
|
||||
[Y, "u1"],
|
||||
]) {
|
||||
shared.idb.data.set(`${pid}|${url}`, { key: `${pid}|${url}`, projectId: pid, url });
|
||||
}
|
||||
await A.sw.leaveProject(X);
|
||||
out.leave = {
|
||||
keys: keys(A.win),
|
||||
expected: [...keep].sort(),
|
||||
entered: A.win.sessionStorage.getItem("aislo:entered-project"),
|
||||
local: localKeep.map((k) => shared.localStorage.getItem(k)),
|
||||
idb: [...shared.idb.data.keys()].sort(),
|
||||
};
|
||||
// 다른 탭(B) 세션은 안 건드림
|
||||
B.win.sessionStorage.setItem(`aislo:draft:structures:${X}`, "1");
|
||||
await A.sw.leaveProject(X);
|
||||
out.leave.otherTabKept = B.win.sessionStorage.getItem(`aislo:draft:structures:${X}`);
|
||||
|
||||
// 4. 떠남 물음 — 고친 가드만 모아 한 번 · 저장 실패 = 머묾 · 폐기 · 같은 페이지 · 빠진 페이지
|
||||
const root1 = { isConnected: true };
|
||||
const root2 = { isConnected: true };
|
||||
const state = { d1: false, d2: false, saves: [], discards: [], saveOk: true };
|
||||
A.sw.guardProjectLeave(root1, {
|
||||
dirty: () => state.d1,
|
||||
save: async () => (state.saves.push(1), state.saveOk && (state.d1 = false), state.saveOk),
|
||||
discard: () => state.discards.push(1),
|
||||
});
|
||||
A.sw.guardProjectLeave(root2, {
|
||||
dirty: () => state.d2,
|
||||
save: async () => (state.saves.push(2), (state.d2 = false), true),
|
||||
message: "둘째 글",
|
||||
});
|
||||
await tick();
|
||||
const ask = router.guard;
|
||||
const unloadBlocked = () => {
|
||||
const event = new BeforeUnload("beforeunload", { cancelable: true });
|
||||
A.win.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
out.guard = { armed: ask === A.sw.askProjectLeave, cleanLeave: await ask() };
|
||||
out.guard.cleanUnload = unloadBlocked();
|
||||
out.guard.asked0 = asked.length;
|
||||
state.d2 = true;
|
||||
out.guard.dirtyUnload = unloadBlocked();
|
||||
choice = "cancel";
|
||||
out.guard.cancel = await ask();
|
||||
choice = "save";
|
||||
out.guard.save = await ask();
|
||||
out.guard.messages = [...asked];
|
||||
state.d1 = true;
|
||||
state.saveOk = false;
|
||||
out.guard.saveFail = await ask();
|
||||
choice = "discard";
|
||||
out.guard.discard = await ask();
|
||||
out.guard.saves = state.saves;
|
||||
out.guard.discards = state.discards.length;
|
||||
router.route = "/b06-section";
|
||||
A.sw.guardProjectLeave(
|
||||
{ isConnected: true },
|
||||
{ dirty: () => true, save: async () => true, samePage: ["/b05-profile", "/b06-section"] },
|
||||
);
|
||||
await tick();
|
||||
const sets = router.sets;
|
||||
const askedBefore = asked.length;
|
||||
out.guard.samePage = await ask();
|
||||
await tick();
|
||||
out.guard.reArmed = router.sets === sets + 1;
|
||||
out.guard.samePageAsked = asked.length - askedBefore;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error && error.stack ? error.stack : error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -17,7 +17,10 @@ const stubs = {
|
||||
},
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {},
|
||||
"./b_asset_cache": {},
|
||||
"./b_project_switch": { enterProject: async () => calls.push("enter") },
|
||||
"./b_project_switch": {
|
||||
enterProject: async () => calls.push("enter"),
|
||||
setCurrentProject: () => undefined, // 탭 프로젝트(401)
|
||||
},
|
||||
"./router": { navigateTo: (route) => calls.push(`go:${route}`) },
|
||||
};
|
||||
const js = ts.transpileModule(
|
||||
|
||||
@@ -42,14 +42,19 @@ def test_지우고_열기_다른_프로젝트_모두_지움(out):
|
||||
|
||||
|
||||
def test_남기기_그_프로젝트_초안만_둠(out):
|
||||
kept = [f"aislo:draft:crossw:{A}:12", f"aislo:draft:structures:{A}", f"b06:culvertopt:{A}:12"]
|
||||
kept = [
|
||||
f"aislo:draft:crossw:{A}:12",
|
||||
f"aislo:draft:structures:{A}",
|
||||
f"b06:culvertopt:{A}:12",
|
||||
f"d02_points_{A}", # D02 옛 키도 초안(401)
|
||||
]
|
||||
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
|
||||
assert out["keep"]["asked"] == 1
|
||||
|
||||
|
||||
def test_같은_프로젝트_다시_들어오면_안_지움(out):
|
||||
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
|
||||
assert len(out["same"]["keys"]) == 11
|
||||
assert len(out["same"]["keys"]) == 13
|
||||
|
||||
|
||||
def test_빈_초안은_고침_아님_취향은_둠(out):
|
||||
|
||||
@@ -46,7 +46,6 @@ def test_옛_페이지_풀림_지금_페이지는_들음(out) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("rel", "types"),
|
||||
[
|
||||
("A00_Common/design/design_leave.ts", "beforeunload"),
|
||||
("ui_template/ui_template_overlay_drag.ts", "pointermove|pointerup|pointercancel|resize"),
|
||||
(
|
||||
"ui_template/ui_template_side_panels.ts",
|
||||
@@ -76,3 +75,10 @@ def test_코리도_캐시는_지금_노선_한_칸() -> None:
|
||||
in code
|
||||
)
|
||||
assert code.count("cache.set(") == 1
|
||||
|
||||
|
||||
def test_떠남_가드는_탭_닫기_듣개_한_번만() -> None:
|
||||
# 401 — 떠남 가드가 공용으로 옮김 · 듣개는 모듈에 한 번 · 페이지 노드는 가드 목록에서 빠지면 버림
|
||||
code = _read("A00_Common/b_project_switch.ts")
|
||||
assert "if (!unloadHooked) {" in code and code.count('addEventListener("beforeunload"') == 1
|
||||
assert "liveGuards(); // 빠진 옛 페이지를 붙들지 않음" in code
|
||||
|
||||
@@ -95,7 +95,9 @@ def test_초안_길은_선언으로_등록_정적_표엔_이름_없음():
|
||||
in router
|
||||
)
|
||||
assert "routeTable[route] ?? draft?.load" in router
|
||||
assert "draft?.workflow" in router and "draft?.project" in router
|
||||
assert (
|
||||
"draft?.workflow" in router and "draftRoute(route)?.project" in router
|
||||
) # 401 isProjectRoute
|
||||
assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts")
|
||||
route = _text(DRAFT / "B09_Draft_Print_Route.ts")
|
||||
assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""401 프로젝트마다 따로 동작(요구 400~403 · PLAN 572).
|
||||
|
||||
- 지금 프로젝트 = 탭마다(탭 세션 칸 먼저 · 새 탭 = 마지막 값) · 다른 프로젝트 화면을 그리려 하면 새로 엶
|
||||
- 떠남 = 그 프로젝트 세션 칸 · 준비 표식 · 3D 보관함 몫만 지움 · 밖 칸 · 취향 · CAD 복구본 · 다른 탭은 그대로
|
||||
- 떠남 물음 = 화면에 붙은 가드 {dirty, save} 를 모아 [취소 · 폐기 · 저장] 한 번
|
||||
`helper_401_project_isolation.cjs` 가 진짜 TS 를 가짜 저장소 · 가짜 IndexedDB 위에서 돌림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
X = "11111111-aaaa-4aaa-8aaa-000000000001"
|
||||
Y = "22222222-bbbb-4bbb-8bbb-000000000002"
|
||||
|
||||
#: 아직 직접 읽는 B · D · E 페이지(572-2 · 572-3 창 몫) — 고치면 여기서 뺌.
|
||||
PENDING = {
|
||||
"B03_FileInput/B03_FileInput_UI_Page.ts",
|
||||
"B03_FileInput/B03_FileInput_UI_Page_Flow.ts",
|
||||
"B03_FileInput/B03_FileInput_UI_PlanImport.ts",
|
||||
"B04_PreProcess/B04_PreProcess_UI_Page.ts",
|
||||
"B07_DesignDetail/B07_DesignDetail_UI_Page.ts",
|
||||
"B08_Quantity/B08_Quantity_UI_Page.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Haul_Modal.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Shell.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts",
|
||||
"B09_Estimation/draft/B09_Draft_Print_Page.ts",
|
||||
"B11_Status/B11_Status_UI_Loading.ts",
|
||||
"D02_PlanBase/D02_PlanBase_UI_Page.ts",
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts",
|
||||
"D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts",
|
||||
"E02_StabGround/E02_StabGround_UI_Page.ts",
|
||||
"E03_StabSection/E03_StabSection_UI_Page.ts",
|
||||
"E04_Stab3D/E04_Stab3D_UI_Page.ts",
|
||||
"E05_StabReport/E05_StabReport_UI_Page.ts",
|
||||
"E06_StabRock/E06_StabRock_UI_Page.ts",
|
||||
"E07_StabArea/E07_StabArea_UI_Page.ts",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_401_project_isolation.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["newTabStartsWithLast"] == X
|
||||
assert out["tabs"] == {"a": X, "b": Y, "fresh": Y, "last": Y}
|
||||
|
||||
|
||||
def test_다른_프로젝트_화면은_새로_엶(out):
|
||||
assert out["reload"] == {"first": False, "same": False, "other": True, "count": 1}
|
||||
|
||||
|
||||
def test_떠남은_그_프로젝트_몫만_지움(out):
|
||||
leave = out["leave"]
|
||||
assert leave["keys"] == leave["expected"] # B · D · E · 옛 키 · 준비 표식 0 · 밖 칸 그대로
|
||||
assert leave["entered"] == X # 탭 프로젝트 표식은 남김
|
||||
assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본
|
||||
assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움
|
||||
assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림
|
||||
|
||||
|
||||
def test_떠남_물음_한_번(out):
|
||||
g = out["guard"]
|
||||
assert g["armed"] and g["cleanLeave"] and not g["cleanUnload"] and g["asked0"] == 0
|
||||
assert g["dirtyUnload"] and g["cancel"] is False and g["save"] is True
|
||||
assert g["messages"] == ["둘째 글", "둘째 글"] # 고친 가드 하나 = 그 글
|
||||
assert g["saveFail"] is False and g["discard"] is True
|
||||
assert g["saves"] == [2, 1] and g["discards"] == 1 # 고친 가드만 저장
|
||||
assert g["samePage"] and g["reArmed"] and g["samePageAsked"] == 0
|
||||
|
||||
|
||||
def test_지금_프로젝트_직접_읽기는_한_곳():
|
||||
hits = set()
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files", "*.ts"], cwd=ROOT, capture_output=True, text=True, check=True
|
||||
).stdout.split() # storage · node_modules 는 훑지 않음
|
||||
for rel in tracked:
|
||||
text = (ROOT / rel).read_text(encoding="utf-8", errors="ignore")
|
||||
if re.search(r"localStorage\.getItem\(\s*CURRENT_PROJECT_ID_KEY", text):
|
||||
hits.add(rel)
|
||||
assert hits - PENDING == {"A00_Common/b_project_switch.ts"} - PENDING
|
||||
assert "A00_Common/b_project_switch.ts" in hits
|
||||
|
||||
|
||||
def test_공용이_도우미를_씀():
|
||||
router = (ROOT / "A00_Common/router.ts").read_text(encoding="utf-8")
|
||||
assert "export const isProjectRoute" in router and "reloadIfOtherProject()" in router
|
||||
group = (ROOT / "A00_Common/group_workflow.ts").read_text(encoding="utf-8")
|
||||
assert "guardProjectLeave(root, { dirty, save, discard, message })" in group
|
||||
leave = (ROOT / "A00_Common/design/design_leave.ts").read_text(encoding="utf-8")
|
||||
assert "guardProjectLeave(root, {" in leave
|
||||
state = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8")
|
||||
for name in ("d02-points", "e02-ground", "e03-prob", "e04-settings", "e05-report"):
|
||||
assert f'"{name}": {{ bucket: "draft", scope: "project"' in state
|
||||
assert '"e06-rock"' in state and '"e07-settings"' in state
|
||||
@@ -0,0 +1,228 @@
|
||||
"""573-1 복제 도구 D · E 갈래 — 임시 storage 사본에서(요구 404 · 405).
|
||||
|
||||
- D(예정노선) — plan_networks · plan_route_pieces · plan_route_years · plan_route_assignees ·
|
||||
plan_base_json · 지표면 · 지형층 행 수 같음 · 자동증가 id 새로 · 지형층 → 새 지표면 ·
|
||||
업체 연결 끊김 · 지형 models_plan · _derived 파일 그대로
|
||||
- E(안정성평가) — 원본 칸(source_project_id 등) 그대로 · 단계 행 여섯 상태 그대로 ·
|
||||
E05 보고서 · _derived 파일 수 같음
|
||||
- 둘 다 — 종류 판별 · 이름 그대로 · 파일 안 옛 UUID 없음 · 원본 행 · 파일 그대로 ·
|
||||
--dry-run 은 쓰지 않음 · 만든 복제본은 끝에 hard_delete_project(임시 사본 폴더째)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import aiomysql
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from helper_temp_storage import _LINKED, temp_project_storage # noqa: E402
|
||||
|
||||
from common_util import common_util_project_copy as cp # noqa: E402
|
||||
from common_util.common_util_storage import resolve_stored_project_path # noqa: E402
|
||||
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
|
||||
|
||||
#: 가이드 예정노선 · 가이드 안정성평가(읽기만)
|
||||
PLAN_ID = "87d47800-9936-4a56-9850-276fbcb34cac"
|
||||
STAB_ID = "088e173d-a8ab-4c51-badb-f5a95afc43bb"
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TABLES = (
|
||||
"surface_models",
|
||||
"project_members",
|
||||
"project_workflow_stages",
|
||||
*cp._PLAN_ALL,
|
||||
)
|
||||
|
||||
|
||||
async def _query(sql, args=()):
|
||||
connection = await aiomysql.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
db=DB_NAME,
|
||||
charset="utf8mb4",
|
||||
autocommit=True,
|
||||
)
|
||||
try:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(sql, args)
|
||||
return await cursor.fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _q(sql, args=()):
|
||||
return asyncio.run(_query(sql, args))
|
||||
|
||||
|
||||
def _project(project_id):
|
||||
rows = _q("SELECT * FROM projects WHERE id = %s", (project_id,))
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def _counts(project_id):
|
||||
return {
|
||||
table: _q(f"SELECT COUNT(*) AS n FROM {table} WHERE project_id = %s", (project_id,))[0]["n"]
|
||||
for table in TABLES
|
||||
}
|
||||
|
||||
|
||||
def _hashes(root: Path) -> dict[str, str]:
|
||||
return {
|
||||
path.relative_to(root).as_posix(): hashlib.sha1(path.read_bytes()).hexdigest()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file() and path.suffix == ".json"
|
||||
}
|
||||
|
||||
|
||||
def _files(root: Path) -> set[str]:
|
||||
"""정션 자리(시험 복사가 건너뜀) 빼고."""
|
||||
names = {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
|
||||
return {n for n in names if not n.startswith(tuple(f"{link}/" for link in _LINKED))}
|
||||
|
||||
|
||||
def _cli():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"tools_clone_project", ROOT / "db_management" / "tools_clone_project.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _copytree(source: Path, target: Path) -> None:
|
||||
"""robocopy 대신 — 시험 사본의 정션(B03 · 전처리 점군)은 따라가지 않음."""
|
||||
shutil.copytree(
|
||||
source,
|
||||
target,
|
||||
ignore=lambda folder, names: [n for n in names if os.path.isjunction(Path(folder, n))],
|
||||
)
|
||||
|
||||
|
||||
async def _hard_delete(project_id, actor):
|
||||
from common_util.common_util_project_delete import hard_delete_project
|
||||
from config.config_db import close_db_pool, init_db_pool
|
||||
|
||||
await init_db_pool()
|
||||
try:
|
||||
return await hard_delete_project(project_id, actor)
|
||||
finally:
|
||||
await close_db_pool()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _clone(source_id, name, monkeypatch):
|
||||
"""임시 storage 사본에서 복제 → (원본 사본 뿌리, 새 id) · 끝에 지움."""
|
||||
source = _project(source_id)
|
||||
if not source:
|
||||
pytest.skip("원본 프로젝트가 DB 에 없음")
|
||||
shared = Path(resolve_stored_project_path(source["storage_path"]))
|
||||
with temp_project_storage(shared) as root:
|
||||
monkeypatch.setattr(cp, "_copy_folder", _copytree)
|
||||
before_rows = _counts(source_id)
|
||||
before_files = _hashes(root)
|
||||
new_id = None
|
||||
try:
|
||||
# dry-run 은 쓰지 않음
|
||||
assert asyncio.run(_cli().main([source_id, "--dry-run"])) is None
|
||||
assert not (root.parent / name).exists()
|
||||
new_id = asyncio.run(_cli().main([source_id, name]))
|
||||
yield source, root, new_id
|
||||
# 원본 그대로
|
||||
assert _counts(source_id) == before_rows
|
||||
assert _hashes(root) == before_files
|
||||
finally:
|
||||
if new_id:
|
||||
assert asyncio.run(_hard_delete(new_id, source["user_id"]))
|
||||
assert _project(new_id) is None
|
||||
assert not (root.parent / new_id).exists()
|
||||
|
||||
|
||||
def _common(source, root, new_id):
|
||||
new = _project(new_id)
|
||||
assert new_id != source["id"] and new["name"].startswith("시험_573_1")
|
||||
assert new["project_kind"] == source["project_kind"]
|
||||
assert new["storage_path"].endswith(new_id)
|
||||
assert _counts(new_id) == _counts(source["id"])
|
||||
new_root = root.parent / new_id
|
||||
manifest = json.loads((new_root / cp.MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
assert manifest["kind"] == cp.KIND_LABELS[source["project_kind"]]
|
||||
assert manifest["skipped_tables"] == {}
|
||||
assert _files(new_root) - {cp.MANIFEST_NAME} == _files(root)
|
||||
for path in new_root.rglob("*.json"):
|
||||
if path.name != cp.MANIFEST_NAME:
|
||||
assert source["id"] not in path.read_text(encoding="utf-8"), path
|
||||
return new, new_root
|
||||
|
||||
|
||||
def test_clone_plan(monkeypatch):
|
||||
with _clone(PLAN_ID, "시험_573_1_D", monkeypatch) as (source, root, new_id):
|
||||
new, new_root = _common(source, root, new_id)
|
||||
assert json.loads(new["plan_base_json"]) == json.loads(source["plan_base_json"])
|
||||
assert (new_root / "B04_PreProcess" / "models_plan").is_dir()
|
||||
assert (new_root / "_derived").is_dir()
|
||||
|
||||
def rows(table, project_id, order):
|
||||
return _q(
|
||||
f"SELECT * FROM {table} WHERE project_id = %s ORDER BY {order}", (project_id,)
|
||||
)
|
||||
|
||||
old_net, new_net = (
|
||||
rows("plan_networks", PLAN_ID, "project_id"),
|
||||
rows("plan_networks", new_id, "project_id"),
|
||||
)
|
||||
assert json.loads(new_net[0]["network_json"]) == json.loads(old_net[0]["network_json"])
|
||||
for table, order in (("plan_route_pieces", "piece_id"), ("plan_route_years", "year")):
|
||||
old, now = rows(table, PLAN_ID, order), rows(table, new_id, order)
|
||||
drop = ("project_id", "updated_at")
|
||||
assert [{k: v for k, v in r.items() if k not in drop} for r in now] == [
|
||||
{k: v for k, v in r.items() if k not in drop} for r in old
|
||||
]
|
||||
old_a = rows("plan_route_assignees", PLAN_ID, "id")
|
||||
new_a = rows("plan_route_assignees", new_id, "id")
|
||||
assert not {r["id"] for r in old_a} & {r["id"] for r in new_a}
|
||||
assert [(r["piece_id"], r["year"], r["company_name"]) for r in new_a] == [
|
||||
(r["piece_id"], r["year"], r["company_name"]) for r in old_a
|
||||
]
|
||||
assert all(r["linked_project_id"] is None for r in new_a)
|
||||
|
||||
old_m = _q("SELECT id FROM surface_models WHERE project_id = %s", (PLAN_ID,))
|
||||
new_m = _q("SELECT id FROM surface_models WHERE project_id = %s", (new_id,))
|
||||
assert {r["id"] for r in new_m}.isdisjoint({r["id"] for r in old_m})
|
||||
layers = _q(
|
||||
"SELECT t.surface_model_id FROM terrain_layers t JOIN surface_models s"
|
||||
" ON s.id = t.surface_model_id WHERE s.project_id = %s",
|
||||
(new_id,),
|
||||
)
|
||||
assert len(layers) == 1
|
||||
|
||||
|
||||
def test_clone_stability(monkeypatch):
|
||||
with _clone(STAB_ID, "시험_573_1_E", monkeypatch) as (source, root, new_id):
|
||||
new, new_root = _common(source, root, new_id)
|
||||
for column in ("source_project_id", "source_copied_at", "source_fingerprint"):
|
||||
assert new[column] == source[column]
|
||||
|
||||
def stages(project_id):
|
||||
return _q(
|
||||
"SELECT stage_no, stage_key, state FROM project_workflow_stages"
|
||||
" WHERE project_id = %s ORDER BY stage_no",
|
||||
(project_id,),
|
||||
)
|
||||
|
||||
assert len(stages(new_id)) == 6 and stages(new_id) == stages(STAB_ID)
|
||||
assert (new_root / "E05_StabReport" / "report.json").is_file()
|
||||
inner = json.loads(
|
||||
(new_root / cp.STABILITY_SOURCE_DIR / cp.MANIFEST_NAME).read_text(encoding="utf-8")
|
||||
)
|
||||
assert inner["project_id"] == new_id
|
||||
assert inner["source_project_id"] == source["source_project_id"]
|
||||
Reference in New Issue
Block a user