feat(520-3): 페이지 넘김 공용 moveStage — 잠금 + 진행 원(n/N 단계 이름 · 지난 초) · B07 · B08 · B09 · 미리보기 [<] [>] · 단계 판 누름을 한 길로
- ui_template setLoadingOverlayProgress(ratio, label) — 잠금판 스피너 자리에 진행 원 · 참조가 모두 풀리면 함께 걷힘
- b_workflow_nav moveStage(projectId, 주소 또는 이동 함수, steps) — 단계 { 이름 · 실행 · 선택 서버 진행 묻기 } · 거짓 · 던짐 = 잠금 풀고 머묾(던짐은 까닭 토스트) · 넘기는 중 다시 누름 무시
- goToWorkflowStage 가 Promise 를 돌려줌 — 잠금이 이동 뒤에 풀림
- 낡은 시험 고침 — 298-2 · 481-2 가짜 의존에 moveStage · 290-1 · 280-5 글 약속을 새 길로 · 520-3 시험 새로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,12 @@ import {
|
||||
ROUTES,
|
||||
type RoutePath,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
setLoadingOverlayProgress,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
fetchConfirmedSurface,
|
||||
@@ -93,9 +99,9 @@ export async function dropGoneProject(): Promise<boolean> {
|
||||
*/
|
||||
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
|
||||
|
||||
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||
export function goToWorkflowStage(projectId: string, route: RoutePath): Promise<void> {
|
||||
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
|
||||
void enterProject(
|
||||
return enterProject(
|
||||
projectId,
|
||||
async (id) => (await fetchWorkflowState(id)).project_name || id,
|
||||
).then(() => {
|
||||
@@ -106,10 +112,84 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||
navigateTo(route);
|
||||
return;
|
||||
}
|
||||
void routeAfterPreloadCheck(projectId, route);
|
||||
return routeAfterPreloadCheck(projectId, route);
|
||||
});
|
||||
}
|
||||
|
||||
/** 페이지 넘김 한 단계(520-3) — 잠금판에 「n/N 이름 · 지난 초」. */
|
||||
export interface StageStep {
|
||||
name: string;
|
||||
/** false = 머묾(까닭은 단계가 알림) · 던지면 까닭 토스트 뒤 머묾 */
|
||||
run: () => Promise<boolean | void>;
|
||||
/** 서버 진행 묻기(선택 · 1 s 마다) — 비율 0~1 · 글 */
|
||||
progress?: () => Promise<{ ratio: number | null; label?: string } | null>;
|
||||
}
|
||||
|
||||
const PROGRESS_POLL_MS = 1000;
|
||||
let moving = false;
|
||||
|
||||
/** 페이지 넘김 공용(520-3) — 잠금 → 단계 차례 → 실패면 잠금 풀고 머묾 → 성공이면 이동.
|
||||
* target = 워크플로 단계 주소 또는 이동 함수(B09 → 인쇄 미리보기). 참 = 옮김. */
|
||||
export async function moveStage(
|
||||
projectId: string,
|
||||
target: RoutePath | (() => void),
|
||||
steps: readonly StageStep[],
|
||||
): Promise<boolean> {
|
||||
if (moving) return false;
|
||||
moving = true;
|
||||
const started = Date.now();
|
||||
let index = 0;
|
||||
let server: { ratio: number | null; label?: string } | null = null;
|
||||
const paint = (): void => {
|
||||
const step = steps[index];
|
||||
const seconds = Math.floor((Date.now() - started) / 1000);
|
||||
const head = step ? `${index + 1}/${steps.length} ${step.name}` : "페이지 여는 중";
|
||||
const tail = step && server?.label ? ` — ${server.label}` : "";
|
||||
const ratio = step ? (index + (server?.ratio ?? 0)) / steps.length : 1;
|
||||
setLoadingOverlayProgress(ratio, `${head}${tail} · ${seconds}초`);
|
||||
};
|
||||
showLoadingOverlay();
|
||||
let asking = false;
|
||||
const timer = window.setInterval(() => {
|
||||
const at = index;
|
||||
const ask = steps[at]?.progress;
|
||||
if (!ask) return paint();
|
||||
if (asking) return;
|
||||
asking = true;
|
||||
void ask()
|
||||
.then(
|
||||
(next) => {
|
||||
if (at === index) server = next;
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
.finally(() => {
|
||||
asking = false;
|
||||
paint();
|
||||
});
|
||||
}, PROGRESS_POLL_MS);
|
||||
try {
|
||||
for (; index < steps.length; index += 1) {
|
||||
server = null;
|
||||
paint();
|
||||
if ((await steps[index].run()) === false) return false;
|
||||
}
|
||||
paint();
|
||||
if (typeof target === "function") target();
|
||||
else await goToWorkflowStage(projectId, target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const name = steps[index]?.name ?? "페이지 이동";
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
showToast(`${name}에서 멈췄습니다 — ${reason}`, "error");
|
||||
return false;
|
||||
} finally {
|
||||
window.clearInterval(timer);
|
||||
hideLoadingOverlay();
|
||||
moving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 담아 둔 자료가 지금 확정본과 같은지 확인하고 화면을 정한다.
|
||||
*
|
||||
|
||||
@@ -31,8 +31,9 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
clearWorkflowStateCache,
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
moveStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
type StageStep,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
@@ -484,28 +485,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
placeTabs();
|
||||
|
||||
/** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
|
||||
let moving = false;
|
||||
const moveToStage = async (stepIndex: number): Promise<void> => {
|
||||
if (!projectId || moving) return;
|
||||
moving = true;
|
||||
try {
|
||||
if (!(await saveEditedDrawings(store))) return;
|
||||
if (stepIndex > 4 && drawings.length > 0) {
|
||||
try {
|
||||
await completeDesignStage(projectId);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
} finally {
|
||||
moving = false;
|
||||
}
|
||||
/** 페이지 이동(281 · 520-3) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
|
||||
const moveToStage = (stepIndex: number): void => {
|
||||
if (!projectId) return;
|
||||
const steps: StageStep[] = [{ name: "도면 저장", run: () => saveEditedDrawings(store) }];
|
||||
if (stepIndex > 4 && drawings.length > 0)
|
||||
steps.push({
|
||||
name: "상세 설계 단계 완료",
|
||||
run: async () => void (await completeDesignStage(projectId)),
|
||||
});
|
||||
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps);
|
||||
};
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
@@ -519,7 +508,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentStage: 5,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
// ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음)
|
||||
onStepClick: (stepIndex) => void moveToStage(stepIndex),
|
||||
onStepClick: (stepIndex) => moveToStage(stepIndex),
|
||||
});
|
||||
layout.root.classList.add("b07-design-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./B08_Quantity_UI_Page.css";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton } 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 { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav";
|
||||
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
|
||||
import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
|
||||
import { renderHydro, request } from "./B08_Quantity_UI_Hydro";
|
||||
@@ -166,17 +166,15 @@ export function renderB08Quantity(root: HTMLElement): void {
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (!projectId) return;
|
||||
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]);
|
||||
})();
|
||||
const steps: StageStep[] = [{ name: "수량 저장", run: saveShown }];
|
||||
// B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾
|
||||
if (stepIndex > 5)
|
||||
steps.push({
|
||||
name: "수량 산출 단계 완료",
|
||||
run: async () =>
|
||||
void (await request(`/projects/${projectId}/quantity/complete`, { method: "POST" })),
|
||||
});
|
||||
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { moveStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
||||
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
|
||||
@@ -357,9 +357,9 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
// 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (!projectId) return;
|
||||
void saveChanged().then(
|
||||
(ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]),
|
||||
);
|
||||
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], [
|
||||
{ name: "원가 저장", run: saveChanged },
|
||||
]);
|
||||
},
|
||||
});
|
||||
// [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로
|
||||
@@ -369,15 +369,14 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
left.addEventListener("ui-step-nav", (event) => {
|
||||
if ((event as CustomEvent<number>).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();
|
||||
})();
|
||||
void moveStage(projectId, goNext, [
|
||||
{ name: "원가 저장", run: saveChanged },
|
||||
{
|
||||
name: "원가 계산 단계 완료",
|
||||
run: async () =>
|
||||
void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })),
|
||||
},
|
||||
]);
|
||||
});
|
||||
root.append(layout.root);
|
||||
show(current);
|
||||
|
||||
@@ -113,7 +113,19 @@ const common = {
|
||||
"../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}`),
|
||||
// 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3)
|
||||
moveStage: async (_p, target, steps) => {
|
||||
for (const step of steps)
|
||||
try {
|
||||
if ((await step.run()) === false) return false;
|
||||
} catch {
|
||||
calls.push("toast:error");
|
||||
return false;
|
||||
}
|
||||
if (typeof target === "function") target();
|
||||
else calls.push(`go:${target}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"../A00_Common/b_page_state": {
|
||||
readState: (name) => store.get(name) ?? null,
|
||||
|
||||
@@ -101,7 +101,19 @@ const common = {
|
||||
"../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}`),
|
||||
// 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3)
|
||||
moveStage: async (_p, target, steps) => {
|
||||
for (const step of steps)
|
||||
try {
|
||||
if ((await step.run()) === false) return false;
|
||||
} catch {
|
||||
calls.push("toast:error");
|
||||
return false;
|
||||
}
|
||||
if (typeof target === "function") target();
|
||||
else calls.push(`go:${target}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"../A00_Common/b_page_state": {
|
||||
readState: (name) => store.get(name) ?? null,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 520-3 페이지 넘김 공용 `moveStage` — 진짜 `b_workflow_nav.ts` 를 트랜스파일해 가짜 잠금 · 토스트 · 이동 위에서 돌리고
|
||||
* 잠금 · 진행 글 · 머묾 · 이동을 JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
const calls = [];
|
||||
let lock = 0;
|
||||
const stubs = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api", CURRENT_PROJECT_ID_KEY: "pid", ROUTES: {} },
|
||||
"@ui/ui_template_elements": {
|
||||
showLoadingOverlay: () => (lock += 1),
|
||||
hideLoadingOverlay: () => (lock = Math.max(0, lock - 1)),
|
||||
setLoadingOverlayProgress: (ratio, label) => calls.push(`p:${ratio.toFixed(2)}:${label}`),
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}:${text}`),
|
||||
},
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {},
|
||||
"./b_asset_cache": {},
|
||||
"./b_project_switch": { enterProject: async () => calls.push("enter") },
|
||||
"./router": { navigateTo: (route) => calls.push(`go:${route}`) },
|
||||
};
|
||||
const js = ts.transpileModule(
|
||||
fs.readFileSync(path.join(ROOT, "A00_Common/b_workflow_nav.ts"), "utf8"),
|
||||
{
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
},
|
||||
).outputText;
|
||||
const mod = { exports: {} };
|
||||
global.window = { setInterval, clearInterval };
|
||||
global.localStorage = { setItem() {} };
|
||||
new Function("exports", "module", "require", js)(mod.exports, mod, (id) => stubs[id] ?? {});
|
||||
const { moveStage } = mod.exports;
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const run = async (target, steps) => {
|
||||
calls.length = 0;
|
||||
const ok = await moveStage("P1", target, steps);
|
||||
return { ok, lock, calls: calls.splice(0) };
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
let lockSeen = -1;
|
||||
out.ok = await run("b08", [
|
||||
{ name: "저장", run: async () => void (lockSeen = lock) },
|
||||
{ name: "완료", run: async () => true },
|
||||
]);
|
||||
out.ok.lockSeen = lockSeen;
|
||||
out.stop = await run("b08", [
|
||||
{ name: "저장", run: async () => false },
|
||||
{ name: "완료", run: async () => calls.push("완료 돎") },
|
||||
]);
|
||||
out.fail = await run("b08", [
|
||||
{
|
||||
name: "저장",
|
||||
run: async () => {
|
||||
throw new Error("Failed to fetch");
|
||||
},
|
||||
},
|
||||
]);
|
||||
let asked = 0;
|
||||
out.poll = await run(
|
||||
() => calls.push("go:fn"),
|
||||
[
|
||||
{
|
||||
name: "서버 저장",
|
||||
run: () => wait(1150),
|
||||
progress: async () => ((asked += 1), { ratio: 0.5, label: "관 차례" }),
|
||||
},
|
||||
],
|
||||
);
|
||||
out.poll.asked = asked;
|
||||
// 넘기는 중 또 누름 = 무시
|
||||
const first = moveStage("P1", "b07", [{ name: "느림", run: () => wait(50) }]);
|
||||
out.busy = await moveStage("P1", "b09", []);
|
||||
await first;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -142,12 +142,12 @@ def test_페이지_확정_단추_잠금_자동이동_없음():
|
||||
assert gone not in page, gone
|
||||
assert "confirmed: false, // 잠금 없음(요구 281)" in page
|
||||
assert "frameRow.append(frameEditor.button);" in page # 298-2 — [도각 편집] = 윗줄
|
||||
assert "onStepClick: (stepIndex) => void moveToStage(stepIndex)," in page
|
||||
assert "onStepClick: (stepIndex) => moveToStage(stepIndex)," in page
|
||||
move = page[
|
||||
page.index("const moveToStage = async") : page.index("const layout = createWorkflowLayout")
|
||||
page.index("const moveToStage = (") : page.index("const layout = createWorkflowLayout")
|
||||
]
|
||||
assert move.index("saveEditedDrawings(store)") < move.index("completeDesignStage(projectId)")
|
||||
assert move.index("completeDesignStage(projectId)") < move.index("goToWorkflowStage(")
|
||||
assert move.index("completeDesignStage(projectId)") < move.index("moveStage(projectId")
|
||||
assert "if (stepIndex > 4 && drawings.length > 0)" in move
|
||||
assert "currentStage: 5," in page
|
||||
assert len(page.splitlines()) <= 700
|
||||
|
||||
@@ -59,6 +59,6 @@ def test_빈_초안은_고침_아님_취향은_둠(out):
|
||||
|
||||
def test_공용_이동_한_곳에서_부름():
|
||||
nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8")
|
||||
assert "void enterProject(" in nav and "./b_project_switch" in nav
|
||||
assert "return enterProject(" in nav and "./b_project_switch" in nav
|
||||
ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8")
|
||||
assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""520-3 페이지 넘김 공용 `moveStage` — 잠금 → 「n/N 이름 · 지난 초」 → 실패면 잠금 풀고 머묾 + 까닭 → 성공이면 이동.
|
||||
|
||||
`helper_520_3_move_stage.cjs` 가 진짜 `A00_Common/b_workflow_nav.ts` 를 가짜 잠금 · 토스트 · 이동 위에서 돌림.
|
||||
"""
|
||||
|
||||
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__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_520_3_move_stage.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_성공_잠금_단계_글_이동_뒤_풀림(r):
|
||||
o = r["ok"]
|
||||
assert o["ok"] is True and o["lock"] == 0 and o["lockSeen"] == 1
|
||||
assert o["calls"][0] == "p:0.00:1/2 저장 · 0초"
|
||||
assert o["calls"][1] == "p:0.50:2/2 완료 · 0초"
|
||||
assert o["calls"][2] == "p:1.00:페이지 여는 중 · 0초"
|
||||
assert o["calls"][-2:] == ["enter", "go:b08"]
|
||||
|
||||
|
||||
def test_단계가_거짓이면_머묾_뒤_단계_안_돎(r):
|
||||
o = r["stop"]
|
||||
assert o["ok"] is False and o["lock"] == 0
|
||||
assert not any(c.startswith("go:") or c == "완료 돎" for c in o["calls"])
|
||||
|
||||
|
||||
def test_던지면_까닭_토스트_풀림_머묾(r):
|
||||
o = r["fail"]
|
||||
assert o["ok"] is False and o["lock"] == 0
|
||||
assert "toast:error:저장에서 멈췄습니다 — Failed to fetch" in o["calls"]
|
||||
assert not any(c.startswith("go:") for c in o["calls"])
|
||||
|
||||
|
||||
def test_서버_진행_묻기_글_비율_함수_이동(r):
|
||||
o = r["poll"]
|
||||
assert o["asked"] >= 1 and o["lock"] == 0
|
||||
assert any(c.startswith("p:0.50:1/1 서버 저장 — 관 차례 · 1초") for c in o["calls"])
|
||||
assert o["calls"][-1] == "go:fn"
|
||||
|
||||
|
||||
def test_넘기는_중_또_누름_무시(r):
|
||||
assert r["busy"] is False
|
||||
|
||||
|
||||
def test_페이지_셋이_공용_길():
|
||||
for rel in (
|
||||
"B07_DesignDetail/B07_DesignDetail_UI_Page.ts",
|
||||
"B08_Quantity/B08_Quantity_UI_Page.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Shell.ts",
|
||||
):
|
||||
page = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert "void moveStage(projectId" in page and "goToWorkflowStage(" not in page, rel
|
||||
@@ -10,6 +10,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress";
|
||||
|
||||
// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다.
|
||||
@@ -225,6 +226,7 @@ export function createTag(label: string, variant: TagVariant = "neutral"): HTMLS
|
||||
|
||||
let overlayEl: HTMLDivElement | null = null;
|
||||
let overlayCount = 0;
|
||||
let overlayProgress: ProgressCircleHandle | null = null;
|
||||
|
||||
function ensureOverlay(): HTMLDivElement {
|
||||
if (overlayEl) return overlayEl;
|
||||
@@ -248,10 +250,25 @@ export function showLoadingOverlay(): void {
|
||||
export function hideLoadingOverlay(): void {
|
||||
overlayCount = Math.max(0, overlayCount - 1);
|
||||
if (overlayCount === 0 && overlayEl) {
|
||||
overlayEl.classList.remove("is-active");
|
||||
overlayEl.classList.remove("is-active", "ui-overlay--progress");
|
||||
overlayProgress?.remove();
|
||||
overlayProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 띄운 잠금판에 진행 원 · 글(520-3) — 스피너 자리 · 모든 참조가 풀리면 함께 걷힘.
|
||||
* ratio null = 진행률 미상(회전만). 잠금판이 안 떠 있으면 아무것도 안 함. */
|
||||
export function setLoadingOverlayProgress(ratio: number | null, label?: string): void {
|
||||
if (overlayCount === 0) return;
|
||||
const overlay = ensureOverlay();
|
||||
if (!overlayProgress) {
|
||||
overlayProgress = createProgressCircle();
|
||||
overlay.append(overlayProgress.root);
|
||||
overlay.classList.add("ui-overlay--progress");
|
||||
}
|
||||
overlayProgress.set(ratio, label);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 6. 토스트 알림 (Toast) — 성공/에러 배너
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -204,6 +204,9 @@ const BASE_CSS = `
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
.ui-overlay.is-active { display: flex; }
|
||||
/* 진행 잠금판(520-3) — 스피너 대신 진행 원 · 단계 글 */
|
||||
.ui-overlay--progress > .ui-spinner { display: none; }
|
||||
.ui-overlay .ui-progress-circle__label { max-width: 40ch; }
|
||||
.ui-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
Reference in New Issue
Block a user