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:
jim800121chen 2026-05-04 13:56:54 +08:00
parent 1231bf0ed2
commit e02059eff2
29 changed files with 9565 additions and 3 deletions

View File

@ -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 labelfieldset 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;
/** 整組 disableduploading 時不該再改) */
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>
);
}

View File

@ -0,0 +1,139 @@
/**
* ExpiredView Phase 0.8 F-T9 sub-1
*
*
* - wireframe-conversion.md §3.6 + §8.2Expired state
* - flow-conversion.md §6.3Job 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 端 statusfrontend 自己判斷 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("顯示過期 herorole=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();
});
});

View 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.3Job 7 bootstrap / polling expiredUI
* - feature-converter-integration.md §F4converter 7 GCUI
* - 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 patternalert 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>
);
}

View File

@ -0,0 +1,314 @@
/**
* FailedView Phase 0.8 F-T8
*
*
* - wireframe-conversion.md §3.5Failed state
* - flow-conversion.md §5.6failed + i18n fallback
* - feature-converter-integration.md §F5
*
*
* - + suggestions
* - unknown fallback + unknown suggestions
* - job_id 8 ID title
* - store.reset
* - a11yrole="alert" heroerror 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("getErrorSuggestionssuggestion1 找不到 → 直接停(不會跳號去拿 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"]);
});
});

View 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 §6error_code backend i18n key
*
* F-T8
* - hero + titlerole="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
* - IDwireframe §8 nice-to-haveF-T9
*
* a11y
* - hero `role="alert"` + `aria-live="assertive"`
* - error code `<code>` pre-formattedSR
* - 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 §7892 === 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;
// fallbackunknown 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 23
* - 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 必有 jobstore 切 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>
);
}

View File

@ -0,0 +1,166 @@
"use client";
/**
* FileDropzone /
*
* Phase 0.8 conversion ( .autoflow/03-design/wireframes/wireframe-conversion.md §4.1)
*
*
* - accept=.onnx,.tfliteReference imagesaccept=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>
);
}

View 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 enabledtaskName stem
* - .pt disabled
* - 600 MB disabled
* - chipUI + checked
* - store.startConversionargs 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 的 FileFile 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 inputdropzone 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 inputmultiple */
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);
// 預設 chipKL720 應該被選中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");
// 按鈕 enabledchip 預設 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 帶完整 argsfile / chip / taskName", () => {
const startSpy = vi
.spyOn(useConversionStore.getState(), "startConversion")
.mockImplementation(async () => {});
// 把 spy 寫回 storezustand 的 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");
});
});

View 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 §1initConversion request shape
* - i18n keys zh-Hant.ts / en.ts `conversion.upload.*`
*
* F-T4
* - source modeltaskNametargetChipref images
* - .onnx / .tflite 500 MBref images 100 + 10 MB
* - store.startConversion store uploading stateXHR 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 MBwireframe §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 metadataF-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>
);
}

View File

