diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index c835d8c85..251ffbc22 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -42,7 +42,12 @@ function note(text: string): HTMLElement { return p; } -export async function renderHydro(host: HTMLElement, projectId: string): Promise { +/** `onSave` = 페이지 이동 전 저장 등록(481-2) — 고친 측점 없으면 요청 없이 참. */ +export async function renderHydro( + host: HTMLElement, + projectId: string, + onSave?: (save: () => Promise) => void, +): Promise { host.replaceChildren(note("홍수량 계산서를 불러오는 중…")); let sheet: HydroPayload; let interval = 20; @@ -133,7 +138,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise }; /** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */ - const saveAll = async (): Promise => { + const saveAll = async (): Promise => { const stations = Object.fromEntries( sheet.stations.map((s) => [keyOf(s), unlinked(s, inputsOf(s))]), ); @@ -148,12 +153,15 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise clearState("b08-hydro", projectId); showToast("수리집수면적표를 저장했습니다.", "success"); open(openIndex); + return true; } catch (error) { showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); + return false; } finally { hideLoadingOverlay(); } }; open(0); + onSave?.(async () => !Object.keys(drafts).length || saveAll()); } diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index b7f7c984f..b1e1f50a5 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -22,7 +22,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { createDataTable } from "@ui/ui_template_data_table"; -import { clearState, writeState } from "../A00_Common/b_page_state"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, matched, @@ -117,11 +117,12 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { : ""; } -/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ +/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. `onSave` = 페이지 이동 전 저장 등록(481-2). */ export async function renderMaterialSheet( host: HTMLElement, projectId: string, sheet: MaterialSheetId, + onSave?: (save: () => Promise) => void, ): Promise { host.replaceChildren(note("자재 집계표를 불러오는 중…")); let loaded: Awaited>; @@ -199,7 +200,7 @@ export async function renderMaterialSheet( }); /** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */ - const saveAll = async (): Promise => { + const saveAll = async (): Promise => { showLoadingOverlay(); try { payload = await request(`/projects/${projectId}/quantity/materials`, { @@ -211,10 +212,13 @@ export async function renderMaterialSheet( clearState("b08-materials", projectId); redraw(); showToast("자재 집계표를 저장했습니다.", "success"); + return true; } catch (error) { showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); + return false; } finally { hideLoadingOverlay(); } }; + onSave?.(async () => !readState("b08-materials", projectId) || saveAll()); } diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index 9255a5cf5..2adb98b0e 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -100,8 +100,12 @@ function hint(text: string): HTMLElement { return p; } -/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. */ -export function createOptionsPanel(projectId: string | null, onSaved: () => void): HTMLElement { +/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. + * `save` = 페이지 이동 전 저장(481-2) — 옵션 · 이정 캐시 없으면 요청 없이 참 · 실패면 거짓. */ +export function createOptionsPanel( + projectId: string | null, + onSaved: () => void, +): { panel: HTMLElement; save: () => Promise } { const one = createInputField({ label: "배관 1본 길이 (m)", type: "number", @@ -192,12 +196,12 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void input.addEventListener("change", show); } - const saveAll = async (): Promise => { - if (!projectId) return; + const saveAll = async (): Promise => { + if (!projectId) return true; const draft = readDraft(projectId); if (draft.one_pipe_length_m === null) { showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error"); - return; + return false; } showLoadingOverlay(); try { @@ -215,8 +219,10 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void show(); showToast("수량 산출 옵션을 저장했습니다.", "success"); onSaved(); + return true; } catch (error) { showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); + return false; } finally { hideLoadingOverlay(); } @@ -256,5 +262,8 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void }, (error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"), ); - return panel; + const changed = (): boolean => + !!projectId && + (!!readState("b08-options", projectId) || !!readState("b08-milestone", projectId)); + return { panel, save: async () => !changed() || saveAll() }; } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 2a8ed9154..d0784ab1c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -7,6 +7,7 @@ * 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. * 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122). + * 단계 이동(☰ · 진행 단계 · [<] [>]) = 보이는 화면(좌측 옵션 + 지금 탭) 저장 성공 뒤 · 실패면 머묾(481-2). * ========================================================================== */ import "@ui/ui_template_general_layout.css"; @@ -47,6 +48,9 @@ const SHEETS = [ type SheetId = (typeof SHEETS)[number]["id"]; +/** 지금 탭의 이동 전 저장 — 자재 · 수리 탭이 서면 등록 · 떠난 탭(`host.isConnected` 거짓)은 안 씀(481-2). */ +type TabSave = (host: HTMLElement, save: () => Promise) => void; + function pendingNote(): HTMLElement { const pending = document.createElement("p"); pending.className = "ui-general-block__empty"; @@ -55,7 +59,7 @@ function pendingNote(): HTMLElement { } /** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */ -function renderMaterials(host: HTMLElement, projectId: string | null): void { +function renderMaterials(host: HTMLElement, projectId: string | null, onSave: TabSave): void { const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-structure__tabs"; tabs.setAttribute("role", "tablist"); @@ -78,17 +82,17 @@ function renderMaterials(host: HTMLElement, projectId: string | null): void { const body = document.createElement("div"); view.replaceChildren(body); if (!projectId) body.append(pendingNote()); - else void renderMaterialSheet(body, projectId, picked); + else void renderMaterialSheet(body, projectId, picked, (save) => onSave(body, save)); }; host.replaceChildren(tabs, view); select("material"); } /** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */ -function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void { +function renderTab(host: HTMLElement, tab: TabId, projectId: string | null, onSave: TabSave): void { const view = document.createElement("div"); host.replaceChildren(view); - if (tab === "material") return renderMaterials(view, projectId); + if (tab === "material") return renderMaterials(view, projectId, onSave); if (tab === "summary" && projectId) { // 보조 탭 줄 자리(170-4) — 보이지 않는 sm 단추 한 줄 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐 const blank = document.createElement("div"); @@ -101,7 +105,8 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi view.append(blank, body); return void renderStructureSummary(body, projectId); } - if (tab === "hydrology" && projectId) return void renderHydro(view, projectId); + if (tab === "hydrology" && projectId) + return void renderHydro(view, projectId, (save) => onSave(view, save)); if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId); if (tab === "milestone" && projectId) return renderMilestone(view, projectId); view.append(pendingNote()); @@ -118,6 +123,8 @@ export function renderB08Quantity(root: HTMLElement): void { panel.setAttribute("role", "tabpanel"); let current: TabId = "summary"; + let tabSave: { host: HTMLElement; save: () => Promise } | null = null; + const onTabSave: TabSave = (host, save) => (tabSave = { host, save }); const select = (tab: TabId): void => { current = tab; tabBar.replaceChildren( @@ -133,7 +140,7 @@ export function renderB08Quantity(root: HTMLElement): void { return button; }), ); - renderTab(panel, tab, projectId); + renderTab(panel, tab, projectId, onTabSave); }; const body = document.createElement("div"); @@ -141,16 +148,26 @@ export function renderB08Quantity(root: HTMLElement): void { body.append(tabBar, panel); select("summary"); + // [저장] 뒤 열린 탭을 서버 정본으로 다시 그림 + const options = createOptionsPanel(projectId, () => select(current)); + // 왼쪽 → 지금 탭 차례 · 탭 저장은 왼쪽 저장이 탭을 다시 그리기 전에 쥠 + const saveShown = async (): Promise => { + const tab = tabSave?.host.isConnected ? tabSave.save : null; + return (await options.save()) && (!tab || (await tab())); + }; + const layout = createWorkflowLayout({ title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, mainContent: body, - // [저장] 뒤 열린 탭을 서버 정본으로 다시 그림 - leftPanel: createOptionsPanel(projectId, () => select(current)), + leftPanel: options.panel, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + if (!projectId) return; + void saveShown().then( + (ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]), + ); }, }); root.append(layout.root); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index ad09ecbaa..6f348881f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -275,15 +275,15 @@ export function renderB09Estimation(root: HTMLElement): void { show(current); }; - // keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림) + // keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림) · 참 = 성공 const run = async ( path: string, method: string, done: string, init?: Row, keep = false, - ): Promise => { - if (!projectId) return; + ): Promise => { + if (!projectId) return false; showLoadingOverlay(); try { const next = await request(`/projects/${projectId}${path}`, { @@ -293,18 +293,18 @@ export function renderB09Estimation(root: HTMLElement): void { if (!keep) clearState("b09-estimation", projectId); apply(next, !keep); showToast(done, "success"); + return true; } catch (error) { showToast(`하지 못했습니다 — ${String(error)}`, "error"); + return false; } finally { hideLoadingOverlay(); } }; - const save = createButton({ - label: "저장", - variant: "filled", - onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }), - }); + const saveUser = (): Promise => + run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }); + const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveUser() }); save.dataset.b09Save = ""; const refresh = createButton({ label: "업데이트", @@ -352,8 +352,13 @@ export function renderB09Estimation(root: HTMLElement): void { mainContent: body, leftPanel: left, routes: WORKFLOW_STEP_ROUTES, + // 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이 onStepClick: (stepIndex: number) => { - if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + if (!projectId) return; + const changed = payload && readState("b09-estimation", projectId); + void (changed ? saveUser() : Promise.resolve(true)).then( + (ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]), + ); }, }); root.append(layout.root); diff --git a/resources/tester/helper_481_2_save_move.cjs b/resources/tester/helper_481_2_save_move.cjs new file mode 100644 index 000000000..2c30cf863 --- /dev/null +++ b/resources/tester/helper_481_2_save_move.cjs @@ -0,0 +1,251 @@ +/* 481-2 B08 · B09 단계 이동 = 저장 성공 뒤 — 진짜 `B08_Quantity_UI_Page.ts` · `B09_Estimation_UI_Shell.ts` 를 + * 트랜스파일해 가짜 DOM · 가짜 의존 위에서 돌리고 값을 JSON 으로 냄. + * 의존은 기록용 가짜 — 지정 안 한 import 는 무엇이든 받아 주는 빈 값(any). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const ROOT = path.join(__dirname, "..", ".."); + +// 무엇이든 받아 주는 빈 값 — 부르면 또 빈 값 · 펼치면 비어 있음 · 글자로는 "" +function any() { + const store = {}; + return new Proxy(function () {}, { + get(_t, k) { + if (k in store) return store[k]; + if (k === "then") return undefined; + if (k === Symbol.iterator) return function* () {}; + if (k === Symbol.toPrimitive) return () => ""; + return (store[k] = any()); + }, + set(_t, k, v) { + store[k] = v; + return true; + }, + apply: () => any(), + construct: () => any(), + }); +} + +// 가짜 요소 — isConnected = 뿌리까지 부모 줄이 이어짐 +class Node { + constructor(tag = "div") { + this.tagName = tag.toUpperCase(); + this.children = []; + this.parent = null; + this.dataset = {}; + this.style = {}; + this.classList = { add() {}, toggle() {}, remove() {}, contains: () => false }; + } + get isConnected() { + let n = this; + while (n.parent) n = n.parent; + return n.isRoot === true; + } + append(...kids) { + kids.forEach((k) => { + if (k instanceof Node) k.parent = this; + this.children.push(k); + }); + } + replaceChildren(...kids) { + this.children.forEach((k) => k instanceof Node && (k.parent = null)); + this.children = []; + this.append(...kids); + } + setAttribute() {} + querySelector() { + return null; + } + querySelectorAll() { + return []; + } + addEventListener() {} +} +global.document = { createElement: (tag) => new Node(tag) }; + +function load(rel, stubs) { + let src = fs + .readFileSync(path.join(ROOT, rel), "utf8") + .replace(/import\.meta\.glob\(/g, "(() => ({}))("); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const mod = { exports: {} }; + const req = (id) => (id in stubs ? stubs[id] : id.endsWith(".css") ? {} : any()); + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const flush = () => new Promise((r) => setTimeout(r, 0)); + +const calls = []; +let layoutOpts = null; +const store = new Map(); // 가짜 세션 캐시 — 이름 → 값 +const common = { + "@ui/ui_template_workflow_layout": { + createWorkflowLayout: (opts) => { + layoutOpts = opts; + const root = new Node(); + root.append(opts.leftPanel, opts.mainContent); + return { root }; + }, + }, + "@ui/ui_template_elements": { + createButton: (o) => Object.assign(new Node("button"), { onClick: o.onClick }), + showToast: (text, kind) => calls.push(`toast:${kind}`), + showLoadingOverlay() {}, + hideLoadingOverlay() {}, + showConfirmDialog: async () => false, + }, + "@ui/ui_template_locale": { ui_locales: any(), currentLanguageIndex: 0 }, + "@config/config_frontend": { CURRENT_PROJECT_ID_KEY: "pid", API_BASE_URL: "/api" }, + "../A00_Common/b_page_scaffold": { workflowSteps: () => [] }, + "../A00_Common/b_workflow_nav": { + WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"], + goToWorkflowStage: (_p, route) => calls.push(`go:${route}`), + }, + "../A00_Common/b_page_state": { + readState: (name) => store.get(name) ?? null, + writeState: (name, v) => store.set(name, v), + clearState: (name) => store.delete(name), + }, +}; +global.localStorage = { getItem: () => "P1" }; + +async function b08() { + let optionsOk = true; + let tabOk = true; + let registerMaterial = null; + let registerHydro = null; + const m = load("B08_Quantity/B08_Quantity_UI_Page.ts", { + ...common, + "./B08_Quantity_UI_Options": { + createOptionsPanel: () => ({ + panel: new Node(), + save: async () => (calls.push("save:options"), optionsOk), + }), + }, + "./B08_Quantity_UI_Material": { + renderMaterialSheet: (_h, _p, _s, onSave) => (registerMaterial = onSave), + }, + "./B08_Quantity_UI_Hydro": { renderHydro: (_h, _p, onSave) => (registerHydro = onSave) }, + }); + const root = Object.assign(new Node(), { isRoot: true }); + m.renderB08Quantity(root); + const body = layoutOpts.mainContent; + const tabBar = body.children[0]; + const clickTab = (id) => tabBar.children.find((b) => b.dataset.b08Tab === id).onClick(); + const step = async (i) => { + calls.length = 0; + layoutOpts.onStepClick(i); + await flush(); + await flush(); + return [...calls]; + }; + const tabSave = (tag) => async () => (calls.push(`save:${tag}`), tabOk); + const out = {}; + out.summaryOk = await step(6); + optionsOk = false; + out.optionsFail = await step(6); + optionsOk = true; + clickTab("material"); + registerMaterial(tabSave("material")); + out.materialOk = await step(4); + tabOk = false; + out.materialFail = await step(4); + optionsOk = false; + out.optionsFailSkipsTab = await step(4); + optionsOk = true; + tabOk = false; + clickTab("hydrology"); + registerHydro(tabSave("hydro")); + out.hydroFail = await step(6); + clickTab("earthwork"); // 수리 탭 떠남 — 떨어진 탭 저장은 안 부름 + out.leftTab = await step(6); + return out; +} + +async function b09() { + let fail = false; + global.fetch = async (url, init) => { + calls.push(`fetch:${init?.method ?? "GET"}:${url}`); + if (fail && init?.method === "PUT") return { ok: false, status: 500, json: async () => ({}) }; + return { ok: true, status: 200, json: async () => any(), text: async () => "{}" }; + }; + const m = load("B09_Estimation/B09_Estimation_UI_Shell.ts", common); + calls.length = 0; + m.renderB09Estimation(Object.assign(new Node(), { isRoot: true })); + const step = async (i) => { + calls.length = 0; + layoutOpts.onStepClick(i); + for (let k = 0; k < 5; k++) await flush(); + return calls.filter((c) => !c.startsWith("toast")); + }; + const out = {}; + out.beforeLoad = await (async () => { + store.set("b09-estimation", { x: 1 }); + return step(5); // 불러오기 전 — 저장 없이 이동 · 캐시는 남음 + })(); + for (let k = 0; k < 5; k++) await flush(); // GET 응답 반영 + store.delete("b09-estimation"); + out.clean = await step(5); + store.set("b09-estimation", { x: 1 }); + fail = true; + out.fail = await step(5); + fail = false; + out.ok = await step(5); + return out; +} + +// 진짜 좌측 옵션 `save` — 캐시 없으면 요청 없이 참 · 1본 빈칸 = 거짓 · 요청 실패 = 거짓 +async function options() { + let fail = false; + const m = load("B08_Quantity/B08_Quantity_UI_Options.ts", { + ...common, + "@ui/ui_template_elements": { + ...common["@ui/ui_template_elements"], + createInputField: () => ({ input: new Node("input"), root: new Node() }), + }, + "@ui/ui_template_collapsible": { attachCollapsible() {}, createPanelCard: () => new Node() }, + "./B08_Quantity_UI_Hydro": { + request: async (url, init) => { + calls.push(`req:${init?.method ?? "GET"}`); + if (fail && init?.method === "PUT") throw new Error("500"); + return { one_pipe_length_m: 6, rock_ratios_pct: { 연암: 90 }, material_surcharge_pct: {} }; + }, + }, + "./B08_Quantity_UI_Milestone_State": { + saveMilestone: async () => calls.push("req:milestone"), + }, + }); + const panel = m.createOptionsPanel("P1", () => calls.push("redraw")); + await flush(); + const run = async () => { + calls.length = 0; + const ok = await panel.save(); + return [ok, calls.filter((c) => !c.startsWith("toast"))]; + }; + const out = {}; + store.clear(); + out.clean = await run(); + store.set("b08-options", { soft_rock_pct: 80 }); + out.changed = await run(); + out.after = store.has("b08-options"); + store.set("b08-milestone", { x: 1 }); + out.milestoneOnly = await run(); + store.set("b08-options", { one_pipe_length_m: null }); + out.blankPipe = await run(); + store.set("b08-options", { soft_rock_pct: 70 }); + fail = true; + out.fail = await run(); + out.keptOnFail = store.has("b08-options"); + store.clear(); + return out; +} + +(async () => { + const r = { b08: await b08(), b09: await b09(), options: await options() }; + console.log(JSON.stringify(r)); +})().catch((e) => { + console.error(e && e.stack ? e.stack : e); + process.exit(1); +}); diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index e789ec9d1..ef0c22283 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -224,7 +224,7 @@ def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch): def test_화면_연결(): page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") - assert "renderMaterialSheet(body, projectId, picked)" in page + assert "renderMaterialSheet(body, projectId, picked, (save) => onSave(body, save))" in page ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts" ).read_text(encoding="utf-8") diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index e063a0454..826e9d6ca 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -138,7 +138,8 @@ def test_라우터_등록_응답_칸(): def test_좌측_패널_컨테이너_셋과_저장_줄(): page = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8") - assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page + assert "createOptionsPanel(projectId, () => select(current))" in page + assert "leftPanel: options.panel" in page # 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재) tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표 ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page) diff --git a/resources/tester/test_481_2_save_move.py b/resources/tester/test_481_2_save_move.py new file mode 100644 index 000000000..6834b36a6 --- /dev/null +++ b/resources/tester/test_481_2_save_move.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""481-2 B08 · B09 단계 이동(☰ · 진행 단계 · [<] [>]) = 보이는 화면 저장 성공 뒤 · 실패면 머묾. + +`helper_481_2_save_move.cjs` 가 진짜 `B08_Quantity_UI_Page.ts` · `B08_Quantity_UI_Options.ts` · +`B09_Estimation_UI_Shell.ts` 를 가짜 DOM · 가짜 의존 위에서 돌림. +""" + +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__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_481_2_save_move.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_B08_저장_성공_뒤_이동_왼쪽_다음_지금_탭(r): + b = r["b08"] + assert b["summaryOk"] == ["save:options", "go:b09"] + assert b["materialOk"] == ["save:options", "save:material", "go:b07"] + + +def test_B08_저장_실패면_머묾(r): + b = r["b08"] + assert b["optionsFail"] == ["save:options"] + assert b["materialFail"] == ["save:options", "save:material"] + assert b["optionsFailSkipsTab"] == ["save:options"] + assert b["hydroFail"] == ["save:options", "save:hydro"] + + +def test_B08_떠난_탭은_저장_안_부름(r): + assert r["b08"]["leftTab"] == ["save:options", "go:b09"] + + +def test_B08_좌측_옵션_save(r): + o = r["options"] + assert o["clean"] == [True, []] # 고침 없음 = 요청 없이 참 + assert o["changed"] == [True, ["req:PUT", "req:milestone", "redraw"]] + assert o["after"] is False + assert o["milestoneOnly"][0] is True and "req:PUT" in o["milestoneOnly"][1] + assert o["blankPipe"] == [False, []] # 1본 빈칸 = 머묾 + assert o["fail"] == [False, ["req:PUT"]] + assert o["keptOnFail"] is True + + +def test_B09_고친_값_저장_성공_뒤_이동(r): + b = r["b09"] + assert b["beforeLoad"] == ["go:b08"] # 불러오기 전 = 저장 없이(캐시 남음) + assert b["clean"] == ["go:b08"] # 고침 없음 = 요청 없이 + assert b["fail"] == ["fetch:PUT:/api/projects/P1/estimation"] # 실패 = 머묾 + assert b["ok"] == ["fetch:PUT:/api/projects/P1/estimation", "go:b08"] + + +def test_자재_수리_탭_저장_등록(): + material = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + hydro = (ROOT / "B08_Quantity/B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + assert re.search( + r'onSave\?\.\(async \(\) => !readState\("b08-materials", projectId\) \|\| saveAll\(\)\)', + material, + ) + assert re.search( + r"onSave\?\.\(async \(\) => !Object\.keys\(drafts\)\.length \|\| saveAll\(\)\)", hydro + ) + for src in (material, hydro): + assert "const saveAll = async (): Promise" in src