Files
tools/vue/toolkit/src/composables/browser/useImage/index.test.ts
T
robonen 59e995d0b5 feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
2026-06-08 15:51:16 +07:00

255 lines
6.4 KiB
TypeScript

import { effectScope, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useImage } from '.';
interface FakeImage {
src: string;
srcset: string;
sizes: string;
alt: string;
className: string;
loading: string;
crossOrigin: string | null;
referrerPolicy: string;
width: number;
height: number;
decoding: string;
fetchPriority: string;
isMap: boolean;
useMap: string;
onload: (() => void) | null;
onerror: ((err: unknown) => void) | null;
}
let lastImage: FakeImage | undefined;
// Decides whether the next constructed image "loads" or "errors".
let shouldFail = false;
function createImage(): FakeImage {
const img: FakeImage = {
src: '',
srcset: '',
sizes: '',
alt: '',
className: '',
loading: '',
crossOrigin: null,
referrerPolicy: '',
width: 0,
height: 0,
decoding: '',
fetchPriority: '',
isMap: false,
useMap: '',
onload: null,
onerror: null,
};
lastImage = img;
// Mimic the browser firing load/error asynchronously after src is set.
queueMicrotask(() => {
if (shouldFail)
img.onerror?.(new Error('load-error'));
else
img.onload?.();
});
return img;
}
function createFakeWindow(): Window {
const Image = function Image(): FakeImage {
return createImage();
} as unknown as new () => HTMLImageElement;
return { Image } as unknown as Window;
}
describe(useImage, () => {
let window: Window;
beforeEach(() => {
lastImage = undefined;
shouldFail = false;
window = createFakeWindow();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('loads an image and exposes the element as state', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window });
});
expect(result.isLoading.value).toBeTruthy();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBe(null);
await nextTick();
await nextTick();
expect(result.isLoading.value).toBeFalsy();
expect(result.isReady.value).toBeTruthy();
expect(result.error.value).toBe(null);
expect(result.state.value).toBe(lastImage);
expect(lastImage?.src).toBe('/cat.png');
scope.stop();
});
it('applies the provided image attributes', async () => {
const scope = effectScope();
scope.run(() => {
useImage(
{
src: '/cat.png',
srcset: '/cat-2x.png 2x',
sizes: '100vw',
alt: 'a cat',
class: 'rounded',
loading: 'lazy',
crossorigin: 'anonymous',
referrerPolicy: 'no-referrer',
width: 320,
height: 240,
decoding: 'async',
fetchPriority: 'high',
ismap: true,
usemap: '#map',
},
{ window },
);
});
await nextTick();
await nextTick();
expect(lastImage).toBeDefined();
expect(lastImage!.src).toBe('/cat.png');
expect(lastImage!.srcset).toBe('/cat-2x.png 2x');
expect(lastImage!.sizes).toBe('100vw');
expect(lastImage!.alt).toBe('a cat');
expect(lastImage!.className).toBe('rounded');
expect(lastImage!.loading).toBe('lazy');
expect(lastImage!.crossOrigin).toBe('anonymous');
expect(lastImage!.referrerPolicy).toBe('no-referrer');
expect(lastImage!.width).toBe(320);
expect(lastImage!.height).toBe(240);
expect(lastImage!.decoding).toBe('async');
expect(lastImage!.fetchPriority).toBe('high');
expect(lastImage!.isMap).toBeTruthy();
expect(lastImage!.useMap).toBe('#map');
scope.stop();
});
it('captures load errors', async () => {
shouldFail = true;
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/missing.png' }, { window });
});
await nextTick();
await nextTick();
expect(result.isLoading.value).toBeFalsy();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBeInstanceOf(Error);
expect(result.state.value).toBe(undefined);
scope.stop();
});
it('reloads when a reactive source changes', async () => {
const src = ref('/a.png');
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage(() => ({ src: src.value }), { window });
});
await nextTick();
await nextTick();
expect(lastImage?.src).toBe('/a.png');
expect(result.isReady.value).toBeTruthy();
src.value = '/b.png';
await nextTick();
// resetOnExecute clears state and flips loading back on
expect(result.isLoading.value).toBeTruthy();
expect(result.state.value).toBe(undefined);
await nextTick();
await nextTick();
expect(lastImage?.src).toBe('/b.png');
expect(result.isReady.value).toBeTruthy();
scope.stop();
});
it('does not set up a watcher for a plain options object', async () => {
const watchSpy = vi.fn();
const scope = effectScope();
scope.run(() => {
const result = useImage({ src: '/static.png' }, { window });
// execute is the only thing a reload would call; spy after initial run
result.execute = watchSpy;
});
await nextTick();
await nextTick();
expect(watchSpy).not.toHaveBeenCalled();
scope.stop();
});
it('does not call execute immediately when immediate is false', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window, immediate: false });
});
expect(result.isLoading.value).toBeFalsy();
expect(lastImage).toBeUndefined();
await result.execute();
expect(lastImage?.src).toBe('/cat.png');
expect(result.isReady.value).toBeTruthy();
scope.stop();
});
it('rejects when no Image constructor is available (SSR path)', async () => {
const onError = vi.fn();
// A window without an Image constructor stands in for a non-DOM environment.
const ssrWindow = {} as unknown as Window;
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window: ssrWindow, onError });
});
await nextTick();
await nextTick();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBeInstanceOf(Error);
expect(onError).toHaveBeenCalledTimes(1);
scope.stop();
});
});