@ -0,0 +1,464 @@
/**
* ProcessingView Phase 0.8 F-T6
*
*
* - wireframe-conversion.md §3.3Processing state
* - flow-conversion.md §5.3queued 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 prefixunmount
*
*
* - polling store
* - i18n key parityi18n.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 或 -10UI 顯示與 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 加上 prefixunmount → 還原", () => {
setRunning("bie", 45);
expect(document.title).toBe(ORIGINAL_TITLE);
const { unmount } = renderView();
// 至少有 prefixzh 或 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 時顯示 bannerrole=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");
// 文案對齊 i18nzh-Hant 預設)
expect(banner).toHaveTextContent(
/您已有一個轉檔正在進行中,已切換至該任務/,
);
});
it("queued 狀態下也顯示 banneruser 收 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", "關閉提示");
});
});

View File

@ -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.3queued running succeeded/failed transitions
* - flow-conversion.md §4 state machineuiState='queued' / 'running'
* - api-conversion.md §2 GET response shapestatus / 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 indicatoronnx bie nef converter stage
* - Progress bar
* queued progress bar+ aria-busy
* running progress progress=null indeterminatepulse
* - ETA converter ETA/
* - 7
* - Tab titlemount () < title>unmount
*
*
* - beforeunload warningprocessing backend user polling
* - polling store F-T3 setTimeout + visibilitychangeUI 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=currentconverter 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[];
}
/* -------------------------------------------------------------------------- */
/* hooktab 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 prefixHMR 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 後設為 trueflow §6.1
const switchedFromActiveJob = useConversionStore(
(s) => s.switchedFromActiveJob,
);
// banner 是否已被使用者 dismisslocal statecomponent 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");
// 來源檔名 + 目標 chipjob 必存在於此 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 bannerflow §6.1
使 dismiss× dismiss local statestore.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>
);
}

View 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.5user
* - api-conversion.md §3 promote-to-modelsbody namedescription 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 dialoguser
* - 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 F6name 上限 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.1409 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>
);
}

View File

@ -0,0 +1,534 @@
/**
* SuccessView Phase 0.8 F-T7
*
*
* - wireframe-conversion.md §3.4 + §7.1Success 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 → 因為 disableddialog 不會關
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:00Zexpires = 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」雙語混塞 patternm4 已修)
expect(/\d+d\s*\d+h/.test(hint)).toBe(false);
});
it("已過期:兩按鈕都是真實 disabled <button> / 文字切「已過期」", () => {
// now = 2026-04-30T12:00:00Zexpires 在過去
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 可 focusdisabled 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> 不會渲染成 anchorhasAttribute("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");
});
});

View 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
* - cardsource filename target chipchecksum 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 trapRadix
* - 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-Hant6 23 /23 45 /45
* - en6d 23h/23h 45m/45m
*
* wireframe §3.4
*
* i18n keytemplates `{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 setIntervaltick 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.1toast「已加入模型庫」+「前往模型庫」連結
// 用 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 tagasChild— 不需 preventDefault
toast.success(t("conversion.success.download.toastStart"), {
description: t("conversion.success.download.toastHint"),
});
};
const handleStartNew = () => {
// store.reset() 切回 idlepolling / 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 focusSR disabled racemousedownmouseup
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>
);
}

View File

@ -0,0 +1,263 @@
/**
* UploadingView Phase 0.8 F-T5
*
*
* - wireframe-conversion.md §3.2Uploading state
* - flow-conversion.md §5.2uploading state
*
*
* - 0% / 50% / 100% progress bar +
* - ETA X < 5
* - AlertDialog store.cancelUpload dialog
* - mount addEventListener('beforeunload')unmount removeEventListener
* - + target chipjob
*
*
* - 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 防呆:不會 NaNprogress=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 referenceadd[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();
// 模擬 beforeunloadpreventDefault + 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();
});
});

View File

@ -0,0 +1,402 @@
"use client";
/**
* UploadingView Uploading stateXHR + ETA + + beforeunload
*
* Phase 0.8 conversion ( .autoflow/03-design/wireframes/wireframe-conversion.md §3.2 + §4.2)
*
*
* - flow-conversion.md §5.2uploading state / /
* - architecture/conversion.md §4.3.1streaming proxy
* XHR onprogress browserbackend
*
*
* - + target chip
* - Progress barstore.uploadProgress 0100%
* - {loaded MB} / {total MB} · {eta}
* - AlertDialog store.cancelUpload()
* - view `beforeunload` listenerstate / unmount
*
*
* - APIcancel conversion-store source of truth
* - toasttoast store / page
*
* UX
* - ETA EWMAalpha=0.3
* - eta < 5 3 5 2
* - upload loaded=0
*
* a11yreview M2
* - **** aria-live 50250ms 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 計算 ETAEWMA */
/* -------------------------------------------------------------------------- */
interface EtaState {
/** 平滑後速度bytes/secnull 代表還沒有足夠資料 */
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);
// 更新 refcaller 端的責任)— 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;
}
/* -------------------------------------------------------------------------- */
/* hookbeforeunload 警告(只在掛載期間有效) */
/* -------------------------------------------------------------------------- */
/**
* uploading `beforeunload` listenerunmount / 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 statepage.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 每 50250ms 變一次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 50250ms 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>
);
}

View 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` stubSuccessView / FailedView
* - `sonner` `toast` vi.mock success/error
* - `vi.useFakeTimers()` polling5s / 10s tick
* - `vi.spyOn(Date, "now")` SuccessView countdown
*
* mock XHR
* `initConversion` unit test XHR progress eventsignalabort
* e2e store mockflow 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 idletest 5
*
*
* - IdleForm.test
* - PromoteDialog / spinner / router pushSuccessView.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 inputmultiple */
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:00Zexpires_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 1Happy 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 1bootstrap → 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 是 microtaskflush 需 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 次 pollingadvance 10squeued 間隔) → mock 第 1 筆queued===
await vi.advanceTimersByTimeAsync(10_000);
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
// === 第 2 次 polling仍是 queued10s 間隔 → mock 第 2 筆running===
await vi.advanceTimersByTimeAsync(10_000);
expect(mockedApi.getConversion).toHaveBeenCalledTimes(2);
expect(
screen.getByTestId("conversion-processing").getAttribute("data-phase"),
).toBe("running");
// === 第 3 次 pollingrunning 5s 間隔 ===
await vi.advanceTimersByTimeAsync(5_000);
expect(mockedApi.getConversion).toHaveBeenCalledTimes(3);
// === 第 4 次 pollingrunning → 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();
// 後續不再 polling30s 內沒新 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 2Variant — .onnx + KL720 + 5 ref images */
/* ========================================================================== */
describe("E2E flow — variant5 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 成 succeededconverter 極快情境也合法 — 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 時是新 instancelocal 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 4polling 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 次 poll5s 後running 間隔)→ 成功 (running 70%) ===
await vi.advanceTimersByTimeAsync(5_000);
expect(mockedApi.getConversion).toHaveBeenCalledTimes(1);
expect(useConversionStore.getState().pollErrorCount).toBe(0);
// === 第 2 次 poll5s 後 → 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失敗後排 10s10 * 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失敗後排 20s10 * 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_THRESHOLDstore 預設 ≥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 5bootstrap 拿到 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();
});
});

