From c3e23e3be9e65fcc26d127021fc52c4d525994f3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 20:28:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(497-2=20B08=20=C2=B7=20B09=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=A4=84):=20[=EC=B4=88=EA=B8=B0=ED=99=94]=20=3D?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20=EC=B0=BD=20=E2=86=92=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=E2=86=92=20=EC=84=9C=EB=B2=84=20reset=20=E2=86=92?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20[>]=20=3D?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=E2=86=92=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=E2=86=92=20=EB=8B=A4=EC=9D=8C=20=EA=B8=B8?= =?UTF-8?q?=20=C2=B7=20B09=20[=EC=A0=80=EC=9E=A5]=20=EC=A7=80=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B08 줄 [?][<][>][저장][초기화] · POST quantity/reset · 초안 넷 비움 · 이정 저장본 다시 읽음 - B08 B09 쪽 이동 = 저장 뒤 POST quantity/complete · 실패면 머묾 - B09 줄 [?][<][>][초기화] · [내역 초기화] → [초기화] 하나(POST estimation/reset) - B09 [>] = 고친 값 저장 → POST estimation/complete → DRAFT.next(인쇄 미리보기) · 초안 없으면 흐림 - 시험: 새 test_298_2_b08_b09_buttons · test_184_3 · test_304 · test_481_2 고쳐 씀 Co-Authored-By: Claude Opus 5.5 --- B08_Quantity/B08_Quantity_UI_Options.ts | 42 ++- B08_Quantity/B08_Quantity_UI_Page.ts | 19 +- B09_Estimation/B09_Estimation_UI_Dock.ts | 2 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 51 ++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 4 +- B09_Estimation/draft/B09_Draft_Print_UI.ts | 5 +- .../tester/helper_298_2_b08_b09_buttons.cjs | 294 ++++++++++++++++++ resources/tester/helper_481_2_save_move.cjs | 5 +- resources/tester/test_184_3_b09_bill_menu.py | 18 +- .../tester/test_298_2_b08_b09_buttons.py | 108 +++++++ resources/tester/test_304_b09_view_setting.py | 2 +- resources/tester/test_481_2_save_move.py | 4 +- 12 files changed, 514 insertions(+), 40 deletions(-) create mode 100644 resources/tester/helper_298_2_b08_b09_buttons.cjs create mode 100644 resources/tester/test_298_2_b08_b09_buttons.py diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index 2adb98b0e..aee840a18 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -12,12 +12,15 @@ * [저장] 한 번에 같이 PUT(`…_UI_Milestone_State`) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. + * [초기화](요구 298-2) = 확인 창 → POST …/quantity/reset(사용자 값 · 설정 지움 · 1본 · 이정 그림 남김) → + * 초안 넷 비움 → 열린 탭을 지금 B05 · B06 저장본 셈 값으로 다시 그림. * ========================================================================== */ import { createButton, createInputField, hideLoadingOverlay, + showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; @@ -27,7 +30,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; -import { saveMilestone } from "./B08_Quantity_UI_Milestone_State"; +import { loadMilestone, saveMilestone } from "./B08_Quantity_UI_Milestone_State"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -228,15 +231,50 @@ export function createOptionsPanel( } }; + // [초기화] — 연암 · 할증 · 재료 칸 · 수리 · 이정 지움 → 기준 = 지금 앞 단계 셈 값(떠 두지 않음) + const resetAll = async (): Promise => { + if (!projectId) return; + const message = + "수량 산출에서 고친 값과 설정(연암 · 할증 · 자재 표 칸 · 수리 · 거리이정표)을 모두 지우고 지금 노선 · 횡단 설계로 다시 셈합니다. 배관 1본 길이 · 올린 이정 그림은 남습니다."; + if (!(await showConfirmDialog(message, "초기화"))) return; + showLoadingOverlay(); + try { + const body = await request(`/projects/${projectId}/quantity/reset`, { + method: "POST", + }); + saved = { + one: body.one_pipe_length_m, + soft: body.rock_ratios_pct.연암, + surcharge: body.material_surcharge_pct, + }; + for (const name of ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"] as const) + clearState(name, projectId); + await loadMilestone(projectId, true); + show(); + showToast("수량 산출을 초기값으로 되돌렸습니다.", "success"); + onSaved(); + } catch (error) { + showToast(`초기화하지 못했습니다 — ${String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + }; + const save = createButton({ label: L("B08_Quantity_Btn_Save"), variant: "filled", onClick: () => void saveAll(), }); save.dataset.b08OptionsSave = ""; + const reset = createButton({ + label: L("Common_Btn_Reset"), + variant: "danger-ghost", + onClick: () => void resetAll(), + }); + reset.dataset.b08OptionsReset = ""; const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; - actions.append(save); + actions.append(save, reset); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel b08-options"; diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index d0784ab1c..fd8f4d8f1 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -8,19 +8,20 @@ * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. * 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122). * 단계 이동(☰ · 진행 단계 · [<] [>]) = 보이는 화면(좌측 옵션 + 지금 탭) 저장 성공 뒤 · 실패면 머묾(481-2). + * B09 쪽 이동 = 저장 뒤 POST …/quantity/complete(단계 5 완료 · 요구 298-2 · 300 B1) 성공 뒤. * ========================================================================== */ import "@ui/ui_template_general_layout.css"; import "./B08_Quantity_UI_Page.css"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderMaterialSheet } from "./B08_Quantity_UI_Material"; -import { renderHydro } from "./B08_Quantity_UI_Hydro"; +import { renderHydro, request } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; import { createOptionsPanel } from "./B08_Quantity_UI_Options"; import { renderMilestone } from "./B08_Quantity_UI_Milestone"; @@ -165,9 +166,17 @@ export function renderB08Quantity(root: HTMLElement): void { routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (!projectId) return; - void saveShown().then( - (ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]), - ); + void (async () => { + if (!(await saveShown())) return; + // B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾 + if (stepIndex > 5) + try { + await request(`/projects/${projectId}/quantity/complete`, { method: "POST" }); + } catch (error) { + return showToast(`단계를 마치지 못했습니다 — ${String(error)}`, "error"); + } + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + })(); }, }); root.append(layout.root); diff --git a/B09_Estimation/B09_Estimation_UI_Dock.ts b/B09_Estimation/B09_Estimation_UI_Dock.ts index c71514586..09a608005 100644 --- a/B09_Estimation/B09_Estimation_UI_Dock.ts +++ b/B09_Estimation/B09_Estimation_UI_Dock.ts @@ -4,7 +4,7 @@ * ① [박스보기|호표보기] 묶음 탭 셋 보기(304-1 · 한 값) * ② [하이퍼링크|팝업] 행 누름(303 · 기본 팝업) * ③ `DRAFT.actions` 단추(초안 · 비면 줄 없음) - * ④ [?][저장][내역 초기화](도움말 줄 `data-help-row`) + * ④ [?][<][>][초기화](도움말 줄 `data-help-row` · 요구 298-2 · 301) * * 스위치 값 = pref global(계정 공용 · [저장] 필요 없음) · 바닥 고정 = 공용 `.ui-sidebar-actions` 한 벌 * ========================================================================== */ diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 6f348881f..83559a3cd 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -4,7 +4,9 @@ * * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + - * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 저장(PUT → 서버 정본) = 단계 이동 · [>] 때(481-2 · [저장] 단추 없음 · 요구 301) + * [>] = 고친 값 저장 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾 + * [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ @@ -302,10 +304,11 @@ export function renderB09Estimation(root: HTMLElement): void { } }; - const saveUser = (): Promise => - run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }); - const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveUser() }); - save.dataset.b09Save = ""; + // 481-2 고친 값만 저장 · 불러오기 전 · 고침 없음 = 요청 없이 참 + const saveChanged = (): Promise => + payload && projectId && readState("b09-estimation", projectId) + ? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }) + : Promise.resolve(true); const refresh = createButton({ label: "업데이트", variant: "ghost", @@ -320,26 +323,24 @@ export function renderB09Estimation(root: HTMLElement): void { }); refresh.dataset.b09Refresh = ""; const check = createCheckPanel(refresh, (link) => show(link.tab, link.key)); - // 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역 + // 298-2 [초기화] — 내역 · 단가 · 공기 · 요율 등 사용자 값 · 설정 모두 지움 → B08 수량 셈 값(견적서 파일 남김) const reset = createButton({ - label: "내역 초기화", - variant: "ghost", + label: L("Common_Btn_Reset"), + variant: "danger-ghost", onClick: () => void showConfirmDialog( - "설계내역서에서 고친 칸 · 더한 줄 · 지운 줄 · 차례를 모두 지우고 기본 로직 내역으로 되돌립니다. 바로 저장됩니다.", + "원가 계산에서 고친 값과 설정(설계내역서 · 단가 · 공기 · 요율 등)을 모두 지우고 지금 수량 산출 값으로 다시 셈합니다. 올린 견적서 파일은 남습니다.", "초기화", ).then((ok) => { - if (!ok) return; - const { bill: _bill, ...rest } = user; - void run("/estimation", "PUT", "설계내역서를 초기값으로 되돌렸습니다.", { user: rest }); + if (ok) void run("/estimation/reset", "POST", "원가 계산을 초기값으로 되돌렸습니다."); }), }); - reset.dataset.b09BillReset = ""; + reset.dataset.b09Reset = ""; // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) const section = createPanelCard("데이터 점검", true); // 311 기본 접힘 section.append(refresh, check.list); - // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화] - const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); + // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][<][>][초기화] + const actions = createDock([reset], () => isGroupTab(current) && show(current)); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); @@ -355,12 +356,28 @@ export function renderB09Estimation(root: HTMLElement): void { // 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이 onStepClick: (stepIndex: number) => { if (!projectId) return; - const changed = payload && readState("b09-estimation", projectId); - void (changed ? saveUser() : Promise.resolve(true)).then( + void saveChanged().then( (ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]), ); }, }); + // [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로 + const next = left.querySelector('[data-step-nav="1"]'); + const goNext = DRAFT.next; + if (next && goNext) next.disabled = false; + left.addEventListener("ui-step-nav", (event) => { + if ((event as CustomEvent).detail !== 1 || !goNext || !projectId) return; + event.stopPropagation(); + void (async () => { + if (!(await saveChanged())) return; + try { + await request(`/projects/${projectId}/estimation/complete`, { method: "POST" }); + } catch (error) { + return showToast(`단계를 마치지 못했습니다 — ${String(error)}`, "error"); + } + goNext(); + })(); + }); root.append(layout.root); show(current); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index b17177b4b..e5f21b5ac 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -124,12 +124,14 @@ export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) · - * actions = 좌측 하단 고정 줄 단추(303-1 · [저장] 줄 위 한 줄 · 비면 줄 없음) */ + * actions = 좌측 하단 고정 줄 단추(303-1 · [?] 줄 위 한 줄 · 비면 줄 없음) · + * next = [>] 저장 · 단계 완료 뒤 갈 길(298-2 · 없으면 [>] 흐림) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; actions: Array<() => HTMLElement>; + next?: () => void; } = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts index b4c1fc357..e495bfaf0 100644 --- a/B09_Estimation/draft/B09_Draft_Print_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -1,7 +1,8 @@ /* ============================================================================= * B09_Draft_Print_UI.ts * 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 - * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → #/b09-print(`…_Print_Route`) + * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → #/b09-print(`…_Print_Route`) · + * [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2) * draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐 * ========================================================================== */ @@ -11,6 +12,8 @@ import { navigateTo } from "../../A00_Common/router"; import { DRAFT } from "../B09_Estimation_UI_Tabs"; import { ROUTE } from "./B09_Draft_Print_Route"; +DRAFT.next = () => navigateTo(ROUTE.path as RoutePath); + DRAFT.actions.push(() => { const button = createButton({ label: "인쇄 미리보기", diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs new file mode 100644 index 000000000..5a70069b4 --- /dev/null +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -0,0 +1,294 @@ +/* 298-2 B08 · B09 좌측 아래 단추 줄 — 진짜 `B08_Quantity_UI_Options.ts` · `B08_Quantity_UI_Page.ts` · + * `B09_Estimation_UI_Shell.ts` 를 트랜스파일해 가짜 DOM · 가짜 의존 위에서 돌리고 값을 JSON 으로 냄. + * [초기화] = 확인 창 → POST reset · [>] = 저장 → POST complete → 다음 길 · 실패면 머묾. */ +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(), + }); +} + +class Node { + constructor(tag = "div") { + this.tagName = tag.toUpperCase(); + this.children = []; + this.parent = null; + this.dataset = {}; + this.style = {}; + this.listeners = {}; + 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(name, fn) { + (this.listeners[name] ??= []).push(fn); + } +} +global.document = { createElement: (tag) => new Node(tag) }; + +function load(rel, stubs) { + const 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 = async () => { + for (let k = 0; k < 8; k++) await new Promise((r) => setTimeout(r, 0)); +}; + +const calls = []; +const buttons = []; // 만든 단추 — { variant, node } +let layoutOpts = null; +let confirmAnswer = false; +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) => { + const node = Object.assign(new Node("button"), { onClick: o.onClick, variant: o.variant }); + buttons.push(node); + return node; + }, + createInputField: () => ({ input: new Node("input"), root: new Node() }), + showToast: (_text, kind) => calls.push(`toast:${kind}`), + showLoadingOverlay: () => calls.push("lock"), + hideLoadingOverlay: () => calls.push("unlock"), + showConfirmDialog: async () => (calls.push("confirm"), confirmAnswer), + }, + "@ui/ui_template_collapsible": { attachCollapsible() {}, createPanelCard: () => new Node() }, + "@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" }; +const take = () => calls.splice(0).filter((c) => !c.startsWith("toast:success")); + +// B08 좌측 옵션 [저장] [초기화] +async function b08Options() { + let fail = false; + const m = load("B08_Quantity/B08_Quantity_UI_Options.ts", { + ...common, + "./B08_Quantity_UI_Hydro": { + request: async (url, init) => { + const method = init?.method ?? "GET"; + if (method !== "GET") calls.push(`req:${method}:${url}`); + if (fail && method === "POST") 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"), + loadMilestone: async (_p, force) => calls.push(`milestone:${force}`), + }, + }); + buttons.length = 0; + const panel = m.createOptionsPanel("P1", () => calls.push("redraw")); + await flush(); + const actions = panel.panel.children.find((c) => c.className === "ui-sidebar-actions"); + const out = { order: actions.children.map((b) => b.variant) }; + const reset = actions.children[1]; + const drafts = ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"]; + const fill = () => drafts.forEach((d) => store.set(d, { x: 1 })); + const press = async () => { + take(); + reset.onClick(); + await flush(); + return take(); + }; + fill(); + confirmAnswer = false; + out.cancel = [await press(), drafts.filter((d) => store.has(d))]; + confirmAnswer = true; + out.ok = [await press(), drafts.filter((d) => store.has(d))]; + fill(); + fail = true; + out.fail = [await press(), drafts.filter((d) => store.has(d))]; + store.clear(); + return out; +} + +// B08 진행 단계 · [>] — B09 쪽 = 저장 → POST complete → 이동 +async function b08Page() { + let completeFail = false; + const m = load("B08_Quantity/B08_Quantity_UI_Page.ts", { + ...common, + "./B08_Quantity_UI_Options": { + createOptionsPanel: () => ({ panel: new Node(), save: async () => true }), + }, + "./B08_Quantity_UI_Hydro": { + renderHydro() {}, + request: async (url, init) => { + calls.push(`req:${init?.method}:${url}`); + if (completeFail) throw new Error("500"); + }, + }, + }); + m.renderB08Quantity(Object.assign(new Node(), { isRoot: true })); + const step = async (i) => { + take(); + layoutOpts.onStepClick(i); + await flush(); + return take(); + }; + const out = { next: await step(6), back: await step(4) }; + completeFail = true; + out.fail = await step(6); + return out; +} + +// B09 [초기화] · [>](공용 틀 대신 셸) — draft = DRAFT.next 있음 / 없음 +async function b09(withNext) { + let failOn = null; // "PUT" | "complete" + global.fetch = async (url, init) => { + const method = init?.method ?? "GET"; + if (method !== "GET") calls.push(`fetch:${method}:${url}`); + const bad = failOn === method || (failOn === "complete" && url.endsWith("/complete")); + if (bad) return { ok: false, status: 500, json: async () => ({}) }; + return { ok: true, status: 200, json: async () => any() }; + }; + const tabs = any(); + tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] }; + if (withNext) tabs.DRAFT.next = () => calls.push("next"); + const nextBtn = Object.assign(new Node("button"), { disabled: true }); + const m = load("B09_Estimation/B09_Estimation_UI_Shell.ts", { + ...common, + "./B09_Estimation_UI_Tabs": tabs, + "./B09_Estimation_UI_Dock": { + linkMode: () => "popup", + createDock: (list) => { + const dock = new Node(); + dock.append(...list); + return dock; + }, + }, + "@ui/ui_template_workflow_layout": { + createWorkflowLayout: (opts) => { + layoutOpts = opts; + // 공용 [?] [<] [>] 자리 흉내 — B09 [>] = 흐림 + opts.leftPanel.querySelector = (sel) => (sel === '[data-step-nav="1"]' ? nextBtn : null); + const root = new Node(); + root.append(opts.leftPanel, opts.mainContent); + return { root }; + }, + }, + }); + buttons.length = 0; + m.renderB09Estimation(Object.assign(new Node(), { isRoot: true })); + await flush(); // GET 응답 반영 + const left = layoutOpts.leftPanel; + const dock = left.children[left.children.length - 1]; + const out = { dock: dock.children.map((b) => b.variant), nextEnabled: !nextBtn.disabled }; + const nav = async (detail) => { + take(); + let stopped = false; + const event = { detail, stopPropagation: () => (stopped = true) }; + (left.listeners["ui-step-nav"] ?? []).forEach((fn) => fn(event)); + await flush(); + return [stopped, take()]; + }; + if (!withNext) { + out.nav = await nav(1); + return out; + } + out.clean = await nav(1); + store.set("b09-estimation", { x: 1 }); + out.changed = await nav(1); + out.cleared = !store.has("b09-estimation"); + store.set("b09-estimation", { x: 1 }); + failOn = "PUT"; + out.saveFail = await nav(1); + failOn = "complete"; + store.delete("b09-estimation"); + out.completeFail = await nav(1); + failOn = null; + out.prev = await nav(-1); + // [초기화] + const reset = dock.children[0]; + const press = async () => { + take(); + reset.onClick(); + await flush(); + return take(); + }; + store.set("b09-estimation", { x: 1 }); + confirmAnswer = false; + out.resetCancel = [await press(), store.has("b09-estimation")]; + confirmAnswer = true; + out.resetOk = [await press(), store.has("b09-estimation")]; + store.clear(); + return out; +} + +(async () => { + const r = { + options: await b08Options(), + page: await b08Page(), + b09: await b09(true), + b09NoDraft: await b09(false), + }; + console.log(JSON.stringify(r)); +})().catch((e) => { + console.error(e && e.stack ? e.stack : e); + process.exit(1); +}); diff --git a/resources/tester/helper_481_2_save_move.cjs b/resources/tester/helper_481_2_save_move.cjs index 2c30cf863..c7ee6cf34 100644 --- a/resources/tester/helper_481_2_save_move.cjs +++ b/resources/tester/helper_481_2_save_move.cjs @@ -127,7 +127,10 @@ async function b08() { "./B08_Quantity_UI_Material": { renderMaterialSheet: (_h, _p, _s, onSave) => (registerMaterial = onSave), }, - "./B08_Quantity_UI_Hydro": { renderHydro: (_h, _p, onSave) => (registerHydro = onSave) }, + "./B08_Quantity_UI_Hydro": { + renderHydro: (_h, _p, onSave) => (registerHydro = onSave), + request: async (url) => calls.push(`req:${url.split("/").pop()}`), // 298-2 B09 쪽 = 단계 5 완료 + }, }); const root = Object.assign(new Node(), { isRoot: true }); m.renderB08Quantity(root); diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 8fbb62580..12591151d 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -1,5 +1,5 @@ """184-3 · 246-1 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 삭제 → gone · 옮김 · 새 공종(←) · -자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선. +자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [초기화] 배선(298-2). - 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄 - 저장 = `user.bill` v2 {v: 2, rows: 표 차례 {key · parent · 고친 칸}, gone} @@ -278,12 +278,12 @@ def test_표_칸_강조_번호_연결_칸(): assert out["renamed"] == "절토공" # 번호는 떼고 이름만 -def test_내역_초기화_배선(): +def test_초기화_배선(): src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - block = src[src.index('label: "내역 초기화"') :] - block = block[: block.index("b09BillReset")] - # 확인 창 → bill 뺀 사용자 값으로 바로 PUT(덮개 빔) · 좌측 단추 줄 - assert "showConfirmDialog(" in block and "if (!ok) return;" in block - assert "const { bill: _bill, ...rest } = user;" in block - assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block - assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 + block = src[src.index('label: L("Common_Btn_Reset")') :] + block = block[: block.index("b09Reset")] + # 298-2 [내역 초기화] → [초기화] 하나 — 확인 창 → POST …/estimation/reset(내역 + 그 페이지 값 · 설정) + assert 'variant: "danger-ghost"' in block and "showConfirmDialog(" in block + assert 'if (ok) void run("/estimation/reset", "POST"' in block + assert "내역 초기화" not in src and "b09BillReset" not in src + assert "createDock([reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 · [저장] 없음(요구 301) diff --git a/resources/tester/test_298_2_b08_b09_buttons.py b/resources/tester/test_298_2_b08_b09_buttons.py new file mode 100644 index 000000000..64f2735fc --- /dev/null +++ b/resources/tester/test_298_2_b08_b09_buttons.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""298-2 B08 · B09 좌측 아래 단추 줄 — [초기화] · [>] (PLAN 497-2 · 요구 300 C1 · C3 · C4 · C5 · 301). + +`helper_298_2_b08_b09_buttons.cjs` 가 진짜 `B08_Quantity_UI_Options.ts` · `B08_Quantity_UI_Page.ts` · +`B09_Estimation_UI_Shell.ts` 를 가짜 DOM · 가짜 의존 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = os.path.dirname(os.path.abspath(__file__)) +DRAFTS = ["b08-options", "b08-materials", "b08-hydro", "b08-milestone"] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_298_2_b08_b09_buttons.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): + assert r["options"]["order"] == ["filled", "danger-ghost"] # [?][<][>] 은 공용이 앞에 + + +def test_B08_초기화_취소면_그대로(r): + assert r["options"]["cancel"] == [["confirm"], DRAFTS] + + +def test_B08_초기화_확인_잠금_요청_초안_비움_다시_그림(r): + calls, left = r["options"]["ok"] + assert calls == [ + "confirm", + "lock", + "req:POST:/projects/P1/quantity/reset", + "milestone:true", # 이정 저장본 다시 읽음 + "redraw", + "unlock", + ] + assert left == [] + + +def test_B08_초기화_실패면_초안_남음(r): + calls, left = r["options"]["fail"] + assert "toast:error" in calls and "redraw" not in calls and left == DRAFTS + + +def test_B08_다음_단계_완료_뒤_이동_실패면_머묾(r): + p = r["page"] + assert p["next"] == ["req:POST:/projects/P1/quantity/complete", "go:b09"] + assert p["back"] == ["go:b07"] # 앞 단계 = 완료 없음 + assert p["fail"] == ["req:POST:/projects/P1/quantity/complete", "toast:error"] + + +def test_B09_단추_줄_초기화_하나_저장_없음(r): + assert r["b09"]["dock"] == ["danger-ghost"] + assert r["b09"]["nextEnabled"] is True + + +def test_B09_다음_저장_완료_인쇄(r): + b = r["b09"] + assert b["clean"] == [True, ["fetch:POST:/api/projects/P1/estimation/complete", "next"]] + assert b["changed"] == [ + True, + [ + "lock", + "fetch:PUT:/api/projects/P1/estimation", + "unlock", + "fetch:POST:/api/projects/P1/estimation/complete", + "next", + ], + ] + assert b["cleared"] is True + assert "next" not in b["saveFail"][1] and "toast:error" in b["saveFail"][1] + assert b["completeFail"][1] == [ + "fetch:POST:/api/projects/P1/estimation/complete", + "toast:error", + ] + assert b["prev"] == [False, []] # [<] = 공용 틀 몫 + + +def test_B09_초기화(r): + b = r["b09"] + assert b["resetCancel"] == [["confirm"], True] + assert b["resetOk"] == [ + ["confirm", "lock", "fetch:POST:/api/projects/P1/estimation/reset", "unlock"], + False, + ] + + +def test_B09_초안_없으면_다음_흐림(r): + assert r["b09NoDraft"] == {"dock": ["danger-ghost"], "nextEnabled": False, "nav": [False, []]} + + +def test_초안_다음_길_등록(): + src = (ROOT / "B09_Estimation/draft/B09_Draft_Print_UI.ts").read_text("utf-8") + assert "DRAFT.next = () => navigateTo(ROUTE.path as RoutePath);" in src diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py index 5f29ce13b..7379d6055 100644 --- a/resources/tester/test_304_b09_view_setting.py +++ b/resources/tester/test_304_b09_view_setting.py @@ -98,6 +98,6 @@ def test_잇기(): groups = (ROOT / GROUPS).read_text("utf-8") assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell + assert "createDock([reset], () => isGroupTab(current) && show(current))" in shell state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state diff --git a/resources/tester/test_481_2_save_move.py b/resources/tester/test_481_2_save_move.py index 6834b36a6..6ba5d26c1 100644 --- a/resources/tester/test_481_2_save_move.py +++ b/resources/tester/test_481_2_save_move.py @@ -32,7 +32,7 @@ def r(): def test_B08_저장_성공_뒤_이동_왼쪽_다음_지금_탭(r): b = r["b08"] - assert b["summaryOk"] == ["save:options", "go:b09"] + assert b["summaryOk"] == ["save:options", "req:complete", "go:b09"] # 298-2 단계 5 완료 assert b["materialOk"] == ["save:options", "save:material", "go:b07"] @@ -45,7 +45,7 @@ def test_B08_저장_실패면_머묾(r): def test_B08_떠난_탭은_저장_안_부름(r): - assert r["b08"]["leftTab"] == ["save:options", "go:b09"] + assert r["b08"]["leftTab"] == ["save:options", "req:complete", "go:b09"] def test_B08_좌측_옵션_save(r):