|
| 1 | +// @vitest-environment happy-dom |
| 2 | + |
| 3 | +import React, { act } from "react"; |
| 4 | +import { createRoot, type Root } from "react-dom/client"; |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 6 | +import { ImageThumbnail } from "./ImageThumbnail"; |
| 7 | + |
| 8 | +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { |
| 9 | + configurable: true, |
| 10 | + value: true, |
| 11 | +}); |
| 12 | + |
| 13 | +// --- Observer stubs: fire "intersecting" immediately on observe --- |
| 14 | +class MockIntersectionObserver { |
| 15 | + private cb: IntersectionObserverCallback; |
| 16 | + constructor(cb: IntersectionObserverCallback) { |
| 17 | + this.cb = cb; |
| 18 | + } |
| 19 | + observe() { |
| 20 | + this.cb( |
| 21 | + [{ isIntersecting: true } as IntersectionObserverEntry], |
| 22 | + this as unknown as IntersectionObserver, |
| 23 | + ); |
| 24 | + } |
| 25 | + disconnect() {} |
| 26 | + unobserve() {} |
| 27 | + takeRecords(): IntersectionObserverEntry[] { |
| 28 | + return []; |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +class MockResizeObserver { |
| 33 | + observe() {} |
| 34 | + disconnect() {} |
| 35 | + unobserve() {} |
| 36 | +} |
| 37 | + |
| 38 | +// --- Image stub: captures instances so tests fire load/error deterministically --- |
| 39 | +class MockImage { |
| 40 | + static instances: MockImage[] = []; |
| 41 | + onload: (() => void) | null = null; |
| 42 | + onerror: (() => void) | null = null; |
| 43 | + naturalWidth = 0; |
| 44 | + naturalHeight = 0; |
| 45 | + src = ""; |
| 46 | + constructor() { |
| 47 | + MockImage.instances.push(this); |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +const originalIO = globalThis.IntersectionObserver; |
| 52 | +const originalRO = globalThis.ResizeObserver; |
| 53 | +const originalImage = globalThis.Image; |
| 54 | + |
| 55 | +let host: HTMLDivElement; |
| 56 | +let root: Root | null = null; |
| 57 | + |
| 58 | +beforeEach(() => { |
| 59 | + globalThis.IntersectionObserver = |
| 60 | + MockIntersectionObserver as unknown as typeof IntersectionObserver; |
| 61 | + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; |
| 62 | + globalThis.Image = MockImage as unknown as typeof Image; |
| 63 | + MockImage.instances = []; |
| 64 | + host = document.createElement("div"); |
| 65 | + document.body.append(host); |
| 66 | +}); |
| 67 | + |
| 68 | +afterEach(() => { |
| 69 | + act(() => root?.unmount()); |
| 70 | + root = null; |
| 71 | + globalThis.IntersectionObserver = originalIO; |
| 72 | + globalThis.ResizeObserver = originalRO; |
| 73 | + globalThis.Image = originalImage; |
| 74 | + document.body.innerHTML = ""; |
| 75 | +}); |
| 76 | + |
| 77 | +function render(props: { imageSrc: string; label?: string; labelColor?: string }) { |
| 78 | + root = createRoot(host); |
| 79 | + act(() => { |
| 80 | + root!.render( |
| 81 | + <ImageThumbnail |
| 82 | + imageSrc={props.imageSrc} |
| 83 | + label={props.label ?? ""} |
| 84 | + labelColor={props.labelColor ?? "#fff"} |
| 85 | + />, |
| 86 | + ); |
| 87 | + }); |
| 88 | +} |
| 89 | + |
| 90 | +function lastProbe(): MockImage { |
| 91 | + const probe = MockImage.instances.at(-1); |
| 92 | + expect(probe).toBeDefined(); |
| 93 | + return probe!; |
| 94 | +} |
| 95 | + |
| 96 | +/** Assert at least one tile rendered and the first tile serves `expectedSrc`. */ |
| 97 | +function expectFirstTileSrc(expectedSrc: string): void { |
| 98 | + const imgs = [...host.querySelectorAll("img")]; |
| 99 | + expect(imgs.length).toBeGreaterThanOrEqual(1); |
| 100 | + expect(imgs[0].getAttribute("src")).toBe(expectedSrc); |
| 101 | +} |
| 102 | + |
| 103 | +describe("ImageThumbnail", () => { |
| 104 | + it("shows the loading shimmer before the image resolves", () => { |
| 105 | + render({ imageSrc: "/api/projects/p/preview/assets/pic.png" }); |
| 106 | + expect(host.querySelector(".animate-pulse")).not.toBeNull(); |
| 107 | + expect(host.querySelectorAll("img").length).toBe(0); |
| 108 | + }); |
| 109 | + |
| 110 | + it("probes the resolved src and renders repeated object-cover tiles on load", () => { |
| 111 | + render({ imageSrc: "/api/projects/p/preview/assets/pic.png" }); |
| 112 | + const probe = lastProbe(); |
| 113 | + expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png"); |
| 114 | + |
| 115 | + act(() => { |
| 116 | + probe.naturalWidth = 1920; |
| 117 | + probe.naturalHeight = 1080; |
| 118 | + probe.onload?.(); |
| 119 | + }); |
| 120 | + |
| 121 | + const imgs = [...host.querySelectorAll("img")]; |
| 122 | + expect(imgs.length).toBeGreaterThanOrEqual(1); |
| 123 | + for (const img of imgs) { |
| 124 | + expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png"); |
| 125 | + expect(img.className).toContain("object-cover"); |
| 126 | + } |
| 127 | + expect(host.querySelector(".animate-pulse")).toBeNull(); |
| 128 | + }); |
| 129 | + |
| 130 | + it("drops the shimmer and renders no tiles when a raster image fails to load", () => { |
| 131 | + render({ imageSrc: "/api/projects/p/preview/assets/missing.png" }); |
| 132 | + act(() => lastProbe().onerror?.()); |
| 133 | + expect(host.querySelectorAll("img").length).toBe(0); |
| 134 | + expect(host.querySelector(".animate-pulse")).toBeNull(); |
| 135 | + }); |
| 136 | + |
| 137 | + it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => { |
| 138 | + render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" }); |
| 139 | + const probe = lastProbe(); |
| 140 | + |
| 141 | + act(() => { |
| 142 | + // naturalWidth stays 0 — SVG with no width/height attribute |
| 143 | + probe.onload?.(); |
| 144 | + }); |
| 145 | + |
| 146 | + expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg"); |
| 147 | + expect(host.querySelector(".animate-pulse")).toBeNull(); |
| 148 | + }); |
| 149 | + |
| 150 | + it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => { |
| 151 | + // Some browser/sandbox environments fire onerror for SVGs even though the |
| 152 | + // <img> element itself can render the file — we must not blank the strip. |
| 153 | + render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" }); |
| 154 | + |
| 155 | + act(() => lastProbe().onerror?.()); |
| 156 | + |
| 157 | + expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg"); |
| 158 | + expect(host.querySelector(".animate-pulse")).toBeNull(); |
| 159 | + }); |
| 160 | + |
| 161 | + it("renders the label above the strip when provided", () => { |
| 162 | + render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" }); |
| 163 | + act(() => { |
| 164 | + const probe = lastProbe(); |
| 165 | + probe.naturalWidth = 100; |
| 166 | + probe.naturalHeight = 100; |
| 167 | + probe.onload?.(); |
| 168 | + }); |
| 169 | + const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero"); |
| 170 | + expect(label).toBeDefined(); |
| 171 | + expect(label!.closest(".z-10")).not.toBeNull(); |
| 172 | + }); |
| 173 | +}); |
0 commit comments