// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ImageThumbnail } from "./ImageThumbnail";
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
// --- Observer stubs: fire "intersecting" immediately on observe ---
class MockIntersectionObserver {
private cb: IntersectionObserverCallback;
constructor(cb: IntersectionObserverCallback) {
this.cb = cb;
}
observe() {
this.cb(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
disconnect() {}
unobserve() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
class MockResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
}
// --- Image stub: captures instances so tests fire load/error deterministically ---
class MockImage {
static instances: MockImage[] = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 0;
naturalHeight = 0;
src = "";
constructor() {
MockImage.instances.push(this);
}
}
const originalIO = globalThis.IntersectionObserver;
const originalRO = globalThis.ResizeObserver;
const originalImage = globalThis.Image;
let host: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
globalThis.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.Image = MockImage as unknown as typeof Image;
MockImage.instances = [];
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
globalThis.IntersectionObserver = originalIO;
globalThis.ResizeObserver = originalRO;
globalThis.Image = originalImage;
document.body.innerHTML = "";
});
function render(props: { imageSrc: string; label?: string; labelColor?: string }) {
root = createRoot(host);
act(() => {
root!.render(
,
);
});
}
function lastProbe(): MockImage {
const probe = MockImage.instances.at(-1);
expect(probe).toBeDefined();
return probe!;
}
/** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
function expectFirstTileSrc(expectedSrc: string): void {
const imgs = [...host.querySelectorAll("img")];
expect(imgs.length).toBeGreaterThanOrEqual(1);
expect(imgs[0].getAttribute("src")).toBe(expectedSrc);
}
describe("ImageThumbnail", () => {
it("shows the loading shimmer before the image resolves", () => {
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
expect(host.querySelector(".animate-pulse")).not.toBeNull();
expect(host.querySelectorAll("img").length).toBe(0);
});
it("probes the resolved src and renders repeated object-cover tiles on load", () => {
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
const probe = lastProbe();
expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
act(() => {
probe.naturalWidth = 1920;
probe.naturalHeight = 1080;
probe.onload?.();
});
const imgs = [...host.querySelectorAll("img")];
expect(imgs.length).toBeGreaterThanOrEqual(1);
for (const img of imgs) {
expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png");
expect(img.className).toContain("object-cover");
}
expect(host.querySelector(".animate-pulse")).toBeNull();
});
it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
act(() => lastProbe().onerror?.());
expect(host.querySelectorAll("img").length).toBe(0);
expect(host.querySelector(".animate-pulse")).toBeNull();
});
it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
const probe = lastProbe();
act(() => {
// naturalWidth stays 0 — SVG with no width/height attribute
probe.onload?.();
});
expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg");
expect(host.querySelector(".animate-pulse")).toBeNull();
});
it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
// Some browser/sandbox environments fire onerror for SVGs even though the
//
element itself can render the file — we must not blank the strip.
render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
act(() => lastProbe().onerror?.());
expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
expect(host.querySelector(".animate-pulse")).toBeNull();
});
it("renders the label above the strip when provided", () => {
render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
act(() => {
const probe = lastProbe();
probe.naturalWidth = 100;
probe.naturalHeight = 100;
probe.onload?.();
});
const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero");
expect(label).toBeDefined();
expect(label!.closest(".z-10")).not.toBeNull();
});
});