驗收後兩個 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>
270 lines
9.2 KiB
TypeScript
270 lines
9.2 KiB
TypeScript
/**
|
||
* 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");
|
||
});
|
||
});
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Upload(mock 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 object(closure 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 / uploadBatchImages(XHR mock)", () => {
|
||
const origXHR = globalThis.XMLHttpRequest;
|
||
beforeEach(() => {
|
||
lastXhr = null;
|
||
});
|
||
afterEach(() => {
|
||
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
|
||
});
|
||
|
||
it("uploadImage:POST /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/seek,body 帶 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();
|
||
});
|
||
});
|