feat(visionA-frontend): Phase 0.8 conversion UI — sidebar tab + 6 view + 5 e2e flow tests
Phase 0.8 對應後端轉檔功能的 frontend UI。完整 state machine(idle / uploading /
queued / running / succeeded / failed / expired)+ XHR upload progress + polling +
half-auto result handling(加到模型庫 / 下載)。
新增 src/app/conversion/(11 元件 + 12 test files,~5,000 行 prod+test,310/310 tests 全綠):
- page.tsx:state router(mount → bootstrap → 依 store.uiState 切換 view)+ toast on
store.error(aborted / active_job_exists 不 toast,避免雙重提示)
- IdleForm.tsx + FileDropzone.tsx + ChipSelector.tsx:上傳表單(拖放 + .onnx/.tflite
format 驗證 + 500MB 大小限制 + KL520/630/720/730 chip 選擇 + ref images ≤100×10MB)
- UploadingView.tsx:XHR upload progress 0-100% + EWMA ETA(alpha=0.3 平滑,<5s 顯示
「即將完成」避抖動)+ beforeunload warning + AlertDialog 取消
- ProcessingView.tsx:StageIndicator 三段式(解析模型 → 量化編譯 → 編譯 NEF)+
Progress bar 三模式(queued / determinate / indeterminate)+ tab title `(轉檔中)` 防疊加 +
409 active_job_exists banner(switchedFromActiveJob flag)
- SuccessView.tsx + PromoteDialog.tsx:兩按鈕(加到模型庫 / 下載)視覺平衡不暗示預設答案 +
7 天 expires_at 倒數(mount 時 setTimeout 鎖過期那刻 + 60s tick)+ PromoteDialog 單欄位
name 驗證(≤100 字元 / 無 /\\)+ spinner-during-close 阻擋 + 409 already_imported 特殊
訊息 + toast.success router.push 跳模型清單
- FailedView.tsx:5 個 known error code 翻譯(UNSUPPORTED_FORMAT / INVALID_CHECKSUM /
QUANTIZATION_FAILED / MODEL_TOO_LARGE / QUOTA_EXCEEDED)+ unknown fallback +
3 個建議解決方法 + Job ID 前 8 字元(供回報)+ 「重新開始」
- ExpiredView.tsx:橘色 hero(過期不 alarming)+ source/chip 摘要 + 「重新轉檔」→
store.reset()
新增 src/stores/conversion-store.ts(Zustand store + 29 tests):
- 7 個 UI state machine(不允許跳階段)
- recursive setTimeout polling(running 5s / queued 10s / 5xx 指數退避 cap 30s)+
visibilitychange 暫停/恢復
- 不持久化 jobId(純靠 backend getActiveConversion() lazy rebuild ownership)
- AbortController 防 stale request + 取消上傳
- switchedFromActiveJob flag(409 自動切到既存 job + UI 顯示 banner)
- formDraft(chip / taskName 提到 store,failed→idle 後保留設定,file picker 重選;
File 物件不能序列化只留 local)
新增 src/lib/api/conversion.ts + types/conversion.ts(5 client 函式 + 22 tests):
- initConversion:XHR multipart + onUploadProgress + AbortSignal
- getActiveConversion / getConversion / promoteConversionToModels:標準 fetch
- getConversionDownloadURL:純函式,回 `/api/conversion/{job_id}/download` 給 anchor
download 觸發(server-side 302 → FAA,token 不過 frontend JS)
- ConversionAPIError(status, code, message, requestId?),code 統一全小寫對齊
conversion.error.<code> i18n key 命名
新增 src/lib/utils/eta.ts(EWMA 演算法純函式 + 19 tests):抽出 smoothSpeed /
estimateRemainingSeconds / instantSpeedBytesPerSec / computeEtaUpdate(test 友善)。
新增 src/app/conversion/e2e-conversion-flow.test.tsx(5 e2e flow tests):
- happy path .onnx + KL720 + 0 ref images(idle → upload → polling → succeeded → 加到模型庫)
- variant 5 ref images
- upload fail → retry(form 設定保留)
- polling 5xx 指數退避 → 恢復繼續
- expired job → ExpiredView → 重新轉檔
修改 sidebar.tsx:左側 nav 新增「轉檔」tab(Wand2 icon,模型庫之後)。
修改 i18n 字典:新增 ~150 個 conversion.* keys(中英雙語對齊)。
PRD §9 14 條功能驗收條件全達成(4 條整合驗收等 stage 部署)。
驗證:pnpm lint / test (310/310) / build 全綠。
對齊文件:
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md
- .autoflow/04-architecture/api/api-conversion.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1231bf0ed2
commit
e02059eff2
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ChipSelector — KL520 / KL630 / KL720 / KL730 RadioGroup
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §4.1)
|
||||
*
|
||||
* 設計目標:
|
||||
* - 4 個 chip 並排,視覺像「選擇按鈕」(active 是實心、未選是 outline)
|
||||
* - 鍵盤 ←/→ 切換、Tab 進出、Enter / Space 選中(標準 radiogroup 行為,由原生 radio input 提供)
|
||||
*
|
||||
* a11y:
|
||||
* - 用原生 `<input type="radio">` 包 `<label>`,最便宜、最可靠
|
||||
* - 包成 fieldset 讓 SR 讀出 group label;fieldset 上的 aria-label 由呼叫端透過 aria-labelledby 指向 Label 元件
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import type { TargetChip } from "@/types/conversion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CHIPS: TargetChip[] = ["KL520", "KL630", "KL720", "KL730"];
|
||||
|
||||
interface ChipSelectorProps {
|
||||
value: TargetChip | null;
|
||||
onChange: (next: TargetChip) => void;
|
||||
/** 整組 disabled(uploading 時不該再改) */
|
||||
disabled?: boolean;
|
||||
/** SR 用的可見 Label id(例:<Label id="chip-label">…</Label>) */
|
||||
ariaLabelledBy?: string;
|
||||
/** 給錯誤訊息的 aria-describedby */
|
||||
errorId?: string;
|
||||
}
|
||||
|
||||
export function ChipSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabelledBy,
|
||||
errorId,
|
||||
}: ChipSelectorProps) {
|
||||
// 同一個 page 可能渲多個 ChipSelector(雖然此頁只有一組),用 useId 確保 name 唯一
|
||||
const groupName = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={errorId}
|
||||
className="flex flex-wrap gap-2"
|
||||
data-testid="chip-selector"
|
||||
>
|
||||
{CHIPS.map((chip) => {
|
||||
const checked = value === chip;
|
||||
const id = `${groupName}-${chip}`;
|
||||
return (
|
||||
<label
|
||||
key={chip}
|
||||
htmlFor={id}
|
||||
data-testid={`chip-${chip.toLowerCase()}`}
|
||||
data-checked={checked ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-input flex h-10 min-w-20 cursor-pointer items-center justify-center rounded-md border px-4 text-sm font-medium transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
checked &&
|
||||
"bg-primary text-primary-foreground border-primary hover:bg-primary/90 hover:text-primary-foreground",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="radio"
|
||||
name={groupName}
|
||||
value={chip}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={() => onChange(chip)}
|
||||
/>
|
||||
{chip}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* ExpiredView 單元測試(Phase 0.8 F-T9 sub-1)
|
||||
*
|
||||
* 對齊:
|
||||
* - wireframe-conversion.md §3.6 + §8.2(Expired state 視覺)
|
||||
* - flow-conversion.md §6.3(Job 7 天後過期 — 引導重做)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 過期 hero 顯示 + role="alert" + aria-live="polite"
|
||||
* - source filename → target chip 顯示
|
||||
* - 「重新轉檔」按鈕 → 呼叫 store.reset
|
||||
* - job=null 仍能渲(極端情境)
|
||||
*
|
||||
* Mock 策略:
|
||||
* - 真 LocaleProvider + zh-Hant 字典(驗證 i18n 串接)
|
||||
* - store 用 setState 設定 expired job
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
import { ExpiredView } from "./ExpiredView";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function makeExpiredJob(overrides: Partial<ConversionJob> = {}): ConversionJob {
|
||||
return {
|
||||
job_id: "abcdef12-3456-7890-aaaa-bbbbccccdddd",
|
||||
status: "succeeded", // backend 端 status;frontend 自己判斷 expires_at < now → expired
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: "2026-04-23T12:00:00Z",
|
||||
expires_at: "2026-04-30T12:00:00Z", // 過期(在當前時間之前)
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setExpired(job: ConversionJob | null = makeExpiredJob()) {
|
||||
useConversionStore.setState({
|
||||
uiState: "expired",
|
||||
job,
|
||||
});
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ExpiredView />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 主流程 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ExpiredView />", () => {
|
||||
it("顯示過期 hero(role=status + aria-live=polite + heading)", () => {
|
||||
// F-T9 M1 m1:用 role="status"(implicit polite)取代 role="alert"+aria-live="polite"
|
||||
// —— alert 隱含 assertive 與 polite 並存對 SR 是矛盾語意
|
||||
setExpired();
|
||||
renderView();
|
||||
|
||||
const hero = screen.getByTestId("expired-hero");
|
||||
expect(hero).toBeInTheDocument();
|
||||
expect(hero).toHaveAttribute("role", "status");
|
||||
expect(hero).toHaveAttribute("aria-live", "polite");
|
||||
|
||||
expect(screen.getByText("此轉檔結果已過期")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("expired-description")).toHaveTextContent(
|
||||
/轉檔結果保留期為 7 天/,
|
||||
);
|
||||
});
|
||||
|
||||
it("顯示 source filename → target chip 摘要", () => {
|
||||
setExpired();
|
||||
renderView();
|
||||
|
||||
expect(screen.getByTestId("expired-source-filename")).toHaveTextContent(
|
||||
"yolov5s.onnx",
|
||||
);
|
||||
expect(screen.getByTestId("expired-target-chip")).toHaveTextContent(
|
||||
"KL720",
|
||||
);
|
||||
});
|
||||
|
||||
it("顯示「重新轉檔」按鈕,aria-label 對齊 i18n", () => {
|
||||
setExpired();
|
||||
renderView();
|
||||
|
||||
const btn = screen.getByTestId("expired-start-new");
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toHaveTextContent("重新轉檔");
|
||||
expect(btn).toHaveAttribute("aria-label", "重新開始一次轉檔");
|
||||
});
|
||||
|
||||
it("點「重新轉檔」→ 呼叫 store.reset() 切回 idle", () => {
|
||||
setExpired();
|
||||
// spy 必須在 render 前 — 否則 useConversionStore selector 已 capture 原始 reset
|
||||
const resetSpy = vi.spyOn(useConversionStore.getState(), "reset");
|
||||
renderView();
|
||||
|
||||
fireEvent.click(screen.getByTestId("expired-start-new"));
|
||||
|
||||
expect(resetSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("job=null 仍能渲(不顯示摘要 card 但 hero / 按鈕仍在)", () => {
|
||||
setExpired(null);
|
||||
renderView();
|
||||
|
||||
// hero / 按鈕仍存在
|
||||
expect(screen.getByTestId("expired-hero")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("expired-start-new")).toBeInTheDocument();
|
||||
// 摘要 card 不顯示(沒有檔名 / chip 可秀)
|
||||
expect(screen.queryByTestId("expired-summary")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("data-testid='conversion-expired' 容器存在(給 e2e 用)", () => {
|
||||
setExpired();
|
||||
renderView();
|
||||
expect(screen.getByTestId("conversion-expired")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
139
visionA-frontend/src/app/conversion/components/ExpiredView.tsx
Normal file
139
visionA-frontend/src/app/conversion/components/ExpiredView.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ExpiredView — Expired state(轉檔結果已過期、引導重做)
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.6 + §8.2)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §6.3(Job 7 天後過期 — bootstrap / polling 自動切 expired;UI 引導重做)
|
||||
* - feature-converter-integration.md §F4(converter 7 天 GC,UI 顯式提醒)
|
||||
* - flow §11 (Q4) — 「重新轉檔」放在 hero 卡內,動線單一
|
||||
*
|
||||
* 範圍(F-T9 sub-1):
|
||||
* - hero 區塊:⚠️ 過期提示(紅色但不 alarming — 過期是預期行為,不是錯誤)
|
||||
* - 任務摘要:source filename → target chip(與 success / failed view 對齊)
|
||||
* - 解釋訊息(為什麼過期 + 怎麼處理)
|
||||
* - 「重新轉檔」主按鈕 → store.reset() 切 idle
|
||||
*
|
||||
* a11y:
|
||||
* - hero 用 `role="status"` + `aria-live="polite"`(過期不是緊急錯誤,用 polite 而非 assertive)
|
||||
* —— 過去用 `role="alert"` + `aria-live="polite"` 是 ARIA 反 pattern:alert 隱含 assertive,
|
||||
* 兩者並存某些 SR 會視為矛盾(F-T9 M1 修補時順手調整)
|
||||
* - 「重新轉檔」按鈕有明確 aria-label
|
||||
* - 顏色:橘紅而非 destructive,避免暗示「失敗」
|
||||
*
|
||||
* 不做:
|
||||
* - 「複製 Job ID」(過期 job 的 ID 對 ops 沒意義 — converter 已 GC)
|
||||
* - 倒數計時(已過期,不需要)
|
||||
*/
|
||||
|
||||
import { Clock4Icon, RefreshCwIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
export function ExpiredView() {
|
||||
const t = useT();
|
||||
const job = useConversionStore((s) => s.job);
|
||||
const reset = useConversionStore((s) => s.reset);
|
||||
|
||||
// bootstrap 命中 expired 時 store 一定會塞 job;保險起見 job=null 也能渲(極端情境:
|
||||
// GET /active 回 404 但前端誤切 expired — 不該發生)
|
||||
return <ExpiredViewInner job={job} reset={reset} t={t} />;
|
||||
}
|
||||
|
||||
interface ExpiredViewInnerProps {
|
||||
job: ConversionJob | null;
|
||||
reset: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function ExpiredViewInner({ job, reset, t }: ExpiredViewInnerProps) {
|
||||
const sourceFilename = job?.source_filename ?? "";
|
||||
const targetChip = job?.target_chip ?? "";
|
||||
|
||||
const handleStartNew = () => {
|
||||
// store.reset() 會清掉 polling / upload controller;切回 idle 後 page.tsx 自動換 IdleForm
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="conversion-expired" className="space-y-6">
|
||||
{/* 過期 hero — role="status" + aria-live="polite"(預期行為,不需 assertive 打斷)
|
||||
注意:原本用 role="alert" 會隱含 assertive,再寫 aria-live="polite" 會與 SR 行為矛盾;
|
||||
已改為 role="status"(implicit polite),與 ProcessingView banner 的寫法一致 */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("conversion.expired.aria.alert")}
|
||||
data-testid="expired-hero"
|
||||
className="border-amber-300 bg-amber-50/40 dark:border-amber-800 dark:bg-amber-950/20 flex items-start gap-3 rounded-xl border p-6"
|
||||
>
|
||||
<Clock4Icon
|
||||
aria-hidden="true"
|
||||
className="size-6 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("conversion.expired.heading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm" data-testid="expired-description">
|
||||
{t("conversion.expired.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任務摘要(若 job 還有資訊則顯示,與 success / failed 視覺對齊) */}
|
||||
{(sourceFilename || targetChip) && (
|
||||
<div
|
||||
data-testid="expired-summary"
|
||||
className="bg-card space-y-3 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
{sourceFilename ? (
|
||||
<span className="font-mono" data-testid="expired-source-filename">
|
||||
{sourceFilename}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceFilename && targetChip ? (
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
) : null}
|
||||
{targetChip ? (
|
||||
<span
|
||||
className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium"
|
||||
data-testid="expired-target-chip"
|
||||
>
|
||||
{targetChip}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("conversion.expired.subDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主動作:重新轉檔 — 單一 CTA,動線清楚 */}
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleStartNew}
|
||||
aria-label={t("conversion.expired.aria.startNew")}
|
||||
data-testid="expired-start-new"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" className="size-4" />
|
||||
{t("conversion.expired.startNew")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,314 @@
|
||||
/**
|
||||
* FailedView 單元測試(Phase 0.8 F-T8)
|
||||
*
|
||||
* 對齊:
|
||||
* - wireframe-conversion.md §3.5(Failed state 視覺)
|
||||
* - flow-conversion.md §5.6(failed 處理 + i18n fallback 虛擬碼)
|
||||
* - feature-converter-integration.md §F5(錯誤碼對照表)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 已知錯誤碼 → 顯示對應翻譯訊息 + 對應 suggestions
|
||||
* - 未知錯誤碼 → 顯示 unknown fallback 訊息 + unknown suggestions
|
||||
* - job_id 顯示前 8 字(完整 ID 留 title 屬性)
|
||||
* - 「重新開始」按鈕 → 呼叫 store.reset
|
||||
* - a11y:role="alert" 在失敗 hero、error code 用 <code> 標記
|
||||
* - getErrorMessage / getErrorSuggestions helper:邊界情況(空字串、純未知 code)
|
||||
*
|
||||
* Mock 策略:
|
||||
* - 不 mock t();用真 LocaleProvider + zh-Hant 字典確認 i18n 串接到位
|
||||
* - store 用 setState 設定 failed job
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
import {
|
||||
FailedView,
|
||||
getErrorMessage,
|
||||
getErrorSuggestions,
|
||||
} from "./FailedView";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function makeFailedJob(
|
||||
overrides: Partial<ConversionJob> = {},
|
||||
): ConversionJob {
|
||||
return {
|
||||
job_id: "abcdef12-3456-7890-aaaa-bbbbccccdddd",
|
||||
status: "failed",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
error: { code: "QUANTIZATION_FAILED" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setFailed(job: ConversionJob = makeFailedJob()) {
|
||||
useConversionStore.setState({
|
||||
uiState: "failed",
|
||||
job,
|
||||
});
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<FailedView />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 已知錯誤碼 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<FailedView /> — 已知錯誤碼(QUANTIZATION_FAILED)", () => {
|
||||
it("顯示翻譯後的訊息(含「不支援的運算子」字樣)", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
|
||||
const msg = screen.getByTestId("failed-message").textContent ?? "";
|
||||
expect(msg).toContain("不支援的運算子");
|
||||
// 不應該顯示 raw i18n key
|
||||
expect(msg).not.toContain("conversion.error.");
|
||||
});
|
||||
|
||||
it("顯示 3 條對應的 suggestions(簡化模型 / 移除 Custom Op / input shape)", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
|
||||
const list = screen.getByTestId("failed-suggestions-list");
|
||||
const items = list.querySelectorAll("li");
|
||||
// QUANTIZATION_FAILED 字典裡有 3 條
|
||||
expect(items.length).toBe(3);
|
||||
|
||||
const allText = Array.from(items)
|
||||
.map((el) => el.textContent ?? "")
|
||||
.join(" | ");
|
||||
expect(allText).toContain("簡化");
|
||||
expect(allText).toContain("Custom Op");
|
||||
expect(allText).toContain("input shape");
|
||||
});
|
||||
|
||||
it("顯示 source filename → target chip + 失敗字尾", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("failed-source-filename").textContent,
|
||||
).toContain("yolov5s.onnx");
|
||||
expect(screen.getByTestId("failed-target-chip").textContent).toContain(
|
||||
"KL720",
|
||||
);
|
||||
// 「失敗」字尾應出現在 summary 區塊
|
||||
const summary = screen.getByTestId("failed-summary").textContent ?? "";
|
||||
expect(summary).toContain("失敗");
|
||||
});
|
||||
|
||||
it("error code 用 <code> 元素標記、顯示原始 code", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
|
||||
const codeEl = screen.getByTestId("failed-error-code");
|
||||
expect(codeEl.tagName.toLowerCase()).toBe("code");
|
||||
expect(codeEl.textContent).toBe("QUANTIZATION_FAILED");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 各個 PRD §F5 已知錯誤碼 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<FailedView /> — PRD §F5 各個錯誤碼", () => {
|
||||
it.each([
|
||||
["UNSUPPORTED_FORMAT", "不支援"],
|
||||
["INVALID_CHECKSUM", "毀損"],
|
||||
["MODEL_TOO_LARGE", "500 MB"],
|
||||
["QUOTA_EXCEEDED", "繁忙"],
|
||||
])("error.code=%s → 顯示對應翻譯(含 %s)", (code, expectSubstring) => {
|
||||
setFailed(makeFailedJob({ error: { code } }));
|
||||
renderView();
|
||||
|
||||
const msg = screen.getByTestId("failed-message").textContent ?? "";
|
||||
expect(msg).toContain(expectSubstring);
|
||||
// 對應 code 也要出現在 <code> 中
|
||||
expect(screen.getByTestId("failed-error-code").textContent).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 未知錯誤碼 fallback */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<FailedView /> — 未知錯誤碼 fallback", () => {
|
||||
it("error.code = SOME_FUTURE_CODE → 顯示 unknown 訊息 + suggestions", () => {
|
||||
setFailed(makeFailedJob({ error: { code: "SOME_FUTURE_CODE" } }));
|
||||
renderView();
|
||||
|
||||
// unknown.message 字典:「轉檔失敗,請稍後重試。若持續發生請聯絡支援團隊」
|
||||
const msg = screen.getByTestId("failed-message").textContent ?? "";
|
||||
expect(msg).toContain("轉檔失敗");
|
||||
expect(msg).toContain("聯絡支援團隊");
|
||||
|
||||
// unknown.suggestion1:「複製任務 ID 回報給支援團隊」
|
||||
const list = screen.getByTestId("failed-suggestions-list");
|
||||
const items = list.querySelectorAll("li");
|
||||
expect(items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(items[0].textContent).toContain("複製");
|
||||
|
||||
// raw code 仍顯示在 <code>(給 ops debug 用)
|
||||
expect(screen.getByTestId("failed-error-code").textContent).toBe(
|
||||
"SOME_FUTURE_CODE",
|
||||
);
|
||||
});
|
||||
|
||||
it("job.error 缺失 → 視為 unknown,仍能正常渲染", () => {
|
||||
setFailed(makeFailedJob({ error: undefined }));
|
||||
renderView();
|
||||
|
||||
expect(screen.getByTestId("failed-message").textContent).toContain(
|
||||
"轉檔失敗",
|
||||
);
|
||||
expect(screen.getByTestId("failed-error-code").textContent).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* job_id 顯示 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<FailedView /> — Job ID", () => {
|
||||
it("顯示前 8 字元;完整 ID 放在 title 屬性供 hover/copy", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
|
||||
const el = screen.getByTestId("failed-job-id");
|
||||
expect(el.textContent).toBe("abcdef12");
|
||||
expect(el.getAttribute("title")).toBe(
|
||||
"abcdef12-3456-7890-aaaa-bbbbccccdddd",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 重新開始 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<FailedView /> — 重新開始按鈕", () => {
|
||||
it("點按鈕 → 呼叫 store.reset", () => {
|
||||
setFailed();
|
||||
const resetSpy = vi.spyOn(useConversionStore.getState(), "reset");
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("failed-restart"));
|
||||
|
||||
expect(resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("按鈕有明確 aria-label", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
const btn = screen.getByTestId("failed-restart");
|
||||
const ariaLabel = btn.getAttribute("aria-label") ?? "";
|
||||
expect(ariaLabel.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* a11y */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<FailedView /> — a11y", () => {
|
||||
it("失敗 hero role=alert + aria-live=assertive", () => {
|
||||
setFailed();
|
||||
renderView();
|
||||
|
||||
const hero = screen.getByTestId("failed-hero");
|
||||
expect(hero.getAttribute("role")).toBe("alert");
|
||||
expect(hero.getAttribute("aria-live")).toBe("assertive");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* helper 純函式測試 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("getErrorMessage / getErrorSuggestions — fallback 行為", () => {
|
||||
// 模擬 useT 行為:找到 → 回翻譯,找不到 → 回 key
|
||||
function makeT(dict: Record<string, string>) {
|
||||
return (key: string): string => dict[key] ?? key;
|
||||
}
|
||||
|
||||
it("getErrorMessage:已知 code → 翻譯", () => {
|
||||
const t = makeT({
|
||||
"conversion.error.UNSUPPORTED_FORMAT.message": "不支援格式",
|
||||
});
|
||||
expect(getErrorMessage(t, "UNSUPPORTED_FORMAT")).toBe("不支援格式");
|
||||
});
|
||||
|
||||
it("getErrorMessage:未知 code → fallback unknown", () => {
|
||||
const t = makeT({
|
||||
"conversion.error.unknown.message": "通用錯誤",
|
||||
});
|
||||
expect(getErrorMessage(t, "TOTALLY_NEW_CODE")).toBe("通用錯誤");
|
||||
});
|
||||
|
||||
it("getErrorMessage:連 unknown 也找不到 → 空字串(不丟錯)", () => {
|
||||
const t = makeT({}); // 完全空字典
|
||||
expect(getErrorMessage(t, "X")).toBe("");
|
||||
});
|
||||
|
||||
it("getErrorSuggestions:已知 code 有 2 條 → 回 2 條(不混 unknown)", () => {
|
||||
const t = makeT({
|
||||
"conversion.error.X.suggestion1": "A",
|
||||
"conversion.error.X.suggestion2": "B",
|
||||
// X 沒有 suggestion3
|
||||
"conversion.error.unknown.suggestion1": "fallback-1",
|
||||
"conversion.error.unknown.suggestion2": "fallback-2",
|
||||
});
|
||||
expect(getErrorSuggestions(t, "X")).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("getErrorSuggestions:未知 code → fallback 到 unknown 全部 suggestions", () => {
|
||||
const t = makeT({
|
||||
"conversion.error.unknown.suggestion1": "聯絡支援",
|
||||
"conversion.error.unknown.suggestion2": "查看狀態頁",
|
||||
});
|
||||
expect(getErrorSuggestions(t, "MISSING_CODE")).toEqual([
|
||||
"聯絡支援",
|
||||
"查看狀態頁",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getErrorSuggestions:連 unknown 都沒 → 空陣列(不丟錯)", () => {
|
||||
const t = makeT({});
|
||||
expect(getErrorSuggestions(t, "X")).toEqual([]);
|
||||
});
|
||||
|
||||
it("getErrorSuggestions:suggestion1 找不到 → 直接停(不會跳號去拿 suggestion2)", () => {
|
||||
const t = makeT({
|
||||
// 故意只給 suggestion2、沒有 suggestion1
|
||||
"conversion.error.X.suggestion2": "B",
|
||||
"conversion.error.unknown.suggestion1": "fallback",
|
||||
});
|
||||
// suggestion1 找不到 → 直接 break → fallback 到 unknown
|
||||
expect(getErrorSuggestions(t, "X")).toEqual(["fallback"]);
|
||||
});
|
||||
});
|
||||
274
visionA-frontend/src/app/conversion/components/FailedView.tsx
Normal file
274
visionA-frontend/src/app/conversion/components/FailedView.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FailedView — Failed state(轉檔失敗、錯誤訊息翻譯 + 建議解決方法)
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.5 + §8)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.6 failed → 顯示翻譯訊息 + 建議 + 重新開始
|
||||
* - feature-converter-integration.md §F5(錯誤碼對照表 + unknown fallback)
|
||||
* - api-conversion.md §6(error_code 在 backend 直接傳入,非 i18n key 形式)
|
||||
*
|
||||
* 範圍(F-T8):
|
||||
* - 失敗 hero(紅色 ✗ + 「轉檔失敗」title),role="alert" + aria-live="assertive"
|
||||
* - 任務摘要:source filename → target chip(含「失敗」字尾)
|
||||
* - 錯誤訊息卡片:翻譯後的訊息 + error code(給 ops)+ Job ID 前 8 字元
|
||||
* - 建議解決方法清單(最多 3 項;找不到對應 key 走 unknown fallback)
|
||||
* - 「重新開始」主按鈕 → store.reset() 切回 idle
|
||||
* - i18n key fallback:未知 code 一律走 conversion.error.unknown.*
|
||||
*
|
||||
* 不做:
|
||||
* - 「回模型庫」按鈕(wireframe §8 有列出但任務未要求;可由 sidebar 切換)
|
||||
* - 「複製任務 ID」按鈕(wireframe §8 有 nice-to-have;F-T9 視情況補)
|
||||
*
|
||||
* a11y:
|
||||
* - 失敗 hero `role="alert"` + `aria-live="assertive"`(重要訊息、即時播報)
|
||||
* - error code 用 `<code>` 標記(pre-formatted、SR 不會逐字朗讀)
|
||||
* - 「重新開始」按鈕有明確 aria-label
|
||||
* - Dark mode 對齊既有 destructive token
|
||||
*/
|
||||
|
||||
import { AlertCircleIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TranslateFn } from "@/lib/i18n/context";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
/** Job ID 前 N 字元 — wireframe §3.5 顯示「abcd1234(前 8 字元)」 */
|
||||
const JOB_ID_DISPLAY_PREFIX = 8;
|
||||
/** Suggestions 上限 — i18n key 設計支援 suggestion1/2/3 */
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
/** Unknown fallback 用的 i18n code key 段 */
|
||||
const UNKNOWN_CODE_KEY = "unknown";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers — 安全的 i18n lookup(找不到 key 不要 throw / 不要顯示 raw key) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 安全取得錯誤訊息 — 找不到對應 i18n key 時,自動 fallback 到 `conversion.error.unknown.message`。
|
||||
*
|
||||
* useT 的 t(key) 在找不到時回傳 key 本身(context.tsx §78–92);我們用「翻譯結果 === key」
|
||||
* 偵測 miss,然後 fallback。對齊 flow-conversion.md §5.6 的虛擬碼。
|
||||
*
|
||||
* @param t - useT 取得的翻譯函式
|
||||
* @param code - converter 回傳的 error code(如 `QUANTIZATION_FAILED` / 任意未知字串)
|
||||
* @returns 翻譯後的文字;fallback 失敗(連 unknown key 都不存在)時回空字串
|
||||
*/
|
||||
export function getErrorMessage(t: TranslateFn, code: string): string {
|
||||
const key = `conversion.error.${code}.message`;
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
|
||||
// fallback:unknown code
|
||||
const fallbackKey = `conversion.error.${UNKNOWN_CODE_KEY}.message`;
|
||||
const fallback = t(fallbackKey);
|
||||
if (fallback !== fallbackKey) return fallback;
|
||||
|
||||
// fallback 也找不到(不應發生;i18n 字典必含 unknown.message)
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全取得建議解決方法清單(最多 3 條)。
|
||||
*
|
||||
* 規則:
|
||||
* 1. 先嘗試 `conversion.error.<code>.suggestion1/2/3`
|
||||
* 2. 任一條找不到就停(不繼續往下找;3 條共用同一個 namespace)
|
||||
* 3. 全部找不到 → fallback 到 `conversion.error.unknown.suggestion1/2/3`
|
||||
*
|
||||
* 為什麼不混合(找到 1 條後 fallback 補 2、3):
|
||||
* - 各 code 的 suggestion 彼此語意不同(QUANTIZATION_FAILED 的「移除 Custom Op」對 unknown 沒意義)
|
||||
* - 若某 code 只有 suggestion1,那它就只該顯示 1 條,補 unknown 的反而誤導
|
||||
*/
|
||||
export function getErrorSuggestions(t: TranslateFn, code: string): string[] {
|
||||
const suggestions = collectSuggestions(t, code);
|
||||
if (suggestions.length > 0) return suggestions;
|
||||
return collectSuggestions(t, UNKNOWN_CODE_KEY);
|
||||
}
|
||||
|
||||
/** 從特定 code 連續抓 suggestion1..N,遇到第一個 miss 就停 */
|
||||
function collectSuggestions(t: TranslateFn, code: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 1; i <= MAX_SUGGESTIONS; i++) {
|
||||
const key = `conversion.error.${code}.suggestion${i}`;
|
||||
const translated = t(key);
|
||||
if (translated === key) break;
|
||||
out.push(translated);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function FailedView() {
|
||||
const t = useT();
|
||||
const job = useConversionStore((s) => s.job);
|
||||
const reset = useConversionStore((s) => s.reset);
|
||||
|
||||
// 防呆:page.tsx 已 routing;理論上 failed state 必有 job(store 切 failed 同時設 job)
|
||||
if (!job) return null;
|
||||
|
||||
return <FailedViewInner job={job} reset={reset} t={t} />;
|
||||
}
|
||||
|
||||
interface FailedViewInnerProps {
|
||||
job: ConversionJob;
|
||||
reset: () => void;
|
||||
t: TranslateFn;
|
||||
}
|
||||
|
||||
function FailedViewInner({ job, reset, t }: FailedViewInnerProps) {
|
||||
/* ---- 衍生資料 ---- */
|
||||
// error code:優先用 job.error.code;極端情況 backend 漏給時 fallback unknown
|
||||
const errorCode = job.error?.code ?? UNKNOWN_CODE_KEY;
|
||||
const errorMessage = useMemo(
|
||||
() => getErrorMessage(t, errorCode),
|
||||
[t, errorCode],
|
||||
);
|
||||
const suggestions = useMemo(
|
||||
() => getErrorSuggestions(t, errorCode),
|
||||
[t, errorCode],
|
||||
);
|
||||
|
||||
const sourceFilename = job.source_filename || "";
|
||||
const targetChip = job.target_chip;
|
||||
const shortJobId = job.job_id.slice(0, JOB_ID_DISPLAY_PREFIX);
|
||||
|
||||
/* ---- handler ---- */
|
||||
const handleRestart = () => {
|
||||
// store.reset() 會清掉 polling / upload controller;切回 idle 後 page.tsx 自動換 IdleForm
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="conversion-failed" className="space-y-6">
|
||||
{/* 失敗 hero — role="alert" + aria-live="assertive"(重要錯誤訊息應主動播報) */}
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-label={t("conversion.failed.aria.alert")}
|
||||
data-testid="failed-hero"
|
||||
className="border-destructive/40 bg-destructive/5 dark:bg-destructive/10 flex items-start gap-3 rounded-xl border p-6"
|
||||
>
|
||||
<AlertCircleIcon
|
||||
aria-hidden="true"
|
||||
className="text-destructive size-6 shrink-0"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("conversion.failed.heading")}
|
||||
</h2>
|
||||
{/* 翻譯後的錯誤訊息(unknown fallback 已內建於 helper) */}
|
||||
<p
|
||||
data-testid="failed-message"
|
||||
className="text-foreground text-sm"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任務摘要 + 錯誤碼 + Job ID */}
|
||||
<div
|
||||
data-testid="failed-summary"
|
||||
className="bg-card space-y-3 rounded-xl border p-6"
|
||||
>
|
||||
{/* 檔名 → chip(含「失敗」字尾) */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
{sourceFilename ? (
|
||||
<span className="font-mono" data-testid="failed-source-filename">
|
||||
{sourceFilename}
|
||||
</span>
|
||||
) : null}
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span
|
||||
className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium"
|
||||
data-testid="failed-target-chip"
|
||||
>
|
||||
{targetChip}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("conversion.failed.summary.failedSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 錯誤代碼 + Job ID — dl 結構,給 SR 標籤明確 */}
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("conversion.failed.errorCode")}
|
||||
</dt>
|
||||
<dd>
|
||||
<code
|
||||
data-testid="failed-error-code"
|
||||
className="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
>
|
||||
{errorCode}
|
||||
</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("conversion.failed.jobIdLabel")}
|
||||
</dt>
|
||||
<dd
|
||||
data-testid="failed-job-id"
|
||||
className="font-mono text-xs"
|
||||
// 完整 ID 給 ops 複製,但顯示只到前 8 字(wireframe §3.5)
|
||||
title={job.job_id}
|
||||
>
|
||||
{shortJobId}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 建議解決方法 */}
|
||||
{suggestions.length > 0 ? (
|
||||
<div
|
||||
data-testid="failed-suggestions"
|
||||
className="space-y-2"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("conversion.failed.suggestionsTitle")}
|
||||
</p>
|
||||
<ul
|
||||
className="text-muted-foreground list-disc space-y-1 pl-5 text-sm"
|
||||
data-testid="failed-suggestions-list"
|
||||
>
|
||||
{suggestions.map((s, idx) => (
|
||||
<li key={idx} data-testid={`failed-suggestion-${idx}`}>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 主動作:重新開始(wireframe §3.5:在卡片外面下方對齊右側) */}
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleRestart}
|
||||
aria-label={t("conversion.failed.aria.retry")}
|
||||
data-testid="failed-restart"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" className="size-4" />
|
||||
{t("conversion.failed.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
visionA-frontend/src/app/conversion/components/FileDropzone.tsx
Normal file
166
visionA-frontend/src/app/conversion/components/FileDropzone.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FileDropzone — 通用拖放 / 點擊選檔元件
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §4.1)
|
||||
*
|
||||
* 設計目標:
|
||||
* - 同時被「來源模型」(單檔,accept=.onnx,.tflite)與「Reference images」(多檔,accept=image/*)使用
|
||||
* - 拖放與點擊兩種互動都觸發同一個 `onSelect`(呼叫端拿到 File[],再依 multiple 與否處理)
|
||||
* - 已選檔的呈現由呼叫端負責(這裡只做「未選」的 dropzone 視覺;選了之後呼叫端會 hide 這個元件,改顯示 chips / list)
|
||||
*
|
||||
* a11y:
|
||||
* - dropzone 本體是 `<label>` 包 `<input type="file" class="sr-only">`,鍵盤 Tab + Enter / Space 直接觸發 file picker(瀏覽器原生)
|
||||
* - 整體區塊用 `aria-label` 描述功能,dragover 時加 `aria-busy="true"`
|
||||
* - 不用 role="button",避免覆蓋 label/input 的原生語義
|
||||
*
|
||||
* F-T4 不負責的:
|
||||
* - 檔案驗證(副檔名 / size)由呼叫端處理 — 這裡只是 UI 殼
|
||||
* - 拖放動畫(visualOnly hint dragover 高亮即可)
|
||||
*/
|
||||
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
type DragEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useId,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileDropzoneProps {
|
||||
/** `<input accept>` — `.onnx,.tflite` 或 `image/*` 之類 */
|
||||
accept: string;
|
||||
/** 是否允許多檔(ref images 用) */
|
||||
multiple?: boolean;
|
||||
/** 主要文字(拖放區大字) — 例:「拖曳 .onnx / .tflite 到此處」 */
|
||||
primaryLabel: string;
|
||||
/** 「或」之類連接文字,不傳則不顯示 */
|
||||
orLabel?: string;
|
||||
/** 點擊按鈕文字 — 例:「選擇檔案」 */
|
||||
browseLabel: string;
|
||||
/** 下方 hint — 例:「支援格式:.onnx · .tflite · 最大 500 MB」 */
|
||||
hint?: string;
|
||||
/** 拖放或選檔後 callback;總是傳 File[](呼叫端再依 multiple 處理) */
|
||||
onSelect: (files: File[]) => void;
|
||||
/** disabled 時整塊變灰並擋互動 */
|
||||
disabled?: boolean;
|
||||
/** 整塊高度;ref images dropzone 比 source dropzone 矮 */
|
||||
size?: "default" | "compact";
|
||||
/** 自訂 testid,方便測試區分多個 dropzone */
|
||||
"data-testid"?: string;
|
||||
/** 給 form label 用的 aria-describedby(指向錯誤訊息 id) */
|
||||
errorId?: string;
|
||||
/** 外部 className override */
|
||||
className?: string;
|
||||
/** 額外 children(理論上不用,但保留) */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
accept,
|
||||
multiple = false,
|
||||
primaryLabel,
|
||||
orLabel,
|
||||
browseLabel,
|
||||
hint,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
size = "default",
|
||||
"data-testid": dataTestId,
|
||||
errorId,
|
||||
className,
|
||||
children,
|
||||
}: FileDropzoneProps) {
|
||||
const inputId = useId();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
onSelect(Array.from(fileList));
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent<HTMLLabelElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
// 只有在 dataTransfer 帶檔案時才高亮(避免拖文字 / link 也觸發)
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLLabelElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
},
|
||||
[disabled, handleFiles],
|
||||
);
|
||||
|
||||
const heightCls = size === "compact" ? "min-h-20 py-4" : "min-h-32 py-6";
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
data-testid={dataTestId}
|
||||
data-drag-over={isDragOver ? "true" : undefined}
|
||||
aria-label={primaryLabel}
|
||||
aria-busy={isDragOver || undefined}
|
||||
aria-describedby={errorId}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"bg-muted/40 hover:bg-muted/60 border-input flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 text-center transition-colors",
|
||||
heightCls,
|
||||
isDragOver && "border-primary bg-primary/5",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Upload aria-hidden="true" className="text-muted-foreground size-5" />
|
||||
<div className="text-foreground text-sm font-medium">{primaryLabel}</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
{orLabel ? <span>{orLabel}</span> : null}
|
||||
<span className="text-primary underline-offset-2 group-hover:underline">
|
||||
{browseLabel}
|
||||
</span>
|
||||
</div>
|
||||
{hint ? (
|
||||
<p className="text-muted-foreground text-xs">{hint}</p>
|
||||
) : null}
|
||||
{children}
|
||||
|
||||
{/* 真正的 file input — 用 sr-only 讓鍵盤 / 螢幕閱讀器仍可使用 */}
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
// reset value 讓同一檔案重選也會觸發 onChange
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
248
visionA-frontend/src/app/conversion/components/IdleForm.test.tsx
Normal file
248
visionA-frontend/src/app/conversion/components/IdleForm.test.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* IdleForm 單元測試(Phase 0.8 F-T4)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.2 前端驗證表
|
||||
* - wireframe-conversion.md §3.1 / §4.1
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 預設渲染:「開始轉檔」disabled、預設 chip = KL720
|
||||
* - 上傳合法 .onnx:按鈕 enabled、taskName 自動帶檔名 stem、無錯誤
|
||||
* - 上傳 .pt(不支援):顯示格式錯誤、按鈕 disabled
|
||||
* - 上傳 600 MB:顯示太大錯誤、按鈕 disabled
|
||||
* - 切 chip:UI 反映 + checked 屬性正確
|
||||
* - 點「開始轉檔」:呼叫 store.startConversion,args 完整(file / chip / taskName)
|
||||
* - ref images:超過 100 張 / 單張超大會擋
|
||||
*
|
||||
* 注意:
|
||||
* - 不真的測 store 內部行為(store 已在 conversion-store.test.ts 覆蓋)
|
||||
* - 直接 spy `useConversionStore` 的 startConversion action
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
|
||||
import { IdleForm } from "./IdleForm";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 工具:建立指定 size / name 的 File(File constructor 在 happy-dom 可直接用) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function makeFile(name: string, sizeBytes: number, type = "application/octet-stream"): File {
|
||||
// 用 Uint8Array 撐起所需 size;對於很大的(500 MB+)實際上 jsdom/happy-dom 可建但會佔記憶體
|
||||
// 我們改成「fake size」:用一個小 buffer + Object.defineProperty 蓋過 size getter
|
||||
const file = new File([new Uint8Array(1)], name, { type });
|
||||
Object.defineProperty(file, "size", { value: sizeBytes, configurable: true });
|
||||
return file;
|
||||
}
|
||||
|
||||
function makeImage(name: string, sizeBytes: number): File {
|
||||
return makeFile(name, sizeBytes, "image/png");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 工具:用 fireEvent.change 觸發 input[type=file] */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function setInputFiles(input: HTMLInputElement, files: File[]) {
|
||||
// happy-dom 接受 DataTransfer 模式;用 Object.defineProperty 直接塞 files
|
||||
Object.defineProperty(input, "files", {
|
||||
value: files,
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 渲染 helper */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function renderForm() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<IdleForm />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 取「來源模型」的 file input(dropzone label 內的 sr-only input) */
|
||||
function getModelInput(): HTMLInputElement {
|
||||
const dropzone = screen.getByTestId("model-dropzone");
|
||||
const input = dropzone.querySelector(
|
||||
"input[type='file']:not([multiple])",
|
||||
) as HTMLInputElement | null;
|
||||
if (!input) throw new Error("model file input not found");
|
||||
return input;
|
||||
}
|
||||
|
||||
/** 取 ref images 的 file input(multiple) */
|
||||
function getRefImagesInput(): HTMLInputElement {
|
||||
const dropzone = screen.getByTestId("ref-images-dropzone");
|
||||
const input = dropzone.querySelector(
|
||||
"input[type='file'][multiple]",
|
||||
) as HTMLInputElement | null;
|
||||
if (!input) throw new Error("ref images file input not found");
|
||||
return input;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* lifecycle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
beforeEach(() => {
|
||||
// 每個測試前把 store 重置(避免 startConversion mock 累積)
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 測試 */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("<IdleForm />", () => {
|
||||
it("初始渲染:「開始轉檔」disabled、預設 chip = KL720", () => {
|
||||
renderForm();
|
||||
const submit = screen.getByTestId("conversion-start") as HTMLButtonElement;
|
||||
expect(submit.disabled).toBe(true);
|
||||
|
||||
// 預設 chip:KL720 應該被選中(data-checked="true")
|
||||
const chip720 = screen.getByTestId("chip-kl720");
|
||||
expect(chip720.getAttribute("data-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("上傳合法 .onnx → 按鈕 enabled + taskName 自動帶檔名 stem", () => {
|
||||
renderForm();
|
||||
const file = makeFile("yolov5s.onnx", 5 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [file]);
|
||||
|
||||
// 切到「已選檔」UI
|
||||
expect(screen.getByTestId("model-selected")).toBeTruthy();
|
||||
expect(screen.queryByTestId("model-error")).toBeNull();
|
||||
|
||||
// taskName 自動帶
|
||||
const taskInput = screen.getByTestId("task-name-input") as HTMLInputElement;
|
||||
expect(taskInput.value).toBe("yolov5s");
|
||||
|
||||
// 按鈕 enabled(chip 預設 KL720 已選)
|
||||
const submit = screen.getByTestId("conversion-start") as HTMLButtonElement;
|
||||
expect(submit.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("上傳 .pt(不支援格式)→ 顯示格式錯誤、按鈕保持 disabled", () => {
|
||||
renderForm();
|
||||
const bad = makeFile("yolov5s.pt", 1 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [bad]);
|
||||
|
||||
const err = screen.getByTestId("model-error");
|
||||
expect(err.textContent).toContain("ONNX");
|
||||
// 沒切到已選檔(dropzone 應仍可見)
|
||||
expect(screen.queryByTestId("model-selected")).toBeNull();
|
||||
|
||||
const submit = screen.getByTestId("conversion-start") as HTMLButtonElement;
|
||||
expect(submit.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("上傳 600 MB 模型 → 顯示太大錯誤、按鈕 disabled", () => {
|
||||
renderForm();
|
||||
const tooLarge = makeFile("big.onnx", 600 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [tooLarge]);
|
||||
|
||||
const err = screen.getByTestId("model-error");
|
||||
expect(err.textContent).toContain("500 MB");
|
||||
|
||||
const submit = screen.getByTestId("conversion-start") as HTMLButtonElement;
|
||||
expect(submit.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("切換 chip:點 KL520 → checked 屬性切到 KL520", () => {
|
||||
renderForm();
|
||||
|
||||
fireEvent.click(screen.getByTestId("chip-kl520"));
|
||||
expect(screen.getByTestId("chip-kl520").getAttribute("data-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("chip-kl720").getAttribute("data-checked"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("提交:呼叫 store.startConversion 帶完整 args(file / chip / taskName)", () => {
|
||||
const startSpy = vi
|
||||
.spyOn(useConversionStore.getState(), "startConversion")
|
||||
.mockImplementation(async () => {});
|
||||
// 把 spy 寫回 store(zustand 的 getState 回傳 snapshot,需重新 setState 接上)
|
||||
useConversionStore.setState({ startConversion: startSpy });
|
||||
|
||||
renderForm();
|
||||
const file = makeFile("yolov5s.onnx", 5 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [file]);
|
||||
|
||||
// 改 chip 到 KL520
|
||||
fireEvent.click(screen.getByTestId("chip-kl520"));
|
||||
|
||||
// 改 taskName
|
||||
const nameInput = screen.getByTestId("task-name-input") as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: "my-yolo" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("conversion-start"));
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
const args = startSpy.mock.calls[0][0];
|
||||
expect(args.file).toBe(file);
|
||||
expect(args.targetChip).toBe("KL520");
|
||||
expect(args.taskName).toBe("my-yolo");
|
||||
expect(args.refImages).toBeUndefined();
|
||||
});
|
||||
|
||||
it("移除已選模型:dropzone 重新顯示、按鈕回 disabled", () => {
|
||||
renderForm();
|
||||
setInputFiles(getModelInput(), [makeFile("yolov5s.onnx", 5 * 1024 * 1024)]);
|
||||
expect(screen.getByTestId("model-selected")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId("model-remove"));
|
||||
expect(screen.queryByTestId("model-selected")).toBeNull();
|
||||
expect(screen.getByTestId("model-dropzone")).toBeTruthy();
|
||||
|
||||
const submit = screen.getByTestId("conversion-start") as HTMLButtonElement;
|
||||
expect(submit.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("ref images:單張超過 10 MB → 顯示錯誤訊息(含檔名)", () => {
|
||||
renderForm();
|
||||
setInputFiles(getRefImagesInput(), [makeImage("huge.png", 12 * 1024 * 1024)]);
|
||||
|
||||
const err = screen.getByTestId("ref-images-error");
|
||||
expect(err.textContent).toContain("huge.png");
|
||||
expect(err.textContent).toContain("10 MB");
|
||||
});
|
||||
|
||||
it("ref images:超過 100 張 → 顯示「上限 100 張」", () => {
|
||||
renderForm();
|
||||
const tooMany = Array.from({ length: 101 }, (_, i) =>
|
||||
makeImage(`img-${i}.png`, 100 * 1024),
|
||||
);
|
||||
setInputFiles(getRefImagesInput(), tooMany);
|
||||
|
||||
const err = screen.getByTestId("ref-images-error");
|
||||
expect(err.textContent).toContain("100");
|
||||
});
|
||||
|
||||
it("ref images:合法多張 → 顯示總結 + 列表", () => {
|
||||
renderForm();
|
||||
setInputFiles(getRefImagesInput(), [
|
||||
makeImage("a.png", 200 * 1024),
|
||||
makeImage("b.png", 300 * 1024),
|
||||
]);
|
||||
|
||||
const summary = screen.getByTestId("ref-images-selected");
|
||||
expect(summary.textContent).toContain("a.png");
|
||||
expect(summary.textContent).toContain("b.png");
|
||||
});
|
||||
});
|
||||
438
visionA-frontend/src/app/conversion/components/IdleForm.tsx
Normal file
438
visionA-frontend/src/app/conversion/components/IdleForm.tsx
Normal file
@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* IdleForm — Idle state 上傳表單
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.1 + §4.1)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.2(前端驗證表)
|
||||
* - api-conversion.md §1(initConversion request shape)
|
||||
* - i18n keys 已就位於 zh-Hant.ts / en.ts 的 `conversion.upload.*`
|
||||
*
|
||||
* 範圍(F-T4):
|
||||
* - 上傳表單:source model(必填)、taskName(選填)、targetChip(必填)、ref images(選填)
|
||||
* - 前端驗證:副檔名(.onnx / .tflite)、模型 ≤ 500 MB、ref images ≤ 100 張 + 每張 ≤ 10 MB
|
||||
* - 「開始轉檔」呼叫 store.startConversion → store 自動切 uploading state(XHR 進度條由 F-T5 接手)
|
||||
*
|
||||
* 不做(後續任務):
|
||||
* - uploading 進度條(F-T5)
|
||||
* - taskName trim 後重 derive 邏輯(與 store 的 promote-name 對齊:F-T7)
|
||||
* - 「加到模型庫」/「下載」(F-T7)
|
||||
*
|
||||
* UX 注意:
|
||||
* - 模型 dropzone 一旦選了會切成「已選檔」chip 顯示,再次點擊可移除
|
||||
* - ref images 多選:累加而非取代(drop 多次都加);單一檔超 10 MB 會直接拒絕該檔
|
||||
* - 「開始轉檔」按鈕 disabled 條件:未選檔 / 未選 chip / 任一錯誤未清除
|
||||
*/
|
||||
|
||||
import { FileText, ImageIcon, X } from "lucide-react";
|
||||
import { useCallback, useId, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
|
||||
import { ChipSelector } from "./ChipSelector";
|
||||
import { FileDropzone } from "./FileDropzone";
|
||||
|
||||
/** 500 MB(wireframe §3.1 / §4.1 + flow §5.2) */
|
||||
const MAX_MODEL_BYTES = 500 * 1024 * 1024;
|
||||
/** 10 MB / 張 ref image */
|
||||
const MAX_REF_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
/** 100 張 ref images 上限 */
|
||||
const MAX_REF_IMAGE_COUNT = 100;
|
||||
/** 接受的模型副檔名 */
|
||||
const ACCEPTED_MODEL_EXT = [".onnx", ".tflite"] as const;
|
||||
|
||||
/** 把 file.name 去掉副檔名(例:yolov5s.onnx → yolov5s) */
|
||||
function stripExtension(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
return i > 0 ? name.slice(0, i) : name;
|
||||
}
|
||||
|
||||
/** 1.5 MB / 23.4 KB / 480 B 之類,給 UI 顯示用 */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
/** 把多個檔案的總大小相加 */
|
||||
function sumSize(files: File[]): number {
|
||||
return files.reduce((acc, f) => acc + f.size, 0);
|
||||
}
|
||||
|
||||
interface FieldError {
|
||||
/** 顯示文字 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function IdleForm() {
|
||||
const t = useT();
|
||||
const startConversion = useConversionStore((s) => s.startConversion);
|
||||
|
||||
// ── form metadata(F-T9 M1:提到 store) ──
|
||||
// 為什麼 metadata 進 store、File 留 local:見 conversion-store.ts ConversionFormDraft 註解。
|
||||
// 簡述:page.tsx 條件 render 會讓 IdleForm 在 idle ↔ uploading 之間 unmount/re-mount,
|
||||
// metadata 提到 store 才能跨 mount 保留;File 物件不可序列化、不存 store。
|
||||
const formDraft = useConversionStore((s) => s.formDraft);
|
||||
const updateFormDraft = useConversionStore((s) => s.updateFormDraft);
|
||||
|
||||
const taskName = formDraft.taskName;
|
||||
const taskNameTouched = formDraft.taskNameTouched;
|
||||
const chip = formDraft.targetChip;
|
||||
|
||||
// ── File 物件(local;上傳失敗回 idle 後 user 需重選檔) ──
|
||||
// 這也是為什麼上傳失敗 toast 文案改成「請重新選擇檔案再試」—— 對齊實作真實行為
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [refImages, setRefImages] = useState<File[]>([]);
|
||||
|
||||
// ── 各欄位錯誤(前端驗證;不打 API) ──
|
||||
const [fileError, setFileError] = useState<FieldError | null>(null);
|
||||
const [refImagesError, setRefImagesError] = useState<FieldError | null>(null);
|
||||
|
||||
// ── error region id(給 dropzone aria-describedby 指向) ──
|
||||
const fileErrorId = useId();
|
||||
const refImagesErrorId = useId();
|
||||
const chipLabelId = useId();
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
* 模型檔處理
|
||||
* -------------------------------------------------------------------- */
|
||||
const handleModelFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
// 單檔:永遠取第一個(drop 多檔時也只收第一個,避免使用者誤把 ref images 拖過來)
|
||||
const next = files[0];
|
||||
if (!next) return;
|
||||
|
||||
const lower = next.name.toLowerCase();
|
||||
const okExt = ACCEPTED_MODEL_EXT.some((ext) => lower.endsWith(ext));
|
||||
if (!okExt) {
|
||||
setFile(null);
|
||||
updateFormDraft({ fileName: null, fileSize: null });
|
||||
setFileError({ message: t("conversion.upload.error.unsupported") });
|
||||
return;
|
||||
}
|
||||
if (next.size > MAX_MODEL_BYTES) {
|
||||
setFile(null);
|
||||
updateFormDraft({ fileName: null, fileSize: null });
|
||||
setFileError({ message: t("conversion.upload.error.modelTooLarge") });
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(next);
|
||||
// metadata 進 store(給跨 mount 保留 + 顯示用)
|
||||
updateFormDraft({
|
||||
fileName: next.name,
|
||||
fileSize: next.size,
|
||||
// 第一次選檔自動帶任務名(使用者改過後不再覆蓋)
|
||||
...(taskNameTouched ? {} : { taskName: stripExtension(next.name) }),
|
||||
});
|
||||
setFileError(null);
|
||||
},
|
||||
[t, taskNameTouched, updateFormDraft],
|
||||
);
|
||||
|
||||
const handleRemoveModel = useCallback(() => {
|
||||
setFile(null);
|
||||
setFileError(null);
|
||||
updateFormDraft({
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
// 沒手動改過任務名 → 跟著清;改過則保留
|
||||
...(taskNameTouched ? {} : { taskName: "" }),
|
||||
});
|
||||
}, [taskNameTouched, updateFormDraft]);
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
* Ref images 處理(多檔、累加)
|
||||
* -------------------------------------------------------------------- */
|
||||
const handleRefImagesFiles = useCallback(
|
||||
(incoming: File[]) => {
|
||||
// 過濾單檔超大的(顯示第一個違規檔的訊息)
|
||||
const oversized = incoming.find((f) => f.size > MAX_REF_IMAGE_BYTES);
|
||||
if (oversized) {
|
||||
setRefImagesError({
|
||||
message: t("conversion.upload.error.refTooLarge").replace(
|
||||
"{filename}",
|
||||
oversized.name,
|
||||
),
|
||||
});
|
||||
// 仍把符合條件的累加進去(friendly:使用者拖了 5 張,1 張超大不要全擋)
|
||||
const ok = incoming.filter((f) => f.size <= MAX_REF_IMAGE_BYTES);
|
||||
if (ok.length === 0) return;
|
||||
// 跑下方數量檢查
|
||||
incoming = ok;
|
||||
}
|
||||
|
||||
const merged = [...refImages, ...incoming];
|
||||
if (merged.length > MAX_REF_IMAGE_COUNT) {
|
||||
setRefImagesError({
|
||||
message: t("conversion.upload.error.refTooMany"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 沒「上限超過」的錯誤就清除(保留「單檔過大」的 toast 風 message)
|
||||
// 但這裡選擇直接清除;單檔過大只是引導,下次拖正常檔就消失
|
||||
if (!oversized) {
|
||||
setRefImagesError(null);
|
||||
}
|
||||
setRefImages(merged);
|
||||
updateFormDraft({ refImagesNames: merged.map((f) => f.name) });
|
||||
},
|
||||
[refImages, t, updateFormDraft],
|
||||
);
|
||||
|
||||
const handleRemoveRefImage = useCallback(
|
||||
(index: number) => {
|
||||
setRefImages((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
updateFormDraft({ refImagesNames: next.map((f) => f.name) });
|
||||
return next;
|
||||
});
|
||||
setRefImagesError(null);
|
||||
},
|
||||
[updateFormDraft],
|
||||
);
|
||||
|
||||
const handleRemoveAllRefImages = useCallback(() => {
|
||||
setRefImages([]);
|
||||
updateFormDraft({ refImagesNames: [] });
|
||||
setRefImagesError(null);
|
||||
}, [updateFormDraft]);
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
* 「開始轉檔」
|
||||
* -------------------------------------------------------------------- */
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!file) return false;
|
||||
if (fileError) return false;
|
||||
if (!chip) return false;
|
||||
if (refImagesError) return false;
|
||||
return true;
|
||||
}, [file, fileError, chip, refImagesError]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!file || !chip) return;
|
||||
void startConversion({
|
||||
file,
|
||||
refImages: refImages.length > 0 ? refImages : undefined,
|
||||
targetChip: chip,
|
||||
taskName: taskName.trim() || undefined,
|
||||
});
|
||||
}, [file, chip, refImages, taskName, startConversion]);
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
* Render
|
||||
* -------------------------------------------------------------------- */
|
||||
const refImagesSummary = t("conversion.upload.refImages.summary")
|
||||
.replace("{count}", String(refImages.length))
|
||||
.replace("{totalSize}", formatBytes(sumSize(refImages)));
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="conversion-idle-form"
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* ── 來源模型 ── */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("conversion.upload.source.label")}
|
||||
<span aria-hidden="true" className="text-destructive ml-0.5">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{!file ? (
|
||||
<FileDropzone
|
||||
data-testid="model-dropzone"
|
||||
accept={ACCEPTED_MODEL_EXT.join(",")}
|
||||
primaryLabel={t("conversion.upload.source.dropzone")}
|
||||
orLabel={t("conversion.upload.source.or")}
|
||||
browseLabel={t("conversion.upload.source.browse")}
|
||||
hint={t("conversion.upload.source.formatHint")}
|
||||
onSelect={handleModelFiles}
|
||||
errorId={fileError ? fileErrorId : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
data-testid="model-selected"
|
||||
className="bg-card flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<FileText
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveModel}
|
||||
aria-label={t("conversion.upload.source.remove")}
|
||||
data-testid="model-remove"
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
<span className="sr-only sm:not-sr-only">
|
||||
{t("conversion.upload.source.remove")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileError ? (
|
||||
<p
|
||||
id={fileErrorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-destructive text-sm"
|
||||
data-testid="model-error"
|
||||
>
|
||||
{fileError.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── 目標晶片 ── */}
|
||||
<div className="space-y-2">
|
||||
<Label id={chipLabelId}>
|
||||
{t("conversion.upload.chip.label")}
|
||||
<span aria-hidden="true" className="text-destructive ml-0.5">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<ChipSelector
|
||||
value={chip}
|
||||
onChange={(next) => updateFormDraft({ targetChip: next })}
|
||||
ariaLabelledBy={chipLabelId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Reference images(選填) ── */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("conversion.upload.refImages.label")}</Label>
|
||||
<FileDropzone
|
||||
data-testid="ref-images-dropzone"
|
||||
accept="image/*"
|
||||
multiple
|
||||
size="compact"
|
||||
primaryLabel={t("conversion.upload.refImages.dropzone")}
|
||||
orLabel={t("conversion.upload.source.or")}
|
||||
browseLabel={t("conversion.upload.source.browse")}
|
||||
hint={t("conversion.upload.refImages.hint")}
|
||||
onSelect={handleRefImagesFiles}
|
||||
errorId={refImagesError ? refImagesErrorId : undefined}
|
||||
/>
|
||||
|
||||
{refImages.length > 0 ? (
|
||||
<div
|
||||
data-testid="ref-images-selected"
|
||||
className="bg-card space-y-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{refImagesSummary}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveAllRefImages}
|
||||
data-testid="ref-images-remove-all"
|
||||
>
|
||||
{t("conversion.upload.refImages.removeAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{refImages.map((img, i) => (
|
||||
<li
|
||||
key={`${img.name}-${i}`}
|
||||
className="bg-muted/40 flex items-center justify-between gap-2 rounded-md border px-2 py-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ImageIcon
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
/>
|
||||
<span className="truncate text-xs">{img.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRefImage(i)}
|
||||
className="text-muted-foreground hover:text-foreground -mr-1 rounded p-1"
|
||||
aria-label={`${t("conversion.upload.source.remove")}: ${img.name}`}
|
||||
>
|
||||
<X aria-hidden="true" className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{refImagesError ? (
|
||||
<p
|
||||
id={refImagesErrorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-destructive text-sm"
|
||||
data-testid="ref-images-error"
|
||||
>
|
||||
{refImagesError.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── 任務名稱(選填) ── */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conversion-task-name">
|
||||
{t("conversion.upload.name.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="conversion-task-name"
|
||||
data-testid="task-name-input"
|
||||
value={taskName}
|
||||
onChange={(e) =>
|
||||
updateFormDraft({
|
||||
taskName: e.target.value,
|
||||
taskNameTouched: true,
|
||||
})
|
||||
}
|
||||
placeholder="yolov5s"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("conversion.upload.name.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── 提交 ── */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={!canSubmit}
|
||||
data-testid="conversion-start"
|
||||
>
|
||||
{t("conversion.upload.start")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,464 @@
|
||||
/**
|
||||
* ProcessingView 單元測試(Phase 0.8 F-T6)
|
||||
*
|
||||
* 對齊:
|
||||
* - wireframe-conversion.md §3.3(Processing state 視覺)
|
||||
* - flow-conversion.md §5.3(queued → running → succeeded/failed transitions)
|
||||
* - api-conversion.md §2 GET response shape
|
||||
*
|
||||
* 覆蓋:
|
||||
* - status=queued → 排隊文字 + 三段 stage 全 pending
|
||||
* - status=running, stage=onnx → onnx 進行中
|
||||
* - status=running, stage=bie → onnx 完成、bie 進行中
|
||||
* - status=running, stage=nef → onnx/bie 完成、nef 進行中
|
||||
* - progress=45 → progress bar 顯示百分比
|
||||
* - progress=null → indeterminate progress
|
||||
* - 7 天清除提醒顯示
|
||||
* - mount → document.title 含 prefix;unmount → 還原
|
||||
*
|
||||
* 不負責:
|
||||
* - polling 行為(store 自己有測試)
|
||||
* - i18n key parity(i18n.test.ts 已測)
|
||||
* - state 切換到 succeeded/failed 的視覺(F-T7 才接)
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider, useLocale } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob, ConversionStage } from "@/types/conversion";
|
||||
|
||||
import { ProcessingView } from "./ProcessingView";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function makeJob(
|
||||
overrides: Partial<ConversionJob> & {
|
||||
status: ConversionJob["status"];
|
||||
},
|
||||
): ConversionJob {
|
||||
return {
|
||||
job_id: "job-test",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setQueued() {
|
||||
useConversionStore.setState({
|
||||
uiState: "queued",
|
||||
job: makeJob({ status: "queued" }),
|
||||
});
|
||||
}
|
||||
|
||||
function setRunning(stage: ConversionStage, progress: number | undefined) {
|
||||
useConversionStore.setState({
|
||||
uiState: "running",
|
||||
job: makeJob({ status: "running", stage, progress }),
|
||||
});
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ProcessingView />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const ORIGINAL_TITLE = "visionA Cloud — Test";
|
||||
|
||||
beforeEach(() => {
|
||||
useConversionStore.getState().reset();
|
||||
// 統一測試前 title,避免上一個測試殘留
|
||||
document.title = ORIGINAL_TITLE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
document.title = ORIGINAL_TITLE;
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* queued state */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — queued state", () => {
|
||||
it("data-phase='queued' + 顯示「排隊中⋯」文字", () => {
|
||||
setQueued();
|
||||
renderView();
|
||||
|
||||
const view = screen.getByTestId("conversion-processing");
|
||||
expect(view.getAttribute("data-phase")).toBe("queued");
|
||||
|
||||
expect(screen.getByTestId("processing-progress-queued")).toBeTruthy();
|
||||
const queuedText =
|
||||
screen.getByTestId("processing-queued-text").textContent ?? "";
|
||||
expect(
|
||||
queuedText.includes("排隊中") || queuedText.includes("Queued"),
|
||||
).toBe(true);
|
||||
|
||||
// 沒有 determinate / indeterminate progress(兩者都是 running 才有)
|
||||
expect(screen.queryByTestId("processing-progress-determinate")).toBeNull();
|
||||
expect(screen.queryByTestId("processing-progress-indeterminate")).toBeNull();
|
||||
});
|
||||
|
||||
it("三段 stage 全部 pending", () => {
|
||||
setQueued();
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-onnx").getAttribute("data-status"),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-bie").getAttribute("data-status"),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-nef").getAttribute("data-status"),
|
||||
).toBe("pending");
|
||||
// queued 階段沒有 aria-current="step"
|
||||
expect(
|
||||
screen.queryByTestId("processing-stage-onnx")?.getAttribute("aria-current"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* running state — stage indicator */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — running stage indicator", () => {
|
||||
it("stage=onnx → onnx 為 current、其他 pending", () => {
|
||||
setRunning("onnx", undefined);
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-onnx").getAttribute("data-status"),
|
||||
).toBe("current");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-onnx").getAttribute("aria-current"),
|
||||
).toBe("step");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-bie").getAttribute("data-status"),
|
||||
).toBe("pending");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-nef").getAttribute("data-status"),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("stage=bie → onnx 完成、bie current、nef pending", () => {
|
||||
setRunning("bie", 30);
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-onnx").getAttribute("data-status"),
|
||||
).toBe("completed");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-bie").getAttribute("data-status"),
|
||||
).toBe("current");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-bie").getAttribute("aria-current"),
|
||||
).toBe("step");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-nef").getAttribute("data-status"),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("stage=nef → onnx/bie 完成、nef current", () => {
|
||||
setRunning("nef", 80);
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-onnx").getAttribute("data-status"),
|
||||
).toBe("completed");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-bie").getAttribute("data-status"),
|
||||
).toBe("completed");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-nef").getAttribute("data-status"),
|
||||
).toBe("current");
|
||||
expect(
|
||||
screen.getByTestId("processing-stage-nef").getAttribute("aria-current"),
|
||||
).toBe("step");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* progress bar */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — progress bar", () => {
|
||||
it("running + progress=45 → determinate progress 顯示 45%", () => {
|
||||
setRunning("bie", 45);
|
||||
renderView();
|
||||
|
||||
const bar = screen.getByTestId("processing-progress-determinate");
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.getAttribute("aria-valuenow")).toBe("45");
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-progress-pct").textContent,
|
||||
).toContain("45%");
|
||||
|
||||
// 沒有 indeterminate / queued 視覺
|
||||
expect(screen.queryByTestId("processing-progress-indeterminate")).toBeNull();
|
||||
expect(screen.queryByTestId("processing-progress-queued")).toBeNull();
|
||||
});
|
||||
|
||||
it("running + progress=undefined → indeterminate progress + role=progressbar", () => {
|
||||
setRunning("onnx", undefined);
|
||||
renderView();
|
||||
|
||||
const bar = screen.getByTestId("processing-progress-indeterminate");
|
||||
expect(bar).toBeTruthy();
|
||||
expect(bar.getAttribute("role")).toBe("progressbar");
|
||||
expect(bar.getAttribute("aria-busy")).toBe("true");
|
||||
expect(bar.getAttribute("aria-label")?.length ?? 0).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.queryByTestId("processing-progress-determinate")).toBeNull();
|
||||
});
|
||||
|
||||
it("running + progress=0 → 仍走 determinate(顯示 0%)", () => {
|
||||
// 0 是合法的 progress(剛開始),不該被當成 null fall through
|
||||
setRunning("onnx", 0);
|
||||
renderView();
|
||||
|
||||
expect(screen.getByTestId("processing-progress-determinate")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId("processing-progress-pct").textContent,
|
||||
).toContain("0%");
|
||||
});
|
||||
|
||||
it("progress 異常值 clamp(>100 → 100、<0 → 0)— Minor #2 修補", () => {
|
||||
// backend 萬一回 150 或 -10,UI 顯示與 aria-valuenow 都該被 clamp 在 0-100
|
||||
setRunning("nef", 150);
|
||||
const { unmount } = renderView();
|
||||
expect(
|
||||
screen.getByTestId("processing-progress-pct").textContent,
|
||||
).toContain("100%");
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("processing-progress-determinate")
|
||||
.getAttribute("aria-valuenow"),
|
||||
).toBe("100");
|
||||
unmount();
|
||||
|
||||
setRunning("onnx", -10);
|
||||
renderView();
|
||||
expect(
|
||||
screen.getByTestId("processing-progress-pct").textContent,
|
||||
).toContain("0%");
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("processing-progress-determinate")
|
||||
.getAttribute("aria-valuenow"),
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* expiry hint */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — 7 天清除提醒", () => {
|
||||
it("queued + running 都顯示 expiry hint", () => {
|
||||
setQueued();
|
||||
const { unmount } = renderView();
|
||||
expect(screen.getByTestId("processing-expiry-hint")).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
setRunning("bie", 45);
|
||||
renderView();
|
||||
expect(screen.getByTestId("processing-expiry-hint")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 來源檔名 + chip */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — job summary", () => {
|
||||
it("顯示來源檔名 + target chip", () => {
|
||||
setRunning("onnx", undefined);
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-source-filename").textContent,
|
||||
).toContain("yolov5s.onnx");
|
||||
expect(
|
||||
screen.getByTestId("processing-target-chip").textContent,
|
||||
).toContain("KL720");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* tab title */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — tab title", () => {
|
||||
it("mount → document.title 加上 prefix;unmount → 還原", () => {
|
||||
setRunning("bie", 45);
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
|
||||
const { unmount } = renderView();
|
||||
|
||||
// 至少有 prefix(zh 或 en)
|
||||
const titledTitle = document.title;
|
||||
expect(
|
||||
titledTitle.startsWith("(轉檔中) ") ||
|
||||
titledTitle.startsWith("(Converting) "),
|
||||
).toBe(true);
|
||||
expect(titledTitle.endsWith(ORIGINAL_TITLE)).toBe(true);
|
||||
|
||||
unmount();
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("已有 prefix 時不疊加(重新進入頁面安全)", () => {
|
||||
// 模擬上次離開沒清乾淨:title 已含 prefix
|
||||
document.title = "(轉檔中) " + ORIGINAL_TITLE;
|
||||
setRunning("onnx", undefined);
|
||||
|
||||
const { unmount } = renderView();
|
||||
|
||||
// 不會變成「(轉檔中) (轉檔中) ...」
|
||||
const titledTitle = document.title;
|
||||
const zhPrefixCount = (titledTitle.match(/\(轉檔中\) /g) ?? []).length;
|
||||
const enPrefixCount = (titledTitle.match(/\(Converting\) /g) ?? []).length;
|
||||
expect(zhPrefixCount + enPrefixCount).toBe(1);
|
||||
|
||||
unmount();
|
||||
// unmount 後應該回到「沒 prefix 的版本」
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it("locale 切換時不會累積疊加(zh → en → zh)— Major #1 修補", () => {
|
||||
// 內部測試元件:暴露 setLocale 給外部按鈕觸發
|
||||
function LocaleSwitcher() {
|
||||
const { setLocale } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="to-en" onClick={() => setLocale("en")}>
|
||||
en
|
||||
</button>
|
||||
<button data-testid="to-zh" onClick={() => setLocale("zh-Hant")}>
|
||||
zh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
setRunning("bie", 45);
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
|
||||
const { unmount } = render(
|
||||
<LocaleProvider initialLocale="zh-Hant">
|
||||
<LocaleSwitcher />
|
||||
<ProcessingView />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
|
||||
// 1) 初始 zh prefix
|
||||
expect(document.title).toBe("(轉檔中) " + ORIGINAL_TITLE);
|
||||
|
||||
// 2) 切到 en — 應該變成「(Converting) <ORIGINAL>」,不能變成「(Converting) (轉檔中) ...」
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("to-en"));
|
||||
});
|
||||
expect(document.title).toBe("(Converting) " + ORIGINAL_TITLE);
|
||||
// 沒有舊 prefix 殘留
|
||||
expect(document.title.includes("(轉檔中)")).toBe(false);
|
||||
|
||||
// 3) 切回 zh — 還是乾淨的單層 prefix
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByTestId("to-zh"));
|
||||
});
|
||||
expect(document.title).toBe("(轉檔中) " + ORIGINAL_TITLE);
|
||||
expect(document.title.includes("(Converting)")).toBe(false);
|
||||
|
||||
// 4) unmount 還原原始 title(不留任何 prefix)
|
||||
unmount();
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* F-T9 sub-2:「您已有一個轉檔正在進行中」banner */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ProcessingView /> — switched-from-active-job banner (F-T9 sub-2)", () => {
|
||||
it("switchedFromActiveJob=false 時不顯示 banner", () => {
|
||||
setRunning("onnx", undefined);
|
||||
useConversionStore.setState({ switchedFromActiveJob: false });
|
||||
renderView();
|
||||
expect(
|
||||
screen.queryByTestId("processing-switched-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switchedFromActiveJob=true 時顯示 banner(role=status + aria-live=polite)", () => {
|
||||
setRunning("onnx", undefined);
|
||||
useConversionStore.setState({ switchedFromActiveJob: true });
|
||||
renderView();
|
||||
|
||||
const banner = screen.getByTestId("processing-switched-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveAttribute("role", "status");
|
||||
expect(banner).toHaveAttribute("aria-live", "polite");
|
||||
// 文案對齊 i18n(zh-Hant 預設)
|
||||
expect(banner).toHaveTextContent(
|
||||
/您已有一個轉檔正在進行中,已切換至該任務/,
|
||||
);
|
||||
});
|
||||
|
||||
it("queued 狀態下也顯示 banner(user 收 409 後 bootstrap 進來可能在 queued)", () => {
|
||||
setQueued();
|
||||
useConversionStore.setState({ switchedFromActiveJob: true });
|
||||
renderView();
|
||||
expect(
|
||||
screen.getByTestId("processing-switched-banner"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("點 dismiss 按鈕後 banner 消失(local state,不動 store flag)", () => {
|
||||
setRunning("onnx", undefined);
|
||||
useConversionStore.setState({ switchedFromActiveJob: true });
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("processing-switched-banner"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByTestId("processing-switched-banner-dismiss"),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("processing-switched-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
// store flag 不變(dismiss 只清 local state)
|
||||
expect(useConversionStore.getState().switchedFromActiveJob).toBe(true);
|
||||
});
|
||||
|
||||
it("dismiss 按鈕有可被 SR 讀到的 aria-label", () => {
|
||||
setRunning("onnx", undefined);
|
||||
useConversionStore.setState({ switchedFromActiveJob: true });
|
||||
renderView();
|
||||
|
||||
const dismissBtn = screen.getByTestId(
|
||||
"processing-switched-banner-dismiss",
|
||||
);
|
||||
expect(dismissBtn).toHaveAttribute("aria-label", "關閉提示");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProcessingView — Queued / Running state(轉檔處理中)
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.3)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.3(queued → running → succeeded/failed transitions)
|
||||
* - flow-conversion.md §4 state machine(uiState='queued' / 'running' 共用此元件)
|
||||
* - api-conversion.md §2 GET response shape(status / stage / progress / source_filename / target_chip)
|
||||
* - i18n keys 已就位於 zh-Hant.ts / en.ts 的 `conversion.processing.*`(F-T6 新增 stage / eta / expiry / tabTitle 等)
|
||||
*
|
||||
* 範圍(F-T6):
|
||||
* - 同時涵蓋 queued + running 兩種 store.uiState(視覺幾乎相同,差別只在 stage indicator 標示)
|
||||
* - 三段式 stage indicator(onnx → bie → nef)— 對應 converter 真實 stage
|
||||
* - Progress bar:
|
||||
* queued → 隱藏 progress bar,改顯示「排隊中⋯」+ aria-busy
|
||||
* running → 有 progress 顯示百分比;progress=null → indeterminate(pulse 動畫)
|
||||
* - ETA 預估:converter 不給 ETA,所以顯示「請耐心等候」/「剩餘時間估算中」(不自己算)
|
||||
* - 7 天後自動清除提醒
|
||||
* - Tab title:mount 改「(轉檔中) <原 title>」、unmount 還原
|
||||
*
|
||||
* 不做:
|
||||
* - beforeunload warning(processing 是 backend 在跑,user 切走不影響 polling)
|
||||
* - polling 控制(store F-T3 自管 setTimeout + visibilitychange,UI 只訂閱 state)
|
||||
* - 切到 succeeded / failed 的視覺(F-T7 處理;store 自動切 uiState 後 page.tsx 換成 SuccessView / FailedView)
|
||||
*
|
||||
* UX 細節:
|
||||
* - stage indicator 自寫輕量元件(既有 ui/ 沒 stepper;用 <ol role="list"> 語義化 + aria-current)
|
||||
* - progress bar 在 indeterminate 模式下用 sr-only 字 + aria-live 提供 a11y fallback
|
||||
* - prefers-reduced-motion 由 Tailwind `motion-reduce:` 保留(避免 indeterminate 動畫對眩暈使用者造成困擾)
|
||||
*/
|
||||
|
||||
import { CheckIcon, InfoIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionStage } from "@/types/conversion";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* stage 三段(順序固定,與 converter `stage` enum 一致) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const STAGE_ORDER: readonly ConversionStage[] = ["onnx", "bie", "nef"] as const;
|
||||
|
||||
type StageStatus = "completed" | "current" | "pending";
|
||||
|
||||
/**
|
||||
* 計算三段 stage 在當前 job 狀態下的視覺標記。
|
||||
*
|
||||
* 規則(任務描述):
|
||||
* - status=queued → 全部 pending
|
||||
* - status=running, stage=onnx → onnx=current, bie/nef=pending
|
||||
* - status=running, stage=bie → onnx=completed, bie=current, nef=pending
|
||||
* - status=running, stage=nef → onnx=completed, bie=completed, nef=current
|
||||
* - status=running, stage=undefined → onnx=current(converter 還沒回 stage 欄位的 fallback)
|
||||
*/
|
||||
function computeStageStatuses(
|
||||
uiState: "queued" | "running",
|
||||
stage: ConversionStage | undefined,
|
||||
): readonly StageStatus[] {
|
||||
if (uiState === "queued") {
|
||||
return ["pending", "pending", "pending"];
|
||||
}
|
||||
// running
|
||||
const currentIdx = stage ? STAGE_ORDER.indexOf(stage) : 0;
|
||||
// 萬一 backend 回了未知 stage(理論上不會)→ 視為 onnx
|
||||
const safeIdx = currentIdx >= 0 ? currentIdx : 0;
|
||||
return STAGE_ORDER.map((_, idx) => {
|
||||
if (idx < safeIdx) return "completed";
|
||||
if (idx === safeIdx) return "current";
|
||||
return "pending";
|
||||
}) as readonly StageStatus[];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook:tab title 改成「(轉檔中) <原 title>」(mount/unmount 還原) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.3 「Tab title」)
|
||||
*
|
||||
* 設計:
|
||||
* - 第一次 mount 時鎖定 baseTitle(用 useRef,整個元件 lifecycle 不重抓)
|
||||
* - prefix 變化時(例:locale 切換 zh ↔ en)→ useEffect re-run:
|
||||
* cleanup 還原成 baseTitle → 重新 set 為「新 prefix + baseTitle」
|
||||
* 不會讀到含舊 prefix 的 document.title 當 baseline → 避免「(Converting) (轉檔中) ⋯」累積
|
||||
* - unmount:還原 baseTitle
|
||||
*
|
||||
* 為什麼用 useRef 而非 useState:
|
||||
* - 鎖 baseTitle 不需要觸發 re-render
|
||||
* - useState 會把 baseTitle 當依賴或觸發 effect 重跑,反而把問題搞複雜
|
||||
*
|
||||
* 防進入時 document.title 已含本次 prefix(罕見:HMR 重 mount、雙分頁都可能撞):
|
||||
* - 若 baseline 讀到時剛好以 `prefix` 開頭,剝掉 prefix 後再鎖(避免疊加)
|
||||
*/
|
||||
function useTabTitlePrefix(prefix: string): void {
|
||||
const baseTitleRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
// 第一次 mount → 讀 base title 並鎖定。之後 prefix 變化(locale 切換)不再覆蓋
|
||||
if (baseTitleRef.current === null) {
|
||||
const initial = document.title;
|
||||
// 進入時若已含本次 prefix,剝掉再鎖(防 HMR / 雙分頁殘留)
|
||||
baseTitleRef.current = initial.startsWith(prefix)
|
||||
? initial.slice(prefix.length)
|
||||
: initial;
|
||||
}
|
||||
|
||||
const base = baseTitleRef.current;
|
||||
document.title = `${prefix}${base}`;
|
||||
|
||||
return () => {
|
||||
// cleanup:還原成 base(不還原當前 document.title — 因為 prefix 即將變化)
|
||||
// unmount 時也走這條,把 prefix 移除
|
||||
if (baseTitleRef.current !== null) {
|
||||
document.title = baseTitleRef.current;
|
||||
}
|
||||
};
|
||||
}, [prefix]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function ProcessingView() {
|
||||
const t = useT();
|
||||
|
||||
// 訂閱 store(用獨立 selector 減少不必要 re-render)
|
||||
const uiState = useConversionStore((s) => s.uiState);
|
||||
const job = useConversionStore((s) => s.job);
|
||||
// F-T9 sub-2 — 「您已有一個轉檔正在進行中,已切換至該任務」banner 用
|
||||
// store flag 由 startConversion 收 409 active_job_exists 後設為 true(flow §6.1)
|
||||
const switchedFromActiveJob = useConversionStore(
|
||||
(s) => s.switchedFromActiveJob,
|
||||
);
|
||||
|
||||
// banner 是否已被使用者 dismiss(local state;component unmount 重置 — 切回 idle 後再進
|
||||
// processing 是不同 instance,理應重新顯示。store flag 還在 → banner 重新出現是預期行為)
|
||||
const [bannerDismissed, setBannerDismissed] = useState(false);
|
||||
|
||||
// 只在 queued / running 才有意義;page.tsx 已做 routing,但保險
|
||||
const phase: "queued" | "running" =
|
||||
uiState === "running" ? "running" : "queued";
|
||||
|
||||
// tab title 標記(i18n 取 prefix;多語言下會自動切)
|
||||
useTabTitlePrefix(t("conversion.processing.tabTitle.prefix"));
|
||||
|
||||
/* ---- 衍生資料 ---- */
|
||||
const stageStatuses = useMemo(
|
||||
() => computeStageStatuses(phase, job?.stage),
|
||||
[phase, job?.stage],
|
||||
);
|
||||
|
||||
// progress bar 顯示模式:
|
||||
// queued → "queued" (隱藏 bar,顯示排隊文字)
|
||||
// running + progress 是 number → "determinate"(顯示 X%)
|
||||
// running + progress 是 null → "indeterminate"(pulse + sr-only)
|
||||
type ProgressMode = "queued" | "determinate" | "indeterminate";
|
||||
const progress = job?.progress;
|
||||
const progressMode: ProgressMode = useMemo(() => {
|
||||
if (phase === "queued") return "queued";
|
||||
if (typeof progress === "number" && Number.isFinite(progress)) {
|
||||
return "determinate";
|
||||
}
|
||||
return "indeterminate";
|
||||
}, [phase, progress]);
|
||||
|
||||
// ETA 文字:converter 不給 ETA → 不算,只顯示靜態提示
|
||||
const etaText =
|
||||
phase === "queued"
|
||||
? t("conversion.processing.eta.pending")
|
||||
: t("conversion.processing.eta.computing");
|
||||
|
||||
// 來源檔名 + 目標 chip(job 必存在於此 view;保險 fallback 空字串)
|
||||
const sourceFilename = job?.source_filename ?? "";
|
||||
const targetChip = job?.target_chip ?? "";
|
||||
|
||||
/* ---- a11y ids ---- */
|
||||
const headingId = useId();
|
||||
const progressLabelId = useId();
|
||||
|
||||
// progress clamp:防 backend 回異常值(負數 / >100 / NaN 已被 Number.isFinite 擋掉)
|
||||
const safeProgress = Math.max(0, Math.min(100, progress ?? 0));
|
||||
|
||||
// F-T9 sub-2 — 是否顯示「已切換至既存任務」banner
|
||||
// 條件:store flag = true 且使用者尚未 dismiss
|
||||
const showSwitchedBanner = switchedFromActiveJob && !bannerDismissed;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversion-processing-wrapper"
|
||||
data-phase={phase}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* F-T9 sub-2 — 「您已有一個轉檔正在進行中,已切換至該任務」banner(flow §6.1)
|
||||
使用者可 dismiss(× 按鈕),dismiss 只清 local state;store.switchedFromActiveJob
|
||||
flag 由 reset() 清除。下次再開新轉檔被 409 才會再次出現。 */}
|
||||
{showSwitchedBanner ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="processing-switched-banner"
|
||||
className="border-info/30 bg-accent/40 text-accent-foreground flex items-start gap-3 rounded-md border p-3 text-sm"
|
||||
>
|
||||
<InfoIcon
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-4 shrink-0 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
{t("conversion.processing.bannerExisting")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setBannerDismissed(true)}
|
||||
aria-label={t("conversion.processing.bannerDismiss")}
|
||||
data-testid="processing-switched-banner-dismiss"
|
||||
className="h-6 w-6 shrink-0 p-0"
|
||||
>
|
||||
<XIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
data-testid="conversion-processing"
|
||||
data-phase={phase}
|
||||
className="bg-card space-y-6 rounded-xl border p-6"
|
||||
>
|
||||
{/* 標題 + 副標 */}
|
||||
<div className="space-y-1">
|
||||
<h2 id={headingId} className="text-lg font-semibold">
|
||||
{t("conversion.processing.cardHeading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("conversion.processing.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 檔名 → chip */}
|
||||
{(sourceFilename || targetChip) && (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"
|
||||
data-testid="processing-job-summary"
|
||||
>
|
||||
{sourceFilename ? (
|
||||
<span className="font-mono" data-testid="processing-source-filename">
|
||||
{sourceFilename}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceFilename && targetChip ? (
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
{t("conversion.processing.targetChipPrefix")}
|
||||
</span>
|
||||
) : null}
|
||||
{targetChip ? (
|
||||
<span
|
||||
className="bg-accent text-accent-foreground rounded px-2 py-0.5 text-xs font-medium"
|
||||
data-testid="processing-target-chip"
|
||||
>
|
||||
{targetChip}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage indicator */}
|
||||
<StageIndicator
|
||||
statuses={stageStatuses}
|
||||
labelText={t("conversion.processing.cardHeading")}
|
||||
/>
|
||||
|
||||
{/* Progress bar 區 */}
|
||||
<div className="space-y-2">
|
||||
{progressMode === "queued" ? (
|
||||
/* queued — 用 indeterminate 視覺 + 排隊中文字 */
|
||||
<div
|
||||
data-testid="processing-progress-queued"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("conversion.processing.aria.queueProgress")}
|
||||
className="space-y-2"
|
||||
>
|
||||
{/* 跑馬燈(pulse 視覺) — wireframe §6.2 建議:indeterminate 用 animate-pulse */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
"motion-safe:animate-pulse",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/40 motion-reduce:bg-primary/30 absolute inset-y-0 left-0 w-1/3 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Loader2Icon
|
||||
aria-hidden="true"
|
||||
className="text-primary size-4 motion-safe:animate-spin"
|
||||
/>
|
||||
<span data-testid="processing-queued-text">
|
||||
{t("conversion.processing.queued")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : progressMode === "determinate" ? (
|
||||
/* running + 有 progress 百分比 */
|
||||
<div className="space-y-2">
|
||||
<span id={progressLabelId} className="sr-only">
|
||||
{t("conversion.processing.processing")}
|
||||
</span>
|
||||
<Progress
|
||||
value={safeProgress}
|
||||
aria-labelledby={progressLabelId}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(safeProgress)}
|
||||
data-testid="processing-progress-determinate"
|
||||
/>
|
||||
<div
|
||||
className="text-muted-foreground flex items-center justify-between text-sm"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span data-testid="processing-progress-pct">
|
||||
{`${Math.round(safeProgress)}%`}
|
||||
</span>
|
||||
<span data-testid="processing-eta-text">{etaText}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* running + progress=null → indeterminate */
|
||||
<div
|
||||
data-testid="processing-progress-indeterminate"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-busy="true"
|
||||
aria-label={t("conversion.processing.aria.progressIndeterminate")}
|
||||
className="space-y-2"
|
||||
>
|
||||
{/* 不確定百分比 — wireframe §6.2 建議:indeterminate 用 animate-pulse */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
"motion-safe:animate-pulse",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary motion-reduce:bg-primary/70 absolute inset-y-0 left-0 w-2/3 rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground flex items-center justify-between text-sm"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span data-testid="processing-progress-label">
|
||||
{t("conversion.processing.processing")}
|
||||
</span>
|
||||
<span data-testid="processing-eta-text">{etaText}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 7 天清除提醒 */}
|
||||
<p
|
||||
data-testid="processing-expiry-hint"
|
||||
className="text-muted-foreground flex items-start gap-2 border-t pt-3 text-xs"
|
||||
>
|
||||
<InfoIcon aria-hidden="true" className="mt-0.5 size-4 flex-shrink-0" />
|
||||
<span>{t("conversion.processing.expiryHint")}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* StageIndicator — 三段式 stepper(自寫,元件庫沒 stepper) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface StageIndicatorProps {
|
||||
/** 三段(順序對應 onnx / bie / nef) */
|
||||
statuses: readonly StageStatus[];
|
||||
/**
|
||||
* 給 list 整體名稱(套用到 `aria-label`)。
|
||||
* 註:原本同時有 `aria-labelledby` 指向外層 heading 的 id,但該 id 在 DOM 中沒有
|
||||
* 對應元素 → SR 找不到 reference 元素時 `aria-labelledby` 等同沒設,
|
||||
* 反而讓 `aria-label` 也被 override 為空。改純 `aria-label` 更可靠。
|
||||
*/
|
||||
labelText: string;
|
||||
}
|
||||
|
||||
function StageIndicator({ statuses, labelText }: StageIndicatorProps) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<ol
|
||||
role="list"
|
||||
aria-label={labelText}
|
||||
data-testid="processing-stage-indicator"
|
||||
className="flex items-start justify-between gap-2 sm:gap-3"
|
||||
>
|
||||
{STAGE_ORDER.map((stage, idx) => {
|
||||
const status = statuses[idx] ?? "pending";
|
||||
const stageLabel = t(`conversion.processing.stage.${stage}`);
|
||||
const statusLabel = t(
|
||||
`conversion.processing.stage.status.${status}`,
|
||||
);
|
||||
// a11y label: e.g.「解析模型(已完成)」
|
||||
const ariaLabel = t(
|
||||
`conversion.processing.stage.aria.${status}`,
|
||||
).replace("{name}", stageLabel);
|
||||
|
||||
const isLast = idx === STAGE_ORDER.length - 1;
|
||||
const nextCompleted = !isLast && statuses[idx + 1] === "completed";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={stage}
|
||||
role="listitem"
|
||||
aria-label={ariaLabel}
|
||||
aria-current={status === "current" ? "step" : undefined}
|
||||
data-stage={stage}
|
||||
data-status={status}
|
||||
data-testid={`processing-stage-${stage}`}
|
||||
className={cn(
|
||||
"relative flex flex-1 flex-col items-center gap-2 text-center",
|
||||
// 讓步驟之間有連線(除了最後一個)
|
||||
!isLast &&
|
||||
"after:bg-muted after:absolute after:top-4 after:left-[calc(50%+1.25rem)] after:h-0.5 after:w-[calc(100%-2.5rem)] after:content-['']",
|
||||
// 已完成 → 後面那段連線變主色
|
||||
!isLast &&
|
||||
(status === "completed" || nextCompleted) &&
|
||||
"after:bg-primary",
|
||||
)}
|
||||
>
|
||||
{/* 圓圈 */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors",
|
||||
status === "completed" &&
|
||||
"bg-primary border-primary text-primary-foreground",
|
||||
status === "current" &&
|
||||
"border-primary text-primary bg-background ring-primary/30 ring-2 ring-offset-2",
|
||||
status === "pending" &&
|
||||
"bg-muted border-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "completed" ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : status === "current" ? (
|
||||
<Loader2Icon className="size-4 motion-safe:animate-spin" />
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 名稱 */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs sm:text-sm",
|
||||
status === "current" && "text-foreground font-medium",
|
||||
status === "completed" && "text-foreground",
|
||||
status === "pending" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{stageLabel}
|
||||
</span>
|
||||
|
||||
{/* 狀態(完成 / 進行中 / 待處理) */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs",
|
||||
status === "current" && "text-primary font-medium",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
318
visionA-frontend/src/app/conversion/components/PromoteDialog.tsx
Normal file
318
visionA-frontend/src/app/conversion/components/PromoteDialog.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PromoteDialog — 「加到模型庫」確認對話框
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §7.1)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.5「加到模型庫」分支(半自動,user 顯式確認名稱)
|
||||
* - api-conversion.md §3 promote-to-models(body 只送 name;description 留 Phase 1)
|
||||
* - feature-converter-integration.md §F6(同 job 重複呼叫 → 409 already_imported)
|
||||
*
|
||||
* 範圍(F-T7):
|
||||
* - 預設 name = `{source_filename_stem}_{target_chip.lower()}`(flow §5.5 規則)
|
||||
* - 單一輸入欄位 name;驗證:必填 / ≤ 100 字元 / 不可含 `/` `\`
|
||||
* - 確認 → 呼叫 store.promoteToModels(name) → spinner + disable 兩按鈕
|
||||
* - 200 → 關 dialog + onSuccess(modelId);toast 由 caller 在 onSuccess 處理(避免重複觸發)
|
||||
* - 409 already_imported → dialog 內顯示特殊錯誤;不關 dialog(user 可看到提示再選擇下一步)
|
||||
* - 其他錯誤 → dialog 內紅字顯示 + 仍可關閉重試
|
||||
* - Description 欄位 Phase 0.8 不收(PRD F6 + Design §7.1 共識)
|
||||
*
|
||||
* a11y:
|
||||
* - Radix Dialog 自帶 focus trap + ESC 關閉
|
||||
* - 確認按鈕 spinner 期間 aria-busy="true"
|
||||
* - 錯誤訊息 role="alert" + aria-live
|
||||
*
|
||||
* 不做:
|
||||
* - 多語言 url 拼裝(caller 拿到 model_id 自己決定要 navigate 還是顯示 toast 連結)
|
||||
* - 「前往該模型」連結內嵌(toast 由 SuccessView 統一處理)
|
||||
*/
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { TargetChip } from "@/types/conversion";
|
||||
|
||||
/** 對齊 PRD F6:name 上限 100 字元(與 backend schema 相容;wireframe §7.1 寫「最多 100 字元」) */
|
||||
const NAME_MAX_LENGTH = 100;
|
||||
/** 不允許的 path traversal 字元 */
|
||||
const INVALID_NAME_CHARS = /[/\\]/;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 把 file 名稱去掉副檔名(yolov5s.onnx → yolov5s) */
|
||||
function stripExtension(filename: string): string {
|
||||
const i = filename.lastIndexOf(".");
|
||||
return i > 0 ? filename.slice(0, i) : filename;
|
||||
}
|
||||
|
||||
/** 預設 model name = `{stem}_{chip.lower()}`(flow-conversion.md §5.5) */
|
||||
export function buildDefaultPromoteName(
|
||||
sourceFilename: string,
|
||||
chip: TargetChip,
|
||||
): string {
|
||||
const stem = stripExtension(sourceFilename);
|
||||
return `${stem}_${chip.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface PromoteDialogProps {
|
||||
/** 是否開啟 */
|
||||
open: boolean;
|
||||
/** 開關狀態變更 — caller 自己 own 此 state */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 預設名稱(caller 從 job 算好傳進來;元件內部會再用 useState 跟使用者編輯結果) */
|
||||
defaultName: string;
|
||||
/** 給 dialog description 顯示用(無實際邏輯) */
|
||||
targetChip: TargetChip;
|
||||
/** Job id 顯示 short hash 用 */
|
||||
jobId: string;
|
||||
/** 成功時回呼;caller 用 model_id 跳頁 / 顯示 toast */
|
||||
onSuccess: (modelId: string) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Outer wrapper — 把 inner state hooks 包到 `open=true` 才會 mount 的 inner 元件,
|
||||
* 達成「每次開 dialog 重置 name」的效果(取代 useEffect+setState pattern)。
|
||||
*
|
||||
* 原理:Radix Dialog 在 `open=false` 時不掛 Portal/Content,但本元件外層仍需要保持
|
||||
* mount(接收 onOpenChange 訊號)。把 stateful inner 拆出來只在 open=true 才 mount,
|
||||
* 即可確保 useState lazy init 每次都拿到最新 defaultName。
|
||||
*/
|
||||
export function PromoteDialog(props: PromoteDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(next) => props.onOpenChange(next)}
|
||||
>
|
||||
{props.open ? <PromoteDialogInner {...props} /> : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteDialogInner({
|
||||
onOpenChange,
|
||||
defaultName,
|
||||
targetChip,
|
||||
jobId,
|
||||
onSuccess,
|
||||
}: PromoteDialogProps) {
|
||||
const t = useT();
|
||||
const promoteToModels = useConversionStore((s) => s.promoteToModels);
|
||||
|
||||
// lazy init:每次 inner 重新 mount(即每次 dialog 開啟)都用最新 defaultName
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
/** dialog 內顯示的錯誤訊息(已翻譯) */
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
/* ---- 前端驗證 ---- */
|
||||
const trimmedName = name.trim();
|
||||
const validationError = useMemo<string | null>(() => {
|
||||
if (!trimmedName) {
|
||||
return t("conversion.success.import.dialog.nameError.required");
|
||||
}
|
||||
if (trimmedName.length > NAME_MAX_LENGTH) {
|
||||
return t("conversion.success.import.dialog.nameError.tooLong");
|
||||
}
|
||||
if (INVALID_NAME_CHARS.test(trimmedName)) {
|
||||
return t("conversion.success.import.dialog.nameError.invalidChars");
|
||||
}
|
||||
return null;
|
||||
}, [trimmedName, t]);
|
||||
|
||||
// 顯示用:服務端錯誤優先;否則才顯示驗證錯誤(且只在 user 已輸入過 / 嘗試 submit 時顯示)
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
const visibleError = errorMessage ?? (showValidation ? validationError : null);
|
||||
|
||||
/* ---- ids ---- */
|
||||
const titleId = useId();
|
||||
const descId = useId();
|
||||
const nameInputId = useId();
|
||||
const nameErrorId = useId();
|
||||
const nameHintId = useId();
|
||||
|
||||
/* ---- handlers ---- */
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value);
|
||||
// 服務端錯誤在使用者重新編輯後清掉(讓 user 知道編輯後會重試)
|
||||
if (errorMessage) setErrorMessage(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setShowValidation(true);
|
||||
if (validationError) return;
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await promoteToModels(trimmedName);
|
||||
// 成功 — caller 處理後續(toast / 跳頁)
|
||||
onSuccess(result.model_id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
// store 在 promote 失敗時會把 error 寫進 store;但這裡用 throw 拿 code 做特殊處理
|
||||
const code = (err as { code?: string } | undefined)?.code ?? "unknown";
|
||||
// 對齊 wireframe §7.1:409 already_imported / job_not_completed → 對應 i18n
|
||||
// 沒符合的 → fallback errorGeneric
|
||||
let i18nKey: string;
|
||||
if (code === "active_job_exists" || code === "already_imported") {
|
||||
// backend Phase 0.8 文件用 409 已加入過模型庫 → conversion.md §6 對應 i18n key
|
||||
i18nKey = "conversion.success.import.toastDup";
|
||||
} else {
|
||||
i18nKey = "conversion.success.import.errorGeneric";
|
||||
}
|
||||
setErrorMessage(t(i18nKey));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (submitting) return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
/* ---- 衍生顯示資料 ---- */
|
||||
const shortJobId = jobId.slice(0, 8);
|
||||
const sourceValue = t("conversion.success.import.dialog.sourceValue").replace(
|
||||
"{shortJobId}",
|
||||
shortJobId,
|
||||
);
|
||||
const dialogDescription = t(
|
||||
"conversion.success.import.dialog.description",
|
||||
).replace("{chip}", targetChip);
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descId}
|
||||
data-testid="promote-dialog"
|
||||
className="max-w-md"
|
||||
// 上傳中阻擋 ESC / outside click 關閉 dialog(與既有 model-upload-dialog 風格一致)
|
||||
onEscapeKeyDown={(e) => submitting && e.preventDefault()}
|
||||
onPointerDownOutside={(e) => submitting && e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle id={titleId}>
|
||||
{t("conversion.success.import.dialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription id={descId}>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* name 欄位 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={nameInputId}>
|
||||
{t("conversion.success.import.dialog.nameLabel")}
|
||||
<span aria-hidden="true" className="text-destructive ml-0.5">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={nameInputId}
|
||||
data-testid="promote-dialog-name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
maxLength={NAME_MAX_LENGTH + 1 /* 多 1 讓使用者看到「太長」錯誤 */}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
disabled={submitting}
|
||||
aria-invalid={visibleError ? true : undefined}
|
||||
aria-describedby={
|
||||
visibleError ? `${nameErrorId} ${nameHintId}` : nameHintId
|
||||
}
|
||||
/>
|
||||
<p id={nameHintId} className="text-muted-foreground text-xs">
|
||||
{t("conversion.success.import.dialog.nameHint")}
|
||||
</p>
|
||||
{visibleError ? (
|
||||
<p
|
||||
id={nameErrorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-destructive text-sm"
|
||||
data-testid="promote-dialog-error"
|
||||
>
|
||||
{visibleError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 來源 — 只讀顯示,不收使用者輸入 */}
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{t("conversion.success.import.dialog.sourceLabel")}:
|
||||
</span>
|
||||
{sourceValue}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{t("conversion.success.summary.chip")}:
|
||||
</span>
|
||||
{targetChip}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
data-testid="promote-dialog-cancel"
|
||||
>
|
||||
{t("conversion.success.import.dialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
aria-busy={submitting}
|
||||
data-testid="promote-dialog-confirm"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("conversion.success.import.processing")}
|
||||
</>
|
||||
) : (
|
||||
t("conversion.success.import.dialog.confirm")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,534 @@
|
||||
/**
|
||||
* SuccessView 單元測試(Phase 0.8 F-T7)
|
||||
*
|
||||
* 對齊:
|
||||
* - wireframe-conversion.md §3.4 + §7.1(Success state 視覺 + import dialog)
|
||||
* - flow-conversion.md §5.5 succeeded → 兩按鈕半自動分支
|
||||
* - api-conversion.md §3 / §4
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 成功 hero 顯示 title / 描述
|
||||
* - 任務摘要:source filename / target chip / output filename / size / checksum
|
||||
* - 「下載」<a> href = /api/conversion/{jobId}/download
|
||||
* - 「加到模型庫」開 PromoteDialog,預設 name = stem_chip
|
||||
* - PromoteDialog 確認 → 呼叫 store.promoteToModels(name) → toast 顯示
|
||||
* - PromoteDialog 409 already_imported / active_job_exists → 顯示特殊錯誤訊息
|
||||
* - 「開始新轉檔」呼叫 store.reset
|
||||
* - 7 天倒數顯示(mock now / Date.parse)
|
||||
* - 過期當下:兩按鈕 disabled、提示文字切「已過期」
|
||||
*
|
||||
* Mock 策略:
|
||||
* - sonner toast → vi.mock 攔截 toast.success
|
||||
* - store.promoteToModels / reset → vi.spyOn
|
||||
* - Date.now → vi.useFakeTimers + setSystemTime
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
|
||||
import { ConversionAPIError } from "@/lib/api/conversion";
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
import { SuccessView } from "./SuccessView";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock sonner — 攔截 toast.success 的呼叫 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
const warning = vi.fn();
|
||||
const info = vi.fn();
|
||||
return {
|
||||
toast: Object.assign(
|
||||
vi.fn(),
|
||||
{ success, error, warning, info },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock next/navigation — useRouter 在 jsdom 下無 app router context */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const mockRouterPush = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function makeSuccessJob(
|
||||
overrides: Partial<ConversionJob> = {},
|
||||
): ConversionJob {
|
||||
return {
|
||||
job_id: "abcdef12-3456-7890-aaaa-bbbbccccdddd",
|
||||
status: "succeeded",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
result: {
|
||||
output_size_bytes: 12_300_000,
|
||||
output_checksum: "sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setSucceeded(job: ConversionJob = makeSuccessJob()) {
|
||||
useConversionStore.setState({
|
||||
uiState: "succeeded",
|
||||
job,
|
||||
});
|
||||
}
|
||||
|
||||
function renderView() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<SuccessView />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 鎖定 Date.now 到 2026-04-30T12:00:00Z(與 makeSuccessJob 的 created_at 一致;
|
||||
* 配合 expires_at 預設 +7d 算出「剩 7 天」)。
|
||||
*
|
||||
* **不**用 vi.useFakeTimers — fake timers 會把 setTimeout / Promise microtask 鎖住,
|
||||
* 與 Testing Library 的 waitFor 不相容(waitFor 要等 real timer)。改用 spyOn(Date, "now")
|
||||
* 只攔 Date.now 即可(其他 setInterval 在元件 unmount 時 cleanup,不影響)。
|
||||
*/
|
||||
const NOW_FIXED = new Date("2026-04-30T12:00:00Z").getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW_FIXED);
|
||||
useConversionStore.getState().reset();
|
||||
(toast.success as Mock).mockReset();
|
||||
mockRouterPush.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 基本渲染 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<SuccessView /> — 基本渲染", () => {
|
||||
it("顯示成功 hero、任務摘要、兩按鈕、開始新轉檔", () => {
|
||||
setSucceeded();
|
||||
renderView();
|
||||
|
||||
// 成功 hero
|
||||
expect(screen.getByTestId("success-hero")).toBeTruthy();
|
||||
expect(screen.getByTestId("success-description").textContent).toContain(
|
||||
"yolov5s.onnx",
|
||||
);
|
||||
expect(screen.getByTestId("success-description").textContent).toContain(
|
||||
"KL720",
|
||||
);
|
||||
|
||||
// 任務摘要
|
||||
expect(
|
||||
screen.getByTestId("success-source-filename").textContent,
|
||||
).toContain("yolov5s.onnx");
|
||||
expect(screen.getByTestId("success-target-chip").textContent).toContain(
|
||||
"KL720",
|
||||
);
|
||||
expect(screen.getByTestId("success-output-filename").textContent).toContain(
|
||||
"yolov5s_kl720.nef",
|
||||
);
|
||||
expect(screen.getByTestId("success-output-size").textContent).toContain(
|
||||
"MB",
|
||||
);
|
||||
|
||||
// 兩按鈕 + 開始新轉檔
|
||||
expect(screen.getByTestId("success-import-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("success-download-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("success-start-new")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("顯示 checksum 前 16 字元(含 sha256: 前綴)", () => {
|
||||
setSucceeded();
|
||||
renderView();
|
||||
const cs = screen.getByTestId("success-output-checksum").textContent ?? "";
|
||||
expect(cs.startsWith("sha256:")).toBe(true);
|
||||
// 前綴 + 16 字 + 「…」
|
||||
expect(cs.endsWith("…")).toBe(true);
|
||||
// checksum 不應該整段 raw 顯示(避免太長)
|
||||
expect(cs.length).toBeLessThan("sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90".length);
|
||||
});
|
||||
|
||||
it("checksum 為空時不顯示該行", () => {
|
||||
setSucceeded(
|
||||
makeSuccessJob({
|
||||
result: { output_size_bytes: 12_300_000 },
|
||||
}),
|
||||
);
|
||||
renderView();
|
||||
expect(screen.queryByTestId("success-output-checksum")).toBeNull();
|
||||
// size 仍然顯示
|
||||
expect(screen.getByTestId("success-output-size")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 下載按鈕 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<SuccessView /> — 下載按鈕", () => {
|
||||
it("「下載」<a> href 指向 /api/conversion/{jobId}/download", () => {
|
||||
setSucceeded();
|
||||
renderView();
|
||||
|
||||
const button = screen.getByTestId("success-download-button");
|
||||
// asChild + <a> — DOM 上是 anchor
|
||||
const anchor =
|
||||
button.tagName.toLowerCase() === "a"
|
||||
? (button as HTMLAnchorElement)
|
||||
: (button.querySelector("a") as HTMLAnchorElement | null);
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor!.getAttribute("href")).toBe(
|
||||
"/api/conversion/abcdef12-3456-7890-aaaa-bbbbccccdddd/download",
|
||||
);
|
||||
expect(anchor!.hasAttribute("download")).toBe(true);
|
||||
});
|
||||
|
||||
it("點下載 → 顯示「下載已開始」toast", () => {
|
||||
setSucceeded();
|
||||
renderView();
|
||||
|
||||
const button = screen.getByTestId("success-download-button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(toast.success as Mock).toHaveBeenCalledTimes(1);
|
||||
const [msg] = (toast.success as Mock).mock.calls[0];
|
||||
expect(typeof msg).toBe("string");
|
||||
expect((msg as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 加到模型庫 — PromoteDialog */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<SuccessView /> — 加到模型庫", () => {
|
||||
it("點按鈕 → 開啟 PromoteDialog,預設 name = stem_chip", () => {
|
||||
setSucceeded();
|
||||
renderView();
|
||||
|
||||
expect(screen.queryByTestId("promote-dialog")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
|
||||
const dialog = screen.getByTestId("promote-dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
|
||||
const nameInput = screen.getByTestId(
|
||||
"promote-dialog-name",
|
||||
) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("yolov5s_kl720");
|
||||
});
|
||||
|
||||
it("確認 → 呼叫 store.promoteToModels(trimmedName),成功後 toast 顯示", async () => {
|
||||
setSucceeded();
|
||||
const promoteSpy = vi
|
||||
.spyOn(useConversionStore.getState(), "promoteToModels")
|
||||
.mockResolvedValue({ model_id: "model-xyz" });
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
|
||||
// 改個名再 submit
|
||||
const input = screen.getByTestId("promote-dialog-name") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: " my_model " } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(promoteSpy).toHaveBeenCalledWith("my_model");
|
||||
});
|
||||
|
||||
// 成功 toast 顯示
|
||||
await waitFor(() => {
|
||||
expect(toast.success as Mock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// dialog 關閉
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("promote-dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("名稱為空 → 顯示驗證錯誤、不打 API", () => {
|
||||
setSucceeded();
|
||||
const promoteSpy = vi
|
||||
.spyOn(useConversionStore.getState(), "promoteToModels")
|
||||
.mockResolvedValue({ model_id: "model-xyz" });
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
|
||||
const input = screen.getByTestId("promote-dialog-name") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
expect(promoteSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("promote-dialog-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("名稱含 / → 顯示驗證錯誤", () => {
|
||||
setSucceeded();
|
||||
const promoteSpy = vi
|
||||
.spyOn(useConversionStore.getState(), "promoteToModels")
|
||||
.mockResolvedValue({ model_id: "x" });
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
|
||||
fireEvent.change(screen.getByTestId("promote-dialog-name"), {
|
||||
target: { value: "../etc/passwd" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
expect(promoteSpy).not.toHaveBeenCalled();
|
||||
const err = screen.getByTestId("promote-dialog-error");
|
||||
expect(err.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("backend 回 active_job_exists / 409 → dialog 內顯示「已加入過」訊息", async () => {
|
||||
setSucceeded();
|
||||
vi.spyOn(useConversionStore.getState(), "promoteToModels").mockRejectedValue(
|
||||
new ConversionAPIError(409, "active_job_exists", "already imported"),
|
||||
);
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("promote-dialog-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
// dialog 不關閉
|
||||
expect(screen.getByTestId("promote-dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("backend 其他錯誤 → 顯示通用錯誤訊息", async () => {
|
||||
setSucceeded();
|
||||
vi.spyOn(useConversionStore.getState(), "promoteToModels").mockRejectedValue(
|
||||
new ConversionAPIError(500, "internal", "boom"),
|
||||
);
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
const err = screen.getByTestId("promote-dialog-error");
|
||||
expect(err.textContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("spinner 期間:cancel 按鈕 disabled、嘗試關閉不生效(dialog 仍 visible)", async () => {
|
||||
setSucceeded();
|
||||
// 用一個永遠不 resolve 的 promise 鎖定 spinner 狀態
|
||||
let _resolve: (v: { model_id: string }) => void = () => {};
|
||||
const pending = new Promise<{ model_id: string }>((resolve) => {
|
||||
_resolve = resolve;
|
||||
});
|
||||
vi.spyOn(useConversionStore.getState(), "promoteToModels").mockReturnValue(
|
||||
pending,
|
||||
);
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
// 等到 confirm button 進入 spinner 狀態(aria-busy)
|
||||
await waitFor(() => {
|
||||
const confirmBtn = screen.getByTestId(
|
||||
"promote-dialog-confirm",
|
||||
) as HTMLButtonElement;
|
||||
expect(confirmBtn.getAttribute("aria-busy")).toBe("true");
|
||||
expect(confirmBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// cancel 按鈕也應 disabled
|
||||
const cancelBtn = screen.getByTestId(
|
||||
"promote-dialog-cancel",
|
||||
) as HTMLButtonElement;
|
||||
expect(cancelBtn.disabled).toBe(true);
|
||||
|
||||
// 嘗試點 cancel → 因為 disabled,dialog 不會關
|
||||
fireEvent.click(cancelBtn);
|
||||
|
||||
// dialog 仍 visible
|
||||
expect(screen.getByTestId("promote-dialog")).toBeTruthy();
|
||||
|
||||
// 順便驗 ESC / outside click 在 component 內也都被 e.preventDefault 擋掉
|
||||
// (已在 PromoteDialog onEscapeKeyDown / onPointerDownOutside 攔下)
|
||||
// 這裡用 cancel 路徑驗證 spinner 期間「無法 close」的核心保證已足夠
|
||||
|
||||
// 收尾:resolve pending 讓元件 cleanup(避免 act warning)
|
||||
_resolve({ model_id: "after-test" });
|
||||
});
|
||||
|
||||
it("成功 toast 的 action onClick → 透過 next/navigation router.push 導向 /models/{id}(不用 location.href)", async () => {
|
||||
setSucceeded();
|
||||
vi.spyOn(useConversionStore.getState(), "promoteToModels").mockResolvedValue(
|
||||
{ model_id: "m-router-1" },
|
||||
);
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
// 等 toast.success 被呼叫,並拿到 action.onClick
|
||||
await waitFor(() => {
|
||||
expect(toast.success as Mock).toHaveBeenCalled();
|
||||
});
|
||||
const call = (toast.success as Mock).mock.calls[0];
|
||||
const opts = call[1] as { action?: { onClick?: () => void } } | undefined;
|
||||
expect(opts?.action?.onClick).toBeTypeOf("function");
|
||||
|
||||
// 觸發 action onClick → 應 push 到模型詳細頁,而不是 hard reload
|
||||
opts!.action!.onClick!();
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/models/m-router-1");
|
||||
});
|
||||
|
||||
it("成功後重新點按鈕 → 顯示 statusDone label 而非 cta label", async () => {
|
||||
setSucceeded();
|
||||
vi.spyOn(useConversionStore.getState(), "promoteToModels").mockResolvedValue(
|
||||
{ model_id: "m1" },
|
||||
);
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("promote-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
// import 按鈕的文字應該包含「已加入」字樣(中文 ✓ 已加入 / 英文 ✓ Added)
|
||||
const btn = screen.getByTestId("success-import-button");
|
||||
const text = btn.textContent ?? "";
|
||||
expect(/已加入|Added/.test(text)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 開始新轉檔 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<SuccessView /> — 開始新轉檔", () => {
|
||||
it("點「開始新轉檔」→ 呼叫 store.reset", () => {
|
||||
setSucceeded();
|
||||
const resetSpy = vi.spyOn(useConversionStore.getState(), "reset");
|
||||
|
||||
renderView();
|
||||
fireEvent.click(screen.getByTestId("success-start-new"));
|
||||
|
||||
expect(resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 7 天倒數 + 過期 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<SuccessView /> — expiry countdown", () => {
|
||||
it("剩 6 天 23 小時時,zh-Hant 顯示「6 天 23 小時」(locale 純中文,不含 / en mix)", () => {
|
||||
// now = 2026-04-30T12:00:00Z;expires = 2026-05-07T11:00:00Z(剩 6 天 23 小時)
|
||||
setSucceeded(
|
||||
makeSuccessJob({ expires_at: "2026-05-07T11:00:00Z" }),
|
||||
);
|
||||
renderView();
|
||||
|
||||
const hint = screen.getByTestId("success-expiry-hint").textContent ?? "";
|
||||
// 6 天 23 小時 — 中文 locale 應該只看到中文,不應包含 「6d 23h」這種英文字串
|
||||
expect(hint).toContain("6 天");
|
||||
expect(hint).toContain("23 小時");
|
||||
// 不該再有「{n} 天 {n} 小時 / {n}d {n}h」雙語混塞 pattern(m4 已修)
|
||||
expect(/\d+d\s*\d+h/.test(hint)).toBe(false);
|
||||
});
|
||||
|
||||
it("已過期:兩按鈕都是真實 disabled <button> / 文字切「已過期」", () => {
|
||||
// now = 2026-04-30T12:00:00Z;expires 在過去
|
||||
setSucceeded(
|
||||
makeSuccessJob({ expires_at: "2026-04-29T12:00:00Z" }),
|
||||
);
|
||||
renderView();
|
||||
|
||||
const importBtn = screen.getByTestId("success-import-button");
|
||||
const downloadBtn = screen.getByTestId("success-download-button");
|
||||
|
||||
// 兩按鈕都應該是 <button disabled>(不是 <a aria-disabled>),確保:
|
||||
// 1. keyboard 可 focus(disabled button 仍在 tab order 中可被 SR 讀到)
|
||||
// 2. SR 拿到 native disabled 語意
|
||||
// 3. 沒有 race condition(不會在 mousedown→mouseup 之間突然從 a→button 改變)
|
||||
expect(importBtn.tagName.toLowerCase()).toBe("button");
|
||||
expect(downloadBtn.tagName.toLowerCase()).toBe("button");
|
||||
expect((importBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((downloadBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
// 提示文字
|
||||
const hint = screen.getByTestId("success-expiry-hint").textContent ?? "";
|
||||
expect(/已過期|expired/i.test(hint)).toBe(true);
|
||||
});
|
||||
|
||||
it("已過期:download 按鈕沒有 href(避免 user 誤點)", () => {
|
||||
setSucceeded(
|
||||
makeSuccessJob({ expires_at: "2026-04-29T12:00:00Z" }),
|
||||
);
|
||||
renderView();
|
||||
|
||||
const downloadBtn = screen.getByTestId("success-download-button");
|
||||
// disabled <button> 不會渲染成 anchor,hasAttribute("href") 永遠 false
|
||||
expect(downloadBtn.hasAttribute("href")).toBe(false);
|
||||
// 也不會有殘留的子 anchor(過期分支整個改用 button 渲染)
|
||||
expect(downloadBtn.querySelector("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("data-expired 屬性反映過期狀態", () => {
|
||||
setSucceeded(
|
||||
makeSuccessJob({ expires_at: "2026-04-29T12:00:00Z" }),
|
||||
);
|
||||
renderView();
|
||||
expect(
|
||||
screen.getByTestId("conversion-success").getAttribute("data-expired"),
|
||||
).toBe("true");
|
||||
});
|
||||
});
|
||||
540
visionA-frontend/src/app/conversion/components/SuccessView.tsx
Normal file
540
visionA-frontend/src/app/conversion/components/SuccessView.tsx
Normal file
@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SuccessView — Succeeded state(轉檔完成、半自動結果處理)
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.4 + §7.1)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.5 succeeded → 兩按鈕半自動分支(user 顯式選擇)
|
||||
* - feature-converter-integration.md F4 / F6 / F7
|
||||
* - api-conversion.md §3 promote-to-models + §4 download
|
||||
* - lib/api/conversion.ts `getConversionDownloadURL`(純字串組裝)
|
||||
*
|
||||
* 範圍(F-T7):
|
||||
* - 成功 hero(綠色 ✓ + 「轉檔完成」title)
|
||||
* - 任務摘要 card:source filename → target chip、輸出檔名、輸出大小、checksum 前 16 字元
|
||||
* - 「加到模型庫」按鈕 → 開 PromoteDialog;確認後呼叫 store.promoteToModels
|
||||
* - 「下載」按鈕 → `<a href download>` 直連 backend GET /api/conversion/{job_id}/download
|
||||
* - 兩按鈕互不互斥、視覺平衡(同樣 size / 同樣 weight)
|
||||
* - 7 天倒數提醒(每分鐘 update 一次;過期當下自動切視覺)
|
||||
* - 「開始新轉檔」按鈕 → store.reset()
|
||||
*
|
||||
* 不做:
|
||||
* - 「下載」拿到錯誤的 fallback fetch + status 偵測(任務指定方法 1,靠 browser navigation 處理錯誤頁)
|
||||
* - 多語言「描述」欄位(Phase 1)
|
||||
*
|
||||
* a11y:
|
||||
* - 兩按鈕同等視覺權重(aria-label 各自描述具體行為)
|
||||
* - PromoteDialog 內建 focus trap(Radix)
|
||||
* - Toast 由 sonner 自帶 aria-live
|
||||
* - 過期提醒 role="status" + aria-live="polite"
|
||||
*/
|
||||
|
||||
import { CheckCircle2Icon, DownloadIcon, LibraryIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getConversionDownloadURL } from "@/lib/api/conversion";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
import { PromoteDialog, buildDefaultPromoteName } from "./PromoteDialog";
|
||||
|
||||
/** 倒數每分鐘更新一次(wireframe §7.1:「每分鐘 update 一次」) */
|
||||
const COUNTDOWN_TICK_MS = 60_000;
|
||||
/** Checksum 顯示前綴長度(wireframe §3.4:「前 16 字元」) */
|
||||
const CHECKSUM_DISPLAY_PREFIX = 16;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** bytes → 人類可讀(B / KB / MB / GB) */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** 把 file 名稱去掉副檔名(yolov5s.onnx → yolov5s) */
|
||||
function stripExtension(filename: string): string {
|
||||
const i = filename.lastIndexOf(".");
|
||||
return i > 0 ? filename.slice(0, i) : filename;
|
||||
}
|
||||
|
||||
/** 推算輸出檔名(converter Phase 0.8 不直接給 output_filename,依約定組) */
|
||||
function buildOutputFilename(
|
||||
sourceFilename: string,
|
||||
chip: string,
|
||||
): string {
|
||||
const stem = stripExtension(sourceFilename);
|
||||
return `${stem}_${chip.toLowerCase()}.nef`;
|
||||
}
|
||||
|
||||
/**
|
||||
* checksum 顯示:sha256:abcd... 取前 N 字元 + ...
|
||||
*
|
||||
* checksum 通常已是 hex(無 prefix)或 `sha256:hex`;兩者都接受。
|
||||
*/
|
||||
function formatChecksum(raw: string | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.length <= CHECKSUM_DISPLAY_PREFIX) return raw;
|
||||
// 取「:」後的部分截斷(保留 sha256: 前綴更易讀)
|
||||
const colonIdx = raw.indexOf(":");
|
||||
if (colonIdx > 0 && colonIdx < raw.length - 1) {
|
||||
const prefix = raw.slice(0, colonIdx + 1);
|
||||
const rest = raw.slice(colonIdx + 1);
|
||||
return `${prefix}${rest.slice(0, CHECKSUM_DISPLAY_PREFIX)}…`;
|
||||
}
|
||||
return `${raw.slice(0, CHECKSUM_DISPLAY_PREFIX)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「剩餘毫秒」轉成 locale 對應字串:
|
||||
* - zh-Hant:「6 天 23 小時」/「23 小時 45 分鐘」/「45 分鐘」
|
||||
* - en:「6d 23h」/「23h 45m」/「45m」
|
||||
*
|
||||
* 對齊 wireframe §3.4:粒度只到分鐘,不顯示秒。
|
||||
*
|
||||
* 每個分支用獨立 i18n key(避免把中英文塞同一字串),templates 含 `{days}` /
|
||||
* `{hours}` / `{minutes}` 三種佔位符。
|
||||
*/
|
||||
function formatRemainingTime(ms: number, t: (key: string) => string): string {
|
||||
if (ms <= 0) return t("conversion.success.expiry.remaining.minutes").replace("{minutes}", "0");
|
||||
const totalMinutes = Math.floor(ms / 60_000);
|
||||
const days = Math.floor(totalMinutes / (60 * 24));
|
||||
const hours = Math.floor((totalMinutes - days * 60 * 24) / 60);
|
||||
const minutes = totalMinutes - days * 60 * 24 - hours * 60;
|
||||
|
||||
if (days > 0) {
|
||||
return hours > 0
|
||||
? t("conversion.success.expiry.remaining.daysHours")
|
||||
.replace("{days}", String(days))
|
||||
.replace("{hours}", String(hours))
|
||||
: t("conversion.success.expiry.remaining.daysOnly").replace(
|
||||
"{days}",
|
||||
String(days),
|
||||
);
|
||||
}
|
||||
if (hours > 0) {
|
||||
return minutes > 0
|
||||
? t("conversion.success.expiry.remaining.hoursMinutes")
|
||||
.replace("{hours}", String(hours))
|
||||
.replace("{minutes}", String(minutes))
|
||||
: t("conversion.success.expiry.remaining.hoursOnly").replace(
|
||||
"{hours}",
|
||||
String(hours),
|
||||
);
|
||||
}
|
||||
return t("conversion.success.expiry.remaining.minutes").replace(
|
||||
"{minutes}",
|
||||
String(minutes),
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hook:每分鐘更新「剩餘時間」(過期時切 expired 視覺) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CountdownState {
|
||||
/** 剩餘毫秒;< 0 視為已過期 */
|
||||
remainingMs: number;
|
||||
/** 是否已過期 */
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算當下的 countdown — 純函式,不耦合 React state。
|
||||
*
|
||||
* 為了避開 React 19 的 `react-hooks/set-state-in-effect` rule(不允許 effect 內
|
||||
* 同步 setState),我們改成:
|
||||
* - useState 的 lazy initializer 處理「第一次計算」(不靠 effect 觸發 setState)
|
||||
* - useEffect 只 setInterval;tick 是非同步事件,setState 在 tick callback 內合法
|
||||
* - 不監聽 expiresAt 變化重算(succeeded job 的 expires_at 是不可變欄位,
|
||||
* view lifecycle 期間不會變)
|
||||
*/
|
||||
function computeCountdown(expiresAt: string | undefined): CountdownState {
|
||||
if (!expiresAt) return { remainingMs: 0, expired: false };
|
||||
const target = Date.parse(expiresAt);
|
||||
if (!Number.isFinite(target)) return { remainingMs: 0, expired: false };
|
||||
const remaining = target - Date.now();
|
||||
return { remainingMs: remaining, expired: remaining <= 0 };
|
||||
}
|
||||
|
||||
function useExpiryCountdown(expiresAt: string | undefined): CountdownState {
|
||||
// lazy init — 每次 mount 都用最新 Date.now() 算第一筆
|
||||
const [state, setState] = useState<CountdownState>(() =>
|
||||
computeCountdown(expiresAt),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expiresAt) return;
|
||||
// tick 是 setInterval / setTimeout callback,不是同步 effect 內 setState,符合 react-hooks 規則
|
||||
const tick = () => setState(computeCountdown(expiresAt));
|
||||
|
||||
// 60 秒 tick:用來更新「6 天 23 小時 → 6 天 22 小時」這類分鐘級顯示
|
||||
const intervalId = setInterval(tick, COUNTDOWN_TICK_MS);
|
||||
|
||||
// 額外設一個 setTimeout 在「剛好過期那一刻」開火 — 解掉 60s tick 對「剛剛過期」反應遲鈍
|
||||
// 的問題(以前最多 60 秒空窗 user 可能還能點下載結果 4xx)。
|
||||
let expireTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const target = Date.parse(expiresAt);
|
||||
if (Number.isFinite(target)) {
|
||||
const remaining = target - Date.now();
|
||||
if (remaining > 0) {
|
||||
// 對齊精確 expiry 時刻 +50ms 緩衝(避免 Date.parse 微小誤差導致 callback 跑時還沒過期)
|
||||
expireTimeoutId = setTimeout(tick, remaining + 50);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
if (expireTimeoutId !== null) clearTimeout(expireTimeoutId);
|
||||
};
|
||||
}, [expiresAt]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function SuccessView() {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const job = useConversionStore((s) => s.job);
|
||||
const reset = useConversionStore((s) => s.reset);
|
||||
|
||||
const [promoteOpen, setPromoteOpen] = useState(false);
|
||||
/** import 成功後拿到的 model_id(顯示「✓ 已加入」狀態用) */
|
||||
const [importedModelId, setImportedModelId] = useState<string | null>(null);
|
||||
|
||||
const expiry = useExpiryCountdown(job?.expires_at);
|
||||
|
||||
/* ---- 防呆:page.tsx 已 routing;若 job 為 null 直接 return 空(理論上不該發生) ---- */
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SuccessViewInner
|
||||
job={job}
|
||||
reset={reset}
|
||||
router={router}
|
||||
promoteOpen={promoteOpen}
|
||||
setPromoteOpen={setPromoteOpen}
|
||||
importedModelId={importedModelId}
|
||||
setImportedModelId={setImportedModelId}
|
||||
expiry={expiry}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Inner — 把 hooks 收進來,避免 early-return 後 hook 規則失效 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SuccessViewInnerProps {
|
||||
job: ConversionJob;
|
||||
reset: () => void;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
promoteOpen: boolean;
|
||||
setPromoteOpen: (next: boolean) => void;
|
||||
importedModelId: string | null;
|
||||
setImportedModelId: (id: string | null) => void;
|
||||
expiry: CountdownState;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function SuccessViewInner({
|
||||
job,
|
||||
reset,
|
||||
router,
|
||||
promoteOpen,
|
||||
setPromoteOpen,
|
||||
importedModelId,
|
||||
setImportedModelId,
|
||||
expiry,
|
||||
t,
|
||||
}: SuccessViewInnerProps) {
|
||||
/* ---- 衍生資料 ---- */
|
||||
const sourceFilename = job.source_filename || "";
|
||||
const targetChip = job.target_chip;
|
||||
const outputFilename = useMemo(
|
||||
() => buildOutputFilename(sourceFilename, targetChip),
|
||||
[sourceFilename, targetChip],
|
||||
);
|
||||
const outputSize = formatBytes(job.result?.output_size_bytes ?? 0);
|
||||
const checksum = formatChecksum(job.result?.output_checksum);
|
||||
const downloadURL = useMemo(
|
||||
() => getConversionDownloadURL(job.job_id),
|
||||
[job.job_id],
|
||||
);
|
||||
|
||||
const defaultPromoteName = useMemo(
|
||||
() => buildDefaultPromoteName(sourceFilename, targetChip),
|
||||
[sourceFilename, targetChip],
|
||||
);
|
||||
|
||||
/* ---- handlers ---- */
|
||||
const handleImportClick = () => {
|
||||
setPromoteOpen(true);
|
||||
};
|
||||
|
||||
const handlePromoteSuccess = (modelId: string) => {
|
||||
setImportedModelId(modelId);
|
||||
// 對齊 wireframe §7.1:toast「已加入模型庫」+「前往模型庫」連結
|
||||
// 用 Next.js router.push 取代 window.location.href,避免 hard reload 失去 SPA 狀態
|
||||
toast.success(t("conversion.success.import.toastDone"), {
|
||||
description: t("conversion.success.import.statusDone"),
|
||||
action: {
|
||||
label: t("conversion.success.import.toastDoneAction"),
|
||||
onClick: () => {
|
||||
router.push(`/models/${modelId}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
// 對齊 wireframe §7.1 + flow §5.5:點下載 → toast 提示「下載已開始」+ hint
|
||||
// browser 處理 navigation;按鈕本身是 anchor tag(asChild)— 不需 preventDefault
|
||||
toast.success(t("conversion.success.download.toastStart"), {
|
||||
description: t("conversion.success.download.toastHint"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartNew = () => {
|
||||
// store.reset() 切回 idle;polling / upload controller 都會被清掉
|
||||
reset();
|
||||
};
|
||||
|
||||
/* ---- 過期視覺 ---- */
|
||||
// wireframe §7.1 — 過期當下:頁面切「已過期」狀態,按鈕 disabled。
|
||||
// 實作:保留 view 結構但下載 / import 按鈕變 disabled、提示文字換成 expired。
|
||||
const isExpired = expiry.expired;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversion-success"
|
||||
data-expired={isExpired ? "true" : "false"}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 成功 hero */}
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-xl border border-green-300 bg-green-50/40 p-6 dark:border-green-800 dark:bg-green-950/20"
|
||||
data-testid="success-hero"
|
||||
>
|
||||
<CheckCircle2Icon
|
||||
aria-hidden="true"
|
||||
className="size-6 shrink-0 text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("conversion.success.heading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm" data-testid="success-description">
|
||||
{t("conversion.success.description")
|
||||
.replace("{source}", sourceFilename)
|
||||
.replace("{chip}", targetChip)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任務摘要 card */}
|
||||
<div
|
||||
data-testid="success-summary"
|
||||
className="bg-card space-y-3 rounded-xl border p-6"
|
||||
>
|
||||
{/* 檔名 → chip */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-mono" data-testid="success-source-filename">
|
||||
{sourceFilename}
|
||||
</span>
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span
|
||||
className="bg-accent text-accent-foreground rounded px-2 py-0.5 text-xs font-medium"
|
||||
data-testid="success-target-chip"
|
||||
>
|
||||
{targetChip}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 輸出檔資訊 */}
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("conversion.success.summary.outputFile")}
|
||||
</dt>
|
||||
<dd
|
||||
data-testid="success-output-filename"
|
||||
className="font-mono text-sm"
|
||||
>
|
||||
{outputFilename}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("conversion.success.summary.size")}
|
||||
</dt>
|
||||
<dd data-testid="success-output-size" className="text-sm">
|
||||
{outputSize}
|
||||
</dd>
|
||||
</div>
|
||||
{checksum ? (
|
||||
<div className="space-y-0.5 sm:col-span-2">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("conversion.success.summary.checksum")}
|
||||
</dt>
|
||||
<dd
|
||||
data-testid="success-output-checksum"
|
||||
className="font-mono text-xs break-all"
|
||||
>
|
||||
{checksum}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 兩按鈕區(視覺平衡:grid-cols-1 mobile / grid-cols-2 ≥ sm)*/}
|
||||
<div className="space-y-3" data-testid="success-actions">
|
||||
<p className="text-sm font-medium">{t("conversion.success.nextStep")}</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* 加到模型庫 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleImportClick}
|
||||
disabled={isExpired}
|
||||
aria-label={t("conversion.success.import.aria.cta")}
|
||||
data-testid="success-import-button"
|
||||
className="h-auto justify-start gap-3 py-4"
|
||||
>
|
||||
<LibraryIcon aria-hidden="true" className="size-5" />
|
||||
<span className="flex flex-col items-start text-left">
|
||||
<span className="text-base font-semibold">
|
||||
{importedModelId
|
||||
? t("conversion.success.import.statusDone")
|
||||
: t("conversion.success.import.cta")}
|
||||
</span>
|
||||
<span className="text-primary-foreground/80 text-xs font-normal">
|
||||
{t("conversion.success.import.description").replace(
|
||||
"{chip}",
|
||||
targetChip,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* 下載 — 過期分支 / 正常分支拆開:
|
||||
- 過期:渲染 <Button disabled>(真實 button 元素 + native disabled 語意,
|
||||
kbd 不會 focus、SR 拿到 disabled、避免 race(mousedown→mouseup 之間切過期)
|
||||
時 user 點了沒反應且不知為什麼的問題)
|
||||
- 未過期:渲染 <Button asChild><a download>,沿用既有 browser navigation
|
||||
方案(飛 token 不過 frontend JS) */}
|
||||
{isExpired ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled
|
||||
aria-label={t("conversion.success.download.aria.disabled")}
|
||||
data-testid="success-download-button"
|
||||
className="h-auto justify-start gap-3 py-4"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" className="size-5" />
|
||||
<span className="flex flex-col items-start text-left">
|
||||
<span className="text-base font-semibold">
|
||||
{t("conversion.success.download.cta")}
|
||||
</span>
|
||||
<span className="text-xs font-normal opacity-70">
|
||||
{t("conversion.success.download.description")}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
aria-label={t("conversion.success.download.aria.cta")}
|
||||
data-testid="success-download-button"
|
||||
className="h-auto justify-start gap-3 py-4"
|
||||
>
|
||||
<a
|
||||
href={downloadURL}
|
||||
download
|
||||
onClick={handleDownloadClick}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" className="size-5" />
|
||||
<span className="flex flex-col items-start text-left">
|
||||
<span className="text-base font-semibold">
|
||||
{t("conversion.success.download.cta")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{t("conversion.success.download.description")}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7 天倒數提醒 */}
|
||||
<p
|
||||
data-testid="success-expiry-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
<span aria-hidden="true">⏳</span>
|
||||
<span>
|
||||
{isExpired
|
||||
? t("conversion.success.expiry.expired")
|
||||
: t("conversion.success.expiry").replace(
|
||||
"{time}",
|
||||
formatRemainingTime(expiry.remainingMs, t),
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* 「開始新轉檔」(在結果卡片外面 — wireframe §7.3 設計:避免誤點離開結果頁)*/}
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleStartNew}
|
||||
data-testid="success-start-new"
|
||||
>
|
||||
{t("conversion.success.startNew")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PromoteDialog */}
|
||||
<PromoteDialog
|
||||
open={promoteOpen}
|
||||
onOpenChange={setPromoteOpen}
|
||||
defaultName={defaultPromoteName}
|
||||
targetChip={targetChip}
|
||||
jobId={job.job_id}
|
||||
onSuccess={handlePromoteSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* UploadingView 單元測試(Phase 0.8 F-T5)
|
||||
*
|
||||
* 對齊:
|
||||
* - wireframe-conversion.md §3.2(Uploading state 視覺)
|
||||
* - flow-conversion.md §5.2(uploading state 邊界)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 進度 0% / 50% / 100% → progress bar + 文字反映
|
||||
* - ETA 計算:前期顯示「預估剩餘時間…」、有資料後顯示「X 秒」、< 5 秒顯示「即將完成」
|
||||
* - 取消按鈕 → 開 AlertDialog;確認 → 呼叫 store.cancelUpload;取消 dialog 不呼叫
|
||||
* - mount → addEventListener('beforeunload');unmount → removeEventListener
|
||||
* - 顯示來源檔名 + target chip(job 已存在時)
|
||||
*
|
||||
* 不負責:
|
||||
* - store 內部 cancelUpload 行為(store 自己有測試)
|
||||
* - i18n key 完整性(i18n.test.ts 已測)
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
import { UploadingView } from "./UploadingView";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function renderView() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<UploadingView />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function setProgress(loaded: number, total: number) {
|
||||
useConversionStore.setState({
|
||||
uiState: "uploading",
|
||||
uploadProgress: { loaded, total },
|
||||
});
|
||||
}
|
||||
|
||||
/** 5 MB / 10 MB / 100 MB 對應的位元組 */
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
beforeEach(() => {
|
||||
useConversionStore.getState().reset();
|
||||
// 預設進入 uploading state(沒有 progress 也能渲)
|
||||
useConversionStore.setState({ uiState: "uploading" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tests */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<UploadingView /> — 進度顯示", () => {
|
||||
it("初始 0% → progress bar value=0、顯示 0%", () => {
|
||||
setProgress(0, 5 * MB);
|
||||
renderView();
|
||||
|
||||
const progressEl = screen.getByTestId("uploading-progress");
|
||||
// Radix Progress 的 progressbar role 由元件內部 indicator 提供;驗 aria-valuenow 即可
|
||||
expect(progressEl.getAttribute("aria-valuenow")).toBe("0");
|
||||
|
||||
const text = screen.getByTestId("uploading-progress-text").textContent ?? "";
|
||||
expect(text).toContain("0%");
|
||||
});
|
||||
|
||||
it("50% → progress aria-valuenow=50、文字顯示 50%", () => {
|
||||
setProgress(2.5 * MB, 5 * MB);
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("uploading-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("50");
|
||||
expect(
|
||||
screen.getByTestId("uploading-progress-text").textContent,
|
||||
).toContain("50%");
|
||||
});
|
||||
|
||||
it("100% → aria-valuenow=100、進度文字顯示 100%", () => {
|
||||
setProgress(5 * MB, 5 * MB);
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("uploading-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("100");
|
||||
expect(
|
||||
screen.getByTestId("uploading-progress-text").textContent,
|
||||
).toContain("100%");
|
||||
});
|
||||
|
||||
it("total=0 防呆:不會 NaN,progress=0", () => {
|
||||
setProgress(0, 0);
|
||||
renderView();
|
||||
expect(
|
||||
screen.getByTestId("uploading-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UploadingView /> — ETA", () => {
|
||||
it("初始(無速度資料)→ 顯示「預估剩餘時間…」", () => {
|
||||
setProgress(0, 5 * MB);
|
||||
renderView();
|
||||
const etaText = screen.getByTestId("uploading-eta-text").textContent ?? "";
|
||||
// i18n: 「預估剩餘時間…」/ Estimating time remaining…
|
||||
expect(
|
||||
etaText.includes("預估剩餘時間") || etaText.includes("Estimating"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("接近完成(pct >= 99.5%)→ 顯示「即將完成」", () => {
|
||||
setProgress(4.99 * MB, 5 * MB);
|
||||
renderView();
|
||||
const etaText = screen.getByTestId("uploading-eta-text").textContent ?? "";
|
||||
expect(etaText.includes("即將完成") || etaText.includes("Almost")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UploadingView /> — 取消流程", () => {
|
||||
it("點取消按鈕 → 開啟 AlertDialog(顯示確認文字)", () => {
|
||||
setProgress(MB, 5 * MB);
|
||||
renderView();
|
||||
|
||||
fireEvent.click(screen.getByTestId("uploading-cancel"));
|
||||
// AlertDialog 是 Radix portal — confirm 內容應該出現
|
||||
expect(screen.getByTestId("uploading-cancel-confirm")).toBeTruthy();
|
||||
expect(screen.getByTestId("uploading-cancel-confirm-yes")).toBeTruthy();
|
||||
expect(screen.getByTestId("uploading-cancel-confirm-no")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("確認取消 → 呼叫 store.cancelUpload", () => {
|
||||
const cancelUpload = vi.fn();
|
||||
useConversionStore.setState({ cancelUpload });
|
||||
setProgress(MB, 5 * MB);
|
||||
renderView();
|
||||
|
||||
fireEvent.click(screen.getByTestId("uploading-cancel"));
|
||||
fireEvent.click(screen.getByTestId("uploading-cancel-confirm-yes"));
|
||||
expect(cancelUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("選「繼續上傳」→ 不呼叫 cancelUpload", () => {
|
||||
const cancelUpload = vi.fn();
|
||||
useConversionStore.setState({ cancelUpload });
|
||||
setProgress(MB, 5 * MB);
|
||||
renderView();
|
||||
|
||||
fireEvent.click(screen.getByTestId("uploading-cancel"));
|
||||
fireEvent.click(screen.getByTestId("uploading-cancel-confirm-no"));
|
||||
expect(cancelUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UploadingView /> — beforeunload listener", () => {
|
||||
it("mount → addEventListener('beforeunload');unmount → removeEventListener", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
setProgress(0, 5 * MB);
|
||||
const { unmount } = renderView();
|
||||
|
||||
// 找到 beforeunload listener
|
||||
const addCall = addSpy.mock.calls.find((c) => c[0] === "beforeunload");
|
||||
expect(addCall, "should add beforeunload listener on mount").toBeTruthy();
|
||||
|
||||
unmount();
|
||||
|
||||
const removeCall = removeSpy.mock.calls.find((c) => c[0] === "beforeunload");
|
||||
expect(
|
||||
removeCall,
|
||||
"should remove beforeunload listener on unmount",
|
||||
).toBeTruthy();
|
||||
// 確認移除的是同一個 handler reference(add[1] === remove[1])
|
||||
expect(removeCall?.[1]).toBe(addCall?.[1]);
|
||||
});
|
||||
|
||||
it("beforeunload handler 設定 returnValue=''(觸發瀏覽器 generic 警告)", () => {
|
||||
const handlers: EventListenerOrEventListenerObject[] = [];
|
||||
const origAdd = window.addEventListener.bind(window);
|
||||
vi.spyOn(window, "addEventListener").mockImplementation(
|
||||
(type: string, handler, options) => {
|
||||
if (type === "beforeunload" && handler) {
|
||||
handlers.push(handler);
|
||||
}
|
||||
return origAdd(type, handler, options);
|
||||
},
|
||||
);
|
||||
|
||||
setProgress(0, 5 * MB);
|
||||
renderView();
|
||||
|
||||
const handler = handlers[0];
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
// 模擬 beforeunload:preventDefault + returnValue = ""
|
||||
const event = new Event("beforeunload", { cancelable: true }) as BeforeUnloadEvent;
|
||||
// BeforeUnloadEvent 在 happy-dom 是普通 Event;補 returnValue 屬性以驗證設值
|
||||
Object.defineProperty(event, "returnValue", {
|
||||
value: "",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
if (typeof handler === "function") {
|
||||
handler(event);
|
||||
} else {
|
||||
handler.handleEvent(event);
|
||||
}
|
||||
// returnValue 已被 handler 寫成 ""
|
||||
expect(event.returnValue).toBe("");
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UploadingView /> — 來源檔名 / chip 顯示", () => {
|
||||
it("job 帶 source_filename + target_chip → 都顯示", () => {
|
||||
const job: ConversionJob = {
|
||||
job_id: "abc-123",
|
||||
status: "queued",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
};
|
||||
useConversionStore.setState({
|
||||
uiState: "uploading",
|
||||
uploadProgress: { loaded: 0, total: 5 * MB },
|
||||
job,
|
||||
});
|
||||
renderView();
|
||||
|
||||
expect(
|
||||
screen.getByTestId("uploading-source-filename").textContent,
|
||||
).toContain("yolov5s.onnx");
|
||||
const chipText =
|
||||
screen.getByTestId("uploading-target-chip").textContent ?? "";
|
||||
expect(chipText).toContain("KL720");
|
||||
});
|
||||
|
||||
it("沒有 job → 不渲染檔名/chip 區塊(避免空 chip)", () => {
|
||||
setProgress(0, 5 * MB);
|
||||
renderView();
|
||||
expect(screen.queryByTestId("uploading-source-filename")).toBeNull();
|
||||
expect(screen.queryByTestId("uploading-target-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
402
visionA-frontend/src/app/conversion/components/UploadingView.tsx
Normal file
402
visionA-frontend/src/app/conversion/components/UploadingView.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UploadingView — Uploading state(XHR 進度條 + ETA + 取消 + beforeunload)
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.2 + §4.2)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.2(uploading state 邊界 — 切走頁面 / 失敗 / 取消)
|
||||
* - architecture/conversion.md §4.3.1(streaming proxy 進度語意:
|
||||
* XHR onprogress 是 browser→backend 的進度,不是端到端轉檔進度)
|
||||
*
|
||||
* 職責:
|
||||
* - 顯示來源檔名 + target chip
|
||||
* - Progress bar(store.uploadProgress 驅動,0–100%)
|
||||
* - 「{loaded MB} / {total MB} · 預估剩餘 {eta}」即時文字
|
||||
* - 「取消」按鈕 → AlertDialog 確認 → 呼叫 store.cancelUpload()
|
||||
* - 進入此 view 時掛 `beforeunload` listener,離開(state 切走 / unmount)自動移除
|
||||
*
|
||||
* 不做:
|
||||
* - 不直接打 API:所有進度資料、cancel 動作走 conversion-store(單一 source of truth)
|
||||
* - 不顯示 toast:toast 行為由 store / page 層統一處理(避免重複觸發)
|
||||
*
|
||||
* UX 細節:
|
||||
* - ETA 用 EWMA(指數加權移動平均,alpha=0.3)平滑速度,避免第一秒抖動
|
||||
* - eta < 5 秒 → 直接顯示「即將完成」(避免「3 秒 → 5 秒 → 2 秒」抖動)
|
||||
* - upload 一開始(loaded=0)顯示「預估剩餘時間…」
|
||||
*
|
||||
* a11y(review M2 修補):
|
||||
* - 視覺進度文字**不**包 aria-live(每 50–250ms 更新一次,會讓 SR 過度朗讀)
|
||||
* - progressbar 元件自帶 aria-valuenow → SR 會在數值變動時自動 announce
|
||||
* - 額外加一個獨立 sr-only `aria-live="polite"` 區塊,**節流到每 5 秒最多朗讀一次**,
|
||||
* 用 `aria-atomic="true"` 確保朗讀整段不是 diff
|
||||
*/
|
||||
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import {
|
||||
ALMOST_DONE_THRESHOLD_SEC,
|
||||
computeEtaUpdate,
|
||||
type ProgressSample,
|
||||
} from "@/lib/utils/eta";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
|
||||
/** sr-only 節流:5 秒內最多朗讀一次,避免每秒打斷 SR */
|
||||
const SR_ANNOUNCE_INTERVAL_MS = 5000;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 工具:bytes → 人類可讀(B / KB / MB / GB) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 工具:秒數 → 「X 分 Y 秒」/「Y 秒」 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
function formatEtaSeconds(secondsRaw: number): string {
|
||||
const seconds = Math.max(0, Math.round(secondsRaw));
|
||||
if (seconds < 60) return `${seconds} 秒 / sec`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return s === 0 ? `${m} 分 / min` : `${m} 分 ${s} 秒 / min`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook:根據 uploadProgress 計算 ETA(EWMA) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
interface EtaState {
|
||||
/** 平滑後速度,bytes/sec;null 代表還沒有足夠資料 */
|
||||
smoothedSpeed: number | null;
|
||||
/** ETA 秒數;null 代表算不出來(loaded=0 / total=0 / speed=0) */
|
||||
etaSeconds: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據 progress 變化算出 ETA。
|
||||
*
|
||||
* 演算法(EWMA / 邊界)抽到 `@/lib/utils/eta` 的純函式 `computeEtaUpdate`,
|
||||
* 此 hook 只負責:
|
||||
* - 用 ref 保存上一次 sample(不觸發 re-render)
|
||||
* - 把純函式回傳的 next state 寫進 React state
|
||||
*
|
||||
* 詳細邊界(第一次 / dt<100ms / 倒退 / 正常)見 `eta.ts` 與 `eta.test.ts`。
|
||||
*/
|
||||
function useEta(loaded: number, total: number): EtaState {
|
||||
const lastSampleRef = useRef<ProgressSample | null>(null);
|
||||
const [state, setState] = useState<EtaState>({
|
||||
smoothedSpeed: null,
|
||||
etaSeconds: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (total <= 0 || loaded < 0) {
|
||||
// 防呆
|
||||
return;
|
||||
}
|
||||
|
||||
const now =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
const prev = lastSampleRef.current;
|
||||
|
||||
setState((curr) => {
|
||||
const result = computeEtaUpdate(prev, curr.smoothedSpeed, now, loaded, total);
|
||||
|
||||
// 更新 ref(caller 端的責任)— reset / 第一次都要記下這次 sample
|
||||
if (result.resetSample || prev === null || result.updated) {
|
||||
lastSampleRef.current = { at: now, loaded };
|
||||
}
|
||||
|
||||
if (result.resetSample) {
|
||||
return { smoothedSpeed: null, etaSeconds: null };
|
||||
}
|
||||
if (!result.updated) {
|
||||
return curr;
|
||||
}
|
||||
return {
|
||||
smoothedSpeed: result.smoothedSpeed,
|
||||
etaSeconds: result.etaSeconds,
|
||||
};
|
||||
});
|
||||
}, [loaded, total]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* hook:beforeunload 警告(只在掛載期間有效) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 進入 uploading 時掛 `beforeunload` listener,unmount(離開頁面 / state 切走)
|
||||
* 自動移除。Chrome 不會顯示自訂訊息,只會顯示 generic 警告 — `returnValue = ""` 即可觸發。
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/flows/flow-conversion.md §5.2 邊界 — 切走頁面)
|
||||
*/
|
||||
function useBeforeUnloadWarning(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
// 標準寫法:preventDefault + returnValue = ""
|
||||
// Chrome / Edge / Safari 顯示 generic 警告;Firefox 也接受
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handler);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function UploadingView() {
|
||||
const t = useT();
|
||||
|
||||
// store state(用 selector 各別訂閱避免無謂 re-render)
|
||||
const uploadProgress = useConversionStore((s) => s.uploadProgress);
|
||||
const job = useConversionStore((s) => s.job);
|
||||
const cancelUpload = useConversionStore((s) => s.cancelUpload);
|
||||
|
||||
// 取消確認 dialog
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
// mount 期間掛 beforeunload;離開 uploading state(page.tsx 會 unmount 此元件)
|
||||
// 自動移除。這樣不需要額外監聽 store.uiState — React lifecycle 已保證。
|
||||
useBeforeUnloadWarning(true);
|
||||
|
||||
// sr-only 節流朗讀(review M2):每 5 秒最多更新一次
|
||||
const [accessibleStatus, setAccessibleStatus] = useState("");
|
||||
const lastAnnouncedAtRef = useRef<number>(0);
|
||||
|
||||
/* ---- 衍生資料 ---- */
|
||||
const loaded = uploadProgress?.loaded ?? 0;
|
||||
// total 為 0 時可能是 store 還沒寫入;保守用 1 避免 progress = NaN(顯示時用實際 total 判斷)
|
||||
const total = uploadProgress?.total ?? 0;
|
||||
const pct = useMemo(() => {
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.max(0, (loaded / total) * 100));
|
||||
}, [loaded, total]);
|
||||
|
||||
// ETA
|
||||
const { etaSeconds } = useEta(loaded, total);
|
||||
|
||||
/* ---- 顯示文字 ---- */
|
||||
// 進度文字:「11.9 MB / 28.4 MB」
|
||||
const progressText = useMemo(
|
||||
() =>
|
||||
t("conversion.uploading.progress.format")
|
||||
.replace("{loaded}", formatBytes(loaded))
|
||||
.replace("{total}", formatBytes(total)),
|
||||
[t, loaded, total],
|
||||
);
|
||||
|
||||
// ETA 文字:三種狀態
|
||||
// 1. pct >= 99.5%(最後一段,已不需要 ETA) → 「即將完成」
|
||||
// 2. 還沒有速度資料 → 「預估剩餘時間…」
|
||||
// 3. < 5 秒 → 「即將完成」(避免最後幾秒抖動)
|
||||
// 4. 其他 → 「預估剩餘 X 分 Y 秒」
|
||||
const etaText = useMemo(() => {
|
||||
// 優先判斷接近完成(即使 etaSeconds 還沒算出來)
|
||||
if (pct >= 99.5) return t("conversion.uploading.eta.almostDone");
|
||||
if (etaSeconds === null) return t("conversion.uploading.eta.computing");
|
||||
if (etaSeconds < ALMOST_DONE_THRESHOLD_SEC) {
|
||||
return t("conversion.uploading.eta.almostDone");
|
||||
}
|
||||
return t("conversion.uploading.eta.format").replace(
|
||||
"{eta}",
|
||||
formatEtaSeconds(etaSeconds),
|
||||
);
|
||||
}, [t, etaSeconds, pct]);
|
||||
|
||||
// 來源檔名(uploading state 沒有 job.source_filename — 那是 backend 啟動後才知道;
|
||||
// 上傳中 job 通常是 null,因此 fallback 用 i18n 通用標題)
|
||||
const sourceFilename = job?.source_filename ?? "";
|
||||
const targetChip = job?.target_chip;
|
||||
|
||||
/* ---- sr-only 節流朗讀 ---- */
|
||||
// pct / etaText 每 50–250ms 變一次;SR 朗讀區每 5 秒最多更新一次。
|
||||
// 起點(lastAnnouncedAtRef.current === 0)也算一次更新,確保第一次有 announce。
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastAnnouncedAtRef.current >= SR_ANNOUNCE_INTERVAL_MS) {
|
||||
setAccessibleStatus(
|
||||
t("conversion.uploading.aria.status")
|
||||
.replace("{pct}", String(Math.round(pct)))
|
||||
.replace("{eta}", etaText),
|
||||
);
|
||||
lastAnnouncedAtRef.current = now;
|
||||
}
|
||||
}, [t, pct, etaText]);
|
||||
|
||||
/* ---- a11y ids ---- */
|
||||
const headingId = useId();
|
||||
const progressLabelId = useId();
|
||||
|
||||
/* ---- handlers ---- */
|
||||
const handleCancelClick = () => setConfirmOpen(true);
|
||||
const handleConfirmCancel = () => {
|
||||
setConfirmOpen(false);
|
||||
cancelUpload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversion-uploading"
|
||||
className="bg-card space-y-4 rounded-xl border p-6"
|
||||
>
|
||||
{/* 標題區 */}
|
||||
<div className="space-y-1">
|
||||
<h2 id={headingId} className="text-lg font-semibold">
|
||||
{t("conversion.uploading.heading")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("conversion.uploading.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 檔名 + chip */}
|
||||
{(sourceFilename || targetChip) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{sourceFilename ? (
|
||||
<span
|
||||
className="font-mono"
|
||||
data-testid="uploading-source-filename"
|
||||
>
|
||||
{sourceFilename}
|
||||
</span>
|
||||
) : null}
|
||||
{targetChip ? (
|
||||
<span
|
||||
className="bg-accent text-accent-foreground rounded px-2 py-0.5 text-xs font-medium"
|
||||
data-testid="uploading-target-chip"
|
||||
>
|
||||
{t("conversion.uploading.target").replace(
|
||||
"{chip}",
|
||||
targetChip,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<span id={progressLabelId} className="sr-only">
|
||||
{t("conversion.uploading.aria.progress")}
|
||||
</span>
|
||||
<Progress
|
||||
value={pct}
|
||||
aria-labelledby={progressLabelId}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(pct)}
|
||||
data-testid="uploading-progress"
|
||||
/>
|
||||
{/*
|
||||
視覺進度文字 — **不要** aria-live:每 50–250ms 變動一次會打斷 SR。
|
||||
progressbar 的 aria-valuenow 已自帶 a11y,下面 sr-only 區塊負責節流朗讀。
|
||||
*/}
|
||||
<div className="text-muted-foreground flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span data-testid="uploading-progress-text">
|
||||
{progressText}
|
||||
<span className="mx-2">·</span>
|
||||
{`${Math.round(pct)}%`}
|
||||
</span>
|
||||
<span data-testid="uploading-eta-text">{etaText}</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
sr-only 朗讀區(review M2):每 5 秒最多朗讀一次;aria-atomic="true"
|
||||
確保 SR 朗讀整段而不是 diff。視覺使用者看不到。
|
||||
*/}
|
||||
<div
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
role="status"
|
||||
data-testid="uploading-sr-status"
|
||||
>
|
||||
{accessibleStatus}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 取消按鈕 */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelClick}
|
||||
aria-label={t("conversion.uploading.aria.cancel")}
|
||||
data-testid="uploading-cancel"
|
||||
>
|
||||
<XIcon aria-hidden="true" className="size-4" />
|
||||
{t("conversion.uploading.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示:上傳完後會接 processing */}
|
||||
<p className="text-muted-foreground border-t pt-3 text-xs">
|
||||
{t("conversion.uploading.info")}
|
||||
</p>
|
||||
|
||||
{/* 警告:beforeunload 同時有 inline 警示,加強可見性 */}
|
||||
<p className="text-amber-700 dark:text-amber-400 text-xs">
|
||||
{t("conversion.uploading.warning")}
|
||||
</p>
|
||||
|
||||
{/* 取消確認 dialog */}
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent data-testid="uploading-cancel-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("conversion.uploading.cancel.confirm.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("conversion.uploading.cancel.confirm.message")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="uploading-cancel-confirm-no">
|
||||
{t("conversion.uploading.cancel.confirm.no")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleConfirmCancel}
|
||||
data-testid="uploading-cancel-confirm-yes"
|
||||
>
|
||||
{t("conversion.uploading.cancel.confirm.yes")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
615
visionA-frontend/src/app/conversion/e2e-conversion-flow.test.tsx
Normal file
615
visionA-frontend/src/app/conversion/e2e-conversion-flow.test.tsx
Normal file
@ -0,0 +1,615 @@
|
||||
/**
|
||||
* E2E flow 整合測試(Phase 0.8 — F-T10)
|
||||
*
|
||||
* 對齊:
|
||||
* - PRD:`.autoflow/02-prd/features/feature-converter-integration.md` §9 驗收條件
|
||||
* - Flow:`.autoflow/03-design/flows/flow-conversion.md` state machine + §6 邊界
|
||||
* - Wireframe:`.autoflow/03-design/wireframes/wireframe-conversion.md`
|
||||
*
|
||||
* 範圍:
|
||||
* 這次 **不是** unit test 也不是 component test,而是「跨元件 + 跨 store + 跨 API client mock 的
|
||||
* flow test」。從 `<ConversionPage />` 入口出發,走完 idle → uploading → queued → running →
|
||||
* succeeded → 加到模型庫 / 下載 / 開始新轉檔 等鏈路。
|
||||
*
|
||||
* 與既有 unit test 的差異:
|
||||
* - `IdleForm.test.tsx`:spy `store.startConversion`,不真的進 store 內部
|
||||
* - `conversion-store.test.ts`:mock API client,不真的 render UI
|
||||
* - `page.test.tsx`:直接設 `useConversionStore.setState({ uiState })`,跳過 view transition
|
||||
* - **本檔**:mock 最底層的 `@/lib/api/conversion`,讓 store 真的跑 + 讓 view 真的 transit;
|
||||
* 驗 user 從點檔案、到看到 SuccessView 的整條鏈。
|
||||
*
|
||||
* Mock 策略:
|
||||
* - `@/lib/api/conversion` 的 5 個函式 → vi.mock(保留 ConversionAPIError class)
|
||||
* - `next/navigation` 的 `useRouter` → 簡 stub(SuccessView / FailedView 用到)
|
||||
* - `sonner` 的 `toast` → vi.mock,攔截 success/error 呼叫
|
||||
* - `vi.useFakeTimers()` 推進 polling(5s / 10s tick)
|
||||
* - `vi.spyOn(Date, "now")` 鎖時間,讓 SuccessView 的 countdown 不影響其他斷言
|
||||
*
|
||||
* 為什麼不 mock XHR 直接:
|
||||
* `initConversion` 既有 unit test 已覆蓋 XHR 細節(progress event、signal、abort)。
|
||||
* 本檔 e2e 在 store 邊界 mock,焦點放在「flow 串通」而非「XHR 行為」。`onUploadProgress`
|
||||
* callback 改由 mock initConversion 主動呼叫模擬,效果等價但更穩定。
|
||||
*
|
||||
* 涵蓋(PRD §9 驗收條件):
|
||||
* ✅ .onnx + KL720 + 0 ref images → e2e 成功(test 1)
|
||||
* ✅ .onnx + KL720 + 5 ref images → e2e 成功(test 2)
|
||||
* ✅ 上傳失敗 → idle + form 保留 → 重試(test 3)
|
||||
* ✅ polling 5xx → 指數退避 → 恢復繼續(test 4)
|
||||
* ✅ expired job 從 bootstrap 進入 → ExpiredView → reset 回 idle(test 5)
|
||||
*
|
||||
* 不重測的:
|
||||
* - 前端驗證錯誤(IdleForm.test 已蓋)
|
||||
* - PromoteDialog 內部驗證 / spinner / router push(SuccessView.test 已蓋)
|
||||
* - Failed view 細節 / 過期視覺(FailedView.test / SuccessView.test 已蓋)
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
|
||||
import { ConversionAPIError } from "@/lib/api/conversion";
|
||||
import * as conversionApi from "@/lib/api/conversion";
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
import type {
|
||||
ConversionJob,
|
||||
InitConversionArgs,
|
||||
TargetChip,
|
||||
} from "@/types/conversion";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock @/lib/api/conversion — store 行為由 store 自己跑,但底層 5 個 fn 用 mock */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
vi.mock("@/lib/api/conversion", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/conversion")>(
|
||||
"@/lib/api/conversion",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getActiveConversion: vi.fn(),
|
||||
initConversion: vi.fn(),
|
||||
getConversion: vi.fn(),
|
||||
promoteConversionToModels: vi.fn(),
|
||||
getConversionDownloadURL: actual.getConversionDownloadURL,
|
||||
};
|
||||
});
|
||||
|
||||
const mockedApi = conversionApi as unknown as {
|
||||
getActiveConversion: Mock;
|
||||
initConversion: Mock;
|
||||
getConversion: Mock;
|
||||
promoteConversionToModels: Mock;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock next/navigation — SuccessView / FailedView 內用 useRouter */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const mockRouterPush = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock sonner toast — 攔截 success / error 呼叫 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
return {
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success,
|
||||
error,
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
message: vi.fn(),
|
||||
}),
|
||||
Toaster: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
import { toast } from "sonner";
|
||||
import ConversionPage from "./page";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 建立指定 size / name 的 File(避免真的吃 RAM) */
|
||||
function makeFile(name: string, sizeBytes: number, type = "application/octet-stream"): File {
|
||||
const file = new File([new Uint8Array(1)], name, { type });
|
||||
Object.defineProperty(file, "size", { value: sizeBytes, configurable: true });
|
||||
return file;
|
||||
}
|
||||
|
||||
function makeImage(name: string, sizeBytes: number): File {
|
||||
return makeFile(name, sizeBytes, "image/png");
|
||||
}
|
||||
|
||||
/** 把 file 塞進 input[type=file](DataTransfer mock) */
|
||||
function setInputFiles(input: HTMLInputElement, files: File[]) {
|
||||
Object.defineProperty(input, "files", {
|
||||
value: files,
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
/** 取「來源模型」的 file input */
|
||||
function getModelInput(): HTMLInputElement {
|
||||
const dropzone = screen.getByTestId("model-dropzone");
|
||||
const input = dropzone.querySelector("input[type='file']:not([multiple])");
|
||||
if (!input) throw new Error("model file input not found");
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
/** 取 ref images 的 file input(multiple) */
|
||||
function getRefImagesInput(): HTMLInputElement {
|
||||
const dropzone = screen.getByTestId("ref-images-dropzone");
|
||||
const input = dropzone.querySelector("input[type='file'][multiple]");
|
||||
if (!input) throw new Error("ref images file input not found");
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
/** 模板 ConversionJob — 每個測試用 spread 蓋自己想要的欄位 */
|
||||
function makeJob(overrides: Partial<ConversionJob> = {}): ConversionJob {
|
||||
return {
|
||||
job_id: "job-e2e-1",
|
||||
status: "queued",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
// 7 天後(給 SuccessView countdown 顯示「剩 7 天」)
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把整個 ConversionPage render 起來(含 LocaleProvider) */
|
||||
function renderApp() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ConversionPage />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 鎖 Date.now 到 created_at,避免 SuccessView 的 countdown / interval 觸發 setState 引發
|
||||
* act warning。所有 job 的 created_at = 2026-04-30T12:00:00Z、expires_at = +7d。
|
||||
*/
|
||||
const NOW_FIXED = new Date("2026-04-30T12:00:00Z").getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(NOW_FIXED);
|
||||
// 每個測試前清 store + 所有 mock
|
||||
useConversionStore.getState().reset();
|
||||
vi.clearAllMocks();
|
||||
(toast.success as Mock).mockReset();
|
||||
(toast.error as Mock).mockReset();
|
||||
mockRouterPush.mockReset();
|
||||
// 預設 getActiveConversion → null(沒有 active job)
|
||||
mockedApi.getActiveConversion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Test 1:Happy path — .onnx + KL720 + 0 ref images */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("E2E flow — happy path(.onnx + KL720 + 0 ref images)", () => {
|
||||
it("idle → 選檔 → 上傳 → queued → running → succeeded → 加到模型庫", async () => {
|
||||
// 全程 fake timer — 從 startPolling 排第一個 setTimeout 到 polling lifecycle 都受控
|
||||
// 註:fake timer 下 waitFor 會卡住(內部用 setTimeout retry),所以全用 advance + 直接 expect
|
||||
vi.useFakeTimers();
|
||||
|
||||
// 上傳 mock:在 onUploadProgress 推 30% / 70% / 100% 後 resolve queued job
|
||||
let capturedArgs: InitConversionArgs | null = null;
|
||||
mockedApi.initConversion.mockImplementation((args: InitConversionArgs) => {
|
||||
capturedArgs = args;
|
||||
args.onUploadProgress?.(args.file.size * 0.3, args.file.size);
|
||||
args.onUploadProgress?.(args.file.size * 0.7, args.file.size);
|
||||
args.onUploadProgress?.(args.file.size, args.file.size);
|
||||
return Promise.resolve(makeJob({ status: "queued" }));
|
||||
});
|
||||
|
||||
// polling 序列:第 1 次 queued → 第 2 次 running(stage=onnx) → 第 3 次 running(stage=bie)
|
||||
// → 第 4 次 succeeded
|
||||
mockedApi.getConversion
|
||||
.mockResolvedValueOnce(makeJob({ status: "queued" }))
|
||||
.mockResolvedValueOnce(
|
||||
makeJob({ status: "running", stage: "onnx", progress: 25 }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeJob({ status: "running", stage: "bie", progress: 60 }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeJob({
|
||||
status: "succeeded",
|
||||
result: {
|
||||
output_size_bytes: 12_300_000,
|
||||
output_checksum: "sha256:e2eaaaabbbb111222333",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderApp();
|
||||
|
||||
// === Step 1:bootstrap → idle / IdleForm 顯示 ===
|
||||
// bootstrap promise + React effects 走完需要 flush microtask
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(screen.getByTestId("conversion-idle-form")).toBeTruthy();
|
||||
expect(mockedApi.getActiveConversion).toHaveBeenCalled();
|
||||
|
||||
// === Step 2:選 onnx 檔(KL720 是預設 chip) ===
|
||||
const file = makeFile("yolov5s.onnx", 5 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [file]);
|
||||
|
||||
expect(screen.getByTestId("model-selected")).toBeTruthy();
|
||||
const taskInput = screen.getByTestId("task-name-input") as HTMLInputElement;
|
||||
expect(taskInput.value).toBe("yolov5s");
|
||||
expect(screen.getByTestId("chip-kl720").getAttribute("data-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
// === Step 3:點開始轉檔 → 上傳 + 切到 ProcessingView (queued) ===
|
||||
fireEvent.click(screen.getByTestId("conversion-start"));
|
||||
|
||||
// upload promise 是 microtask,flush 需 advance 0ms
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const queuedView = screen.getByTestId("conversion-processing");
|
||||
expect(queuedView.getAttribute("data-phase")).toBe("queued");
|
||||
|
||||
expect(mockedApi.initConversion).toHaveBeenCalledTimes(1);
|
||||
expect(capturedArgs!.file).toBe(file);
|
||||
expect(capturedArgs!.targetChip).toBe<TargetChip>("KL720");
|
||||
expect(capturedArgs!.taskName).toBe("yolov5s");
|
||||
expect(capturedArgs!.refImages).toBeUndefined();
|
||||
|
||||
// === 第 1 次 polling:advance 10s(queued 間隔) → mock 第 1 筆(queued)===
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
|
||||
|
||||
// === 第 2 次 polling:仍是 queued,10s 間隔 → mock 第 2 筆(running)===
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
screen.getByTestId("conversion-processing").getAttribute("data-phase"),
|
||||
).toBe("running");
|
||||
|
||||
// === 第 3 次 polling:running 5s 間隔 ===
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(3);
|
||||
|
||||
// === 第 4 次 polling:running → succeeded ===
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(screen.getByTestId("conversion-success")).toBeTruthy();
|
||||
expect(screen.getByTestId("success-import-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("success-download-button")).toBeTruthy();
|
||||
|
||||
// 後續不再 polling(30s 內沒新 call)
|
||||
mockedApi.getConversion.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
|
||||
// === Step 5:點「加到模型庫」→ PromoteDialog → 確認 promote ===
|
||||
// TD-17: 切 useRealTimers 前先 clearAllTimers,避免 fake timers 殘留 callback
|
||||
// 在 real timers 模式下意外觸發
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
mockedApi.promoteConversionToModels.mockResolvedValue({
|
||||
model_id: "model-from-onnx",
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("success-import-button"));
|
||||
expect(screen.getByTestId("promote-dialog")).toBeTruthy();
|
||||
const nameInput = screen.getByTestId(
|
||||
"promote-dialog-name",
|
||||
) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("yolov5s_kl720");
|
||||
|
||||
fireEvent.click(screen.getByTestId("promote-dialog-confirm"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.promoteConversionToModels).toHaveBeenCalledWith(
|
||||
"job-e2e-1",
|
||||
{ name: "yolov5s_kl720" },
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.success as Mock).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("promote-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
// === Step 6:「下載」<a href> 指向 /api/conversion/{jobId}/download ===
|
||||
const downloadBtn = screen.getByTestId("success-download-button");
|
||||
const anchor =
|
||||
downloadBtn.tagName.toLowerCase() === "a"
|
||||
? (downloadBtn as HTMLAnchorElement)
|
||||
: (downloadBtn.querySelector("a") as HTMLAnchorElement | null);
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor!.getAttribute("href")).toBe(
|
||||
"/api/conversion/job-e2e-1/download",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Test 2:Variant — .onnx + KL720 + 5 ref images */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("E2E flow — variant(5 ref images)", () => {
|
||||
it("帶 5 張 ref images → initConversion 收到 refImages 5 個", async () => {
|
||||
// TD-18: 這個 test 故意用 shouldAdvanceTime: true 模式(不同於其他 test 的純 fake timer)
|
||||
// 因為這條 path 不需要驗 polling tick,只看 form submit → store init → mock 立刻 succeed;
|
||||
// shouldAdvanceTime 讓 microtask queue / timer 自動前進,避免每個 await 後手動 advance。
|
||||
// 別把這當成「應該統一」的 anti-pattern,是這條 test 的 trade-off。
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
let capturedArgs: InitConversionArgs | null = null;
|
||||
mockedApi.initConversion.mockImplementation((args: InitConversionArgs) => {
|
||||
capturedArgs = args;
|
||||
// 即時 resolve 成 succeeded(converter 極快情境也合法 — store 會直接切 succeeded)
|
||||
return Promise.resolve(
|
||||
makeJob({
|
||||
status: "succeeded",
|
||||
result: { output_size_bytes: 9_876_543 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
renderApp();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-idle-form")).toBeTruthy();
|
||||
});
|
||||
|
||||
// === Step 1:選 onnx ===
|
||||
const file = makeFile("mobilenet.onnx", 8 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [file]);
|
||||
|
||||
// === Step 2:選 5 張 ref images ===
|
||||
const refs = [
|
||||
makeImage("ref-1.png", 200 * 1024),
|
||||
makeImage("ref-2.png", 250 * 1024),
|
||||
makeImage("ref-3.png", 300 * 1024),
|
||||
makeImage("ref-4.png", 180 * 1024),
|
||||
makeImage("ref-5.png", 220 * 1024),
|
||||
];
|
||||
setInputFiles(getRefImagesInput(), refs);
|
||||
|
||||
// 確認 ref images 已被收下
|
||||
const summary = screen.getByTestId("ref-images-selected");
|
||||
expect(summary.textContent).toContain("ref-1.png");
|
||||
expect(summary.textContent).toContain("ref-5.png");
|
||||
|
||||
// === Step 3:開始轉檔 ===
|
||||
fireEvent.click(screen.getByTestId("conversion-start"));
|
||||
|
||||
// 切 uploading(短暫)→ 直接到 succeeded
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-success")).toBeTruthy();
|
||||
});
|
||||
|
||||
// === Step 4:驗 initConversion 收到 5 張 ref images ===
|
||||
expect(mockedApi.initConversion).toHaveBeenCalledTimes(1);
|
||||
expect(capturedArgs!.refImages).toBeDefined();
|
||||
expect(capturedArgs!.refImages).toHaveLength(5);
|
||||
expect(capturedArgs!.refImages![0].name).toBe("ref-1.png");
|
||||
expect(capturedArgs!.refImages![4].name).toBe("ref-5.png");
|
||||
expect(capturedArgs!.targetChip).toBe<TargetChip>("KL720");
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Test 3:上傳失敗 → idle + form 保留 → user retry 成功 */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("E2E flow — upload fails → retry 成功", () => {
|
||||
it("上傳 5xx 失敗 → toast.error 顯示 → 切回 idle → user 重選後可再次提交", async () => {
|
||||
// 第 1 次:rejected with 500
|
||||
// 第 2 次:成功(succeeded — 為了流程簡單,直接結尾)
|
||||
mockedApi.initConversion
|
||||
.mockRejectedValueOnce(
|
||||
new ConversionAPIError(500, "internal_error", "boom"),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeJob({
|
||||
status: "succeeded",
|
||||
result: { output_size_bytes: 1_000_000 },
|
||||
}),
|
||||
);
|
||||
|
||||
renderApp();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-idle-form")).toBeTruthy();
|
||||
});
|
||||
|
||||
// === 第 1 次:選檔 → 點開始 → 失敗 ===
|
||||
const file = makeFile("retry.onnx", 5 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [file]);
|
||||
fireEvent.click(screen.getByTestId("conversion-start"));
|
||||
|
||||
// store 切回 idle + error 寫入;page.tsx 的 effect 顯示 toast
|
||||
await waitFor(() => {
|
||||
expect(toast.error as Mock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// toast 訊息含「上傳失敗」字樣
|
||||
const [msg] = (toast.error as Mock).mock.calls[0];
|
||||
expect(typeof msg).toBe("string");
|
||||
expect((msg as string).length).toBeGreaterThan(0);
|
||||
|
||||
// 確認回到 idle 視圖
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-idle-form")).toBeTruthy();
|
||||
});
|
||||
// 不在 uploading / processing / success / failed
|
||||
expect(screen.queryByTestId("conversion-uploading")).toBeNull();
|
||||
expect(screen.queryByTestId("conversion-processing")).toBeNull();
|
||||
expect(screen.queryByTestId("conversion-success")).toBeNull();
|
||||
expect(screen.queryByTestId("conversion-failed")).toBeNull();
|
||||
|
||||
// === 第 2 次:重選檔(idle 後 IdleForm 是新 mount)→ 再點開始 → 走到成功 ===
|
||||
// 註:page.tsx 把 uploading / idle 用 conditional render 切換,IdleForm 從 uploading
|
||||
// 切回 idle 時是新 instance,local state 重置;user 需重新選檔(與既有產品行為一致)。
|
||||
const file2 = makeFile("retry-2.onnx", 4 * 1024 * 1024);
|
||||
setInputFiles(getModelInput(), [file2]);
|
||||
fireEvent.click(screen.getByTestId("conversion-start"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-success")).toBeTruthy();
|
||||
});
|
||||
|
||||
// initConversion 被叫了 2 次(第 1 次失敗 + 第 2 次成功)
|
||||
expect(mockedApi.initConversion).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Test 4:polling 5xx → 指數退避 → 恢復後繼續 */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("E2E flow — polling 失敗指數退避 + 恢復", () => {
|
||||
it("running → 連 2 次 5xx → 第 3 次成功 → UI 不切 failed,繼續顯示 running", async () => {
|
||||
// 全程 fake timer(包含 bootstrap 後 startPolling 排的第一個 setTimeout)
|
||||
vi.useFakeTimers();
|
||||
|
||||
// 模擬:bootstrap 直接拿到一個 running 的 job(跳過 upload)
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running", stage: "bie", progress: 50 }),
|
||||
);
|
||||
|
||||
// polling 序列:第 1 次成功 (running 70%) → 第 2-3 次 5xx → 第 4 次成功 (running 90%)
|
||||
mockedApi.getConversion
|
||||
.mockResolvedValueOnce(
|
||||
makeJob({ status: "running", stage: "bie", progress: 70 }),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new ConversionAPIError(503, "service_unavailable", "transient"),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new ConversionAPIError(503, "service_unavailable", "transient"),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeJob({ status: "running", stage: "nef", progress: 90 }),
|
||||
);
|
||||
|
||||
renderApp();
|
||||
|
||||
// bootstrap promise + React effects 走完需要 flush microtask
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(
|
||||
screen.getByTestId("conversion-processing").getAttribute("data-phase"),
|
||||
).toBe("running");
|
||||
|
||||
// === 第 1 次 poll:5s 後(running 間隔)→ 成功 (running 70%) ===
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(0);
|
||||
|
||||
// === 第 2 次 poll:5s 後 → 5xx → pollErrorCount=1 ===
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(1);
|
||||
// UI 仍是 running — 不切 failed
|
||||
expect(
|
||||
screen.getByTestId("conversion-processing").getAttribute("data-phase"),
|
||||
).toBe("running");
|
||||
|
||||
// === 第 3 次 poll:失敗後排 10s(10 * 2^0),先推 9s 不該觸發 ===
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
|
||||
// 推到 11s 總共 → 觸發第 3 次(仍 5xx)
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(3);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(2);
|
||||
|
||||
// === 第 4 次 poll:失敗後排 20s(10 * 2^1),推 21s 觸發 → 成功(running 90%)===
|
||||
// pollErrorCount 重設為 0(成功 case 在 store 內 setState 重設)
|
||||
await vi.advanceTimersByTimeAsync(21_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(4);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(0);
|
||||
|
||||
// TD-19: 也驗 pollVisibleError flag — 連續 5xx 沒到 GIVE_UP_VISIBLE_THRESHOLD(store 預設 ≥3 才標 true)
|
||||
// 本 test 連續 2 次 5xx 後就成功,因此 pollVisibleError 應為 false(沒讓 user 看到「retrying」UI)
|
||||
expect(useConversionStore.getState().pollVisibleError).toBe(false);
|
||||
|
||||
// UI 仍 running — 沒被 transient 5xx 誤殺成 failed
|
||||
expect(
|
||||
screen.getByTestId("conversion-processing").getAttribute("data-phase"),
|
||||
).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Test 5:bootstrap 拿到 expired job → ExpiredView → reset 回 idle */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("E2E flow — expired job 邊界", () => {
|
||||
it("bootstrap 拿到一個過期 job → ExpiredView → 點重新轉檔回 idle", async () => {
|
||||
// expires_at 在 NOW_FIXED 之前 → store 直接切 expired
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({
|
||||
status: "succeeded",
|
||||
expires_at: "2026-04-23T12:00:00Z", // 比 NOW_FIXED 早 7 天
|
||||
result: { output_size_bytes: 5_000_000 },
|
||||
}),
|
||||
);
|
||||
|
||||
renderApp();
|
||||
|
||||
// 等 ExpiredView 顯示
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-expired")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("expired-hero")).toBeTruthy();
|
||||
|
||||
// 摘要顯示原始檔名 + chip
|
||||
const summary = screen.getByTestId("expired-summary");
|
||||
expect(within(summary).getByTestId("expired-source-filename").textContent).toContain(
|
||||
"yolov5s.onnx",
|
||||
);
|
||||
expect(within(summary).getByTestId("expired-target-chip").textContent).toContain(
|
||||
"KL720",
|
||||
);
|
||||
|
||||
// 點「重新轉檔」→ store.reset() → 切回 idle
|
||||
fireEvent.click(screen.getByTestId("expired-start-new"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversion-idle-form")).toBeTruthy();
|
||||
});
|
||||
// ExpiredView 已被卸載
|
||||
expect(screen.queryByTestId("conversion-expired")).toBeNull();
|
||||
});
|
||||
});
|
||||
368
visionA-frontend/src/app/conversion/page.test.tsx
Normal file
368
visionA-frontend/src/app/conversion/page.test.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
/**
|
||||
* /conversion 頁單元測試(Phase 0.8 F-T4 + F-T9)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §5.1 idle bootstrap
|
||||
* - flow-conversion.md §4 state machine
|
||||
* - flow-conversion.md §6.2(上傳失敗 toast)
|
||||
* - flow-conversion.md §6.4(多 tab 一致性)
|
||||
*
|
||||
* 覆蓋(F-T4):
|
||||
* - mount → 呼叫 store.bootstrap()(一次)
|
||||
* - isInitializing=true → 顯示 loading skeleton
|
||||
* - 各 uiState → 顯示對應 view
|
||||
*
|
||||
* 覆蓋(F-T9 sub-3 / sub-4):
|
||||
* - store.error null → 非 null:toast.error 觸發一次(不重複)
|
||||
* - error.code='aborted' / 'active_job_exists' 不 toast
|
||||
* - 多 tab:頁面 mount 各自獨立 bootstrap(兩個 tab 都會呼叫 store.bootstrap)
|
||||
*
|
||||
* 不負責:
|
||||
* - IdleForm 自己的行為(已在 IdleForm.test.tsx 測)
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
|
||||
// SuccessView 與 FailedView 內部使用 next/navigation 的 useRouter;jsdom 沒有 app router
|
||||
// context,需 mock 一個極簡 stub 才能讓 succeeded / failed 分支被渲染。
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock sonner — 攔截 toast.error 的呼叫(F-T9 sub-3)
|
||||
vi.mock("sonner", () => {
|
||||
return {
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
message: vi.fn(),
|
||||
}),
|
||||
Toaster: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import ConversionPage from "./page";
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ConversionPage />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useConversionStore.getState().reset();
|
||||
(toast.error as Mock).mockReset();
|
||||
(toast.success as Mock).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useConversionStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("<ConversionPage />", () => {
|
||||
it("mount 時呼叫 store.bootstrap()", () => {
|
||||
const bootstrap = vi
|
||||
.spyOn(useConversionStore.getState(), "bootstrap")
|
||||
.mockImplementation(async () => {});
|
||||
useConversionStore.setState({ bootstrap });
|
||||
|
||||
renderPage();
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("isInitializing=true → 顯示 loading skeleton(不渲 IdleForm)", () => {
|
||||
useConversionStore.setState({
|
||||
isInitializing: true,
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByTestId("conversion-loading")).toBeTruthy();
|
||||
expect(screen.queryByTestId("conversion-idle-form")).toBeNull();
|
||||
});
|
||||
|
||||
it("uiState=idle 且非 initializing → 顯示 IdleForm", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "idle",
|
||||
isInitializing: false,
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByTestId("conversion-idle-form")).toBeTruthy();
|
||||
// 沒進度 / 結果 placeholder
|
||||
expect(screen.queryByTestId("conversion-processing")).toBeNull();
|
||||
expect(screen.queryByTestId("conversion-loading")).toBeNull();
|
||||
});
|
||||
|
||||
it("uiState=uploading → 顯示 UploadingView(F-T5)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "uploading",
|
||||
isInitializing: false,
|
||||
uploadProgress: { loaded: 0, total: 1024 },
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByTestId("conversion-uploading")).toBeTruthy();
|
||||
// 不渲染 ProcessingView / IdleForm
|
||||
expect(screen.queryByTestId("conversion-processing")).toBeNull();
|
||||
expect(screen.queryByTestId("conversion-idle-form")).toBeNull();
|
||||
});
|
||||
|
||||
it("uiState=queued → 顯示 ProcessingView(F-T6)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "queued",
|
||||
isInitializing: false,
|
||||
job: {
|
||||
job_id: "job-q",
|
||||
status: "queued",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
const view = screen.getByTestId("conversion-processing");
|
||||
expect(view).toBeTruthy();
|
||||
expect(view.getAttribute("data-phase")).toBe("queued");
|
||||
});
|
||||
|
||||
it("uiState=running → 顯示 ProcessingView(F-T6)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "running",
|
||||
isInitializing: false,
|
||||
job: {
|
||||
job_id: "job-r",
|
||||
status: "running",
|
||||
stage: "bie",
|
||||
progress: 45,
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
const view = screen.getByTestId("conversion-processing");
|
||||
expect(view).toBeTruthy();
|
||||
expect(view.getAttribute("data-phase")).toBe("running");
|
||||
});
|
||||
|
||||
it("uiState=succeeded → 顯示 SuccessView(F-T7 已接)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "succeeded",
|
||||
isInitializing: false,
|
||||
job: {
|
||||
job_id: "job-s",
|
||||
status: "succeeded",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
result: { output_size_bytes: 12_300_000 },
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByTestId("conversion-success")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uiState=failed → 顯示 FailedView(F-T8 已接)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "failed",
|
||||
isInitializing: false,
|
||||
job: {
|
||||
job_id: "abcdef12-3456-7890-aaaa-bbbbccccdddd",
|
||||
status: "failed",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
error: { code: "QUANTIZATION_FAILED" },
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
expect(screen.getByTestId("conversion-failed")).toBeTruthy();
|
||||
// FailedView 才有的 hero / restart 按鈕
|
||||
expect(screen.getByTestId("failed-hero")).toBeTruthy();
|
||||
expect(screen.getByTestId("failed-restart")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uiState=expired → 顯示 ExpiredView(F-T9 sub-1)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "expired",
|
||||
isInitializing: false,
|
||||
job: {
|
||||
job_id: "job-x",
|
||||
status: "succeeded",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: "2026-04-23T00:00:00Z",
|
||||
expires_at: "2026-04-30T00:00:00Z", // 已過期
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
renderPage();
|
||||
// ExpiredView 提供的 hero / 按鈕
|
||||
expect(screen.getByTestId("conversion-expired")).toBeTruthy();
|
||||
expect(screen.getByTestId("expired-hero")).toBeTruthy();
|
||||
expect(screen.getByTestId("expired-start-new")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* F-T9 sub-3:上傳失敗 toast */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ConversionPage /> — error toast (F-T9 sub-3)", () => {
|
||||
it("一般錯誤(status >= 500)→ 顯示「上傳失敗」toast", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "idle",
|
||||
isInitializing: false,
|
||||
error: {
|
||||
code: "internal_error",
|
||||
status: 500,
|
||||
message: "internal",
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
const [msg] = (toast.error as Mock).mock.calls[0];
|
||||
expect(msg).toMatch(/上傳失敗/);
|
||||
});
|
||||
|
||||
it("network 錯誤(status=0)→ toast 顯示網路錯誤訊息", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "idle",
|
||||
isInitializing: false,
|
||||
error: {
|
||||
code: "network",
|
||||
status: 0,
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
const [msg, opts] = (toast.error as Mock).mock.calls[0];
|
||||
expect(msg).toMatch(/網路錯誤|上傳失敗/);
|
||||
// 描述包含 retryHint(F-T9 M1:toast 文案改成「請重新選擇檔案」對齊真實行為 —
|
||||
// metadata 由 store.formDraft 保留、File 物件需重選)
|
||||
expect(opts?.description).toMatch(/重新選擇檔案|re-select/i);
|
||||
});
|
||||
|
||||
it("error.code='aborted' 不 toast(取消上傳)", () => {
|
||||
useConversionStore.setState({
|
||||
uiState: "idle",
|
||||
isInitializing: false,
|
||||
error: {
|
||||
code: "aborted",
|
||||
status: 0,
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error.code='active_job_exists' 不 toast(已由 banner 提示)", () => {
|
||||
useConversionStore.setState({
|
||||
// active_job_exists 後 store 會走 bootstrap → 切到 queued/running;
|
||||
// 這裡測 page 邏輯,模擬 idle + 該 code 仍存在的極端情況
|
||||
uiState: "idle",
|
||||
isInitializing: false,
|
||||
error: {
|
||||
code: "active_job_exists",
|
||||
status: 409,
|
||||
},
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("同一個 error reference 不會 toast 兩次(避免重複觸發)", () => {
|
||||
const errObj = { code: "internal_error", status: 500, message: "x" };
|
||||
useConversionStore.setState({
|
||||
uiState: "idle",
|
||||
isInitializing: false,
|
||||
error: errObj,
|
||||
bootstrap: vi.fn(async () => {}),
|
||||
});
|
||||
const { rerender } = renderPage();
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 重新 render 同一個 error reference → 不該再觸發
|
||||
rerender(
|
||||
<LocaleProvider>
|
||||
<ConversionPage />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* F-T9 sub-4:多 tab 一致性 — 兩個 tab 都會 mount 各自的 ConversionPage */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("<ConversionPage /> — multi-tab consistency (F-T9 sub-4)", () => {
|
||||
it("兩個獨立 ConversionPage instance 都會呼叫 store.bootstrap()(從 backend 取一致狀態)", () => {
|
||||
const bootstrap = vi.fn(async () => {});
|
||||
useConversionStore.setState({ bootstrap });
|
||||
|
||||
// 模擬「同 user 開兩個 tab」— 兩個 ConversionPage instance 各自獨立
|
||||
// 這裡用同一個 store(zustand 單例 — Phase 0.8 conversion 對齊:天然 cohesion,
|
||||
// store 用 backend 當 source of truth)
|
||||
const tabA = render(
|
||||
<LocaleProvider>
|
||||
<ConversionPage />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
const tabB = render(
|
||||
<LocaleProvider>
|
||||
<ConversionPage />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
|
||||
// 兩個 tab 都 bootstrap(各自從 backend 取一致狀態 —— 真實環境是兩個獨立 zustand
|
||||
// instance;jsdom 共用 store 不影響「都會 bootstrap」這個基本性質)
|
||||
expect(bootstrap).toHaveBeenCalledTimes(2);
|
||||
|
||||
tabA.unmount();
|
||||
tabB.unmount();
|
||||
});
|
||||
});
|
||||
150
visionA-frontend/src/app/conversion/page.tsx
Normal file
150
visionA-frontend/src/app/conversion/page.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /conversion — 轉檔主頁(Phase 0.8 — feature-converter-integration)
|
||||
*
|
||||
* 對齊:
|
||||
* - Wireframe:.autoflow/03-design/wireframes/wireframe-conversion.md
|
||||
* - Flow: .autoflow/03-design/flows/flow-conversion.md
|
||||
* - PRD: .autoflow/02-prd/features/feature-converter-integration.md
|
||||
* - API: .autoflow/04-architecture/api/api-conversion.md
|
||||
*
|
||||
* F-T4 範圍:
|
||||
* - mount → 呼叫 store.bootstrap()(從 backend 重抓 active job 重建狀態)
|
||||
* - 根據 store.uiState 切渲染:
|
||||
* - isInitializing → loading skeleton(避免 SSR/hydration 後第一幀閃 idle)
|
||||
* - idle → <IdleForm />
|
||||
* - uploading → <UploadingView />(F-T5:XHR 進度條 + ETA + 取消)
|
||||
* - queued/running → <ProcessingView />(F-T6:stage indicator + progress + tab title)
|
||||
* - succeeded/failed/expired → 結果 placeholder(F-T7/F-T8)
|
||||
*
|
||||
* F-T9 sub-3:
|
||||
* - 訂閱 store.error,從 null → 非 null 時顯示 toast「上傳失敗」/「無法載入」
|
||||
* (flow §6.2:上傳失敗 → toast + 保留 form 內容讓使用者重試)
|
||||
*
|
||||
* 不做:
|
||||
* - Upload Dialog 模式(wireframe §4 寫的是 Dialog,但 PM/Design 確認後 F-T4 改在主畫面 inline 顯示表單;
|
||||
* 差異紀錄在交付報告,後續若 PM 認為要回 Dialog 模式再切換)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useConversionStore } from "@/stores/conversion-store";
|
||||
|
||||
import { ExpiredView } from "./components/ExpiredView";
|
||||
import { FailedView } from "./components/FailedView";
|
||||
import { IdleForm } from "./components/IdleForm";
|
||||
import { ProcessingView } from "./components/ProcessingView";
|
||||
import { SuccessView } from "./components/SuccessView";
|
||||
import { UploadingView } from "./components/UploadingView";
|
||||
|
||||
export default function ConversionPage() {
|
||||
const t = useT();
|
||||
const uiState = useConversionStore((s) => s.uiState);
|
||||
const isInitializing = useConversionStore((s) => s.isInitializing);
|
||||
const bootstrap = useConversionStore((s) => s.bootstrap);
|
||||
// F-T9 sub-3 — 上傳 / promote / bootstrap 失敗時,store 寫入 error;這裡 toast 提示
|
||||
const error = useConversionStore((s) => s.error);
|
||||
|
||||
// mount 時 bootstrap(store 內部已有重複呼叫保護)
|
||||
// Phase 0.8 conversion (見 .autoflow/03-design/flows/flow-conversion.md §5.1)
|
||||
useEffect(() => {
|
||||
void bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
// F-T9 sub-3 — 監聽 store.error(null → 非 null)→ 顯示 toast
|
||||
// 設計:
|
||||
// - 用 ref 記住上次看到的 error reference,避免 toast 在 re-render 時重複觸發
|
||||
// - error.code === 'aborted' 不 toast(取消上傳已由 cancelUpload 流程處理)
|
||||
// - error.code === 'active_job_exists' 不 toast(已由 banner 提示)
|
||||
// - 其他 error → toast「上傳失敗:{訊息}」/「無法載入轉檔狀態」
|
||||
//
|
||||
// F-T9 M1 修補(form 保留設計):
|
||||
// - page.tsx 是條件 render(idle / uploading 各自分支),IdleForm 在 idle ↔ uploading
|
||||
// 之間會 unmount → re-mount。原本假設「同一棵 tree」是錯的。
|
||||
// - 修法:IdleForm 的 metadata(chip / taskName / 上次選的檔名清單)提到 store.formDraft
|
||||
// —— 跨 mount 自動保留。File 物件本身不存 store(不可序列化 / unsafe),
|
||||
// 所以失敗回 idle 後使用者仍需重選檔;toast.retryHint 文案已對齊真實行為。
|
||||
const lastErrorRef = useRef<typeof error>(null);
|
||||
useEffect(() => {
|
||||
if (error === lastErrorRef.current) return;
|
||||
lastErrorRef.current = error;
|
||||
if (!error) return;
|
||||
|
||||
// 不 toast 的情況
|
||||
if (error.code === "aborted" || error.code === "active_job_exists") return;
|
||||
|
||||
// 用 toastFailed 統一文案;reason 在「網路類錯誤」用人話、其他用 error code(給 ops 偵錯)
|
||||
const isNetworkLike =
|
||||
error.status === 0 || error.status >= 500 || error.code === "network";
|
||||
const message = isNetworkLike
|
||||
? t("conversion.uploading.toastFailed").replace(
|
||||
"{reason}",
|
||||
t("conversion.toast.networkError"),
|
||||
)
|
||||
: t("conversion.uploading.toastFailed").replace(
|
||||
"{reason}",
|
||||
error.code,
|
||||
);
|
||||
toast.error(message, {
|
||||
description: t("conversion.toast.retryHint"),
|
||||
});
|
||||
}, [error, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto max-w-7xl space-y-6 px-6 py-8"
|
||||
data-testid="conversion-page"
|
||||
>
|
||||
{/* 頁面標題 */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">{t("conversion.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("conversion.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 內容區 — 依 store.uiState 切換 */}
|
||||
<div data-ui-state={uiState}>
|
||||
{isInitializing ? (
|
||||
<ConversionLoadingSkeleton />
|
||||
) : uiState === "idle" ? (
|
||||
<IdleForm />
|
||||
) : uiState === "uploading" ? (
|
||||
<UploadingView />
|
||||
) : uiState === "queued" || uiState === "running" ? (
|
||||
<ProcessingView />
|
||||
) : uiState === "succeeded" ? (
|
||||
<SuccessView />
|
||||
) : uiState === "failed" ? (
|
||||
<FailedView />
|
||||
) : uiState === "expired" ? (
|
||||
<ExpiredView />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton — bootstrap 進行中時顯示,避免 SSR 第一幀閃 idle / 又秒切走的視覺跳動。
|
||||
*
|
||||
* 視覺與後續 IdleForm / progress card 寬度對齊(都是 max-w-7xl 容器內全寬)。
|
||||
*/
|
||||
function ConversionLoadingSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-testid="conversion-loading"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="bg-muted h-10 w-full animate-pulse rounded-md" />
|
||||
<div className="bg-muted h-32 w-full animate-pulse rounded-md" />
|
||||
<div className="bg-muted h-10 w-1/3 animate-pulse rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ describe("<Sidebar />", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("包含 pages.md 總覽規定的 6 個主導航項目", () => {
|
||||
it("包含 pages.md 總覽規定的 7 個主導航項目(Phase 0.8 加入「轉檔」)", () => {
|
||||
usePathnameMock.mockReturnValue("/");
|
||||
|
||||
render(
|
||||
@ -97,12 +97,15 @@ describe("<Sidebar />", () => {
|
||||
</LocaleProvider>,
|
||||
);
|
||||
|
||||
// nav 內部的 link 數 = 6(不含品牌 logo link)
|
||||
// nav 內部的 link 數 = 7(不含品牌 logo link)
|
||||
// Phase 0.8 conversion: 新增「轉檔」tab,見 .autoflow/03-design/wireframes/wireframe-conversion.md §1.1
|
||||
const nav = screen.getByRole("navigation");
|
||||
const links = nav.querySelectorAll("a");
|
||||
expect(links).toHaveLength(6);
|
||||
expect(links).toHaveLength(7);
|
||||
|
||||
// 抽樣:確保 clusters 已納入(雲端版新增)
|
||||
expect(screen.getByRole("link", { name: /叢集/ })).toBeInTheDocument();
|
||||
// 抽樣:確保「轉檔」tab 存在(Phase 0.8 新增)
|
||||
expect(screen.getByRole("link", { name: /轉檔/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
Network,
|
||||
Play,
|
||||
Settings,
|
||||
Wand2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@ -50,6 +51,10 @@ const NAV_ITEMS: readonly NavItem[] = [
|
||||
{ href: "/", labelKey: "nav.dashboard", icon: LayoutDashboard },
|
||||
{ href: "/devices", labelKey: "nav.devices", icon: Cable },
|
||||
{ href: "/models", labelKey: "nav.models", icon: Boxes },
|
||||
// Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §1.1):
|
||||
// 「轉檔」放在「模型」之後,與 Models 相鄰 — 心智模型上「外部模型 → 轉檔產生模型」
|
||||
// 是延伸動作。Icon 採 Wand2 取「魔法轉換」隱喻,與 Boxes / Cable / Play 視覺密度相近。
|
||||
{ href: "/conversion", labelKey: "nav.conversion", icon: Wand2 },
|
||||
{ href: "/workspace", labelKey: "nav.workspace", icon: Play },
|
||||
{ href: "/clusters", labelKey: "nav.clusters", icon: Network },
|
||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
||||
|
||||
552
visionA-frontend/src/lib/api/conversion.test.ts
Normal file
552
visionA-frontend/src/lib/api/conversion.test.ts
Normal file
@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Conversion API Client 單元測試(Phase 0.8 F-T2)
|
||||
*
|
||||
* 覆蓋 5 個 endpoint:
|
||||
* 1. initConversion — XHR multipart upload + progress / abort / 409
|
||||
* 2. getActiveConversion — has_active true / false / 異常
|
||||
* 3. getConversion — 200 / 403 / 404
|
||||
* 4. promoteConversionToModels — 201 / 409 / parse 缺欄
|
||||
* 5. getConversionDownloadURL — 純函式輸入輸出
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ConversionAPIError,
|
||||
getActiveConversion,
|
||||
getConversion,
|
||||
getConversionDownloadURL,
|
||||
initConversion,
|
||||
promoteConversionToModels,
|
||||
} from "./conversion";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* XHR mock helper — 取代 jsdom 的 XMLHttpRequest */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface MockXHRInstance {
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
abort: ReturnType<typeof vi.fn>;
|
||||
setRequestHeader: ReturnType<typeof vi.fn>;
|
||||
getResponseHeader: ReturnType<typeof vi.fn>;
|
||||
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
|
||||
withCredentials: boolean;
|
||||
status: number;
|
||||
responseText: string;
|
||||
onload: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
ontimeout: (() => void) | null;
|
||||
// 觸發測試輔助
|
||||
_fireLoad: (status: number, body: unknown, headers?: Record<string, string>) => void;
|
||||
_fireError: () => void;
|
||||
_fireProgress: (loaded: number, total: number) => void;
|
||||
_formDataSent: FormData | null;
|
||||
}
|
||||
|
||||
function installMockXHR(): { instances: MockXHRInstance[] } {
|
||||
const instances: MockXHRInstance[] = [];
|
||||
|
||||
// 必須是真 constructor function(不能用 vi.fn().mockImplementation — 那回的是 mock,不可 `new`)
|
||||
function MockXHR(this: MockXHRInstance) {
|
||||
const headers: Record<string, string> = {};
|
||||
// 直接賦值到 this,避免 this 別名被 lint 警示
|
||||
this.open = vi.fn();
|
||||
this.abort = vi.fn();
|
||||
this.setRequestHeader = vi.fn();
|
||||
this.getResponseHeader = vi.fn((k: string) => headers[k.toLowerCase()] ?? null);
|
||||
this.upload = { onprogress: null };
|
||||
this.withCredentials = false;
|
||||
this.status = 0;
|
||||
this.responseText = "";
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.ontimeout = null;
|
||||
this._formDataSent = null;
|
||||
// send / fire* 需要持有 instance 參考;用 instance push 後再裝上
|
||||
instances.push(this);
|
||||
const idx = instances.length - 1;
|
||||
this.send = vi.fn((body: unknown) => {
|
||||
instances[idx]._formDataSent = body instanceof FormData ? body : null;
|
||||
});
|
||||
this._fireLoad = (status, body, hdrs) => {
|
||||
const ref = instances[idx];
|
||||
ref.status = status;
|
||||
ref.responseText =
|
||||
typeof body === "string" ? body : body == null ? "" : JSON.stringify(body);
|
||||
if (hdrs) {
|
||||
for (const [k, v] of Object.entries(hdrs)) headers[k.toLowerCase()] = v;
|
||||
}
|
||||
ref.onload?.();
|
||||
};
|
||||
this._fireError = () => {
|
||||
instances[idx].onerror?.();
|
||||
};
|
||||
this._fireProgress = (loaded, total) => {
|
||||
instances[idx].upload.onprogress?.({
|
||||
lengthComputable: true,
|
||||
loaded,
|
||||
total,
|
||||
} as ProgressEvent);
|
||||
};
|
||||
}
|
||||
|
||||
// 覆蓋 global XMLHttpRequest
|
||||
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = MockXHR;
|
||||
return { instances };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* fetch mock helper — 給 4 個非 init 的 endpoint */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 1. initConversion */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("initConversion", () => {
|
||||
it("happy path:multipart 含 model + ref_images + platform,回 normalized Job", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["onnx-bytes"], "yolov5s.onnx", {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
const ref = new File(["img"], "ref.jpg", { type: "image/jpeg" });
|
||||
|
||||
const promise = initConversion({
|
||||
file,
|
||||
refImages: [ref],
|
||||
targetChip: "KL720",
|
||||
taskName: "yolov5s_test",
|
||||
});
|
||||
|
||||
// 確認 XHR 已建立並送 form
|
||||
expect(instances.length).toBe(1);
|
||||
const xhr = instances[0];
|
||||
expect(xhr.open).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
expect.stringMatching(/\/api\/conversion\/init$/),
|
||||
true,
|
||||
);
|
||||
expect(xhr.withCredentials).toBe(true);
|
||||
|
||||
const form = xhr._formDataSent;
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
// model 檔
|
||||
expect((form!.get("model") as File).name).toBe("yolov5s.onnx");
|
||||
// ref_images[](多筆同 key)
|
||||
const refs = form!.getAll("ref_images[]");
|
||||
expect(refs.length).toBe(1);
|
||||
expect((refs[0] as File).name).toBe("ref.jpg");
|
||||
// text fields — backend 用 `model_id` 欄裝 taskName、`platform` 用 wire 數字格式
|
||||
expect(form!.get("model_id")).toBe("yolov5s_test");
|
||||
expect(form!.get("platform")).toBe("720");
|
||||
expect(form!.get("version")).toBe("v1.0.0");
|
||||
|
||||
// 模擬後端 200 回 envelope
|
||||
xhr._fireLoad(200, {
|
||||
success: true,
|
||||
data: {
|
||||
job_id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
status: "running",
|
||||
stage: "onnx",
|
||||
progress: 0,
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "720",
|
||||
},
|
||||
});
|
||||
|
||||
const job = await promise;
|
||||
expect(job.job_id).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(job.status).toBe("running");
|
||||
expect(job.target_chip).toBe("KL720"); // wire 520→KL520 normalize
|
||||
expect(job.stage).toBe("onnx");
|
||||
});
|
||||
|
||||
it("沒給 taskName 時用檔名當 model_id", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["x"], "model.onnx");
|
||||
const promise = initConversion({ file, targetChip: "KL520" });
|
||||
const xhr = instances[0];
|
||||
xhr._fireLoad(200, {
|
||||
success: true,
|
||||
data: {
|
||||
job_id: "j1",
|
||||
status: "running",
|
||||
stage: "onnx",
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
source_filename: "model.onnx",
|
||||
target_chip: "520",
|
||||
},
|
||||
});
|
||||
await promise;
|
||||
expect(xhr._formDataSent!.get("model_id")).toBe("model.onnx");
|
||||
expect(xhr._formDataSent!.get("platform")).toBe("520");
|
||||
});
|
||||
|
||||
it("onUploadProgress 被呼叫", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["x".repeat(100)], "m.onnx");
|
||||
const onUploadProgress = vi.fn();
|
||||
|
||||
const promise = initConversion({
|
||||
file,
|
||||
targetChip: "KL520",
|
||||
onUploadProgress,
|
||||
});
|
||||
|
||||
const xhr = instances[0];
|
||||
xhr._fireProgress(50, 100);
|
||||
xhr._fireProgress(100, 100);
|
||||
xhr._fireLoad(200, {
|
||||
success: true,
|
||||
data: {
|
||||
job_id: "j2",
|
||||
status: "running",
|
||||
stage: "onnx",
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
source_filename: "m.onnx",
|
||||
target_chip: "520",
|
||||
},
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(onUploadProgress).toHaveBeenCalledWith(50, 100);
|
||||
expect(onUploadProgress).toHaveBeenCalledWith(100, 100);
|
||||
expect(onUploadProgress).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("409 active_job_exists → throw ConversionAPIError(409, 'active_job_exists', requestId)", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["x"], "m.onnx");
|
||||
const promise = initConversion({ file, targetChip: "KL520" });
|
||||
|
||||
instances[0]._fireLoad(
|
||||
409,
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "active_job_exists",
|
||||
message: "你已有進行中任務",
|
||||
},
|
||||
},
|
||||
{ "X-Request-Id": "req-abc" },
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(ConversionAPIError);
|
||||
try {
|
||||
await promise;
|
||||
} catch (e) {
|
||||
const err = e as ConversionAPIError;
|
||||
expect(err.status).toBe(409);
|
||||
expect(err.code).toBe("active_job_exists");
|
||||
expect(err.message).toBe("你已有進行中任務");
|
||||
expect(err.requestId).toBe("req-abc");
|
||||
}
|
||||
});
|
||||
|
||||
it("network error → throw ConversionAPIError(0, 'network_error')", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["x"], "m.onnx");
|
||||
const promise = initConversion({ file, targetChip: "KL520" });
|
||||
instances[0]._fireError();
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
name: "ConversionAPIError",
|
||||
status: 0,
|
||||
code: "network_error",
|
||||
});
|
||||
});
|
||||
|
||||
it("AbortSignal.abort → 呼叫 xhr.abort 並 reject ConversionAPIError(0, 'aborted')", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["x"], "m.onnx");
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const promise = initConversion({
|
||||
file,
|
||||
targetChip: "KL520",
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
ctrl.abort();
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
name: "ConversionAPIError",
|
||||
code: "aborted",
|
||||
});
|
||||
expect(instances[0].abort).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("已 abort 的 signal → 立刻 reject 不送 XHR", async () => {
|
||||
const { instances } = installMockXHR();
|
||||
const file = new File(["x"], "m.onnx");
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
|
||||
const promise = initConversion({
|
||||
file,
|
||||
targetChip: "KL520",
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ code: "aborted" });
|
||||
expect(instances[0].send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 2. getActiveConversion */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("getActiveConversion", () => {
|
||||
it("has_active=true → 回 normalized Job", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
has_active: true,
|
||||
job: {
|
||||
job_id: "j-active",
|
||||
status: "running",
|
||||
stage: "bie",
|
||||
progress: 45,
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "720",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const job = await getActiveConversion();
|
||||
expect(job).not.toBeNull();
|
||||
expect(job!.job_id).toBe("j-active");
|
||||
expect(job!.target_chip).toBe("KL720");
|
||||
expect(job!.stage).toBe("bie");
|
||||
expect(job!.progress).toBe(45);
|
||||
});
|
||||
|
||||
it("has_active=false → 回 null", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: { has_active: false, job: null } }),
|
||||
);
|
||||
const job = await getActiveConversion();
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
it("backend 401 → throw ConversionAPIError(401, 'unauthorized')", async () => {
|
||||
// F-T2 review Major #1:code 統一全小寫,對齊 conversion.md §6 i18n key 命名
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 401 }));
|
||||
await expect(getActiveConversion()).rejects.toMatchObject({
|
||||
name: "ConversionAPIError",
|
||||
status: 401,
|
||||
code: "unauthorized",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 3. getConversion */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("getConversion", () => {
|
||||
it("成功回 normalized Job(completed → succeeded)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
job_id: "j-1",
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
source_filename: "m.onnx",
|
||||
target_chip: "520",
|
||||
error_code: null,
|
||||
error_message: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const job = await getConversion("j-1");
|
||||
expect(job.status).toBe("succeeded"); // completed → succeeded
|
||||
expect(job.target_chip).toBe("KL520");
|
||||
expect(job.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("failed 時 error_code/message 包成 error 物件", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
job_id: "j-2",
|
||||
status: "failed",
|
||||
progress: 30,
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
source_filename: "m.onnx",
|
||||
target_chip: "720",
|
||||
error_code: "QUANTIZATION_FAILED",
|
||||
error_message: "Custom op X not supported",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const job = await getConversion("j-2");
|
||||
expect(job.status).toBe("failed");
|
||||
expect(job.error).toEqual({
|
||||
code: "QUANTIZATION_FAILED",
|
||||
message: "Custom op X not supported",
|
||||
});
|
||||
});
|
||||
|
||||
it("404 not_found → throw ConversionAPIError(404, 'not_found')", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "not_found", message: "任務不存在" } },
|
||||
404,
|
||||
),
|
||||
);
|
||||
await expect(getConversion("missing")).rejects.toMatchObject({
|
||||
name: "ConversionAPIError",
|
||||
status: 404,
|
||||
code: "not_found",
|
||||
});
|
||||
});
|
||||
|
||||
it("403 forbidden → throw ConversionAPIError(403, 'forbidden')", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "forbidden", message: "你無權存取此任務" } },
|
||||
403,
|
||||
),
|
||||
);
|
||||
await expect(getConversion("other-user-job")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: "forbidden",
|
||||
});
|
||||
});
|
||||
|
||||
it("空 jobId → 立刻 throw ConversionAPIError 不打 fetch", async () => {
|
||||
await expect(getConversion("")).rejects.toMatchObject({
|
||||
code: "validation_failed",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 4. promoteConversionToModels */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("promoteConversionToModels", () => {
|
||||
it("成功回 { model_id }", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
model_id: "m-abc-123",
|
||||
source: "converted",
|
||||
source_job_id: "j-1",
|
||||
name: "yolov5s_kl720",
|
||||
target_chip: "kl720",
|
||||
file_size: 12345678,
|
||||
status: "ready",
|
||||
created_at: "2026-04-30T12:30:00Z",
|
||||
},
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
|
||||
const res = await promoteConversionToModels("j-1", { name: "yolov5s_kl720" });
|
||||
expect(res).toEqual({ model_id: "m-abc-123" });
|
||||
|
||||
// 驗證 request body
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe(JSON.stringify({ name: "yolov5s_kl720" }));
|
||||
});
|
||||
|
||||
it("body 省略時送空物件", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: true, data: { model_id: "m-1" } },
|
||||
200,
|
||||
),
|
||||
);
|
||||
await promoteConversionToModels("j-2");
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("409 job_not_completed → throw ConversionAPIError(409, 'job_not_completed')", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "job_not_completed", message: "尚未完成" } },
|
||||
409,
|
||||
),
|
||||
);
|
||||
await expect(promoteConversionToModels("j-3")).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "job_not_completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("response 缺 model_id → throw ConversionAPIError(500, 'parse_error')", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: { source: "converted" } }, 201),
|
||||
);
|
||||
await expect(promoteConversionToModels("j-4")).rejects.toMatchObject({
|
||||
code: "parse_error",
|
||||
status: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("空 jobId → 立刻 throw 不打 fetch", async () => {
|
||||
await expect(promoteConversionToModels("")).rejects.toMatchObject({
|
||||
code: "validation_failed",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 5. getConversionDownloadURL */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("getConversionDownloadURL", () => {
|
||||
it("組相對 URL(不含 base,給 anchor / location.href 用)", () => {
|
||||
expect(getConversionDownloadURL("550e8400-e29b-41d4-a716-446655440000")).toBe(
|
||||
"/api/conversion/550e8400-e29b-41d4-a716-446655440000/download",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodeURIComponent 防止 jobId 含特殊字元注入", () => {
|
||||
expect(getConversionDownloadURL("a/b?c=d")).toBe(
|
||||
"/api/conversion/a%2Fb%3Fc%3Dd/download",
|
||||
);
|
||||
});
|
||||
});
|
||||
442
visionA-frontend/src/lib/api/conversion.ts
Normal file
442
visionA-frontend/src/lib/api/conversion.ts
Normal file
@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Conversion API Client(Phase 0.8)
|
||||
*
|
||||
* 對齊:
|
||||
* - `.autoflow/04-architecture/api/api-conversion.md`(5 個 endpoint 完整 spec)
|
||||
* - `.autoflow/04-architecture/conversion.md` §6(錯誤碼 mapping)
|
||||
* - `.autoflow/03-design/wireframes/wireframe-conversion.md`(UI 對應)
|
||||
*
|
||||
* 設計原則:
|
||||
* 1. **沿用 `@/lib/api` 的 fetch wrapper**:所有 GET / POST 走 `api.get` / `api.post`,
|
||||
* 自動帶 `credentials: 'include'`、自動解 envelope、ApiError mapping。
|
||||
* 2. **唯一例外是 `initConversion`**:multipart upload 需要 progress callback,
|
||||
* `lib/api.ts` 的 `upload()` 只支援單 Blob,因此這裡自寫 XHR multipart 版。
|
||||
* 3. **錯誤型別**:對外暴露 `ConversionAPIError`(含 status / code / message / requestId),
|
||||
* 讓 store / UI 用 `error.code` 對應 i18n。底層仍以 `ApiError` 為來源,convert 包一層。
|
||||
* _client 層不翻譯錯誤成中文_ — i18n 留在 store / UI。
|
||||
* 4. **wire format 正規化**:backend 用 snake_case + chip 數字字串 `520` / `720`、
|
||||
* status `created` / `completed`;client 統一輸出 camelCase / `KL520` / `queued` / `succeeded`。
|
||||
* 5. **download 不 fetch**:`getConversionDownloadURL` 純字串組裝,給 UI 用 `<a href>` /
|
||||
* `window.location.href`,server-side 302 redirect 流程詳見 api-conversion.md §4。
|
||||
*/
|
||||
|
||||
import { ApiError, api, getApiBaseUrl } from "@/lib/api";
|
||||
import type {
|
||||
ActiveConversionResponse,
|
||||
ConversionJob,
|
||||
ConversionStage,
|
||||
ConversionStatus,
|
||||
InitConversionArgs,
|
||||
PromoteConversionBody,
|
||||
PromoteConversionResult,
|
||||
TargetChip,
|
||||
} from "@/types/conversion";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Error class */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Conversion 專用錯誤。store / UI 用 `error.code` 對應 i18n key
|
||||
* (`conversion.error.<code>`),不需要 client 端先翻譯。
|
||||
*
|
||||
* 來源:
|
||||
* - 底層 `ApiError`(fetch wrapper / XHR)→ wrap 成 `ConversionAPIError`
|
||||
* - XHR network / abort → 直接 throw 對應的 ConversionAPIError(0, "network_error" | "aborted", ...)
|
||||
*/
|
||||
export class ConversionAPIError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly requestId?: string;
|
||||
|
||||
constructor(status: number, code: string, message: string, requestId?: string) {
|
||||
super(message);
|
||||
this.name = "ConversionAPIError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
if (typeof Error.captureStackTrace === "function") {
|
||||
Error.captureStackTrace(this, ConversionAPIError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把底層 `ApiError` 包成 `ConversionAPIError`(保留 status / code / message)
|
||||
*
|
||||
* code 統一全小寫 — 對齊 conversion.md §6 i18n key 命名規範(`conversion.error.<code>` 永遠小寫)。
|
||||
* F-T2 review Major #1:原本 ApiError 路徑保留大寫(`UNAUTHORIZED`),其他錯誤 toLowerCase,
|
||||
* 同函式兩套規範會讓 store/UI 對 i18n key 對應時要做大小寫處理。統一為小寫單一規範。
|
||||
*/
|
||||
function wrapApiError(err: unknown): ConversionAPIError {
|
||||
if (err instanceof ConversionAPIError) return err;
|
||||
if (err instanceof ApiError) {
|
||||
return new ConversionAPIError(err.status, err.code.toLowerCase(), err.message);
|
||||
}
|
||||
// NetworkError / TimeoutError / AbortError / ParseError 都掉到這
|
||||
if (err instanceof Error) {
|
||||
// 用 BaseApiClientError.code(NETWORK_ERROR / TIMEOUT / ABORTED / PARSE_ERROR)
|
||||
const maybeCode = (err as unknown as { code?: unknown }).code;
|
||||
const code =
|
||||
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
|
||||
return new ConversionAPIError(0, code, err.message);
|
||||
}
|
||||
return new ConversionAPIError(0, "unknown", String(err));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Wire format normalize */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** wire `520` / `720` / `630` / `730` → UI `KL520` ... */
|
||||
function normalizeTargetChip(raw: unknown): TargetChip {
|
||||
const v = String(raw ?? "").toUpperCase();
|
||||
// 兩種格式都接:純數字 ("520") 或已帶 KL 前綴 ("KL520" / "kl520")
|
||||
const num = v.startsWith("KL") ? v.slice(2) : v;
|
||||
switch (num) {
|
||||
case "520":
|
||||
return "KL520";
|
||||
case "630":
|
||||
return "KL630";
|
||||
case "720":
|
||||
return "KL720";
|
||||
case "730":
|
||||
return "KL730";
|
||||
default:
|
||||
// 未知 chip 回 KL520 作 fallback(不該發生;backend schema 限制 4 種)
|
||||
return "KL520";
|
||||
}
|
||||
}
|
||||
|
||||
/** UI `KL520` ... → wire `520` ... */
|
||||
function targetChipToWire(chip: TargetChip): string {
|
||||
return chip.replace(/^KL/, "");
|
||||
}
|
||||
|
||||
/** backend status `created` / `completed` → UI `queued` / `succeeded` */
|
||||
function normalizeStatus(raw: unknown): ConversionStatus {
|
||||
const v = String(raw ?? "").toLowerCase();
|
||||
switch (v) {
|
||||
case "created":
|
||||
case "queued":
|
||||
return "queued";
|
||||
case "running":
|
||||
return "running";
|
||||
case "completed":
|
||||
case "succeeded":
|
||||
return "succeeded";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
// 未知狀態保守當 running(讓 polling 繼續)
|
||||
return "running";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStage(raw: unknown): ConversionStage | undefined {
|
||||
const v = String(raw ?? "").toLowerCase();
|
||||
if (v === "onnx" || v === "bie" || v === "nef") return v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 backend job DTO(snake_case)正規化為 client `ConversionJob`。
|
||||
*
|
||||
* 寬容讀取:同時接受 snake_case / camelCase(避免 backend 之後改格式時 client 即崩)。
|
||||
*/
|
||||
function normalizeJob(raw: unknown): ConversionJob {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
|
||||
const errorCode = (r.error_code as string | null | undefined) ?? null;
|
||||
const errorMessage = (r.error_message as string | null | undefined) ?? null;
|
||||
|
||||
// result 欄位:backend Phase 0.8 spec 沒包,但留位置給未來擴充
|
||||
const rawResult = r.result as Record<string, unknown> | undefined;
|
||||
const result =
|
||||
rawResult && typeof rawResult === "object"
|
||||
? {
|
||||
output_size_bytes: Number(
|
||||
rawResult.output_size_bytes ?? rawResult.outputSizeBytes ?? 0,
|
||||
),
|
||||
output_checksum: (rawResult.output_checksum ??
|
||||
rawResult.outputChecksum ??
|
||||
undefined) as string | undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const progressRaw = r.progress;
|
||||
const progress =
|
||||
typeof progressRaw === "number" && Number.isFinite(progressRaw)
|
||||
? progressRaw
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
job_id: String(r.job_id ?? r.jobId ?? ""),
|
||||
status: normalizeStatus(r.status),
|
||||
stage: normalizeStage(r.stage),
|
||||
progress,
|
||||
source_filename: String(r.source_filename ?? r.sourceFilename ?? ""),
|
||||
target_chip: normalizeTargetChip(r.target_chip ?? r.targetChip),
|
||||
created_at: String(r.created_at ?? r.createdAt ?? ""),
|
||||
expires_at: String(r.expires_at ?? r.expiresAt ?? ""),
|
||||
error: errorCode ? { code: errorCode, message: errorMessage ?? undefined } : undefined,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 1. POST /api/conversion/init — multipart streaming upload (XHR) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 啟動轉檔。multipart 上傳,支援 onUploadProgress callback。
|
||||
*
|
||||
* @see api-conversion.md §1
|
||||
*
|
||||
* 為什麼自寫 XHR 而不用 `lib/api.ts` 的 `upload()`:
|
||||
* - 既有 `upload(path, file, options)` 只接受單一 Blob,不支援 multipart 多檔 + text fields。
|
||||
* - fetch API 不支援 upload progress(只有 download progress),必填 XHR。
|
||||
*/
|
||||
export function initConversion(args: InitConversionArgs): Promise<ConversionJob> {
|
||||
const url = `${getApiBaseUrl()}/api/conversion/init`;
|
||||
|
||||
// multipart fields — 對齊 api-conversion.md §1 表格
|
||||
const form = new FormData();
|
||||
form.append("model", args.file);
|
||||
if (args.refImages && args.refImages.length > 0) {
|
||||
for (const img of args.refImages) {
|
||||
// backend 接 `ref_images[]`(converter 規格)— FormData 多筆同 key 即可
|
||||
form.append("ref_images[]", img);
|
||||
}
|
||||
}
|
||||
// backend 必填欄位:`model_id`(1–65535 字串使用者編號)+ `version` + `platform`
|
||||
// task spec 用 taskName 作 model_id(轉檔任務的顯示名稱),非 model 庫的 model_id
|
||||
form.append("model_id", args.taskName ?? args.file.name);
|
||||
form.append("version", args.version ?? "v1.0.0");
|
||||
form.append("platform", targetChipToWire(args.targetChip));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url, true);
|
||||
// BFF cookie session — 同 `lib/api.ts` upload()
|
||||
xhr.withCredentials = true;
|
||||
// 不設 Content-Type,讓 browser 自動帶 boundary
|
||||
|
||||
if (args.onUploadProgress) {
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
// multipart 上傳:以 ev.total 為準(含 boundary 開銷,誤差 < 1%)
|
||||
if (ev.lengthComputable) {
|
||||
args.onUploadProgress!(ev.loaded, ev.total);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
const requestId = xhr.getResponseHeader("X-Request-Id") ?? undefined;
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
// 解 envelope { success, data }
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
||||
} catch {
|
||||
reject(
|
||||
new ConversionAPIError(
|
||||
xhr.status,
|
||||
"parse_error",
|
||||
"init: invalid JSON response",
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const env = payload as
|
||||
| { success?: boolean; data?: unknown; error?: { code?: string; message?: string } }
|
||||
| null;
|
||||
if (env && env.success === true && env.data) {
|
||||
resolve(normalizeJob(env.data));
|
||||
return;
|
||||
}
|
||||
if (env && env.success === false && env.error) {
|
||||
reject(
|
||||
new ConversionAPIError(
|
||||
xhr.status,
|
||||
env.error.code ?? "unknown",
|
||||
env.error.message ?? "init failed",
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// envelope 不符規範 — 視為 parse 失敗
|
||||
reject(
|
||||
new ConversionAPIError(
|
||||
xhr.status,
|
||||
"parse_error",
|
||||
"init: unexpected envelope shape",
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// non-2xx
|
||||
let code = "unknown";
|
||||
let message = `HTTP ${xhr.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText) as {
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
if (parsed?.error) {
|
||||
code = parsed.error.code ?? code;
|
||||
message = parsed.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
// body 非 JSON:保留預設訊息
|
||||
if (xhr.responseText) message = xhr.responseText.slice(0, 200);
|
||||
}
|
||||
reject(new ConversionAPIError(xhr.status, code, message, requestId));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(
|
||||
new ConversionAPIError(0, "network_error", `Failed to reach ${url} (network)`),
|
||||
);
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
reject(new ConversionAPIError(0, "timeout", `Upload to ${url} timed out`));
|
||||
};
|
||||
|
||||
if (args.signal) {
|
||||
if (args.signal.aborted) {
|
||||
reject(new ConversionAPIError(0, "aborted", "Request aborted before send"));
|
||||
return;
|
||||
}
|
||||
args.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
xhr.abort();
|
||||
reject(new ConversionAPIError(0, "aborted", "Request aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2. GET /api/conversion/active */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 查當前 user 的 active job — 給 frontend 在「上傳」UI 進入前 pre-check。
|
||||
*
|
||||
* @see api-conversion.md §5
|
||||
*
|
||||
* Backend 永遠回 200 + envelope;
|
||||
* - `data: { has_active: true, job: {...} }` → 回 normalized Job
|
||||
* - `data: { has_active: false, job: null }` → 回 `null`
|
||||
*
|
||||
* 註:backend Phase 0.8 強化過 lazy rebuild ownership(重啟恢復),對 client 完全透明。
|
||||
*/
|
||||
export async function getActiveConversion(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ActiveConversionResponse> {
|
||||
try {
|
||||
const data = await api.get<{ has_active?: boolean; job?: unknown } | null>(
|
||||
"/api/conversion/active",
|
||||
{ signal },
|
||||
);
|
||||
if (!data || data.has_active !== true || !data.job) return null;
|
||||
return normalizeJob(data.job);
|
||||
} catch (err) {
|
||||
throw wrapApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 3. GET /api/conversion/{job_id} */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Poll 單一 job 狀態(建議 2 秒輪詢,由 store 控制節奏)。
|
||||
*
|
||||
* @see api-conversion.md §2
|
||||
*/
|
||||
export async function getConversion(
|
||||
jobId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ConversionJob> {
|
||||
if (!jobId) {
|
||||
throw new ConversionAPIError(0, "validation_failed", "jobId is required");
|
||||
}
|
||||
try {
|
||||
const data = await api.get<unknown>(
|
||||
`/api/conversion/${encodeURIComponent(jobId)}`,
|
||||
{ signal },
|
||||
);
|
||||
return normalizeJob(data);
|
||||
} catch (err) {
|
||||
throw wrapApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 4. POST /api/conversion/{job_id}/promote-to-models */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 把已完成的轉檔 job 加到模型庫。冪等 — 重複呼叫同一 job_id 回同一個 model record。
|
||||
*
|
||||
* @see api-conversion.md §3
|
||||
*/
|
||||
export async function promoteConversionToModels(
|
||||
jobId: string,
|
||||
body?: PromoteConversionBody,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PromoteConversionResult> {
|
||||
if (!jobId) {
|
||||
throw new ConversionAPIError(0, "validation_failed", "jobId is required");
|
||||
}
|
||||
try {
|
||||
// backend 回的是完整 model record(含 model_id / source / name / ...),
|
||||
// client 只需 model_id 給 UI 跳頁;其他欄位 store fetchModels() 會重拉。
|
||||
const raw = await api.post<{ model_id?: string; modelId?: string }>(
|
||||
`/api/conversion/${encodeURIComponent(jobId)}/promote-to-models`,
|
||||
body ?? {},
|
||||
{ signal },
|
||||
);
|
||||
const modelId = raw?.model_id ?? raw?.modelId;
|
||||
if (!modelId) {
|
||||
throw new ConversionAPIError(
|
||||
500,
|
||||
"parse_error",
|
||||
"promote-to-models: missing model_id in response",
|
||||
);
|
||||
}
|
||||
return { model_id: String(modelId) };
|
||||
} catch (err) {
|
||||
throw wrapApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 5. GET /api/conversion/{job_id}/download — pure URL builder */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 組「下載」endpoint URL — 給 UI 用 `<a href download>` 或 `window.location.href` 觸發。
|
||||
*
|
||||
* **注意:這是 server-side 302 redirect 的 entry point,client 不該用 fetch / XHR 打**:
|
||||
* - fetch redirect 跨 origin 會被 browser 攔,且 Authorization / cookie 不一定 propagate
|
||||
* - browser navigation(anchor / location.href)不適用 CORS,Server-side 302 即可全程串通
|
||||
*
|
||||
* Token 永遠不過 frontend JS — 詳見 api-conversion.md §4。
|
||||
*/
|
||||
export function getConversionDownloadURL(jobId: string): string {
|
||||
// 不接 base URL — anchor / location.href 會自動接當前 origin(同 origin 部署);
|
||||
// 跨 origin dev 環境的下載走 browser navigation 也不需要 CORS。若未來要支援跨 origin,
|
||||
// 需要改回 `${getApiBaseUrl()}/api/conversion/...` 並由 server 設適當的 redirect target。
|
||||
return `/api/conversion/${encodeURIComponent(jobId)}/download`;
|
||||
}
|
||||
@ -32,6 +32,7 @@ export const en: Dictionary = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.devices": "Devices",
|
||||
"nav.models": "Models",
|
||||
"nav.conversion": "Convert",
|
||||
"nav.workspace": "Workspace",
|
||||
"nav.clusters": "Clusters",
|
||||
"nav.settings": "Settings",
|
||||
@ -336,6 +337,288 @@ export const en: Dictionary = {
|
||||
"account.danger.deleteAccount.tooltip":
|
||||
"Please handle this at the Innovedus Account Center.",
|
||||
|
||||
// ── Conversion (Phase 0.8 — feature-converter-integration) ──
|
||||
// Spec: .autoflow/03-design/wireframes/wireframe-conversion.md §11
|
||||
// PRD: .autoflow/02-prd/features/feature-converter-integration.md
|
||||
// Scope: 5 states (idle / uploading / processing / success / failed) + 6 error code translations + expired
|
||||
"conversion.title": "Convert",
|
||||
"conversion.subtitle":
|
||||
"Turn ONNX / TFLite models into .nef so they can run on Kneron edge devices.",
|
||||
|
||||
// idle empty state
|
||||
"conversion.idle.heading": "No conversion in progress",
|
||||
"conversion.idle.description":
|
||||
"Upload an ONNX / TFLite model, pick a target Kneron chip, and we'll produce a flashable .nef file for you.",
|
||||
"conversion.idle.cta": "Start conversion",
|
||||
"conversion.idle.formats": "Supports .onnx / .tflite · max 500 MB",
|
||||
"conversion.idle.about.title": "About conversion",
|
||||
"conversion.idle.about.line1":
|
||||
"Only one conversion job per user at a time (across tabs).",
|
||||
"conversion.idle.about.line2":
|
||||
"Results are downloadable for 7 days, then automatically cleared.",
|
||||
"conversion.idle.about.line3":
|
||||
"Conversion typically takes 1–10 minutes depending on model size.",
|
||||
|
||||
// Upload Dialog
|
||||
"conversion.upload.title": "Start conversion",
|
||||
"conversion.upload.description":
|
||||
"Upload a model, choose a target chip, and optionally attach reference images for better accuracy.",
|
||||
"conversion.upload.source.label": "Source model",
|
||||
"conversion.upload.source.dropzone": "Drag .onnx / .tflite here",
|
||||
"conversion.upload.source.or": "or",
|
||||
"conversion.upload.source.browse": "Choose file",
|
||||
"conversion.upload.source.formatHint":
|
||||
"Format: .onnx · .tflite · max 500 MB",
|
||||
"conversion.upload.source.remove": "Remove",
|
||||
"conversion.upload.name.label": "Job name (optional)",
|
||||
"conversion.upload.name.hint": "Display only; does not change output filename.",
|
||||
"conversion.upload.chip.label": "Target chip",
|
||||
"conversion.upload.refImages.label": "Reference images (optional)",
|
||||
"conversion.upload.refImages.dropzone": "Drag images here (optional)",
|
||||
"conversion.upload.refImages.hint":
|
||||
"Reference images can improve quantized accuracy (max 100 images, ≤ 10 MB each).",
|
||||
"conversion.upload.refImages.summary":
|
||||
"{count} reference images selected ({totalSize})",
|
||||
"conversion.upload.refImages.removeAll": "Remove all",
|
||||
"conversion.upload.cancel": "Cancel",
|
||||
"conversion.upload.start": "Start conversion",
|
||||
"conversion.upload.error.noFile": "Please choose a .onnx or .tflite file.",
|
||||
"conversion.upload.error.unsupported":
|
||||
"Unsupported format — please use ONNX or TFLite.",
|
||||
"conversion.upload.error.modelTooLarge":
|
||||
"Model exceeds the 500 MB limit; please use a smaller model.",
|
||||
"conversion.upload.error.noChip": "Please pick a target chip.",
|
||||
"conversion.upload.error.refTooLarge":
|
||||
"{filename} is over 10 MB — please remove or compress it.",
|
||||
"conversion.upload.error.refTooMany":
|
||||
"Reference images cannot exceed 100 files.",
|
||||
|
||||
// uploading stage (inside Dialog)
|
||||
"conversion.uploading.title": "Uploading",
|
||||
"conversion.uploading.heading": "Uploading to visionA…",
|
||||
"conversion.uploading.progress": "{loaded} / {total} uploaded · {eta} remaining",
|
||||
"conversion.uploading.almostDone": "Almost done…",
|
||||
"conversion.uploading.warning":
|
||||
"Please don't close this tab — the upload will be aborted.",
|
||||
"conversion.uploading.cancel": "Cancel upload",
|
||||
"conversion.uploading.cancelConfirm":
|
||||
"Upload not finished — cancel anyway?",
|
||||
"conversion.uploading.tabTitle": "visionA Cloud · Uploading ({pct}%)",
|
||||
"conversion.uploading.toastCanceled": "Upload canceled.",
|
||||
"conversion.uploading.toastFailed": "Upload failed: {reason}",
|
||||
// F-T9 sub-3 / M1: upload failure toast hint
|
||||
// M1 fix: File objects can't live in the store (non-serializable / unsafe across unmounts);
|
||||
// chip / task name are preserved via store.formDraft, but the file itself must be re-selected.
|
||||
"conversion.toast.retryHint":
|
||||
"Your settings (chip / task name) are preserved — please re-select the file to retry.",
|
||||
"conversion.toast.networkError":
|
||||
"Network error — please check your connection and retry.",
|
||||
"conversion.uploading.toastStarted":
|
||||
"Conversion started (job #{shortJobId}).",
|
||||
// F-T5: ETA / cancel confirm / beforeunload (detailed keys)
|
||||
"conversion.uploading.subtitle": "Uploading model to visionA backend",
|
||||
"conversion.uploading.eta.computing": "Estimating time remaining…",
|
||||
"conversion.uploading.eta.almostDone": "Almost done…",
|
||||
"conversion.uploading.eta.format": "{eta} remaining",
|
||||
"conversion.uploading.progress.format": "{loaded} / {total}",
|
||||
"conversion.uploading.info":
|
||||
"Conversion will start automatically (typically 10–30 seconds).",
|
||||
"conversion.uploading.cancel.confirm.title": "Cancel conversion?",
|
||||
"conversion.uploading.cancel.confirm.message":
|
||||
"Upload is not finished — uploaded progress will be lost. Cancel anyway?",
|
||||
"conversion.uploading.cancel.confirm.yes": "Yes, cancel",
|
||||
"conversion.uploading.cancel.confirm.no": "Keep uploading",
|
||||
"conversion.uploading.warning.beforeunload":
|
||||
"Upload not finished — leaving will abort the upload.",
|
||||
"conversion.uploading.target": "Target: {chip}",
|
||||
"conversion.uploading.aria.cancel": "Cancel upload",
|
||||
"conversion.uploading.aria.progress": "Upload progress",
|
||||
"conversion.uploading.aria.status": "Uploaded {pct}%, {eta}",
|
||||
|
||||
// processing stage
|
||||
"conversion.processing.title": "Convert",
|
||||
"conversion.processing.cardHeading": "In progress",
|
||||
"conversion.processing.statusBadge": "Converting",
|
||||
"conversion.processing.startedAgo": "Started {time}",
|
||||
"conversion.processing.stage1": "Upload complete",
|
||||
"conversion.processing.stage2": "Parsing model",
|
||||
"conversion.processing.stage3": "Compiling NEF",
|
||||
"conversion.processing.processing": "Processing…",
|
||||
"conversion.processing.hint":
|
||||
"Typically 1–10 minutes · You can leave this page; progress will resume when you return.",
|
||||
"conversion.processing.background.title": "Feel free to walk away",
|
||||
"conversion.processing.background.l1":
|
||||
"We poll progress in the background (every 5–10 seconds).",
|
||||
"conversion.processing.background.l2":
|
||||
"The tab title will notify you on completion.",
|
||||
"conversion.processing.background.l3":
|
||||
"Closing this page is fine — it will resume automatically.",
|
||||
"conversion.processing.queueLong":
|
||||
"Queue is currently long — you can come back later.",
|
||||
"conversion.processing.runLong":
|
||||
"This conversion is taking a while; still running.",
|
||||
"conversion.processing.pollFailed":
|
||||
"Couldn't fetch conversion status — please retry.",
|
||||
"conversion.processing.bannerActive":
|
||||
"A conversion was still running when you left.",
|
||||
"conversion.processing.bannerExisting":
|
||||
"You already have a conversion in progress — switched to that job.",
|
||||
// F-T9 sub-2: banner dismiss button a11y label
|
||||
"conversion.processing.bannerDismiss": "Dismiss notice",
|
||||
|
||||
// F-T6: ProcessingView (queued / running shared UI)
|
||||
"conversion.processing.subtitle":
|
||||
"Conversion in progress — please keep this page open",
|
||||
"conversion.processing.queued": "Queued…",
|
||||
"conversion.processing.stage.onnx": "Parsing model",
|
||||
"conversion.processing.stage.bie": "Quantize & compile",
|
||||
"conversion.processing.stage.nef": "Compile NEF",
|
||||
"conversion.processing.stage.status.completed": "Done",
|
||||
"conversion.processing.stage.status.current": "In progress",
|
||||
"conversion.processing.stage.status.pending": "Pending",
|
||||
"conversion.processing.stage.aria.completed": "{name} (completed)",
|
||||
"conversion.processing.stage.aria.current": "{name} (in progress)",
|
||||
"conversion.processing.stage.aria.pending": "{name} (pending)",
|
||||
"conversion.processing.eta.pending": "Please wait",
|
||||
"conversion.processing.eta.computing": "Estimating time remaining",
|
||||
"conversion.processing.expiryHint":
|
||||
"Job will be auto-deleted in 7 days — please download or add it to your model library before then.",
|
||||
"conversion.processing.tabTitle.prefix": "(Converting) ",
|
||||
"conversion.processing.aria.progressIndeterminate":
|
||||
"Conversion in progress; remaining time unknown",
|
||||
"conversion.processing.aria.queueProgress":
|
||||
"Queued, waiting for conversion to start",
|
||||
"conversion.processing.targetChipPrefix": "→",
|
||||
|
||||
// active job hint (also shown on idle)
|
||||
"conversion.busy.title": "You already have a conversion in progress",
|
||||
"conversion.busy.cta": "View progress",
|
||||
|
||||
// success
|
||||
"conversion.success.heading": "Conversion complete",
|
||||
"conversion.success.summary.chip": "Target chip",
|
||||
"conversion.success.summary.size": "Output size",
|
||||
"conversion.success.summary.duration": "Duration",
|
||||
"conversion.success.summary.checksum": "checksum",
|
||||
"conversion.success.summary.jobId": "Job",
|
||||
"conversion.success.nextStep": "What's next?",
|
||||
"conversion.success.import.title": "Add to model library",
|
||||
"conversion.success.import.description":
|
||||
"You can deploy it from the model library to any {chip} device.",
|
||||
"conversion.success.import.cta": "Add to library",
|
||||
"conversion.success.import.dialog.title": "Add to model library",
|
||||
"conversion.success.import.dialog.description":
|
||||
"Add this conversion result to the model library so you can deploy it to {chip} devices.",
|
||||
"conversion.success.import.dialog.nameLabel": "Model name",
|
||||
"conversion.success.import.dialog.nameHint":
|
||||
"Display name in the model library; up to 100 characters; cannot contain / or \\.",
|
||||
"conversion.success.import.dialog.nameError.required": "Please enter a model name.",
|
||||
"conversion.success.import.dialog.nameError.tooLong":
|
||||
"Model name must be 100 characters or fewer.",
|
||||
"conversion.success.import.dialog.nameError.invalidChars":
|
||||
"Model name cannot contain / or \\.",
|
||||
"conversion.success.import.dialog.descLabel": "Description (optional)",
|
||||
"conversion.success.import.dialog.sourceLabel": "Source",
|
||||
"conversion.success.import.dialog.sourceValue":
|
||||
"Conversion (job #{shortJobId})",
|
||||
"conversion.success.import.dialog.confirm": "Add to library",
|
||||
"conversion.success.import.dialog.cancel": "Cancel",
|
||||
"conversion.success.import.processing": "Processing…",
|
||||
"conversion.success.import.toastDone": "Added to model library.",
|
||||
"conversion.success.import.toastDoneAction": "Open model library →",
|
||||
"conversion.success.import.toastDup":
|
||||
"This job has already been added to the library.",
|
||||
"conversion.success.import.toastDupAction": "View existing model →",
|
||||
"conversion.success.import.statusDone": "✓ Added (open it →)",
|
||||
"conversion.success.import.errorGeneric":
|
||||
"Couldn't add to model library; please try again.",
|
||||
"conversion.success.import.aria.cta":
|
||||
"Add to model library (opens a confirmation dialog)",
|
||||
"conversion.success.download.title": "Download .nef",
|
||||
"conversion.success.description":
|
||||
"{source} has been converted into a .nef compatible with {chip}.",
|
||||
"conversion.success.summary.outputFile": "Output file",
|
||||
"conversion.success.summary.notAvailable": "—",
|
||||
"conversion.success.download.description":
|
||||
"Save it locally to use elsewhere.",
|
||||
"conversion.success.download.cta": "Download",
|
||||
"conversion.success.download.preparing": "Preparing download…",
|
||||
"conversion.success.download.toastStart": "Download started.",
|
||||
"conversion.success.download.toastHint":
|
||||
"If no download prompt appears, check your browser settings.",
|
||||
"conversion.success.download.toastFail": "Couldn't fetch download link.",
|
||||
"conversion.success.download.aria.cta": "Download .nef file to your computer",
|
||||
"conversion.success.download.aria.disabled":
|
||||
"Download link expired; start a new conversion",
|
||||
"conversion.success.expiry":
|
||||
"This result will be auto-deleted in {time}; please finish using it before then.",
|
||||
"conversion.success.expiry.expired": "This conversion result has expired.",
|
||||
"conversion.success.expiry.remaining.daysHours": "{days}d {hours}h",
|
||||
"conversion.success.expiry.remaining.daysOnly": "{days}d",
|
||||
"conversion.success.expiry.remaining.hoursMinutes": "{hours}h {minutes}m",
|
||||
"conversion.success.expiry.remaining.hoursOnly": "{hours}h",
|
||||
"conversion.success.expiry.remaining.minutes": "{minutes}m",
|
||||
"conversion.success.startNew": "Start new conversion",
|
||||
|
||||
// failed
|
||||
"conversion.failed.heading": "Conversion failed",
|
||||
"conversion.failed.errorCode": "Error code",
|
||||
"conversion.failed.suggestionsTitle": "Things you can try:",
|
||||
"conversion.failed.retry": "Try again",
|
||||
"conversion.failed.backToModels": "Back to models",
|
||||
"conversion.failed.contactSupport":
|
||||
"If this keeps happening, copy the job ID and contact support.",
|
||||
"conversion.failed.copyJobId": "Copy job ID",
|
||||
"conversion.failed.toastJobIdCopied": "Job ID copied.",
|
||||
// F-T8 — FailedView
|
||||
"conversion.failed.aria.alert": "Conversion failure notice",
|
||||
"conversion.failed.jobIdLabel": "Job ID",
|
||||
"conversion.failed.summary.failedSuffix": " (failed)",
|
||||
"conversion.failed.aria.retry": "Reset and start a new conversion",
|
||||
|
||||
// error code translations (mirrors zh-Hant; one key per suggestion line)
|
||||
"conversion.error.UNSUPPORTED_FORMAT.message":
|
||||
"This model format isn't supported — please use ONNX / TFLite.",
|
||||
"conversion.error.UNSUPPORTED_FORMAT.suggestion1": "Verify file extension.",
|
||||
"conversion.error.UNSUPPORTED_FORMAT.suggestion2":
|
||||
"Use a standard export tool.",
|
||||
"conversion.error.INVALID_CHECKSUM.message":
|
||||
"File was corrupted in transit — please re-upload.",
|
||||
"conversion.error.INVALID_CHECKSUM.suggestion1": "Restart the upload.",
|
||||
"conversion.error.QUANTIZATION_FAILED.message":
|
||||
"Model contains unsupported operators and can't be quantized for the target chip.",
|
||||
"conversion.error.QUANTIZATION_FAILED.suggestion1": "Simplify the model.",
|
||||
"conversion.error.QUANTIZATION_FAILED.suggestion2": "Remove custom ops.",
|
||||
"conversion.error.QUANTIZATION_FAILED.suggestion3":
|
||||
"Use a smaller input shape.",
|
||||
"conversion.error.MODEL_TOO_LARGE.message": "Model exceeds the 500 MB limit.",
|
||||
"conversion.error.MODEL_TOO_LARGE.suggestion1": "Use a smaller model.",
|
||||
"conversion.error.MODEL_TOO_LARGE.suggestion2":
|
||||
"Try pruning / quantization first.",
|
||||
"conversion.error.QUOTA_EXCEEDED.message":
|
||||
"System is busy right now — please try again later.",
|
||||
"conversion.error.QUOTA_EXCEEDED.suggestion1": "Wait 5 minutes and retry.",
|
||||
"conversion.error.unknown.message":
|
||||
"Conversion failed. If it keeps happening, contact support.",
|
||||
"conversion.error.unknown.suggestion1":
|
||||
"Copy the job ID and report it to support.",
|
||||
|
||||
// expired / not-found
|
||||
"conversion.expired.heading": "This conversion result has expired",
|
||||
"conversion.expired.description":
|
||||
"Conversion results are kept for 7 days; this one has been auto-cleared.",
|
||||
// F-T9 sub-1: ExpiredView extra description — explain how to recover
|
||||
"conversion.expired.subDescription":
|
||||
"To get a new conversion result, please submit the model again.",
|
||||
// F-T9 sub-1: ExpiredView a11y
|
||||
"conversion.expired.aria.alert": "Conversion result has expired notice",
|
||||
"conversion.expired.aria.startNew": "Reset and start a new conversion",
|
||||
"conversion.expired.startNew": "Start new conversion",
|
||||
|
||||
// mobile hint
|
||||
"conversion.mobileHint":
|
||||
"Uploading large models on mobile can be unreliable; we recommend a desktop browser.",
|
||||
|
||||
// ── Clusters (F7 stub) ──
|
||||
"clusters.title": "Clusters",
|
||||
"clusters.subtitle":
|
||||
|
||||
@ -35,6 +35,7 @@ export const zhHant: Dictionary = {
|
||||
"nav.dashboard": "儀表板",
|
||||
"nav.devices": "裝置",
|
||||
"nav.models": "模型",
|
||||
"nav.conversion": "轉檔",
|
||||
"nav.workspace": "推論工作區",
|
||||
"nav.clusters": "叢集",
|
||||
"nav.settings": "設定",
|
||||
@ -325,6 +326,268 @@ export const zhHant: Dictionary = {
|
||||
"account.danger.deleteAccount.tooltip":
|
||||
"請至 Innovedus 帳號中心處理。",
|
||||
|
||||
// ── Conversion / 轉檔(Phase 0.8 — feature-converter-integration)──
|
||||
// 設計來源:.autoflow/03-design/wireframes/wireframe-conversion.md §11
|
||||
// 對應 PRD:.autoflow/02-prd/features/feature-converter-integration.md
|
||||
// 範圍:5 個 state(idle / uploading / processing / success / failed)+ 6 種錯誤碼翻譯 + 過期狀態
|
||||
"conversion.title": "轉檔",
|
||||
"conversion.subtitle":
|
||||
"把 ONNX / TFLite 模型轉成 .nef,跑在 Kneron 邊緣裝置上",
|
||||
|
||||
// idle 空狀態
|
||||
"conversion.idle.heading": "還沒有進行中的轉檔",
|
||||
"conversion.idle.description":
|
||||
"上傳一個 ONNX / TFLite 模型,選擇目標 Kneron 晶片,我們幫你產出可直接燒錄的 .nef 檔案",
|
||||
"conversion.idle.cta": "開始轉檔",
|
||||
"conversion.idle.formats": "支援 .onnx / .tflite · 最大 500 MB",
|
||||
"conversion.idle.about.title": "關於轉檔",
|
||||
"conversion.idle.about.line1": "一次只能跑一個轉檔任務(包含其他分頁)",
|
||||
"conversion.idle.about.line2": "完成後 7 天內可下載結果,過期自動清除",
|
||||
"conversion.idle.about.line3": "轉檔約耗時 1–10 分鐘,依模型大小而定",
|
||||
|
||||
// Upload Dialog
|
||||
"conversion.upload.title": "開始轉檔",
|
||||
"conversion.upload.description":
|
||||
"上傳模型、選擇目標晶片,可選擇加上 reference images 提升精度",
|
||||
"conversion.upload.source.label": "來源模型",
|
||||
"conversion.upload.source.dropzone": "拖曳 .onnx / .tflite 到此處",
|
||||
"conversion.upload.source.or": "或",
|
||||
"conversion.upload.source.browse": "選擇檔案",
|
||||
"conversion.upload.source.formatHint":
|
||||
"支援格式:.onnx · .tflite · 最大 500 MB",
|
||||
"conversion.upload.source.remove": "移除",
|
||||
"conversion.upload.name.label": "任務名稱(選填)",
|
||||
"conversion.upload.name.hint": "顯示用,不影響輸出檔名",
|
||||
"conversion.upload.chip.label": "目標晶片",
|
||||
"conversion.upload.refImages.label": "Reference images(選填)",
|
||||
"conversion.upload.refImages.dropzone": "拖曳圖片到此處(選填)",
|
||||
"conversion.upload.refImages.hint":
|
||||
"加上 ref images 可提升量化後精度(最多 100 張,每張 ≤ 10 MB)",
|
||||
"conversion.upload.refImages.summary":
|
||||
"已選 {count} 張 ref images(共 {totalSize})",
|
||||
"conversion.upload.refImages.removeAll": "移除全部",
|
||||
"conversion.upload.cancel": "取消",
|
||||
"conversion.upload.start": "開始轉檔",
|
||||
"conversion.upload.error.noFile": "請選擇 .onnx 或 .tflite 檔案",
|
||||
"conversion.upload.error.unsupported":
|
||||
"不支援的格式,請改用 ONNX 或 TFLite",
|
||||
"conversion.upload.error.modelTooLarge":
|
||||
"模型超過 500 MB 上限,請改用較小的模型",
|
||||
"conversion.upload.error.noChip": "請選擇目標晶片",
|
||||
"conversion.upload.error.refTooLarge":
|
||||
"{filename} 超過 10 MB,請移除或壓縮後再試",
|
||||
"conversion.upload.error.refTooMany": "Reference images 上限 100 張",
|
||||
|
||||
// uploading 階段(Dialog 內)
|
||||
"conversion.uploading.title": "上傳中",
|
||||
"conversion.uploading.heading": "正在上傳到 visionA…",
|
||||
"conversion.uploading.progress": "已上傳 {loaded} / {total} · 預估剩餘 {eta}",
|
||||
"conversion.uploading.almostDone": "即將完成…",
|
||||
"conversion.uploading.warning": "請勿關閉此分頁,否則上傳會中斷",
|
||||
"conversion.uploading.cancel": "取消上傳",
|
||||
"conversion.uploading.cancelConfirm": "上傳尚未完成,確定取消?",
|
||||
"conversion.uploading.tabTitle": "visionA Cloud · 上傳中 ({pct}%)",
|
||||
"conversion.uploading.toastCanceled": "已取消上傳",
|
||||
"conversion.uploading.toastFailed": "上傳失敗:{reason}",
|
||||
"conversion.uploading.toastStarted": "已開始轉檔(任務 #{shortJobId})",
|
||||
// F-T9 sub-3 / M1:上傳失敗 toast hint
|
||||
// M1 修補:File 物件不能保留在 store(不可序列化、跨 unmount 不安全),
|
||||
// 失敗回 idle 後 chip / taskName 透過 store.formDraft 保留,但檔案需重選
|
||||
"conversion.toast.retryHint": "您之前的設定(晶片 / 任務名稱)仍保留,請重新選擇檔案再試",
|
||||
"conversion.toast.networkError": "網路錯誤,請檢查連線後重試",
|
||||
// F-T5:ETA / 取消確認 / beforeunload(細節 keys)
|
||||
"conversion.uploading.subtitle": "正在上傳模型到 visionA backend",
|
||||
"conversion.uploading.eta.computing": "預估剩餘時間…",
|
||||
"conversion.uploading.eta.almostDone": "即將完成…",
|
||||
"conversion.uploading.eta.format": "預估剩餘 {eta}",
|
||||
"conversion.uploading.progress.format": "{loaded} / {total}",
|
||||
"conversion.uploading.info": "上傳完成後將開始轉檔(10–30 秒不等)",
|
||||
"conversion.uploading.cancel.confirm.title": "取消轉檔?",
|
||||
"conversion.uploading.cancel.confirm.message":
|
||||
"上傳尚未完成,已上傳的進度會丟失。確定要取消嗎?",
|
||||
"conversion.uploading.cancel.confirm.yes": "確定取消",
|
||||
"conversion.uploading.cancel.confirm.no": "繼續上傳",
|
||||
"conversion.uploading.warning.beforeunload":
|
||||
"上傳尚未完成,離開將中斷上傳。",
|
||||
"conversion.uploading.target": "目標晶片:{chip}",
|
||||
"conversion.uploading.aria.cancel": "取消上傳",
|
||||
"conversion.uploading.aria.progress": "上傳進度",
|
||||
"conversion.uploading.aria.status": "已上傳 {pct}%,{eta}",
|
||||
|
||||
// processing 階段(轉檔進行中)
|
||||
"conversion.processing.title": "轉檔",
|
||||
"conversion.processing.cardHeading": "進行中",
|
||||
"conversion.processing.statusBadge": "轉檔中",
|
||||
"conversion.processing.startedAgo": "開始於 {time}",
|
||||
"conversion.processing.stage1": "上傳完成",
|
||||
"conversion.processing.stage2": "解析模型",
|
||||
"conversion.processing.stage3": "編譯 NEF",
|
||||
"conversion.processing.processing": "處理中…",
|
||||
"conversion.processing.hint":
|
||||
"通常需要 1–10 分鐘 · 你可以離開此頁面,回來時會自動更新進度",
|
||||
"conversion.processing.background.title": "你可以放著不管",
|
||||
"conversion.processing.background.l1":
|
||||
"我們會在背景持續查詢進度(每 5–10 秒一次)",
|
||||
"conversion.processing.background.l2": "完成後分頁標題會通知你",
|
||||
"conversion.processing.background.l3":
|
||||
"此頁面關掉也沒關係,回來時會自動恢復",
|
||||
"conversion.processing.queueLong":
|
||||
"目前排隊較久,你可以離開此頁稍後再回",
|
||||
"conversion.processing.runLong": "轉檔耗時較長,仍在進行中",
|
||||
"conversion.processing.pollFailed": "無法取得轉檔狀態,請重試",
|
||||
"conversion.processing.bannerActive": "您離開前的轉檔仍在進行中",
|
||||
"conversion.processing.bannerExisting":
|
||||
"您已有一個轉檔正在進行中,已切換至該任務",
|
||||
// F-T9 sub-2:banner dismiss 按鈕的 a11y label
|
||||
"conversion.processing.bannerDismiss": "關閉提示",
|
||||
|
||||
// F-T6:ProcessingView(queued / running 共用 UI)
|
||||
// 任務描述對應 converter `stage` enum(onnx / bie / nef)— 與既有 stage1/2/3
|
||||
// (上傳完成 / 解析模型 / 編譯 NEF)的「使用者感知」三段不同;ProcessingView
|
||||
// 採用 onnx / bie / nef 對應 converter 真實 stage。詳見任務說明 §wireframe §3.3。
|
||||
"conversion.processing.subtitle": "正在轉檔,請保持頁面開啟",
|
||||
"conversion.processing.queued": "排隊中⋯",
|
||||
"conversion.processing.stage.onnx": "解析模型",
|
||||
"conversion.processing.stage.bie": "量化編譯",
|
||||
"conversion.processing.stage.nef": "編譯 NEF",
|
||||
"conversion.processing.stage.status.completed": "完成",
|
||||
"conversion.processing.stage.status.current": "進行中",
|
||||
"conversion.processing.stage.status.pending": "待處理",
|
||||
"conversion.processing.stage.aria.completed": "{name}(已完成)",
|
||||
"conversion.processing.stage.aria.current": "{name}(進行中)",
|
||||
"conversion.processing.stage.aria.pending": "{name}(待處理)",
|
||||
"conversion.processing.eta.pending": "請耐心等候",
|
||||
"conversion.processing.eta.computing": "剩餘時間估算中",
|
||||
"conversion.processing.expiryHint":
|
||||
"7 天後將自動清除任務,請完成下載或加到模型庫",
|
||||
"conversion.processing.tabTitle.prefix": "(轉檔中) ",
|
||||
"conversion.processing.aria.progressIndeterminate":
|
||||
"轉檔進行中,剩餘時間未知",
|
||||
"conversion.processing.aria.queueProgress": "排隊中,等待轉檔開始",
|
||||
"conversion.processing.targetChipPrefix": "→",
|
||||
|
||||
// 已有 active job 提示(idle 頁也會用到)
|
||||
"conversion.busy.title": "您已有一個轉檔正在進行中",
|
||||
"conversion.busy.cta": "查看進度",
|
||||
|
||||
// success 結果
|
||||
"conversion.success.heading": "轉檔完成",
|
||||
"conversion.success.summary.chip": "目標晶片",
|
||||
"conversion.success.summary.size": "輸出大小",
|
||||
"conversion.success.summary.duration": "耗時",
|
||||
"conversion.success.summary.checksum": "checksum",
|
||||
"conversion.success.summary.jobId": "任務",
|
||||
"conversion.success.nextStep": "接下來要做什麼?",
|
||||
"conversion.success.import.title": "加到模型庫",
|
||||
"conversion.success.import.description":
|
||||
"之後可以從模型庫部署到任何 {chip} 裝置",
|
||||
"conversion.success.import.cta": "加到模型庫",
|
||||
"conversion.success.import.dialog.title": "加到模型庫",
|
||||
"conversion.success.import.dialog.description":
|
||||
"把這個轉檔結果加到模型庫,之後可以直接部署到 {chip} 裝置",
|
||||
"conversion.success.import.dialog.nameLabel": "模型名稱",
|
||||
"conversion.success.import.dialog.nameHint":
|
||||
"顯示在模型庫中的名稱,最多 100 字元;不可包含 / 或 \\",
|
||||
"conversion.success.import.dialog.nameError.required": "請輸入模型名稱",
|
||||
"conversion.success.import.dialog.nameError.tooLong":
|
||||
"模型名稱不可超過 100 字元",
|
||||
"conversion.success.import.dialog.nameError.invalidChars":
|
||||
"模型名稱不可包含 / 或 \\",
|
||||
"conversion.success.import.dialog.descLabel": "描述(選填)",
|
||||
"conversion.success.import.dialog.sourceLabel": "來源",
|
||||
"conversion.success.import.dialog.sourceValue": "轉檔(job #{shortJobId})",
|
||||
"conversion.success.import.dialog.confirm": "加到模型庫",
|
||||
"conversion.success.import.dialog.cancel": "取消",
|
||||
"conversion.success.import.processing": "處理中…",
|
||||
"conversion.success.import.toastDone": "已加入模型庫",
|
||||
"conversion.success.import.toastDoneAction": "前往模型庫 →",
|
||||
"conversion.success.import.toastDup": "此任務已加入過模型庫",
|
||||
"conversion.success.import.toastDupAction": "查看現有模型 →",
|
||||
"conversion.success.import.statusDone": "✓ 已加入(前往查看 →)",
|
||||
"conversion.success.import.errorGeneric":
|
||||
"加到模型庫失敗,請稍後再試",
|
||||
"conversion.success.import.aria.cta":
|
||||
"加到模型庫(會開啟確認對話框)",
|
||||
"conversion.success.download.title": "下載 .nef",
|
||||
"conversion.success.description":
|
||||
"{source} 已成功轉成 {chip} 可用的 .nef 檔",
|
||||
"conversion.success.summary.outputFile": "輸出檔案",
|
||||
"conversion.success.summary.notAvailable": "—",
|
||||
"conversion.success.download.description": "存到本機自行使用",
|
||||
"conversion.success.download.cta": "下載",
|
||||
"conversion.success.download.preparing": "準備下載…",
|
||||
"conversion.success.download.toastStart": "下載已開始",
|
||||
"conversion.success.download.toastHint":
|
||||
"若沒看到下載提示,請檢查瀏覽器設定",
|
||||
"conversion.success.download.toastFail": "下載連結取得失敗",
|
||||
"conversion.success.download.aria.cta": "下載 .nef 檔到本機",
|
||||
"conversion.success.download.aria.disabled":
|
||||
"下載連結已過期,請開始新轉檔",
|
||||
"conversion.success.expiry":
|
||||
"此轉檔結果將在 {time} 後自動清除,請在期限內完成處理",
|
||||
"conversion.success.expiry.expired": "此轉檔結果已過期",
|
||||
"conversion.success.expiry.remaining.daysHours": "{days} 天 {hours} 小時",
|
||||
"conversion.success.expiry.remaining.daysOnly": "{days} 天",
|
||||
"conversion.success.expiry.remaining.hoursMinutes":
|
||||
"{hours} 小時 {minutes} 分鐘",
|
||||
"conversion.success.expiry.remaining.hoursOnly": "{hours} 小時",
|
||||
"conversion.success.expiry.remaining.minutes": "{minutes} 分鐘",
|
||||
"conversion.success.startNew": "開始新轉檔",
|
||||
|
||||
// failed 結果
|
||||
"conversion.failed.heading": "轉檔失敗",
|
||||
"conversion.failed.errorCode": "錯誤代碼",
|
||||
"conversion.failed.suggestionsTitle": "你可以試試:",
|
||||
"conversion.failed.retry": "重新開始",
|
||||
"conversion.failed.backToModels": "回模型庫",
|
||||
"conversion.failed.contactSupport":
|
||||
"若持續發生,請複製任務 ID 聯絡支援團隊",
|
||||
"conversion.failed.copyJobId": "複製任務 ID",
|
||||
"conversion.failed.toastJobIdCopied": "已複製任務 ID",
|
||||
// F-T8 新增 — FailedView 用
|
||||
"conversion.failed.aria.alert": "轉檔失敗通知",
|
||||
"conversion.failed.jobIdLabel": "Job ID",
|
||||
"conversion.failed.summary.failedSuffix": "(失敗)",
|
||||
"conversion.failed.aria.retry": "回到起點,重新開始一次轉檔",
|
||||
|
||||
// 錯誤碼翻譯(對應 PRD §F5 + .autoflow/04-architecture/conversion.md)
|
||||
"conversion.error.UNSUPPORTED_FORMAT.message":
|
||||
"此模型格式目前不支援,請改用 ONNX / TFLite",
|
||||
"conversion.error.UNSUPPORTED_FORMAT.suggestion1": "確認檔案副檔名",
|
||||
"conversion.error.UNSUPPORTED_FORMAT.suggestion2": "用標準 export 工具",
|
||||
"conversion.error.INVALID_CHECKSUM.message":
|
||||
"檔案傳輸過程毀損,請重新上傳",
|
||||
"conversion.error.INVALID_CHECKSUM.suggestion1": "重新開始上傳",
|
||||
"conversion.error.QUANTIZATION_FAILED.message":
|
||||
"模型內含不支援的運算子,無法量化到目標晶片",
|
||||
"conversion.error.QUANTIZATION_FAILED.suggestion1": "簡化模型結構",
|
||||
"conversion.error.QUANTIZATION_FAILED.suggestion2": "移除 Custom Op",
|
||||
"conversion.error.QUANTIZATION_FAILED.suggestion3": "改用較小的 input shape",
|
||||
"conversion.error.MODEL_TOO_LARGE.message": "模型超過 500 MB 上限",
|
||||
"conversion.error.MODEL_TOO_LARGE.suggestion1": "改用較小模型",
|
||||
"conversion.error.MODEL_TOO_LARGE.suggestion2": "嘗試 Pruning / Quantization",
|
||||
"conversion.error.QUOTA_EXCEEDED.message": "系統暫時繁忙,請稍後再試",
|
||||
"conversion.error.QUOTA_EXCEEDED.suggestion1": "等 5 分鐘後重試",
|
||||
"conversion.error.unknown.message":
|
||||
"轉檔失敗,請稍後重試。若持續發生請聯絡支援團隊",
|
||||
"conversion.error.unknown.suggestion1": "複製任務 ID 回報給支援團隊",
|
||||
|
||||
// 已過期 / 找不到
|
||||
"conversion.expired.heading": "此轉檔結果已過期",
|
||||
"conversion.expired.description":
|
||||
"轉檔結果保留期為 7 天,目前已超過保留期限並自動清除。",
|
||||
// F-T9 sub-1:ExpiredView 補充說明 — 解釋為什麼過期 + 怎麼處理
|
||||
"conversion.expired.subDescription":
|
||||
"如需重新取得轉檔結果,請按下方按鈕重新提交一次。",
|
||||
// F-T9 sub-1:ExpiredView a11y
|
||||
"conversion.expired.aria.alert": "轉檔結果已過期通知",
|
||||
"conversion.expired.aria.startNew": "重新開始一次轉檔",
|
||||
"conversion.expired.startNew": "重新轉檔",
|
||||
|
||||
// mobile hint(≥ 500 MB 檔在手機上傳體驗差)
|
||||
"conversion.mobileHint":
|
||||
"Mobile 設備上傳大型模型可能不穩定,建議使用桌面版瀏覽器",
|
||||
|
||||
// ── Clusters(F7 新增 stub)──
|
||||
"clusters.title": "叢集",
|
||||
"clusters.subtitle": "把多台 Kneron 裝置組成平行推論叢集",
|
||||
|
||||
170
visionA-frontend/src/lib/utils/eta.test.ts
Normal file
170
visionA-frontend/src/lib/utils/eta.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ETA 純函式單元測試(Phase 0.8 F-T5 — M1 補強)
|
||||
*
|
||||
* 對應 review M1:F-T5 review 指出 UploadingView.test.tsx 13 個測試裡 ETA 只測
|
||||
* 「無資料」+「pct≥99.5%」兩個邊界,EWMA 演算法本身完全沒測。本檔把核心
|
||||
* 演算法抽出為純函式測,覆蓋:
|
||||
* - smoothSpeed 第一次採用 instant、之後跑 EWMA 公式
|
||||
* - estimateRemainingSeconds 正常 / 邊界
|
||||
* - computeEtaUpdate:第一次 / 倒退 / dt<100ms / 正常 / EWMA 平滑
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeEtaUpdate,
|
||||
estimateRemainingSeconds,
|
||||
EWMA_ALPHA,
|
||||
instantSpeedBytesPerSec,
|
||||
smoothSpeed,
|
||||
MIN_SAMPLE_INTERVAL_MS,
|
||||
} from "./eta";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe("smoothSpeed — EWMA 平滑", () => {
|
||||
it("第一次(prev=null)直接採 instant,不從 0 起算被拉低", () => {
|
||||
expect(smoothSpeed(null, 10 * MB)).toBe(10 * MB);
|
||||
});
|
||||
|
||||
it("EWMA 公式:instant 暴增(10→50 MB/s)→ next = 0.3*50 + 0.7*10 = 22 MB/s", () => {
|
||||
const prev = 10 * MB;
|
||||
const instant = 50 * MB;
|
||||
const next = smoothSpeed(prev, instant);
|
||||
// 0.3 * 50 + 0.7 * 10 = 15 + 7 = 22
|
||||
expect(next).toBeCloseTo(EWMA_ALPHA * instant + (1 - EWMA_ALPHA) * prev, 6);
|
||||
expect(next / MB).toBeCloseTo(22, 6);
|
||||
});
|
||||
|
||||
it("instant 暴跌(50→10 MB/s)→ next = 0.3*10 + 0.7*50 = 38 MB/s", () => {
|
||||
const next = smoothSpeed(50 * MB, 10 * MB);
|
||||
expect(next / MB).toBeCloseTo(38, 6);
|
||||
});
|
||||
|
||||
it("速度穩定 → smoothed 不變", () => {
|
||||
expect(smoothSpeed(20 * MB, 20 * MB)).toBeCloseTo(20 * MB, 6);
|
||||
});
|
||||
|
||||
it("自訂 alpha:alpha=1 → 完全採 instant;alpha=0 → 完全採 prev", () => {
|
||||
expect(smoothSpeed(10, 50, 1)).toBe(50);
|
||||
expect(smoothSpeed(10, 50, 0)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instantSpeedBytesPerSec", () => {
|
||||
it("dBytes=10MB / dt=1000ms → 10 MB/s", () => {
|
||||
expect(instantSpeedBytesPerSec(10 * MB, 1000) / MB).toBeCloseTo(10, 6);
|
||||
});
|
||||
|
||||
it("dt<=0 → 0(防呆,避免除以 0)", () => {
|
||||
expect(instantSpeedBytesPerSec(10 * MB, 0)).toBe(0);
|
||||
expect(instantSpeedBytesPerSec(10 * MB, -50)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateRemainingSeconds", () => {
|
||||
it("100MB total / 20MB loaded / 10 MB/s → ETA=8s", () => {
|
||||
const eta = estimateRemainingSeconds(20 * MB, 100 * MB, 10 * MB);
|
||||
expect(eta).toBeCloseTo(8, 6);
|
||||
});
|
||||
|
||||
it("speed<=0 → null", () => {
|
||||
expect(estimateRemainingSeconds(10, 100, 0)).toBeNull();
|
||||
expect(estimateRemainingSeconds(10, 100, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it("total<=0 → null", () => {
|
||||
expect(estimateRemainingSeconds(0, 0, 10)).toBeNull();
|
||||
expect(estimateRemainingSeconds(0, -1, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("loaded >= total → 0(已完成,不要回負數)", () => {
|
||||
expect(estimateRemainingSeconds(100, 100, 10)).toBe(0);
|
||||
expect(estimateRemainingSeconds(120, 100, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEtaUpdate — progress 一次更新", () => {
|
||||
it("第一次採樣(prevSample=null)→ updated=false、etaSeconds=null", () => {
|
||||
const r = computeEtaUpdate(null, null, 1000, 10 * MB, 100 * MB);
|
||||
expect(r.updated).toBe(false);
|
||||
expect(r.etaSeconds).toBeNull();
|
||||
expect(r.smoothedSpeed).toBeNull();
|
||||
expect(r.resetSample).toBe(false);
|
||||
});
|
||||
|
||||
it("ETA 正常計算:loaded 10MB→20MB / dt=1s / total=100MB → speed=10MB/s, ETA=8s", () => {
|
||||
const prevSample = { at: 1000, loaded: 10 * MB };
|
||||
const r = computeEtaUpdate(prevSample, null, 2000, 20 * MB, 100 * MB);
|
||||
expect(r.updated).toBe(true);
|
||||
// 第一次 EWMA:prevSpeed=null → smoothed=instant=10MB/s
|
||||
expect(r.smoothedSpeed! / MB).toBeCloseTo(10, 6);
|
||||
expect(r.etaSeconds).toBeCloseTo(8, 6);
|
||||
expect(r.resetSample).toBe(false);
|
||||
});
|
||||
|
||||
it("EWMA 平滑:instant 暴增不會讓 ETA 跳到瞬時值", () => {
|
||||
// 已平滑速度 = 10 MB/s;本次 dt=1s, 收到 50 MB → instant=50MB/s
|
||||
const prevSample = { at: 1000, loaded: 50 * MB };
|
||||
const prevSpeed = 10 * MB;
|
||||
const r = computeEtaUpdate(prevSample, prevSpeed, 2000, 100 * MB, 200 * MB);
|
||||
expect(r.updated).toBe(true);
|
||||
// newSpeed = 0.3*50 + 0.7*10 = 22 MB/s(不是 50)
|
||||
expect(r.smoothedSpeed! / MB).toBeCloseTo(22, 6);
|
||||
// ETA = (200-100)/22 ≈ 4.545s(不是 (200-100)/50 = 2s)
|
||||
expect(r.etaSeconds).toBeCloseTo(100 / 22, 4);
|
||||
});
|
||||
|
||||
it("loaded 倒退 → resetSample=true、smoothedSpeed 重置為 null", () => {
|
||||
const prevSample = { at: 1000, loaded: 50 * MB };
|
||||
const r = computeEtaUpdate(prevSample, 20 * MB, 2000, 10 * MB, 100 * MB);
|
||||
expect(r.updated).toBe(false);
|
||||
expect(r.resetSample).toBe(true);
|
||||
expect(r.smoothedSpeed).toBeNull();
|
||||
expect(r.etaSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it(`dt < ${MIN_SAMPLE_INTERVAL_MS}ms → 跳過採樣(updated=false、smoothedSpeed 維持 prev)`, () => {
|
||||
const prevSample = { at: 1000, loaded: 10 * MB };
|
||||
const prevSpeed = 5 * MB;
|
||||
// dt = 50ms < 100ms
|
||||
const r = computeEtaUpdate(prevSample, prevSpeed, 1050, 11 * MB, 100 * MB);
|
||||
expect(r.updated).toBe(false);
|
||||
expect(r.smoothedSpeed).toBe(prevSpeed); // 不動
|
||||
expect(r.etaSeconds).toBeNull();
|
||||
expect(r.resetSample).toBe(false);
|
||||
});
|
||||
|
||||
it(`dt 剛好 ${MIN_SAMPLE_INTERVAL_MS}ms → 採樣(邊界)`, () => {
|
||||
const prevSample = { at: 1000, loaded: 10 * MB };
|
||||
const r = computeEtaUpdate(
|
||||
prevSample,
|
||||
null,
|
||||
1000 + MIN_SAMPLE_INTERVAL_MS,
|
||||
11 * MB,
|
||||
100 * MB,
|
||||
);
|
||||
expect(r.updated).toBe(true);
|
||||
});
|
||||
|
||||
it("初始 sample 採用 instant(不是 0 * 0.7 + instant * 0.3)", () => {
|
||||
// prevSpeed=null(第一次平滑),確認 smoothSpeed 直接採 instant
|
||||
const prevSample = { at: 0, loaded: 0 };
|
||||
const r = computeEtaUpdate(prevSample, null, 1000, 5 * MB, 100 * MB);
|
||||
expect(r.updated).toBe(true);
|
||||
// dt=1000ms, dBytes=5MB → instant=5MB/s;smoothed=null→直接 5MB/s(不是 1.5MB/s)
|
||||
expect(r.smoothedSpeed! / MB).toBeCloseTo(5, 6);
|
||||
// ETA = (100-5)/5 = 19s
|
||||
expect(r.etaSeconds).toBeCloseTo(19, 6);
|
||||
});
|
||||
|
||||
it("ETA < 5s 邊界:構造 loaded/total/speed 讓 eta=3 秒", () => {
|
||||
// total=100MB, loaded=70MB, speed=10MB/s → ETA=3s
|
||||
// 用 computeEtaUpdate 驗 ETA 算出 3 秒(UI 端再判 < 5s 顯示「即將完成」)
|
||||
const prevSample = { at: 0, loaded: 60 * MB };
|
||||
const r = computeEtaUpdate(prevSample, null, 1000, 70 * MB, 100 * MB);
|
||||
expect(r.updated).toBe(true);
|
||||
expect(r.smoothedSpeed! / MB).toBeCloseTo(10, 6);
|
||||
expect(r.etaSeconds).toBeCloseTo(3, 6);
|
||||
});
|
||||
});
|
||||
156
visionA-frontend/src/lib/utils/eta.ts
Normal file
156
visionA-frontend/src/lib/utils/eta.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* ETA 工具:EWMA 平滑速度 + 剩餘秒數
|
||||
*
|
||||
* Phase 0.8 conversion (見 `.autoflow/05-implementation/phase-0.8-F-T5.md`)
|
||||
*
|
||||
* 拆出純函式(從 UploadingView 內 useEta hook)的理由:
|
||||
* - 可獨立、確定性測試(不依賴 React render lifecycle、performance.now、useRef)
|
||||
* - 邏輯與顯示解耦:UploadingView 只負責記 lastSample(in ref)、呼叫純函式
|
||||
*
|
||||
* 對齊:
|
||||
* - wireframe-conversion.md §3.2(uploading state 視覺)
|
||||
* - architecture/conversion.md §4.3.1(XHR onprogress 是 browser→backend,不是端到端)
|
||||
*/
|
||||
|
||||
/** EWMA 平滑因子(0–1):越大越敏感、越小越平滑 */
|
||||
export const EWMA_ALPHA = 0.3;
|
||||
|
||||
/** ETA < 此秒數 → UI 顯示「即將完成」,避免最後幾秒抖動 */
|
||||
export const ALMOST_DONE_THRESHOLD_SEC = 5;
|
||||
|
||||
/** 兩次 sample 間隔太短就跳過(避免 0 秒除法 + 高頻 onprogress 噪音) */
|
||||
export const MIN_SAMPLE_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* 用 EWMA 平滑速度。
|
||||
*
|
||||
* - prev === null → 第一次 sample,直接採 instant(不要從 0 起算被拉低)
|
||||
* - 否則 → alpha * instant + (1 - alpha) * prev
|
||||
*
|
||||
* @param prev 上次平滑後速度(bytes/sec),null = 第一次
|
||||
* @param instant 本次瞬時速度(bytes/sec)
|
||||
* @param alpha EWMA alpha(0–1),預設 0.3
|
||||
*/
|
||||
export function smoothSpeed(
|
||||
prev: number | null,
|
||||
instant: number,
|
||||
alpha: number = EWMA_ALPHA,
|
||||
): number {
|
||||
if (prev === null) return instant;
|
||||
return alpha * instant + (1 - alpha) * prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根據已上傳 / 總量 / 平滑速度算剩餘秒數。
|
||||
*
|
||||
* @returns 剩餘秒數;無法估算(speed<=0 / total<=0 / 已超過)回傳 null
|
||||
*/
|
||||
export function estimateRemainingSeconds(
|
||||
loaded: number,
|
||||
total: number,
|
||||
smoothedSpeed: number,
|
||||
): number | null {
|
||||
if (smoothedSpeed <= 0) return null;
|
||||
if (total <= 0) return null;
|
||||
const remaining = total - loaded;
|
||||
if (remaining <= 0) return 0;
|
||||
return remaining / smoothedSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算瞬時速度(bytes/sec)。
|
||||
*
|
||||
* @param dBytes 本次間隔內新增 bytes
|
||||
* @param dtMs 本次間隔(ms)
|
||||
* @returns 瞬時速度(bytes/sec);dtMs<=0 回傳 0
|
||||
*/
|
||||
export function instantSpeedBytesPerSec(dBytes: number, dtMs: number): number {
|
||||
if (dtMs <= 0) return 0;
|
||||
return (dBytes / dtMs) * 1000;
|
||||
}
|
||||
|
||||
/** 一次 progress sample 的描述 */
|
||||
export interface ProgressSample {
|
||||
/** 時間戳(performance.now() 或 Date.now()) */
|
||||
at: number;
|
||||
/** 已上傳 bytes */
|
||||
loaded: number;
|
||||
}
|
||||
|
||||
export interface EtaCalcResult {
|
||||
/** 是否更新了 EWMA state(false 代表 sample 太密 / 第一次 / 倒退;caller 仍要更新 lastSample) */
|
||||
updated: boolean;
|
||||
/** 新的平滑速度(bytes/sec),未更新時為 prevSpeed */
|
||||
smoothedSpeed: number | null;
|
||||
/** 新 ETA 秒數,未更新時為 null */
|
||||
etaSeconds: number | null;
|
||||
/** caller 是否該重置 lastSample(loaded 倒退時 = true) */
|
||||
resetSample: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 progress 的 EWMA 更新(純函式版本)。
|
||||
*
|
||||
* 行為:
|
||||
* - prevSample === null(第一次)→ updated=false,caller 要把當下存進 lastSample
|
||||
* - loaded 倒退(不該發生,但保險)→ resetSample=true,平滑速度歸 null
|
||||
* - dt < MIN_SAMPLE_INTERVAL_MS → updated=false(跳過避免噪音)
|
||||
* - 其他 → 計算 instant、跑 EWMA、算 ETA
|
||||
*
|
||||
* 此函式**不**寫 ref / state — caller 拿 result 自己決定要不要更新。
|
||||
*
|
||||
* @param prevSample 上次 sample(null = 第一次)
|
||||
* @param prevSpeed 上次平滑速度(null = 還沒平滑過)
|
||||
* @param now 本次時間戳(ms)
|
||||
* @param loaded 本次 loaded bytes
|
||||
* @param total 總 bytes
|
||||
*/
|
||||
export function computeEtaUpdate(
|
||||
prevSample: ProgressSample | null,
|
||||
prevSpeed: number | null,
|
||||
now: number,
|
||||
loaded: number,
|
||||
total: number,
|
||||
): EtaCalcResult {
|
||||
// 第一次 sample — 不算速度,caller 應記下 lastSample
|
||||
if (prevSample === null) {
|
||||
return {
|
||||
updated: false,
|
||||
smoothedSpeed: prevSpeed,
|
||||
etaSeconds: null,
|
||||
resetSample: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 倒退(不該發生)→ 重置
|
||||
if (loaded < prevSample.loaded) {
|
||||
return {
|
||||
updated: false,
|
||||
smoothedSpeed: null,
|
||||
etaSeconds: null,
|
||||
resetSample: true,
|
||||
};
|
||||
}
|
||||
|
||||
const dtMs = now - prevSample.at;
|
||||
if (dtMs < MIN_SAMPLE_INTERVAL_MS) {
|
||||
return {
|
||||
updated: false,
|
||||
smoothedSpeed: prevSpeed,
|
||||
etaSeconds: null,
|
||||
resetSample: false,
|
||||
};
|
||||
}
|
||||
|
||||
const dBytes = loaded - prevSample.loaded;
|
||||
const instant = instantSpeedBytesPerSec(dBytes, dtMs);
|
||||
const nextSmoothed = smoothSpeed(prevSpeed, instant);
|
||||
const eta = estimateRemainingSeconds(loaded, total, nextSmoothed);
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
smoothedSpeed: nextSmoothed,
|
||||
etaSeconds: eta,
|
||||
resetSample: false,
|
||||
};
|
||||
}
|
||||
881
visionA-frontend/src/stores/conversion-store.test.ts
Normal file
881
visionA-frontend/src/stores/conversion-store.test.ts
Normal file
@ -0,0 +1,881 @@
|
||||
/**
|
||||
* Conversion Store 單元測試(Phase 0.8 F-T3)
|
||||
*
|
||||
* 對齊:
|
||||
* - flow-conversion.md §4 state machine + §5.4 polling 策略
|
||||
* - api-conversion.md §2 / §3 / §5
|
||||
*
|
||||
* 覆蓋:
|
||||
* - bootstrap 沒 active job → idle
|
||||
* - bootstrap 有 active job(running) → 開始 polling
|
||||
* - bootstrap 有 active job 且已過期 → expired
|
||||
* - startConversion happy path
|
||||
* - startConversion 409 → 自動 bootstrap
|
||||
* - startConversion onUploadProgress 觸發 uploadProgress state
|
||||
* - startConversion 上傳失敗(其他錯誤)→ 回 idle + error
|
||||
* - polling 收 succeeded → stopPolling + state succeeded
|
||||
* - polling 收 failed → state failed + error
|
||||
* - polling 收 5xx → 指數退避 + pollErrorCount 累計
|
||||
* - polling 5 次失敗 → pollVisibleError = true
|
||||
* - visibilitychange 隱藏 → 暫停;顯示 → 立刻 poll
|
||||
* - promoteToModels happy
|
||||
* - promoteToModels 409 → throw 給上層
|
||||
* - cancelUpload → AbortController.abort + state idle
|
||||
* - reset → 清所有 state + 停 polling
|
||||
*
|
||||
* Mock 策略:
|
||||
* - lib/api/conversion 的 5 個函式直接 vi.mock
|
||||
* - vi.useFakeTimers() 控制 setTimeout(polling delay)
|
||||
* - document.hidden / visibilityState 透過 Object.defineProperty 覆寫(happy-dom / jsdom 都吃)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ConversionAPIError } from "@/lib/api/conversion";
|
||||
import * as conversionApi from "@/lib/api/conversion";
|
||||
import type { ConversionJob } from "@/types/conversion";
|
||||
|
||||
import { useConversionStore } from "./conversion-store";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock lib/api/conversion */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
vi.mock("@/lib/api/conversion", async () => {
|
||||
// 保留 ConversionAPIError export
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/conversion")>(
|
||||
"@/lib/api/conversion",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getActiveConversion: vi.fn(),
|
||||
initConversion: vi.fn(),
|
||||
getConversion: vi.fn(),
|
||||
promoteConversionToModels: vi.fn(),
|
||||
getConversionDownloadURL: actual.getConversionDownloadURL,
|
||||
};
|
||||
});
|
||||
|
||||
const mockedApi = conversionApi as unknown as {
|
||||
getActiveConversion: ReturnType<typeof vi.fn>;
|
||||
initConversion: ReturnType<typeof vi.fn>;
|
||||
getConversion: ReturnType<typeof vi.fn>;
|
||||
promoteConversionToModels: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function makeJob(overrides: Partial<ConversionJob> = {}): ConversionJob {
|
||||
return {
|
||||
job_id: "j-1",
|
||||
status: "running",
|
||||
stage: "bie",
|
||||
progress: 50,
|
||||
source_filename: "yolov5s.onnx",
|
||||
target_chip: "KL720",
|
||||
created_at: "2026-04-30T12:00:00Z",
|
||||
expires_at: "2026-05-07T12:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把 document.hidden / visibilityState 設為指定值,並 dispatch event */
|
||||
function setDocumentVisibility(visible: boolean): void {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => !visible,
|
||||
});
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => (visible ? "visible" : "hidden"),
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
/** 清 store 回初始狀態 + 卸 polling listener(必做,否則跨測試 leak) */
|
||||
function resetStore(): void {
|
||||
// reset() 會卸 visibility hook + 清 timer
|
||||
useConversionStore.getState().reset();
|
||||
// 確保 setDocumentVisibility 不會洩漏到下個測試
|
||||
setDocumentVisibility(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// 確保所有 fake timer / listener 都清掉
|
||||
vi.useRealTimers();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* bootstrap */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("bootstrap", () => {
|
||||
it("沒 active job → state 維持 idle", async () => {
|
||||
mockedApi.getActiveConversion.mockResolvedValue(null);
|
||||
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("idle");
|
||||
expect(s.job).toBeNull();
|
||||
expect(s.isInitializing).toBe(false);
|
||||
expect(s.error).toBeNull();
|
||||
});
|
||||
|
||||
it("有 active job(running) → state running + 開始 polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
const job = makeJob({ status: "running" });
|
||||
mockedApi.getActiveConversion.mockResolvedValue(job);
|
||||
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("running");
|
||||
expect(s.job?.job_id).toBe("j-1");
|
||||
// 排程的 timer 還沒觸發 → mockedApi.getConversion 還沒被叫
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
// 推進 5 秒(POLL_INTERVAL_RUNNING_MS)→ 應該打 GET /api/conversion/{id}
|
||||
mockedApi.getConversion.mockResolvedValue(makeJob({ status: "running" }));
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledWith(
|
||||
"j-1",
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("有 active job 且已過期 → state expired,不 polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
// expires_at 在過去
|
||||
const expiredJob = makeJob({
|
||||
status: "running",
|
||||
expires_at: "2020-01-01T00:00:00Z",
|
||||
});
|
||||
mockedApi.getActiveConversion.mockResolvedValue(expiredJob);
|
||||
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
expect(useConversionStore.getState().uiState).toBe("expired");
|
||||
// 推進時間,確認沒有 polling
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("有 active job(succeeded) → state succeeded,不 polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "succeeded" }),
|
||||
);
|
||||
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
expect(useConversionStore.getState().uiState).toBe("succeeded");
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstrap 失敗 → state idle + error", async () => {
|
||||
mockedApi.getActiveConversion.mockRejectedValue(
|
||||
new ConversionAPIError(500, "internal_error", "boom"),
|
||||
);
|
||||
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("idle");
|
||||
expect(s.isInitializing).toBe(false);
|
||||
expect(s.error?.code).toBe("internal_error");
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* startConversion */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("startConversion", () => {
|
||||
it("happy path:state 經 uploading → running,開始 polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
const file = new File(["x".repeat(100)], "model.onnx");
|
||||
mockedApi.initConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
|
||||
const promise = useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL720",
|
||||
});
|
||||
|
||||
// 上傳啟動瞬間應該切到 uploading
|
||||
expect(useConversionStore.getState().uiState).toBe("uploading");
|
||||
expect(useConversionStore.getState().uploadProgress).toEqual({
|
||||
loaded: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("running");
|
||||
expect(s.job?.job_id).toBe("j-1");
|
||||
expect(s.uploadProgress).toBeNull();
|
||||
|
||||
// 確認 initConversion 被正確呼叫
|
||||
expect(mockedApi.initConversion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
file,
|
||||
targetChip: "KL720",
|
||||
signal: expect.any(AbortSignal),
|
||||
onUploadProgress: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("onUploadProgress 觸發 uploadProgress state 更新", async () => {
|
||||
const file = new File(["x"], "model.onnx");
|
||||
let captured: ((loaded: number, total: number) => void) | null = null;
|
||||
mockedApi.initConversion.mockImplementation(async (args) => {
|
||||
captured = args.onUploadProgress ?? null;
|
||||
// 不立刻 resolve,讓我們有時間呼叫 progress
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(makeJob({ status: "running" })), 0);
|
||||
});
|
||||
});
|
||||
|
||||
const promise = useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL520",
|
||||
});
|
||||
|
||||
// 等 microtask flush,captured 應已就位
|
||||
await Promise.resolve();
|
||||
expect(captured).not.toBeNull();
|
||||
captured!(50, 200);
|
||||
expect(useConversionStore.getState().uploadProgress).toEqual({
|
||||
loaded: 50,
|
||||
total: 200,
|
||||
});
|
||||
captured!(150, 200);
|
||||
expect(useConversionStore.getState().uploadProgress).toEqual({
|
||||
loaded: 150,
|
||||
total: 200,
|
||||
});
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("startConversion 收 409 active_job_exists → 自動 bootstrap + switchedFromActiveJob = true", async () => {
|
||||
const file = new File(["x"], "m.onnx");
|
||||
mockedApi.initConversion.mockRejectedValue(
|
||||
new ConversionAPIError(
|
||||
409,
|
||||
"active_job_exists",
|
||||
"你已有進行中任務",
|
||||
"req-x",
|
||||
),
|
||||
);
|
||||
// bootstrap 會 fallback 找到既存 active job
|
||||
const existingJob = makeJob({ job_id: "j-existing", status: "running" });
|
||||
mockedApi.getActiveConversion.mockResolvedValue(existingJob);
|
||||
|
||||
await useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL720",
|
||||
});
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
// bootstrap 把 state 切到 running、job 變既存的
|
||||
expect(s.uiState).toBe("running");
|
||||
expect(s.job?.job_id).toBe("j-existing");
|
||||
// banner flag 應為 true(給 UI 顯示「已切換至該任務」)
|
||||
expect(s.switchedFromActiveJob).toBe(true);
|
||||
// 不寫 error(這不是錯誤,是業務狀態切換)
|
||||
expect(s.error).toBeNull();
|
||||
expect(mockedApi.getActiveConversion).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("startConversion 一般錯誤 → 回 idle + 寫 error", async () => {
|
||||
const file = new File(["x"], "m.onnx");
|
||||
mockedApi.initConversion.mockRejectedValue(
|
||||
new ConversionAPIError(413, "payload_too_large", "too big"),
|
||||
);
|
||||
|
||||
await useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL520",
|
||||
});
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("idle");
|
||||
expect(s.uploadProgress).toBeNull();
|
||||
expect(s.error?.code).toBe("payload_too_large");
|
||||
expect(s.error?.status).toBe(413);
|
||||
});
|
||||
|
||||
it("startConversion 在非 idle 狀態被叫 → 不動", async () => {
|
||||
// 先強制 state 進 running
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "running",
|
||||
job: makeJob({ status: "running" }),
|
||||
});
|
||||
const file = new File(["x"], "m.onnx");
|
||||
|
||||
await useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL520",
|
||||
});
|
||||
|
||||
expect(mockedApi.initConversion).not.toHaveBeenCalled();
|
||||
// state 維持 running
|
||||
expect(useConversionStore.getState().uiState).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* polling */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("polling", () => {
|
||||
it("收 succeeded → stopPolling + state succeeded", async () => {
|
||||
vi.useFakeTimers();
|
||||
// 先 bootstrap 進 running
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
// 第一次 poll 回 succeeded
|
||||
mockedApi.getConversion.mockResolvedValueOnce(
|
||||
makeJob({ status: "succeeded" }),
|
||||
);
|
||||
|
||||
// 推 5s 觸發 poll
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("succeeded");
|
||||
expect(s.job?.status).toBe("succeeded");
|
||||
|
||||
// 確認後續不再 polling(再推 30s)
|
||||
mockedApi.getConversion.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("收 failed → state failed + error 來自 job.error", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
mockedApi.getConversion.mockResolvedValueOnce(
|
||||
makeJob({
|
||||
status: "failed",
|
||||
error: { code: "QUANTIZATION_FAILED", message: "op not supported" },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("failed");
|
||||
expect(s.error?.code).toBe("QUANTIZATION_FAILED");
|
||||
expect(s.error?.message).toBe("op not supported");
|
||||
});
|
||||
|
||||
it("收 queued → 維持 polling(10s 間隔)", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "queued" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
mockedApi.getConversion.mockResolvedValue(makeJob({ status: "queued" }));
|
||||
|
||||
// queued 用 10s 間隔,5s 不會觸發
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
|
||||
// 再 5s(總 10s)觸發第一次
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 再 10s 觸發第二次
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
|
||||
|
||||
// 維持 queued state
|
||||
expect(useConversionStore.getState().uiState).toBe("queued");
|
||||
});
|
||||
|
||||
it("polling 收到 expires_at < now → 切 expired", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
mockedApi.getConversion.mockResolvedValueOnce(
|
||||
makeJob({ status: "running", expires_at: "2020-01-01T00:00:00Z" }),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(useConversionStore.getState().uiState).toBe("expired");
|
||||
// 不再 polling
|
||||
mockedApi.getConversion.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("polling 失敗 → 累計 pollErrorCount + 指數退避", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
// 全程都 reject
|
||||
mockedApi.getConversion.mockRejectedValue(
|
||||
new ConversionAPIError(500, "internal_error", "boom"),
|
||||
);
|
||||
|
||||
// 第一次:5s 後觸發
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(1);
|
||||
|
||||
// 第二次:失敗後排 10s(10 * 2^0),9s 內不該打
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
|
||||
// 推到 10.5s 觸發第二次
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(2);
|
||||
|
||||
// 第三次:失敗後排 20s(10 * 2^1),15s 內不該打
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
|
||||
// 推到 21s 觸發第三次
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(3);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(3);
|
||||
});
|
||||
|
||||
it("polling 連 5 次失敗 → pollVisibleError = true", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
mockedApi.getConversion.mockRejectedValue(
|
||||
new ConversionAPIError(500, "internal_error", "boom"),
|
||||
);
|
||||
|
||||
// 連續 5 次失敗:跳到時間軸末端(足夠涵蓋所有 backoff)
|
||||
// 退避序列:5s (initial), 10s, 20s, 30s (cap), 30s(cap) — 共需 95s+
|
||||
await vi.advanceTimersByTimeAsync(5_000); // 1st
|
||||
await vi.advanceTimersByTimeAsync(10_000); // 2nd
|
||||
await vi.advanceTimersByTimeAsync(20_000); // 3rd
|
||||
await vi.advanceTimersByTimeAsync(30_000); // 4th
|
||||
await vi.advanceTimersByTimeAsync(30_000); // 5th
|
||||
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(5);
|
||||
expect(useConversionStore.getState().pollErrorCount).toBe(5);
|
||||
expect(useConversionStore.getState().pollVisibleError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* visibilitychange */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("visibilitychange", () => {
|
||||
it("tab 切走 → 暫停下次 poll;回來 → 立刻打一次", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
// 切到隱藏:scheduler.timerId 應被清掉
|
||||
setDocumentVisibility(false);
|
||||
|
||||
// 推進時間,不會打
|
||||
mockedApi.getConversion.mockResolvedValue(makeJob({ status: "running" }));
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
|
||||
// 切回可見:立刻打一次
|
||||
setDocumentVisibility(true);
|
||||
// pollOnce 是 async,需 microtask flush
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* promoteToModels */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("promoteToModels", () => {
|
||||
it("happy path → 回 model_id", async () => {
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "succeeded",
|
||||
job: makeJob({ status: "succeeded" }),
|
||||
});
|
||||
mockedApi.promoteConversionToModels.mockResolvedValue({
|
||||
model_id: "m-abc",
|
||||
});
|
||||
|
||||
const result = await useConversionStore
|
||||
.getState()
|
||||
.promoteToModels("yolov5s_kl720");
|
||||
|
||||
expect(result.model_id).toBe("m-abc");
|
||||
expect(mockedApi.promoteConversionToModels).toHaveBeenCalledWith(
|
||||
"j-1",
|
||||
{ name: "yolov5s_kl720" },
|
||||
);
|
||||
// state 維持 succeeded
|
||||
expect(useConversionStore.getState().uiState).toBe("succeeded");
|
||||
expect(useConversionStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it("沒給 name → 送空 body", async () => {
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "succeeded",
|
||||
job: makeJob({ status: "succeeded" }),
|
||||
});
|
||||
mockedApi.promoteConversionToModels.mockResolvedValue({
|
||||
model_id: "m-abc",
|
||||
});
|
||||
|
||||
await useConversionStore.getState().promoteToModels();
|
||||
|
||||
expect(mockedApi.promoteConversionToModels).toHaveBeenCalledWith("j-1", {});
|
||||
});
|
||||
|
||||
it("409 already imported → throw + 寫入 error", async () => {
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "succeeded",
|
||||
job: makeJob({ status: "succeeded" }),
|
||||
});
|
||||
mockedApi.promoteConversionToModels.mockRejectedValue(
|
||||
new ConversionAPIError(409, "already_imported", "已加入過模型庫"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
useConversionStore.getState().promoteToModels("dup"),
|
||||
).rejects.toBeInstanceOf(ConversionAPIError);
|
||||
|
||||
expect(useConversionStore.getState().error?.code).toBe("already_imported");
|
||||
});
|
||||
|
||||
it("沒 active job → throw validation_failed", async () => {
|
||||
await expect(
|
||||
useConversionStore.getState().promoteToModels("x"),
|
||||
).rejects.toMatchObject({ code: "validation_failed" });
|
||||
});
|
||||
|
||||
it("非 succeeded state → throw job_not_completed", async () => {
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "running",
|
||||
job: makeJob({ status: "running" }),
|
||||
});
|
||||
await expect(
|
||||
useConversionStore.getState().promoteToModels("x"),
|
||||
).rejects.toMatchObject({ code: "job_not_completed" });
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* cancelUpload + reset */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("cancelUpload", () => {
|
||||
it("uploading 中呼叫 → state 回 idle + 上傳被 abort", async () => {
|
||||
const file = new File(["x"], "m.onnx");
|
||||
let initSignal: AbortSignal | null = null;
|
||||
mockedApi.initConversion.mockImplementation(async (args) => {
|
||||
initSignal = args.signal ?? null;
|
||||
return new Promise((_, reject) => {
|
||||
args.signal?.addEventListener("abort", () => {
|
||||
reject(
|
||||
new ConversionAPIError(0, "aborted", "Request aborted"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const promise = useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL720",
|
||||
});
|
||||
// 微任務 flush,讓 initConversion 的 mock 被呼到
|
||||
await Promise.resolve();
|
||||
expect(useConversionStore.getState().uiState).toBe("uploading");
|
||||
|
||||
useConversionStore.getState().cancelUpload();
|
||||
expect(useConversionStore.getState().uiState).toBe("idle");
|
||||
expect(useConversionStore.getState().uploadProgress).toBeNull();
|
||||
|
||||
// initConversion 的 promise 應該被 abort 觸發 reject
|
||||
await promise;
|
||||
expect(initSignal).not.toBeNull();
|
||||
expect(initSignal!.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("非 uploading 狀態呼叫 cancelUpload → no-op", () => {
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "running",
|
||||
job: makeJob({ status: "running" }),
|
||||
});
|
||||
useConversionStore.getState().cancelUpload();
|
||||
expect(useConversionStore.getState().uiState).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("清所有 state + 停 polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
await useConversionStore.getState().bootstrap();
|
||||
expect(useConversionStore.getState().uiState).toBe("running");
|
||||
|
||||
useConversionStore.getState().reset();
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("idle");
|
||||
expect(s.job).toBeNull();
|
||||
expect(s.uploadProgress).toBeNull();
|
||||
expect(s.error).toBeNull();
|
||||
expect(s.pollErrorCount).toBe(0);
|
||||
expect(s.pollVisibleError).toBe(false);
|
||||
expect(s.switchedFromActiveJob).toBe(false);
|
||||
|
||||
// 不再 polling
|
||||
mockedApi.getConversion.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(mockedApi.getConversion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reset 清掉 switchedFromActiveJob = false(user dismiss banner)", () => {
|
||||
useConversionStore.getState()._setState({
|
||||
uiState: "running",
|
||||
job: makeJob({ status: "running" }),
|
||||
switchedFromActiveJob: true,
|
||||
});
|
||||
expect(useConversionStore.getState().switchedFromActiveJob).toBe(true);
|
||||
|
||||
useConversionStore.getState().reset();
|
||||
|
||||
expect(useConversionStore.getState().switchedFromActiveJob).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* switchedFromActiveJob — banner flag */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* ========================================================================== */
|
||||
/* F-T9 M1:formDraft —— IdleForm metadata 提到 store */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("formDraft (F-T9 M1)", () => {
|
||||
it("初始值為 DEFAULT_FORM_DRAFT(chip=KL720、其他空)", () => {
|
||||
const { formDraft } = useConversionStore.getState();
|
||||
expect(formDraft).toEqual({
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
refImagesNames: [],
|
||||
targetChip: "KL720",
|
||||
taskName: "",
|
||||
taskNameTouched: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("updateFormDraft 部分 patch:只覆蓋指定欄位、其他保留", () => {
|
||||
useConversionStore
|
||||
.getState()
|
||||
.updateFormDraft({ taskName: "yolov5s", taskNameTouched: true });
|
||||
let s = useConversionStore.getState().formDraft;
|
||||
expect(s.taskName).toBe("yolov5s");
|
||||
expect(s.taskNameTouched).toBe(true);
|
||||
// 其他欄位保留
|
||||
expect(s.targetChip).toBe("KL720");
|
||||
|
||||
useConversionStore.getState().updateFormDraft({ targetChip: "KL520" });
|
||||
s = useConversionStore.getState().formDraft;
|
||||
expect(s.targetChip).toBe("KL520");
|
||||
// 不會清掉之前 patch 的值
|
||||
expect(s.taskName).toBe("yolov5s");
|
||||
});
|
||||
|
||||
it("clearFormDraft 直接回 DEFAULT_FORM_DRAFT", () => {
|
||||
useConversionStore.getState().updateFormDraft({
|
||||
fileName: "model.onnx",
|
||||
fileSize: 1024,
|
||||
taskName: "task-x",
|
||||
taskNameTouched: true,
|
||||
targetChip: "KL520",
|
||||
refImagesNames: ["a.png"],
|
||||
});
|
||||
useConversionStore.getState().clearFormDraft();
|
||||
expect(useConversionStore.getState().formDraft).toEqual({
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
refImagesNames: [],
|
||||
targetChip: "KL720",
|
||||
taskName: "",
|
||||
taskNameTouched: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("startConversion 上傳成功後 → 自動清 formDraft(user 完成提交)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const file = new File(["x"], "model.onnx");
|
||||
mockedApi.initConversion.mockResolvedValue(makeJob({ status: "running" }));
|
||||
|
||||
useConversionStore.getState().updateFormDraft({
|
||||
fileName: "model.onnx",
|
||||
fileSize: 1024,
|
||||
taskName: "task-x",
|
||||
});
|
||||
|
||||
await useConversionStore
|
||||
.getState()
|
||||
.startConversion({ file, targetChip: "KL720" });
|
||||
|
||||
const { formDraft } = useConversionStore.getState();
|
||||
expect(formDraft.fileName).toBeNull();
|
||||
expect(formDraft.taskName).toBe("");
|
||||
});
|
||||
|
||||
it("startConversion 上傳失敗 → formDraft 保留(讓 user 重試時看到上次設定)", async () => {
|
||||
const file = new File(["x"], "m.onnx");
|
||||
mockedApi.initConversion.mockRejectedValue(
|
||||
new ConversionAPIError(500, "internal_error", "boom"),
|
||||
);
|
||||
|
||||
useConversionStore.getState().updateFormDraft({
|
||||
fileName: "m.onnx",
|
||||
fileSize: 2048,
|
||||
taskName: "my-task",
|
||||
taskNameTouched: true,
|
||||
targetChip: "KL520",
|
||||
});
|
||||
|
||||
await useConversionStore
|
||||
.getState()
|
||||
.startConversion({ file, targetChip: "KL520" });
|
||||
|
||||
// 失敗後 state 切回 idle,但 formDraft 留著(user 看得到上次設定,重新選檔即可重試)
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("idle");
|
||||
expect(s.error?.code).toBe("internal_error");
|
||||
expect(s.formDraft.fileName).toBe("m.onnx");
|
||||
expect(s.formDraft.taskName).toBe("my-task");
|
||||
expect(s.formDraft.targetChip).toBe("KL520");
|
||||
expect(s.formDraft.taskNameTouched).toBe(true);
|
||||
});
|
||||
|
||||
it("reset() 把 formDraft 一併清回預設", () => {
|
||||
useConversionStore.getState().updateFormDraft({
|
||||
fileName: "m.onnx",
|
||||
fileSize: 100,
|
||||
taskName: "x",
|
||||
taskNameTouched: true,
|
||||
targetChip: "KL520",
|
||||
refImagesNames: ["a.png", "b.png"],
|
||||
});
|
||||
useConversionStore.getState().reset();
|
||||
|
||||
expect(useConversionStore.getState().formDraft).toEqual({
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
refImagesNames: [],
|
||||
targetChip: "KL720",
|
||||
taskName: "",
|
||||
taskNameTouched: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("cancelUpload 不清 formDraft(使用者主動取消可能想重新調整再送)", async () => {
|
||||
const file = new File(["x"], "m.onnx");
|
||||
mockedApi.initConversion.mockImplementation(async (args) => {
|
||||
return new Promise((_, reject) => {
|
||||
args.signal?.addEventListener("abort", () => {
|
||||
reject(new ConversionAPIError(0, "aborted", "Request aborted"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
useConversionStore
|
||||
.getState()
|
||||
.updateFormDraft({ fileName: "m.onnx", fileSize: 100, taskName: "draft" });
|
||||
|
||||
const promise = useConversionStore
|
||||
.getState()
|
||||
.startConversion({ file, targetChip: "KL720" });
|
||||
await Promise.resolve();
|
||||
expect(useConversionStore.getState().uiState).toBe("uploading");
|
||||
|
||||
useConversionStore.getState().cancelUpload();
|
||||
await promise;
|
||||
|
||||
// formDraft 保留(即便 startConversion 一開始會清,但因為失敗時也保留,這裡先驗證
|
||||
// cancel 後 state 為 idle 且 draft 仍是 user 之前設的)
|
||||
// 注意:startConversion 進入 uploading 時不會清 formDraft(清是在 success 路徑);
|
||||
// cancel 走 abort → catch 看到 aborted code → 維持 idle 行為,不動 formDraft。
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("idle");
|
||||
expect(s.formDraft.fileName).toBe("m.onnx");
|
||||
expect(s.formDraft.taskName).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchedFromActiveJob", () => {
|
||||
it("初始值為 false", () => {
|
||||
expect(useConversionStore.getState().switchedFromActiveJob).toBe(false);
|
||||
});
|
||||
|
||||
it("bootstrap 不主動清 switchedFromActiveJob(保留 caller 設定)", async () => {
|
||||
// 模擬 startConversion 409 流程:caller 先設 true,再呼 bootstrap
|
||||
useConversionStore.getState()._setState({ switchedFromActiveJob: true });
|
||||
mockedApi.getActiveConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
|
||||
await useConversionStore.getState().bootstrap();
|
||||
|
||||
const s = useConversionStore.getState();
|
||||
expect(s.uiState).toBe("running");
|
||||
// bootstrap 不能把 caller 設好的 banner flag 清掉
|
||||
expect(s.switchedFromActiveJob).toBe(true);
|
||||
});
|
||||
|
||||
it("startConversion 從 idle 啟動會清掉殘留的 switchedFromActiveJob", async () => {
|
||||
// 假設先前殘留 true
|
||||
useConversionStore.getState()._setState({ switchedFromActiveJob: true });
|
||||
const file = new File(["x"], "m.onnx");
|
||||
mockedApi.initConversion.mockResolvedValue(
|
||||
makeJob({ status: "running" }),
|
||||
);
|
||||
|
||||
await useConversionStore.getState().startConversion({
|
||||
file,
|
||||
targetChip: "KL720",
|
||||
});
|
||||
|
||||
// 新轉檔成功啟動 → flag 應已清
|
||||
expect(useConversionStore.getState().switchedFromActiveJob).toBe(false);
|
||||
});
|
||||
});
|
||||
754
visionA-frontend/src/stores/conversion-store.ts
Normal file
754
visionA-frontend/src/stores/conversion-store.ts
Normal file
@ -0,0 +1,754 @@
|
||||
/**
|
||||
* Conversion Store — visionA Cloud(Phase 0.8 — F-T3)
|
||||
*
|
||||
* 對齊:
|
||||
* - `.autoflow/03-design/flows/flow-conversion.md`(state machine + polling 策略 + 邊界 6 種情境)
|
||||
* - `.autoflow/03-design/wireframes/wireframe-conversion.md`(5 個 state 視覺)
|
||||
* - `.autoflow/04-architecture/api/api-conversion.md` §2 / §3 / §5
|
||||
* - `visionA-frontend/src/lib/api/conversion.ts`(F-T2 client,5 個函式 + ConversionAPIError)
|
||||
*
|
||||
* 職責:
|
||||
* - 管理「轉檔頁面 + 任意分頁背景 polling」共用的單一 source of truth。
|
||||
* - 把 backend 給的 ConversionJob 翻譯成 UI 用的 ConversionUIState(state machine)。
|
||||
* - 自管 polling lifecycle(recursive setTimeout + visibilitychange + 5xx 退避)。
|
||||
* - 自管 upload AbortController(給「取消上傳」按鈕用)。
|
||||
*
|
||||
* 設計原則:
|
||||
* 1. **不持久化 jobId**(flow-conversion.md §11 wireframe §1.2)—— 每次 boot 走
|
||||
* `getActiveConversion()` 從 backend 重抓 ownership,避免「換瀏覽器 / 多分頁 / 私密模式」壞掉。
|
||||
* 2. **state machine 嚴格**(flow §4)—— 不允許跳階段(idle → succeeded 是非法的)。
|
||||
* polling 收到的 job.status → mapJobStatusToUI 算 next state,再走 setUIStateFromJob。
|
||||
* 3. **polling 用 recursive setTimeout 不用 setInterval** —— 避免 overlapping request;
|
||||
* 每次 fetch 完才排下一次(含失敗退避)。
|
||||
* 4. **document.hidden 暫停** —— tab 切走暫停 polling,回來立刻打一次。
|
||||
* 用 visibilitychange listener;store 自管 listener add/remove(boot/destroy)。
|
||||
* 5. **錯誤 code 直接傳給 UI** —— UI 用 `conversion.error.<code>` 取 i18n(F-T1 已就位)。
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import {
|
||||
ConversionAPIError,
|
||||
getActiveConversion,
|
||||
getConversion,
|
||||
initConversion,
|
||||
promoteConversionToModels,
|
||||
} from "@/lib/api/conversion";
|
||||
import type {
|
||||
ConversionJob,
|
||||
ConversionStatus,
|
||||
TargetChip,
|
||||
} from "@/types/conversion";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** UI state machine 的合法狀態(flow-conversion.md §4) */
|
||||
export type ConversionUIState =
|
||||
| "idle" // 還沒開始 / 上一個結束已 dismissed
|
||||
| "uploading" // multipart upload 進行中
|
||||
| "queued" // upload 完,converter 還沒開始(status=queued)
|
||||
| "running" // converter 處理中
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "expired"; // 7 天後 GC
|
||||
|
||||
/** 上傳進度(XHR onprogress) */
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle 表單草稿(F-T9 M1 修補:form state 提到 store)
|
||||
*
|
||||
* 為什麼放 store:
|
||||
* page.tsx 在 idle / uploading 之間是條件 render(不是 display:none),所以 IdleForm 在
|
||||
* `idle → uploading → 失敗回 idle` 路徑中會 unmount → re-mount,原本掛在 IdleForm useState 的
|
||||
* form 內容會跟著新 instance 一起重置。把 metadata 提到 store 後,無論 IdleForm 重 mount
|
||||
* 多少次都能恢復使用者前一次填過的設定。
|
||||
*
|
||||
* 為什麼不存 File 物件(File / refImages):
|
||||
* - File 不可序列化、跨 unmount 拿到也是 stale reference(瀏覽器並不擔保檔案內容仍可讀)
|
||||
* - 存進 in-memory store 雖技術上可行,但會 leak File handle 到結束 session 才釋放
|
||||
* - 取捨後選方案 A(最務實):metadata 進 store、File 留 local;上傳失敗回 idle 時 form
|
||||
* 會顯示之前的 chip / taskName / 上次選的檔名(顯示用),但 file picker 仍 empty 需重選。
|
||||
* 對應 toast 文案改為「您之前的設定(晶片 / 任務名稱)仍保留,請重新選擇檔案再試」。
|
||||
*/
|
||||
export interface ConversionFormDraft {
|
||||
/** 上次選的模型檔名(僅顯示用;File 物件本身不存 store) */
|
||||
fileName: string | null;
|
||||
/** 上次選的模型大小 bytes(僅顯示用) */
|
||||
fileSize: number | null;
|
||||
/** 上次選的 ref images 檔名清單(僅顯示用;File 物件不存) */
|
||||
refImagesNames: string[];
|
||||
/** 目標晶片(idle 預設 KL720,使用者改了會記在這) */
|
||||
targetChip: TargetChip;
|
||||
/** 任務名稱草稿 */
|
||||
taskName: string;
|
||||
/** 任務名稱是否被使用者親手改過(決定下一次選檔要不要自動帶 file.name stem) */
|
||||
taskNameTouched: boolean;
|
||||
}
|
||||
|
||||
/** 暴露給 UI 的錯誤摘要(i18n key 用 `conversion.error.<code>`) */
|
||||
export interface ConversionStoreError {
|
||||
/** 來自 ConversionAPIError.code(小寫 snake / api-conversion.md §6) */
|
||||
code: string;
|
||||
/** 給 debug 看的;UI 預設不顯示 */
|
||||
message?: string;
|
||||
/** 0 = network/abort/timeout */
|
||||
status: number;
|
||||
/** 相關聯的 backend request id(若有) */
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/** Phase 0.8 polling 排程常數(flow-conversion.md §5.4 + api-conversion.md §2 polling 建議) */
|
||||
const POLL_INTERVAL_RUNNING_MS = 5_000;
|
||||
const POLL_INTERVAL_QUEUED_MS = 10_000;
|
||||
const POLL_BACKOFF_BASE_MS = 10_000;
|
||||
const POLL_BACKOFF_MAX_MS = 30_000;
|
||||
/** 連 N 次 polling 失敗後 UI 標記「無法取得狀態」(仍繼續排下一次,不放棄)。 */
|
||||
const POLL_GIVE_UP_VISIBLE_THRESHOLD = 5;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* startConversion args */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface StartConversionArgs {
|
||||
file: File;
|
||||
refImages?: File[];
|
||||
targetChip: TargetChip;
|
||||
taskName?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store state + actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ConversionStoreState {
|
||||
/** UI state machine 當前狀態 */
|
||||
uiState: ConversionUIState;
|
||||
/** 當前 job 完整資料;idle 時為 null */
|
||||
job: ConversionJob | null;
|
||||
/** uploading 狀態時的進度;其他狀態為 null */
|
||||
uploadProgress: UploadProgress | null;
|
||||
/** 最近一次錯誤(uploading 失敗 / promote 失敗 / polling 失敗)。reset / 下一次成功操作會清空 */
|
||||
error: ConversionStoreError | null;
|
||||
/** boot 時 GET /active 進行中(避免初始閃爍) */
|
||||
isInitializing: boolean;
|
||||
/** 連續 polling 失敗計數(顯示「無法取得狀態」judging) */
|
||||
pollErrorCount: number;
|
||||
/** 是否曾顯示過 polling 失敗的提示(reset 時清) */
|
||||
pollVisibleError: boolean;
|
||||
/**
|
||||
* startConversion 收到 409 active_job_exists 後自動 bootstrap 切到既存 job 時為 true。
|
||||
*
|
||||
* 用途:UI 顯示 banner「您已有一個轉檔正在進行中,已切換至該任務」(i18n key
|
||||
* `conversion.processing.bannerExisting`,flow §6.1)。
|
||||
*
|
||||
* 設計(對齊 Reviewer 建議):用獨立 flag 表達語義,避免靠 `error.code === 'active_job_exists'`
|
||||
* 推導;bootstrap 會清 error,但這個 flag 由 caller(startConversion 409 分支)
|
||||
* 在 bootstrap 前設好、bootstrap 不主動清,由 user dismiss(reset)才清。
|
||||
*/
|
||||
switchedFromActiveJob: boolean;
|
||||
|
||||
/**
|
||||
* Idle 表單草稿(F-T9 M1)—— 跨 IdleForm mount/unmount 保留使用者設定。
|
||||
*
|
||||
* 生命週期:
|
||||
* - init:DEFAULT_FORM_DRAFT(chip=KL720,其他為空)
|
||||
* - 使用者編輯 IdleForm → updateFormDraft({...})
|
||||
* - startConversion 上傳失敗回 idle → **不**清(讓 user 直接看到上次設定)
|
||||
* - clearFormDraft():startConversion 上傳成功進 uploading 後 / reset() 時清
|
||||
*/
|
||||
formDraft: ConversionFormDraft;
|
||||
}
|
||||
|
||||
export interface ConversionStoreActions {
|
||||
/** boot:app start / 進入 `/conversion` 時呼叫,從 backend 拿 active job 重建 UI */
|
||||
bootstrap: () => Promise<void>;
|
||||
|
||||
/** 啟動轉檔(XHR upload + 進度 + AbortController;上傳完成自動進 polling) */
|
||||
startConversion: (args: StartConversionArgs) => Promise<void>;
|
||||
|
||||
/** 開始 polling(store 內部自己 setTimeout) */
|
||||
startPolling: () => void;
|
||||
|
||||
/** 停止 polling(清 timer + listener) */
|
||||
stopPolling: () => void;
|
||||
|
||||
/** 加到模型庫;回傳 model_id 給 UI 跳頁 */
|
||||
promoteToModels: (name?: string) => Promise<{ model_id: string }>;
|
||||
|
||||
/** 重置 UI 回 idle(user dismiss 結果頁;reset 不會主動 cancel backend job) */
|
||||
reset: () => void;
|
||||
|
||||
/** 取消上傳(中途)—— AbortController.abort + 立刻轉 idle */
|
||||
cancelUpload: () => void;
|
||||
|
||||
/** F-T9 M1:更新 IdleForm 表單草稿(partial patch) */
|
||||
updateFormDraft: (patch: Partial<ConversionFormDraft>) => void;
|
||||
|
||||
/** F-T9 M1:清空 IdleForm 表單草稿回預設(chip=KL720、其他為空) */
|
||||
clearFormDraft: () => void;
|
||||
|
||||
/* ------------ test helpers(_ 開頭,沿用 model-store / device-store 規範) ------------ */
|
||||
/** 直接塞 state(測試用) */
|
||||
_setState: (partial: Partial<ConversionStoreState>) => void;
|
||||
}
|
||||
|
||||
export type ConversionStore = ConversionStoreState & ConversionStoreActions;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Internal scheduler — recursive setTimeout */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Polling scheduler 的內部狀態(不放進 zustand state,避免 set() 觸發無謂 re-render)。
|
||||
*
|
||||
* 這些變數是模組私有的,但 store 只允許單一 active;多 tab 各自一份。
|
||||
*/
|
||||
interface PollScheduler {
|
||||
/** 當前排程的 timer id(給 stopPolling 清) */
|
||||
timerId: ReturnType<typeof setTimeout> | null;
|
||||
/** 當前 polling 用的 AbortController(給 stopPolling 中斷正在跑的 fetch) */
|
||||
abortController: AbortController | null;
|
||||
/** 是否已掛上 visibilitychange listener(避免重複掛) */
|
||||
visibilityHooked: boolean;
|
||||
}
|
||||
|
||||
const scheduler: PollScheduler = {
|
||||
timerId: null,
|
||||
abortController: null,
|
||||
visibilityHooked: false,
|
||||
};
|
||||
|
||||
/** 上傳的 AbortController;也用在「切頁面 cleanup」時 */
|
||||
let uploadAbortController: AbortController | null = null;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* state machine helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 把 backend 的 ConversionStatus 翻成 UI state(不做合法性檢查,呼叫端自己判斷轉換規則)。
|
||||
*
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/flows/flow-conversion.md §4 state machine)
|
||||
*/
|
||||
function mapJobStatusToUI(status: ConversionStatus): ConversionUIState {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "queued";
|
||||
case "running":
|
||||
return "running";
|
||||
case "succeeded":
|
||||
return "succeeded";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
// exhaustive — TS 會抓
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Job 是否已過期(expires_at < now)。
|
||||
*
|
||||
* 用途:bootstrap / polling 收到 job 後判斷要不要切 expired 狀態。
|
||||
*/
|
||||
function isJobExpired(job: ConversionJob): boolean {
|
||||
if (!job.expires_at) return false;
|
||||
const expiresAt = Date.parse(job.expires_at);
|
||||
if (!Number.isFinite(expiresAt)) return false;
|
||||
return expiresAt < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 ConversionAPIError(或任意錯誤)轉成 store 對外暴露的 ConversionStoreError。
|
||||
*/
|
||||
function toStoreError(err: unknown): ConversionStoreError {
|
||||
if (err instanceof ConversionAPIError) {
|
||||
return {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
status: err.status,
|
||||
requestId: err.requestId,
|
||||
};
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { code: "unknown", message: err.message, status: 0 };
|
||||
}
|
||||
return { code: "unknown", message: String(err), status: 0 };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* visibility listener — 只掛一次 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Phase 0.8 conversion (見 .autoflow/03-design/flows/flow-conversion.md §5.4 polling 策略)
|
||||
*
|
||||
* tab 切走暫停 polling、回來立刻補打一次。掛在 store 第一次 startPolling 時。
|
||||
*
|
||||
* 用 named function 而不是 inline,方便 stopPolling 清掉同一個 reference。
|
||||
*/
|
||||
function onVisibilityChange(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const isVisible = document.visibilityState === "visible";
|
||||
const state = useConversionStore.getState();
|
||||
const isPollingPhase =
|
||||
state.uiState === "queued" || state.uiState === "running";
|
||||
|
||||
if (isVisible && isPollingPhase) {
|
||||
// 回到前景:清掉現有排程、立刻打一次(pollOnce 結束會排下一次)
|
||||
if (scheduler.timerId !== null) {
|
||||
clearTimeout(scheduler.timerId);
|
||||
scheduler.timerId = null;
|
||||
}
|
||||
void pollOnce();
|
||||
} else if (!isVisible && scheduler.timerId !== null) {
|
||||
// 切走:暫停下次排程(正在跑的 fetch 不打斷,讓它自然結束)
|
||||
clearTimeout(scheduler.timerId);
|
||||
scheduler.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVisibilityHook(): void {
|
||||
if (scheduler.visibilityHooked) return;
|
||||
if (typeof document === "undefined") return;
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
scheduler.visibilityHooked = true;
|
||||
}
|
||||
|
||||
function removeVisibilityHook(): void {
|
||||
if (!scheduler.visibilityHooked) return;
|
||||
if (typeof document === "undefined") return;
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
scheduler.visibilityHooked = false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Polling — recursive setTimeout */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 計算下一次 polling 間隔。
|
||||
*
|
||||
* 規則(flow-conversion.md §5.4 + api-conversion.md §2):
|
||||
* - status=queued → 10s(converter 還沒開始)
|
||||
* - status=running → 5s
|
||||
* - 連續失敗 → 指數退避 10s / 20s / 30s(上限 30s)
|
||||
*/
|
||||
function nextPollDelay(uiState: ConversionUIState, errorCount: number): number {
|
||||
if (errorCount > 0) {
|
||||
const delay = POLL_BACKOFF_BASE_MS * Math.pow(2, errorCount - 1);
|
||||
return Math.min(delay, POLL_BACKOFF_MAX_MS);
|
||||
}
|
||||
if (uiState === "queued") return POLL_INTERVAL_QUEUED_MS;
|
||||
return POLL_INTERVAL_RUNNING_MS;
|
||||
}
|
||||
|
||||
/** 排下一次 poll;只在仍處於 polling 階段 + 分頁可見 + 沒有既存 timer 時才排 */
|
||||
function schedulePoll(): void {
|
||||
const state = useConversionStore.getState();
|
||||
const isPollingPhase =
|
||||
state.uiState === "queued" || state.uiState === "running";
|
||||
if (!isPollingPhase) return;
|
||||
if (scheduler.timerId !== null) return; // 已排過
|
||||
|
||||
// tab 切走:不排,等 visibilitychange 觸發 pollOnce
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
|
||||
const delay = nextPollDelay(state.uiState, state.pollErrorCount);
|
||||
scheduler.timerId = setTimeout(() => {
|
||||
scheduler.timerId = null;
|
||||
void pollOnce();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** 打一次 GET /api/conversion/{job_id},依結果更新 state + 排下一次 */
|
||||
async function pollOnce(): Promise<void> {
|
||||
const state = useConversionStore.getState();
|
||||
const job = state.job;
|
||||
// 沒 job / 不在 polling 階段 → 直接結束
|
||||
if (!job || (state.uiState !== "queued" && state.uiState !== "running")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消上一次未完成的 fetch(理論上 schedulePoll 不會 overlap,但保險)
|
||||
if (scheduler.abortController) {
|
||||
scheduler.abortController.abort();
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
scheduler.abortController = ctrl;
|
||||
|
||||
try {
|
||||
const fresh = await getConversion(job.job_id, ctrl.signal);
|
||||
|
||||
// 已過期 → 切 expired
|
||||
if (isJobExpired(fresh)) {
|
||||
stopPollingInternal();
|
||||
useConversionStore.setState({
|
||||
uiState: "expired",
|
||||
job: fresh,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUIState = mapJobStatusToUI(fresh.status);
|
||||
if (nextUIState === "succeeded" || nextUIState === "failed") {
|
||||
stopPollingInternal();
|
||||
useConversionStore.setState({
|
||||
uiState: nextUIState,
|
||||
job: fresh,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
// failed 時把 error 物件提到 top-level error,方便 UI 取
|
||||
error:
|
||||
nextUIState === "failed" && fresh.error
|
||||
? {
|
||||
code: fresh.error.code,
|
||||
message: fresh.error.message,
|
||||
status: 0,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// queued / running 之間切換
|
||||
useConversionStore.setState({
|
||||
uiState: nextUIState,
|
||||
job: fresh,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
});
|
||||
schedulePoll();
|
||||
} catch (err) {
|
||||
// AbortError(人為打斷)→ 不算錯誤
|
||||
if (err instanceof ConversionAPIError && err.code === "aborted") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 5xx / 網路錯誤:累計 + 指數退避
|
||||
const nextErrorCount = state.pollErrorCount + 1;
|
||||
useConversionStore.setState({
|
||||
pollErrorCount: nextErrorCount,
|
||||
pollVisibleError: nextErrorCount >= POLL_GIVE_UP_VISIBLE_THRESHOLD,
|
||||
});
|
||||
schedulePoll();
|
||||
} finally {
|
||||
if (scheduler.abortController === ctrl) {
|
||||
scheduler.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 內部用 stopPolling — 清 timer + 中斷正在跑的 fetch;不動 state */
|
||||
function stopPollingInternal(): void {
|
||||
if (scheduler.timerId !== null) {
|
||||
clearTimeout(scheduler.timerId);
|
||||
scheduler.timerId = null;
|
||||
}
|
||||
if (scheduler.abortController) {
|
||||
scheduler.abortController.abort();
|
||||
scheduler.abortController = null;
|
||||
}
|
||||
removeVisibilityHook();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store 實作 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* F-T9 M1:IdleForm 預設表單草稿(chip=KL720 對齊 IdleForm 的 DEFAULT_CHIP)。
|
||||
*
|
||||
* 抽出來是因為 reset() 與 clearFormDraft() 都要回到同一個基準。
|
||||
*/
|
||||
const DEFAULT_FORM_DRAFT: ConversionFormDraft = {
|
||||
fileName: null,
|
||||
fileSize: null,
|
||||
refImagesNames: [],
|
||||
targetChip: "KL720",
|
||||
taskName: "",
|
||||
taskNameTouched: false,
|
||||
};
|
||||
|
||||
const initialState: ConversionStoreState = {
|
||||
uiState: "idle",
|
||||
job: null,
|
||||
uploadProgress: null,
|
||||
error: null,
|
||||
isInitializing: false,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
switchedFromActiveJob: false,
|
||||
formDraft: DEFAULT_FORM_DRAFT,
|
||||
};
|
||||
|
||||
export const useConversionStore = create<ConversionStore>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
bootstrap: async () => {
|
||||
// 避免重複 boot
|
||||
if (get().isInitializing) return;
|
||||
set({ isInitializing: true, error: null });
|
||||
try {
|
||||
const job = await getActiveConversion();
|
||||
if (!job) {
|
||||
set({
|
||||
uiState: "idle",
|
||||
job: null,
|
||||
isInitializing: false,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 已過期:直接落 expired
|
||||
if (isJobExpired(job)) {
|
||||
set({
|
||||
uiState: "expired",
|
||||
job,
|
||||
isInitializing: false,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const uiState = mapJobStatusToUI(job.status);
|
||||
// queued / running → 開始 polling;succeeded / failed → 直接顯示結果不 polling
|
||||
set({
|
||||
uiState,
|
||||
job,
|
||||
isInitializing: false,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
error:
|
||||
uiState === "failed" && job.error
|
||||
? { code: job.error.code, message: job.error.message, status: 0 }
|
||||
: null,
|
||||
});
|
||||
if (uiState === "queued" || uiState === "running") {
|
||||
get().startPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
// bootstrap 失敗:保留 idle,但記下錯誤讓 UI 提示「無法載入轉檔狀態」
|
||||
set({
|
||||
isInitializing: false,
|
||||
error: toStoreError(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
startConversion: async (args) => {
|
||||
// 已有 active job 的話按理由 bootstrap / 上一次 reset 已切走;保險起見不動
|
||||
if (get().uiState !== "idle") {
|
||||
// 與 §6.1 對齊:上層 UI 不該在非 idle 時呼叫;若呼叫了直接 throw 讓 UI 處理
|
||||
return;
|
||||
}
|
||||
|
||||
// 清掉殘留的 upload controller
|
||||
if (uploadAbortController) {
|
||||
uploadAbortController.abort();
|
||||
}
|
||||
uploadAbortController = new AbortController();
|
||||
const ctrl = uploadAbortController;
|
||||
|
||||
set({
|
||||
uiState: "uploading",
|
||||
job: null,
|
||||
uploadProgress: { loaded: 0, total: args.file.size },
|
||||
error: null,
|
||||
pollErrorCount: 0,
|
||||
pollVisibleError: false,
|
||||
// 啟動新轉檔 → 清掉「已切換到既存 job」banner flag
|
||||
// (只有等下方 catch 收到 409 才會重新 set true)
|
||||
switchedFromActiveJob: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const job = await initConversion({
|
||||
file: args.file,
|
||||
refImages: args.refImages,
|
||||
targetChip: args.targetChip,
|
||||
taskName: args.taskName,
|
||||
signal: ctrl.signal,
|
||||
onUploadProgress: (loaded, total) => {
|
||||
// 只在 ctrl 還是現役 controller 時更新(防 abort 後 race condition)
|
||||
if (uploadAbortController !== ctrl) return;
|
||||
set({ uploadProgress: { loaded, total } });
|
||||
},
|
||||
});
|
||||
|
||||
// 上傳成功:切到對應 polling 階段
|
||||
if (uploadAbortController === ctrl) {
|
||||
uploadAbortController = null;
|
||||
}
|
||||
|
||||
const nextUIState = mapJobStatusToUI(job.status);
|
||||
// 上傳成功理論上 status 是 queued / running;但有極端情況收到 succeeded / failed(converter 極快)
|
||||
if (nextUIState === "succeeded" || nextUIState === "failed") {
|
||||
set({
|
||||
uiState: nextUIState,
|
||||
job,
|
||||
uploadProgress: null,
|
||||
error:
|
||||
nextUIState === "failed" && job.error
|
||||
? {
|
||||
code: job.error.code,
|
||||
message: job.error.message,
|
||||
status: 0,
|
||||
}
|
||||
: null,
|
||||
// F-T9 M1:上傳成功(即使 backend 秒切 succeeded/failed)後清 formDraft
|
||||
// 這是 user 真正完成提交的時機;下一次回 idle 不該再看到舊 draft
|
||||
formDraft: DEFAULT_FORM_DRAFT,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
uiState: nextUIState,
|
||||
job,
|
||||
uploadProgress: null,
|
||||
// F-T9 M1:上傳成功進 queued/running 後清 formDraft
|
||||
formDraft: DEFAULT_FORM_DRAFT,
|
||||
});
|
||||
get().startPolling();
|
||||
} catch (err) {
|
||||
if (uploadAbortController === ctrl) {
|
||||
uploadAbortController = null;
|
||||
}
|
||||
|
||||
// 取消(cancelUpload 觸發 abort 已經先把 state 切回 idle 了,這裡只負責清 progress)
|
||||
if (err instanceof ConversionAPIError && err.code === "aborted") {
|
||||
// 若 cancelUpload 沒被呼叫,但其他原因 abort(罕見)—— 仍切回 idle
|
||||
if (get().uiState === "uploading") {
|
||||
set({
|
||||
uiState: "idle",
|
||||
uploadProgress: null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 409 active_job_exists(flow §6.1):自動 bootstrap 重抓既存 active job,
|
||||
// 並標記 switchedFromActiveJob = true 讓 UI 顯示「已切換」banner
|
||||
// (i18n: conversion.processing.bannerExisting)。
|
||||
// 不寫 error —— 這不是「錯誤」,是業務狀態切換;error 留給真正失敗的場景。
|
||||
if (err instanceof ConversionAPIError && err.code === "active_job_exists") {
|
||||
set({
|
||||
uiState: "idle",
|
||||
uploadProgress: null,
|
||||
error: null,
|
||||
switchedFromActiveJob: true,
|
||||
});
|
||||
// bootstrap 會重抓並切到對應狀態;switchedFromActiveJob 保留(bootstrap 不清)
|
||||
await get().bootstrap();
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他錯誤:回 idle + 顯示錯誤
|
||||
set({
|
||||
uiState: "idle",
|
||||
uploadProgress: null,
|
||||
error: toStoreError(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
startPolling: () => {
|
||||
const { uiState, job } = get();
|
||||
if (!job) return;
|
||||
if (uiState !== "queued" && uiState !== "running") return;
|
||||
ensureVisibilityHook();
|
||||
// 立刻排(從現在算 nextPollDelay;不立刻打,避免 startConversion 後馬上又打一次)
|
||||
schedulePoll();
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
stopPollingInternal();
|
||||
},
|
||||
|
||||
promoteToModels: async (name) => {
|
||||
const { job, uiState } = get();
|
||||
if (!job) {
|
||||
throw new ConversionAPIError(
|
||||
0,
|
||||
"validation_failed",
|
||||
"promoteToModels: no active job",
|
||||
);
|
||||
}
|
||||
if (uiState !== "succeeded") {
|
||||
throw new ConversionAPIError(
|
||||
0,
|
||||
"job_not_completed",
|
||||
"promoteToModels: job not in succeeded state",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await promoteConversionToModels(
|
||||
job.job_id,
|
||||
name ? { name } : {},
|
||||
);
|
||||
// 不切 UI state(succeeded 維持,UI 顯示「已加入模型庫」label)
|
||||
// 清掉 error(避免之前的錯誤訊息殘留)
|
||||
set({ error: null });
|
||||
return result;
|
||||
} catch (err) {
|
||||
// 409 already imported(job 已 promote 過)由 UI 解讀錯誤訊息
|
||||
const storeErr = toStoreError(err);
|
||||
set({ error: storeErr });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
// 中斷 upload / polling,但**不**主動 cancel backend job
|
||||
// (§5.5 / §5.6:success/failed 後按「開始新轉檔」不需要清 backend;converter 7 天 GC)
|
||||
if (uploadAbortController) {
|
||||
uploadAbortController.abort();
|
||||
uploadAbortController = null;
|
||||
}
|
||||
stopPollingInternal();
|
||||
|
||||
set({
|
||||
...initialState,
|
||||
});
|
||||
},
|
||||
|
||||
cancelUpload: () => {
|
||||
if (get().uiState !== "uploading") return;
|
||||
if (uploadAbortController) {
|
||||
uploadAbortController.abort();
|
||||
uploadAbortController = null;
|
||||
}
|
||||
// 立刻切 idle(initConversion 的 catch 也會看到 aborted,但 state 已先回 idle)
|
||||
// 注意:cancelUpload 視為「使用者主動放棄這次上傳」—— formDraft **保留**,user 切回
|
||||
// idle 後可以調整再送一次
|
||||
set({
|
||||
uiState: "idle",
|
||||
uploadProgress: null,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
updateFormDraft: (patch) => {
|
||||
set((state) => ({ formDraft: { ...state.formDraft, ...patch } }));
|
||||
},
|
||||
|
||||
clearFormDraft: () => {
|
||||
set({ formDraft: DEFAULT_FORM_DRAFT });
|
||||
},
|
||||
|
||||
_setState: (partial) => set(partial),
|
||||
}));
|
||||
102
visionA-frontend/src/types/conversion.ts
Normal file
102
visionA-frontend/src/types/conversion.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Conversion 轉檔功能 — 型別定義(Phase 0.8)
|
||||
*
|
||||
* 對齊:
|
||||
* - `.autoflow/04-architecture/api/api-conversion.md`(Frontend ↔ visionA-backend API 契約)
|
||||
* - `.autoflow/04-architecture/conversion.md` §6(錯誤碼 mapping)
|
||||
*
|
||||
* 設計原則:
|
||||
* 1. **UI / Store 層使用 camelCase + 大寫前綴 chip 名稱(KL520 等)** — 與 wireframe 一致。
|
||||
* backend wire format 的 snake_case + 數字字串("520")由 `lib/api/conversion.ts` 的
|
||||
* normalizer 負責雙向轉換,store / UI 不需感知。
|
||||
* 2. **status enum 統一**:backend 用 `created` / `running` / `completed` / `failed`,
|
||||
* 此處正規化為 `queued` / `running` / `succeeded` / `failed`(與 store / wireframe 對齊)。
|
||||
* 3. **error 物件化**:backend 在 job poll response 用 `error_code` + `error_message` 兩欄,
|
||||
* 此處包成 `error: { code, message? }` 方便 UI 直接 i18n(`conversion.error.<code>`)。
|
||||
* 4. **error.code 不在這層翻譯成 i18n key** — 保留 backend 原始 code,store / UI 再 mapping
|
||||
* 到 `conversion.error.<code>`(見 `conversion.md` §6 / `api-conversion.md` 錯誤碼總覽)。
|
||||
*/
|
||||
|
||||
/** 對外(UI)統一狀態 — 對齊 wireframe / store 用語 */
|
||||
export type ConversionStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
/** 對外(UI)統一目標晶片 — 對齊 model-store / wireframe 用語 */
|
||||
export type TargetChip = "KL520" | "KL630" | "KL720" | "KL730";
|
||||
|
||||
/** 轉檔內部 stage(converter 回傳) */
|
||||
export type ConversionStage = "onnx" | "bie" | "nef";
|
||||
|
||||
/** Job poll / active 共用 shape — `lib/api/conversion.ts` 的所有 fetch 都會 normalize 成這個 */
|
||||
export interface ConversionJob {
|
||||
/** UUID(converter 配發) */
|
||||
job_id: string;
|
||||
status: ConversionStatus;
|
||||
/** running 時才有 */
|
||||
stage?: ConversionStage;
|
||||
/** 0–100,整體進度(converter `progress` 欄);optional 因為剛 init 時可能還沒回 */
|
||||
progress?: number;
|
||||
/** 原始上傳檔名(顯示用,例:`yolov5s.onnx`) */
|
||||
source_filename: string;
|
||||
/** 對外用大寫 KL 前綴;wire format 的 `520` / `720` / `630` / `730` 已被 normalize */
|
||||
target_chip: TargetChip;
|
||||
/** RFC3339 */
|
||||
created_at: string;
|
||||
/** RFC3339(`created_at + 7d` — converter GC 截止時間) */
|
||||
expires_at: string;
|
||||
/** failed 時才有 */
|
||||
error?: ConversionError;
|
||||
/** succeeded 時才有(promote 後才有 model_id;此處先放 metadata) */
|
||||
result?: ConversionResult;
|
||||
}
|
||||
|
||||
/** 失敗時的錯誤資訊(backend 拆 `error_code` + `error_message` 兩欄;此處合併成物件) */
|
||||
export interface ConversionError {
|
||||
/**
|
||||
* 機器可讀錯誤代號 — 對應 `conversion.md` §6 / wireframe F5 表的清單:
|
||||
* `UNSUPPORTED_FORMAT` / `INVALID_CHECKSUM` / `QUANTIZATION_FAILED`
|
||||
* / `MODEL_TOO_LARGE` / `QUOTA_EXCEEDED` / `unknown`
|
||||
* UI 層用 `conversion.error.<code>` 取 i18n。
|
||||
*/
|
||||
code: string;
|
||||
/** 後端附帶的原始訊息(debug 用,UI 預設不直接顯示) */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 成功時的轉檔結果 metadata(不含 download URL — 下載走 `getConversionDownloadURL`) */
|
||||
export interface ConversionResult {
|
||||
output_size_bytes: number;
|
||||
/** SHA-256(converter 提供時帶上;optional) */
|
||||
output_checksum?: string;
|
||||
}
|
||||
|
||||
/** `GET /api/conversion/active` 沒有 active job 時,client 適配為 null */
|
||||
export type ActiveConversionResponse = ConversionJob | null;
|
||||
|
||||
/** `POST /api/conversion/init` 的 client 入參 */
|
||||
export interface InitConversionArgs {
|
||||
/** 主模型檔(`.onnx` / `.tflite`,≤ 500MB) */
|
||||
file: File;
|
||||
/** 0–100 張 reference images(≤ 10MB / 張) */
|
||||
refImages?: File[];
|
||||
/** 目標晶片(UI 大寫格式;client 內部轉為 wire 的 `520` / `720` / ...) */
|
||||
targetChip: TargetChip;
|
||||
/** 顯示名(會傳成 backend 的 `model_id` text 欄;wireframe 對應「任務名稱」輸入) */
|
||||
taskName?: string;
|
||||
/** 版本字串(backend 必填;UI 預設 `v1.0.0`) */
|
||||
version?: string;
|
||||
/** XHR 上傳進度 callback */
|
||||
onUploadProgress?: (loaded: number, total: number) => void;
|
||||
/** 取消訊號(使用者按「取消」/ 切頁) */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** `POST /api/conversion/{job_id}/promote-to-models` 的 client 入參 */
|
||||
export interface PromoteConversionBody {
|
||||
/** 顯示名;省略時 backend 用 `{source_filename_stem}_{target_chip}` 作預設 */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Promote 成功後回傳 — UI 拿 `model_id` 跳到 `/models/:id` */
|
||||
export interface PromoteConversionResult {
|
||||
model_id: string;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user