jim800121chen af44a9f9ba feat(frontend): flash 完成工作區入口醒目化 + 影片上限 200→500MB
驗收後兩個 UX 微調:
- flash 載入模型完成後「開啟工作區」入口不明顯、使用者要自己找 → Button
  改 size=lg + ArrowRight 前往圖示 + ring-primary/40 highlight(設計系統
  token、不裸色碼)。保留手動導航(點擊才去、無自動 router.push)、serial
  空 disable + 離線 disable gate 維持
- 影片上傳上限 MAX_VIDEO_BYTES 200→500MB + i18n 兩語系 hint 文案。影片
  upload timeout 本來就是 0(不限、後端決定)→ 500MB 不會撞 timeout

Reviewer 通過(0C/0M/0Mi/1Sug、timeout 獨立驗證、i18n 無殘留 200MB)。
tsc/eslint 0、vitest 39 passed(499/500/501MB 邊界 mock size 無 OOM +
醒目化樣式/行為)、next build 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:17:11 +08:00

270 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* media.ts 單元測試(塊 3
*
* 驗證:
* - validateImageFile / validateVideoFile / validateBatchFiles 各種通過 / 失敗路徑
* - buildBatchImageUrl 組路徑(含 / 不含 cacheBust
* - uploadImage / uploadBatchImages / seekVideo 打對的路徑與 FormData 欄位
* mock XMLHttpRequest + api.post
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
MAX_BATCH_IMAGES,
MAX_VIDEO_BYTES,
VIDEO_FALLBACK_FPS,
buildBatchImageUrl,
frameToSeekSeconds,
seekVideo,
uploadBatchImages,
uploadImage,
validateBatchFiles,
validateImageFile,
validateVideoFile,
} from "./media";
function makeFile(name: string, sizeBytes = 1024): File {
const blob = new Blob([new Uint8Array(sizeBytes)], { type: "application/octet-stream" });
return new File([blob], name);
}
/**
* 建立「宣稱」指定 size 的 File但不實際配置該大小的記憶體。
* 用於大檔邊界測試500 MB 若真配置 Uint8Array 會 OOM——只覆寫 File.size getter。
*/
function makeSizedFile(name: string, sizeBytes: number): File {
const file = new File([new Blob([new Uint8Array(8)])], name);
Object.defineProperty(file, "size", { value: sizeBytes, configurable: true });
return file;
}
describe("media validation", () => {
it("圖片JPG/PNG 通過,其他型別擋 TYPE", () => {
expect(validateImageFile(makeFile("a.jpg"))).toBeNull();
expect(validateImageFile(makeFile("a.jpeg"))).toBeNull();
expect(validateImageFile(makeFile("a.PNG"))).toBeNull();
expect(validateImageFile(makeFile("a.gif"))?.code).toBe("TYPE");
expect(validateImageFile(makeFile("a.mp4"))?.code).toBe("TYPE");
});
it("圖片:超過 20 MB 擋 SIZE", () => {
const big = makeFile("a.jpg", 21 * 1024 * 1024);
expect(validateImageFile(big)?.code).toBe("SIZE");
});
it("影片MP4/AVI/MOV/MPEG 通過,其他擋 TYPE", () => {
expect(validateVideoFile(makeFile("v.mp4"))).toBeNull();
expect(validateVideoFile(makeFile("v.mov"))).toBeNull();
expect(validateVideoFile(makeFile("v.mpeg"))).toBeNull();
expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE");
});
it("影片:上限為 500 MB常數校驗", () => {
expect(MAX_VIDEO_BYTES).toBe(500 * 1024 * 1024);
});
it("影片499 MB 通過,剛好 500 MB 通過501 MB 擋 SIZE邊界", () => {
const mb = 1024 * 1024;
expect(validateVideoFile(makeSizedFile("v.mp4", 499 * mb))).toBeNull();
// 剛好等於上限validateVideoFile 用 > 判斷,等於上限應通過
expect(validateVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull();
expect(validateVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe("SIZE");
});
it("批次:空陣列 → EMPTY超過 50 → COUNT含非圖 → TYPE", () => {
expect(validateBatchFiles([])?.code).toBe("EMPTY");
const tooMany = Array.from({ length: MAX_BATCH_IMAGES + 1 }, (_, i) =>
makeFile(`img${i}.jpg`),
);
expect(validateBatchFiles(tooMany)?.code).toBe("COUNT");
expect(
validateBatchFiles([makeFile("ok.jpg"), makeFile("bad.txt")])?.code,
).toBe("TYPE");
expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull();
});
});
describe("frameToSeekSeconds", () => {
it("有 durationSeconds + totalFrames → 用 duration 換算(不依賴 fps", () => {
// 100 frames / 10 秒 → frame 50 = 5 秒
expect(frameToSeekSeconds(50, 100, 10)).toBe(5);
// frame 0 = 0 秒
expect(frameToSeekSeconds(0, 100, 10)).toBe(0);
// 換算與 fps 常數無關:即使 fps 常數是 15這裡仍用 duration
expect(frameToSeekSeconds(30, 60, 12)).toBe(6);
});
it("缺 durationSeconds → 退用具名 fps 常數frame / VIDEO_FALLBACK_FPS", () => {
expect(frameToSeekSeconds(30, 100, undefined)).toBe(30 / VIDEO_FALLBACK_FPS);
// duration <= 0 也視為缺
expect(frameToSeekSeconds(30, 100, 0)).toBe(30 / VIDEO_FALLBACK_FPS);
});
it("缺 totalFrames → 退用 fps 常數", () => {
expect(frameToSeekSeconds(45, undefined, 10)).toBe(45 / VIDEO_FALLBACK_FPS);
});
it("負 frame → clamp 為 0", () => {
expect(frameToSeekSeconds(-5, 100, 10)).toBe(0);
expect(frameToSeekSeconds(-5, undefined, undefined)).toBe(0);
});
});
describe("buildBatchImageUrl", () => {
// jsdom 有 window 且未設 NEXT_PUBLIC_API_BASE → getApiBaseUrl 回 ""(同 origin 相對路徑)
it("組出 /api/media/batch-images/:index無 cacheBust", () => {
expect(buildBatchImageUrl(3)).toBe("/api/media/batch-images/3");
});
it("帶 cacheBust 加上 _t query", () => {
expect(buildBatchImageUrl(0, "abc")).toBe("/api/media/batch-images/0?_t=abc");
});
});
/* -------------------------------------------------------------------------- */
/* Uploadmock XMLHttpRequest */
/* -------------------------------------------------------------------------- */
interface FakeXHR {
method?: string;
url?: string;
withCredentials?: boolean;
timeout?: number;
sent?: FormData;
status: number;
responseText: string;
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
onload: (() => void) | null;
onerror: (() => void) | null;
ontimeout: (() => void) | null;
open(method: string, url: string): void;
send(body: FormData): void;
abort(): void;
}
let lastXhr: FakeXHR | null = null;
function installXhrMock(responder: (xhr: FakeXHR) => void) {
// 以工廠回傳 plain objectclosure over `xhr`),避免 class + `this` 別名no-this-alias
function XHRMock(this: unknown) {
const xhr: FakeXHR = {
withCredentials: false,
timeout: 0,
status: 200,
responseText: "",
upload: { onprogress: null },
onload: null,
onerror: null,
ontimeout: null,
open(method: string, url: string) {
xhr.method = method;
xhr.url = url;
},
send(body: FormData) {
xhr.sent = body;
queueMicrotask(() => responder(xhr));
},
abort() {},
};
lastXhr = xhr;
return xhr;
}
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest =
XHRMock as unknown as typeof XMLHttpRequest;
}
describe("uploadImage / uploadBatchImagesXHR mock", () => {
const origXHR = globalThis.XMLHttpRequest;
beforeEach(() => {
lastXhr = null;
});
afterEach(() => {
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
});
it("uploadImagePOST /api/media/upload/image、帶 deviceId+file、回傳 data", async () => {
installXhrMock((xhr) => {
xhr.status = 200;
xhr.responseText = JSON.stringify({
success: true,
data: { streamUrl: "/api/camera/stream", sourceType: "image", filename: "a.jpg" },
});
xhr.onload?.();
});
const res = await uploadImage("dev-1", makeFile("a.jpg"));
expect(res.sourceType).toBe("image");
expect(res.streamUrl).toBe("/api/camera/stream");
expect(lastXhr?.method).toBe("POST");
expect(lastXhr?.url).toContain("/api/media/upload/image");
expect(lastXhr?.withCredentials).toBe(true);
expect(lastXhr?.sent?.get("deviceId")).toBe("dev-1");
expect(lastXhr?.sent?.get("file")).toBeInstanceOf(File);
});
it("uploadBatchImages多檔以 files 欄位附加", async () => {
installXhrMock((xhr) => {
xhr.status = 200;
xhr.responseText = JSON.stringify({
success: true,
data: { streamUrl: "/api/camera/stream", sourceType: "batch_image", totalImages: 2 },
});
xhr.onload?.();
});
const res = await uploadBatchImages("dev-1", [makeFile("a.jpg"), makeFile("b.png")]);
expect(res.totalImages).toBe(2);
expect(lastXhr?.url).toContain("/api/media/upload/batch-images");
expect(lastXhr?.sent?.getAll("files")).toHaveLength(2);
});
it("非 2xx 且有 error envelope → 拋 ApiError保留 code", async () => {
installXhrMock((xhr) => {
xhr.status = 400;
xhr.responseText = JSON.stringify({
success: false,
error: { code: "BAD_REQUEST", message: "only JPG/PNG files are supported" },
});
xhr.onload?.();
});
await expect(uploadImage("dev-1", makeFile("a.jpg"))).rejects.toMatchObject({
code: "BAD_REQUEST",
status: 400,
});
});
it("回報上傳進度", async () => {
installXhrMock((xhr) => {
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent);
xhr.status = 200;
xhr.responseText = JSON.stringify({
success: true,
data: { streamUrl: "/s", sourceType: "image" },
});
xhr.onload?.();
});
const onProgress = vi.fn();
await uploadImage("dev-1", makeFile("a.jpg"), { onProgress });
expect(onProgress).toHaveBeenCalledWith(50);
});
});
describe("seekVideo", () => {
it("POST /api/media/seekbody 帶 timeSeconds", async () => {
const apiModule = await import("@/lib/api");
const spy = vi
.spyOn(apiModule.api, "post")
.mockResolvedValue({ seekTo: 3, frameOffset: 45 } as never);
const res = await seekVideo(3);
expect(spy).toHaveBeenCalledWith("/api/media/seek", { timeSeconds: 3 });
expect(res).toEqual({ seekTo: 3, frameOffset: 45 });
spy.mockRestore();
});
});