View 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 nulltoast.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 的 useRouterjsdom 沒有 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 → 顯示 UploadingViewF-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 → 顯示 ProcessingViewF-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 → 顯示 ProcessingViewF-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 → 顯示 SuccessViewF-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 → 顯示 FailedViewF-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 → 顯示 ExpiredViewF-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(/網路錯誤|上傳失敗/);
// 描述包含 retryHintF-T9 M1toast 文案改成「請重新選擇檔案」對齊真實行為 —
// 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 各自獨立
// 這裡用同一個 storezustand 單例 — 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
// instancejsdom 共用 store 不影響「都會 bootstrap」這個基本性質
expect(bootstrap).toHaveBeenCalledTimes(2);
tabA.unmount();
tabB.unmount();
});
});

View 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-T5XHR + ETA +
* - queued/running <ProcessingView />F-T6stage indicator + progress + tab title
* - succeeded/failed/expired placeholderF-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 時 bootstrapstore 內部已有重複呼叫保護)
// Phase 0.8 conversion (見 .autoflow/03-design/flows/flow-conversion.md §5.1)
useEffect(() => {
void bootstrap();
}, [bootstrap]);
// F-T9 sub-3 — 監聽 store.errornull → 非 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 是條件 renderidle / uploading 各自分支IdleForm 在 idle ↔ uploading
// 之間會 unmount → re-mount。原本假設「同一棵 tree」是錯的。
// - 修法IdleForm 的 metadatachip / 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>
);
}

