memos/web/tests/media-metadata-service.test.ts

162 lines
6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
parseExif: vi.fn(),
}));
vi.mock("exifr", () => ({
parse: mocks.parseExif,
}));
import { mediaMetadataService } from "@/components/MemoEditor/services/mediaMetadataService";
describe("mediaMetadataService", () => {
beforeEach(() => {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:test-media"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
vi.stubGlobal(
"createImageBitmap",
vi.fn(async () => ({ width: 640, height: 480, close: vi.fn() })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("normalizes selected EXIF fields without inventing a timezone", async () => {
mocks.parseExif.mockResolvedValue({
Make: " Apple\0 ",
Model: "iPhone",
LensModel: "Main Camera",
Orientation: 6,
ExifImageWidth: 4000,
ExifImageHeight: 3000,
DateTimeOriginal: "2026:08:10 14:32:18",
SubSecTimeOriginal: "123",
latitude: 1.3521,
longitude: 103.8198,
GPSAltitude: 18.4,
GPSAltitudeRef: 0,
FNumber: 1.78,
ExposureTime: 1 / 120,
ISO: 64,
FocalLength: 6.86,
});
const metadata = await mediaMetadataService.extract(new File([new Uint8Array([1, 2, 3])], "photo.jpg", { type: "image/jpeg" }));
expect(metadata?.width).toBe(3000);
expect(metadata?.height).toBe(4000);
expect(metadata?.details.case).toBe("photo");
if (metadata?.details.case !== "photo") throw new Error("expected photo metadata");
expect(metadata.details.value.captureTime?.localDateTime).toBe("2026-08-10T14:32:18.123");
expect(metadata.details.value.captureTime?.utcOffset).toBeUndefined();
expect(metadata.details.value.location).toMatchObject({ latitude: 1.3521, longitude: 103.8198, altitudeMeters: 18.4 });
expect(metadata.details.value).toMatchObject({
sourceExifOrientation: 6,
cameraMake: "Apple",
cameraModel: "iPhone",
lensModel: "Main Camera",
fNumber: 1.78,
exposureTimeSeconds: 1 / 120,
iso: 64,
focalLengthMm: 6.86,
});
});
it("preserves a valid EXIF UTC offset", async () => {
mocks.parseExif.mockResolvedValue({
DateTimeOriginal: "2026:08:10 14:32:18\0 ",
OffsetTimeOriginal: "+08:00\0",
});
const metadata = await mediaMetadataService.extract(new File([new Uint8Array([1])], "photo.jpg", { type: "image/jpeg" }));
expect(metadata?.details.case).toBe("photo");
if (metadata?.details.case !== "photo") throw new Error("expected photo metadata");
expect(metadata.details.value.captureTime?.utcOffset).toBe("+08:00");
});
it("drops incomplete raw GPS coordinates instead of guessing their direction", async () => {
mocks.parseExif.mockResolvedValue({
GPSLatitude: [1, 21, 7.56],
GPSLongitude: [103, 49, 11.28],
GPSLongitudeRef: "E",
});
const metadata = await mediaMetadataService.extract(new File([new Uint8Array([1])], "photo.jpg", { type: "image/jpeg" }));
expect(metadata?.details.case).toBeUndefined();
expect(metadata?.width).toBe(640);
expect(metadata?.height).toBe(480);
});
it("falls back to browser dimensions when EXIF parsing fails", async () => {
mocks.parseExif.mockRejectedValue(new Error("unsupported image"));
const metadata = await mediaMetadataService.extract(new File([new Uint8Array([1])], "photo.webp", { type: "image/webp" }));
expect(metadata?.width).toBe(640);
expect(metadata?.height).toBe(480);
expect(metadata?.details.case).toBeUndefined();
});
it("reads video dimensions and duration and revokes the object URL", async () => {
const createElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => {
const element = createElement(tagName, options);
if (tagName.toLowerCase() !== "video") return element;
Object.defineProperties(element, {
videoWidth: { configurable: true, value: 1920 },
videoHeight: { configurable: true, value: 1080 },
duration: { configurable: true, value: 12.5 },
});
vi.spyOn(element as HTMLVideoElement, "load").mockImplementation(() => {
queueMicrotask(() => element.dispatchEvent(new Event("loadedmetadata")));
});
return element;
});
const metadata = await mediaMetadataService.extract(new File([new Uint8Array([1])], "clip.mp4", { type: "video/mp4" }));
expect(metadata?.width).toBe(1920);
expect(metadata?.height).toBe(1080);
expect(metadata?.details.case).toBe("video");
if (metadata?.details.case !== "video") throw new Error("expected video metadata");
expect(metadata.details.value.durationSeconds).toBe(12.5);
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:test-media");
});
it("times out video extraction, revokes the object URL, and returns no metadata", async () => {
vi.useFakeTimers();
const createElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string, options?: ElementCreationOptions) => {
const element = createElement(tagName, options);
if (tagName.toLowerCase() === "video") {
vi.spyOn(element as HTMLVideoElement, "load").mockImplementation(() => undefined);
}
return element;
});
const extraction = mediaMetadataService.extract(new File([new Uint8Array([1])], "clip.mp4", { type: "video/mp4" }));
await vi.advanceTimersByTimeAsync(5_000);
await expect(extraction).resolves.toBeUndefined();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:test-media");
});
it("ignores non-media files", async () => {
const metadata = await mediaMetadataService.extract(new File(["hello"], "notes.txt", { type: "text/plain" }));
expect(metadata).toBeUndefined();
expect(mocks.parseExif).not.toHaveBeenCalled();
});
});