feat(481-2 B08 · B09 저장 후 이동): 단계 이동 = 보이는 화면 저장 성공 뒤 · 실패면 머묾

- B08 = 왼쪽 옵션(옵션 · 이정 캐시) → 지금 탭(자재 · 수리 · 떠난 탭 host.isConnected 거짓이면 안 부름) · 캐시 없으면 요청 없이
- 저장 함수(옵션 · 자재 · 수리 · B09 run) 성공 여부 돌려줌 · B09 = 불러오기 전 · 고침 없음이면 요청 없이 이동
- 시험 test_481_2_save_move 새로 · 121 · 122 원본 줄 기대 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:14:21 +09:00
co-authored by Claude Opus 5.5
parent 9a8ff3ee86
commit 197f5b589f
9 changed files with 407 additions and 31 deletions
+10 -2
View File
@@ -42,7 +42,12 @@ function note(text: string): HTMLElement {
return p;
}
export async function renderHydro(host: HTMLElement, projectId: string): Promise<void> {
/** `onSave` = 페이지 이동 전 저장 등록(481-2) — 고친 측점 없으면 요청 없이 참. */
export async function renderHydro(
host: HTMLElement,
projectId: string,
onSave?: (save: () => Promise<boolean>) => void,
): Promise<void> {
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<void> => {
const saveAll = async (): Promise<boolean> => {
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());
}
+7 -3
View File
@@ -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<boolean>) => void,
): Promise<void> {
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
let loaded: Awaited<ReturnType<typeof loadMaterials>>;
@@ -199,7 +200,7 @@ export async function renderMaterialSheet(
});
/** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */
const saveAll = async (): Promise<void> => {
const saveAll = async (): Promise<boolean> => {
showLoadingOverlay();
try {
payload = await request<Payload>(`/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());
}
+15 -6
View File
@@ -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<boolean> } {
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<void> => {
if (!projectId) return;
const saveAll = async (): Promise<boolean> => {
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() };
}
+26 -9
View File
@@ -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<boolean>) => 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<boolean> } | 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<boolean> => {
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);
+14 -9
View File
@@ -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<void> => {
if (!projectId) return;
): Promise<boolean> => {
if (!projectId) return false;
showLoadingOverlay();
try {
const next = await request<Row>(`/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<boolean> =>
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);
+251
View File
@@ -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);
});
@@ -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")
+2 -1
View File
@@ -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)
+81
View File
@@ -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<boolean>" in src