View File

@ -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();
});
});

View File

@ -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 },

View 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 pathmultipart 含 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 #1code 統一全小寫,對齊 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 Jobcompleted → 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",
);
});
});

View File

@ -0,0 +1,442 @@
/**
* Conversion API ClientPhase 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'` envelopeApiError 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.codeNETWORK_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 DTOsnake_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`165535 字串使用者編號)+ `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 pointclient fetch / XHR **
* - fetch redirect origin browser Authorization / cookie propagate
* - browser navigationanchor / location.href CORSServer-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`;
}

View File

@ -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 110 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 1030 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 110 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 510 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":

View File

@ -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 個 stateidle / 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": "轉檔約耗時 110 分鐘,依模型大小而定",
// 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-T5ETA / 取消確認 / 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": "上傳完成後將開始轉檔1030 秒不等)",
"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":
"通常需要 110 分鐘 · 你可以離開此頁面,回來時會自動更新進度",
"conversion.processing.background.title": "你可以放著不管",
"conversion.processing.background.l1":
"我們會在背景持續查詢進度(每 510 秒一次)",
"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-2banner dismiss 按鈕的 a11y label
"conversion.processing.bannerDismiss": "關閉提示",
// F-T6ProcessingViewqueued / running 共用 UI
// 任務描述對應 converter `stage` enumonnx / 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-1ExpiredView 補充說明 — 解釋為什麼過期 + 怎麼處理
"conversion.expired.subDescription":
"如需重新取得轉檔結果,請按下方按鈕重新提交一次。",
// F-T9 sub-1ExpiredView a11y
"conversion.expired.aria.alert": "轉檔結果已過期通知",
"conversion.expired.aria.startNew": "重新開始一次轉檔",
"conversion.expired.startNew": "重新轉檔",
// mobile hint≥ 500 MB 檔在手機上傳體驗差)
"conversion.mobileHint":
"Mobile 設備上傳大型模型可能不穩定,建議使用桌面版瀏覽器",
// ── ClustersF7 新增 stub──
"clusters.title": "叢集",
"clusters.subtitle": "把多台 Kneron 裝置組成平行推論叢集",

View File

@ -0,0 +1,170 @@
/**
* ETA Phase 0.8 F-T5 M1
*
* review M1F-T5 review UploadingView.test.tsx 13 ETA
* +pct99.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("自訂 alphaalpha=1 → 完全採 instantalpha=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);
// 第一次 EWMAprevSpeed=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/ssmoothed=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);
});
});

View 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 lifecycleperformance.nowuseRef
* - UploadingView lastSamplein ref
*
*
* - wireframe-conversion.md §3.2uploading state
* - architecture/conversion.md §4.3.1XHR onprogress browserbackend
*/
/** EWMA 平滑因子01越大越敏感、越小越平滑 */
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/secnull =
* @param instant bytes/sec
* @param alpha EWMA alpha01 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/secdtMs<=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 statefalse 代表 sample 太密 / 第一次 / 倒退caller 仍要更新 lastSample */
updated: boolean;
/** 新的平滑速度bytes/sec未更新時為 prevSpeed */
smoothedSpeed: number | null;
/** 新 ETA 秒數,未更新時為 null */
etaSeconds: number | null;
/** caller 是否該重置 lastSampleloaded 倒退時 = true */
resetSample: boolean;
}
/**
* progress EWMA
*
*
* - prevSample === null updated=falsecaller lastSample
* - loaded 退 resetSample=true null
* - dt < MIN_SAMPLE_INTERVAL_MS updated=false
* - instant EWMA ETA
*
* **** ref / state caller result
*
* @param prevSample samplenull =
* @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,
};
}

View 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() setTimeoutpolling 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 pathstate 經 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 flushcaptured 應已就位
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 → 維持 polling10s 間隔)", 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);
// 第二次:失敗後排 10s10 * 2^09s 內不該打
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);
// 第三次:失敗後排 20s10 * 2^115s 內不該打
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), 30scap — 共需 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 = falseuser 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 M1formDraft —— IdleForm metadata 提到 store */
/* ========================================================================== */
describe("formDraft (F-T9 M1)", () => {
it("初始值為 DEFAULT_FORM_DRAFTchip=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 上傳成功後 → 自動清 formDraftuser 完成提交)", 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);
});
});

