162 lines
6 KiB
TypeScript
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();
|
|
});
|
|
});
|