test: cover edit, challenge, advanced settings, and gif preview flows

This commit is contained in:
plebeius
2026-03-08 16:48:01 +08:00
parent 90a01057f2
commit 2b32866b6b
4 changed files with 1144 additions and 0 deletions
@@ -0,0 +1,262 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
cacheGetItemMock: vi.fn(),
cacheSetItemMock: vi.fn(),
fetchMock: vi.fn(),
fileBuffer: new Uint8Array([71, 73, 70]).buffer,
imageShouldFail: false,
nextBlobId: 0,
toBlobReturnsNull: false,
xhrCalls: [] as string[],
xhrResponses: new Map<string, { response?: ArrayBuffer; status: number; statusText: string }>(),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
default: {
createInstance: () => ({
getItem: testState.cacheGetItemMock,
setItem: testState.cacheSetItemMock,
}),
},
}));
type HookResult = {
frameUrl: string | null;
status: 'failed' | 'idle' | 'loading' | 'ready';
};
let container: HTMLDivElement;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let createObjectUrlSpy: ReturnType<typeof vi.fn>;
let revokeObjectUrlSpy: ReturnType<typeof vi.fn>;
let root: Root;
class MockXMLHttpRequest {
onloadend: (() => void) | null = null;
response: ArrayBuffer | undefined;
responseType = '';
status = 0;
statusText = '';
private url = '';
open(_method: string, url: string) {
this.url = url;
}
send() {
testState.xhrCalls.push(this.url);
const response = testState.xhrResponses.get(this.url) ?? {
response: undefined,
status: 500,
statusText: 'Failed',
};
this.response = response.response;
this.status = response.status;
this.statusText = response.statusText;
queueMicrotask(() => this.onloadend?.());
}
}
class MockFileReader {
onload: (() => void) | null = null;
result: ArrayBuffer | null = null;
readAsArrayBuffer() {
this.result = testState.fileBuffer;
queueMicrotask(() => this.onload?.());
}
}
class MockImage {
height = 120;
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
width = 160;
set src(_value: string) {
queueMicrotask(() => {
if (testState.imageShouldFail) {
this.onerror?.();
} else {
this.onload?.();
}
});
}
}
const flushEffects = async (count = 6) => {
for (let index = 0; index < count; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
};
const dispatchRender = async (rootToRender: Root, element: React.ReactElement) => {
await act(async () => {
rootToRender.render(element);
});
await flushEffects();
};
const renderHook = async (source: unknown) => {
vi.resetModules();
const { default: useFetchGifFirstFrame } = await import('../use-fetch-gif-first-frame');
let latestState: HookResult = { frameUrl: null, status: 'idle' };
const HookHarness = ({ value }: { value: unknown }) => {
latestState = useFetchGifFirstFrame(value as any);
return createElement('div', {
'data-frame-url': latestState.frameUrl ?? '',
'data-status': latestState.status,
});
};
await dispatchRender(root, createElement(HookHarness, { value: source }));
return {
getState: () => latestState,
HookHarness,
useFetchGifFirstFrame,
};
};
describe('useFetchGifFirstFrame', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.cacheGetItemMock.mockReset();
testState.cacheSetItemMock.mockReset();
testState.fetchMock.mockReset();
testState.fileBuffer = new Uint8Array([71, 73, 70]).buffer;
testState.imageShouldFail = false;
testState.nextBlobId = 0;
testState.toBlobReturnsNull = false;
testState.xhrCalls = [];
testState.xhrResponses = new Map();
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.stubGlobal('fetch', testState.fetchMock);
vi.stubGlobal('FileReader', MockFileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest as unknown as typeof XMLHttpRequest);
createObjectUrlSpy = vi.fn(() => `blob:generated-${++testState.nextBlobId}`);
revokeObjectUrlSpy = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectUrlSpy,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectUrlSpy,
});
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation(((tagName: string, options?: ElementCreationOptions) => {
if (tagName === 'canvas') {
return {
getContext: () => ({
drawImage: vi.fn(),
}),
height: 0,
toBlob: (callback: (blob: Blob | null) => void) => callback(testState.toBlobReturnsNull ? null : new Blob(['frame'])),
width: 0,
} as unknown as HTMLCanvasElement;
}
return originalCreateElement(tagName, options);
}) as typeof document.createElement);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
consoleErrorSpy.mockRestore();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('returns an idle state when no url is provided', async () => {
const { getState } = await renderHook(undefined);
expect(getState()).toEqual({
frameUrl: null,
status: 'idle',
});
});
it('reuses a cached frame url when the cached asset is still fetchable', async () => {
testState.cacheGetItemMock.mockResolvedValueOnce('blob:cached-frame');
testState.fetchMock.mockResolvedValueOnce({ ok: true });
const { getState } = await renderHook('https://cdn.example/animated.gif');
expect(getState()).toEqual({
frameUrl: 'blob:cached-frame',
status: 'ready',
});
expect(testState.fetchMock).toHaveBeenCalledWith('blob:cached-frame');
expect(testState.xhrCalls).toEqual([]);
expect(testState.cacheSetItemMock).not.toHaveBeenCalled();
});
it('fetches, parses, and caches a generated frame when no usable cache entry exists', async () => {
testState.cacheGetItemMock.mockResolvedValueOnce(null);
testState.xhrResponses.set('https://cdn.example/animated.gif', {
response: new Uint8Array([71, 73, 70]).buffer,
status: 200,
statusText: 'OK',
});
const { getState } = await renderHook('https://cdn.example/animated.gif');
expect(getState().status).toBe('ready');
expect(getState().frameUrl).toBe('blob:generated-2');
expect(testState.xhrCalls).toEqual(['https://cdn.example/animated.gif']);
expect(testState.cacheSetItemMock).toHaveBeenCalledWith('https://cdn.example/animated.gif', 'blob:generated-2');
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:generated-1');
});
it('reads File inputs through FileReader and caches the result', async () => {
testState.cacheGetItemMock.mockResolvedValueOnce(null);
const source = new File(['gif-bytes'], 'reply.gif', { type: 'image/gif' });
const { getState } = await renderHook(source);
expect(getState().status).toBe('ready');
expect(getState().frameUrl).toBe('blob:generated-2');
expect(testState.xhrCalls).toEqual([]);
expect(testState.cacheSetItemMock).toHaveBeenCalledWith(source, 'blob:generated-2');
});
it('marks a failed url and short-circuits retries for the same source', async () => {
testState.cacheGetItemMock.mockResolvedValue(null);
testState.xhrResponses.set('https://cdn.example/broken.gif', {
response: undefined,
status: 500,
statusText: 'Broken',
});
const { HookHarness, getState } = await renderHook('https://cdn.example/broken.gif');
expect(getState()).toEqual({
frameUrl: null,
status: 'failed',
});
expect(testState.xhrCalls).toEqual(['https://cdn.example/broken.gif']);
const retryRootContainer = document.createElement('div');
document.body.appendChild(retryRootContainer);
const retryRoot = createRoot(retryRootContainer);
await dispatchRender(retryRoot, createElement(HookHarness, { value: 'https://cdn.example/broken.gif' }));
expect(testState.xhrCalls).toEqual(['https://cdn.example/broken.gif']);
act(() => retryRoot.unmount());
retryRootContainer.remove();
});
});