View File

@ -0,0 +1,754 @@
/**
* Conversion Store visionA CloudPhase 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 client5 + ConversionAPIError
*
*
* - + polling source of truth
* - backend ConversionJob UI ConversionUIStatestate machine
* - polling lifecyclerecursive 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 listenerstore listener add/removeboot/destroy
* 5. ** code UI** UI `conversion.error.<code>` i18nF-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
* - Ametadata storeFile 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 banneri18n key
* `conversion.processing.bannerExisting`flow §6.1
*
* Reviewer flag `error.code === 'active_job_exists'`
* bootstrap error flag callerstartConversion 409
* bootstrap bootstrap user dismissreset
*/
switchedFromActiveJob: boolean;
/**
* Idle 稿F-T9 M1 IdleForm mount/unmount 使
*
*
* - initDEFAULT_FORM_DRAFTchip=KL720
* - 使 IdleForm updateFormDraft({...})
* - startConversion idle **** user
* - clearFormDraft()startConversion uploading / reset()
*/
formDraft: ConversionFormDraft;
}
export interface ConversionStoreActions {
/** bootapp start / 進入 `/conversion` 時呼叫,從 backend 拿 active job 重建 UI */
bootstrap: () => Promise<void>;
/** 啟動轉檔XHR upload + 進度 + AbortController上傳完成自動進 polling */
startConversion: (args: StartConversionArgs) => Promise<void>;
/** 開始 pollingstore 內部自己 setTimeout */
startPolling: () => void;
/** 停止 polling清 timer + listener */
stopPolling: () => void;
/** 加到模型庫;回傳 model_id 給 UI 跳頁 */
promoteToModels: (name?: string) => Promise<{ model_id: string }>;
/** 重置 UI 回 idleuser 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 10sconverter
* - 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 M1IdleForm 稿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 → 開始 pollingsucceeded / 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 / failedconverter 極快)
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_existsflow §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 statesucceeded 維持UI 顯示「已加入模型庫」label
// 清掉 error避免之前的錯誤訊息殘留
set({ error: null });
return result;
} catch (err) {
// 409 already importedjob 已 promote 過)由 UI 解讀錯誤訊息
const storeErr = toStoreError(err);
set({ error: storeErr });
throw err;
}
},
reset: () => {
// 中斷 upload / polling但**不**主動 cancel backend job
// §5.5 / §5.6success/failed 後按「開始新轉檔」不需要清 backendconverter 7 天 GC
if (uploadAbortController) {
uploadAbortController.abort();
uploadAbortController = null;
}
stopPollingInternal();
set({
...initialState,
});
},
cancelUpload: () => {
if (get().uiState !== "uploading") return;
if (uploadAbortController) {
uploadAbortController.abort();
uploadAbortController = null;
}
// 立刻切 idleinitConversion 的 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),
}));

View 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 codestore / 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";
/** 轉檔內部 stageconverter 回傳) */
export type ConversionStage = "onnx" | "bie" | "nef";
/** Job poll / active 共用 shape — `lib/api/conversion.ts` 的所有 fetch 都會 normalize 成這個 */
export interface ConversionJob {
/** UUIDconverter 配發) */
job_id: string;
status: ConversionStatus;
/** running 時才有 */
stage?: ConversionStage;
/** 0100整體進度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-256converter 提供時帶上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;
/** 0100 張 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;
}