/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl } from '../api/adminApiClient'; import { AdminEditorAssetPreviewDialog } from './AdminEditorAssetMedia'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), isAdminApiError: vi.fn( (error: unknown) => typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number', ), })); afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } test('角色动作预览跨窗口回播时复用父级换签缓存并等待目标帧就绪', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-07-04T10:50:00Z')); const frameObjectKeys = [ 'generated-animations/editor/task-cache/frame00.png', 'generated-animations/editor/task-cache/frame01.png', 'generated-animations/editor/task-cache/frame02.png', 'generated-animations/editor/task-cache/frame03.png', 'generated-animations/editor/task-cache/frame04.png', ] as const; const delayedFrame = createDeferred<{ read: { objectKey: string; signedUrl: string; expiresAt: string; }; }>(); vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { const objectKey = request.objectKey ?? ''; if (objectKey === frameObjectKeys[3]) { return delayedFrame.promise; } return Promise.resolve({ read: { objectKey, signedUrl: `https://signed.example.com/${objectKey}`, expiresAt: '2026-07-04T11:00:00Z', }, }); }); render( ({ imageSrc: `/${objectKey}`, objectKey, width: 192, height: 256, })), imageSequenceDurationMs: 500, }} token="admin-token" onClose={vi.fn()} />, ); const dialog = screen.getByRole('dialog', { name: '素材预览' }); fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); await act(async () => { await vi.advanceTimersByTimeAsync(120); }); const initialFrames = Array.from( dialog.querySelectorAll( '.admin-asset-query-sequence-frame', ), ); expect(initialFrames).toHaveLength(3); initialFrames.forEach((frame) => fireEvent.load(frame)); fireEvent.click(within(dialog).getByRole('button', { name: '播放角色动作' })); for (const elapsedMs of [100, 40, 60, 40, 60]) { await act(async () => { await vi.advanceTimersByTimeAsync(elapsedMs); }); } expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { objectKey: frameObjectKeys[3], expireSeconds: 300, }); expect(resolveVisibleFrameSrc(dialog)).toBe( `https://signed.example.com/${frameObjectKeys[2]}`, ); await act(async () => { delayedFrame.resolve({ read: { objectKey: frameObjectKeys[3], signedUrl: `https://signed.example.com/${frameObjectKeys[3]}`, expiresAt: '2026-07-04T11:00:00Z', }, }); await Promise.resolve(); }); const fourthFrame = resolveFrameByObjectKey(dialog, frameObjectKeys[3]); expect(fourthFrame?.style.opacity).toBe('0'); expect(resolveVisibleFrameSrc(dialog)).toBe( `https://signed.example.com/${frameObjectKeys[2]}`, ); fireEvent.load(fourthFrame!); fireEvent.load(resolveFrameByObjectKey(dialog, frameObjectKeys[4])!); for (let index = 0; index < 3; index += 1) { await act(async () => { await vi.advanceTimersByTimeAsync(100); }); } expect(within(dialog).getByText('1/5')).toBeTruthy(); expect(resolveVisibleFrameSrc(dialog)).toBe( `https://signed.example.com/${frameObjectKeys[0]}`, ); expect( vi .mocked(getAdminAssetReadUrl) .mock.calls.filter( ([, request]) => request.objectKey === frameObjectKeys[0], ), ).toHaveLength(1); expect( dialog.querySelectorAll('.admin-asset-query-sequence-frame').length, ).toBeLessThanOrEqual(3); }); test('角色动作预览打开超过五分钟后回绕播放会在过期窗口内自动换签', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); const frameObjectKeys = Array.from( { length: 5 }, (_, index) => `generated-animations/editor/task-expiry/frame0${index}.png`, ); const requestCounts = new Map(); vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { const objectKey = request.objectKey ?? ''; const requestCount = (requestCounts.get(objectKey) ?? 0) + 1; requestCounts.set(objectKey, requestCount); return Promise.resolve({ read: { objectKey, signedUrl: `https://signed.example.com/v${requestCount}/${objectKey}`, expiresAt: requestCount === 1 ? '2099-01-01T00:05:00Z' : '2099-01-01T00:10:00Z', }, }); }); render( ({ imageSrc: `/${objectKey}`, objectKey, width: 192, height: 256, })), imageSequenceDurationMs: 5_000, }} token="admin-token" onClose={vi.fn()} />, ); const dialog = screen.getByRole('dialog', { name: '素材预览' }); fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); await act(async () => { await vi.advanceTimersByTimeAsync(120); }); loadMountedFrames(dialog); for (let index = 0; index < frameObjectKeys.length; index += 1) { fireEvent.click( within(dialog).getByRole('button', { name: '播放角色动作' }), ); await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); fireEvent.click( within(dialog).getByRole('button', { name: '暂停角色动作' }), ); await act(async () => { await vi.advanceTimersByTimeAsync(120); }); loadMountedFrames(dialog); } expect(within(dialog).getByText('1/5')).toBeTruthy(); frameObjectKeys.forEach((objectKey) => { expect(requestCounts.get(objectKey)).toBe(1); }); await act(async () => { await vi.advanceTimersByTimeAsync(300_001); }); loadMountedFrames(dialog); expect(resolveVisibleFrameSrc(dialog)).toContain('/v2/'); for (let index = 0; index < frameObjectKeys.length; index += 1) { fireEvent.click( within(dialog).getByRole('button', { name: '播放角色动作' }), ); await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); fireEvent.click( within(dialog).getByRole('button', { name: '暂停角色动作' }), ); await act(async () => { await vi.advanceTimersByTimeAsync(120); }); loadMountedFrames(dialog); } expect(within(dialog).getByText('1/5')).toBeTruthy(); expect(resolveVisibleFrameSrc(dialog)).toBe( `https://signed.example.com/v2/${frameObjectKeys[0]}`, ); frameObjectKeys.forEach((objectKey) => { expect(requestCounts.get(objectKey)).toBe(2); }); expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); }); test('角色动作预览提前换签失败时保留已就绪帧并有限重试', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); const frameObjectKeys = [ 'generated-animations/editor/task-refresh-failure/frame00.png', 'generated-animations/editor/task-refresh-failure/frame01.png', ] as const; const requestCounts = new Map(); vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { const objectKey = request.objectKey ?? ''; const requestCount = (requestCounts.get(objectKey) ?? 0) + 1; requestCounts.set(objectKey, requestCount); if (objectKey === frameObjectKeys[0] && requestCount > 1) { return Promise.reject(new Error('refresh unavailable')); } return Promise.resolve({ read: { objectKey, signedUrl: `https://signed.example.com/v${requestCount}/${objectKey}`, expiresAt: requestCount === 1 ? '2099-01-01T00:05:00Z' : '2099-01-01T00:10:00Z', }, }); }); render( ({ imageSrc: `/${objectKey}`, objectKey, width: 192, height: 256, })), imageSequenceDurationMs: 2_000, }} token="admin-token" onClose={vi.fn()} />, ); const dialog = screen.getByRole('dialog', { name: '素材预览' }); fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); await act(async () => { await vi.advanceTimersByTimeAsync(120); }); loadMountedFrames(dialog); const readyFrameSrc = resolveVisibleFrameSrc(dialog); expect(readyFrameSrc).toBe( `https://signed.example.com/v1/${frameObjectKeys[0]}`, ); await act(async () => { await vi.advanceTimersByTimeAsync(270_100); }); expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); for (const retryWindowMs of [450, 1_250, 3_050]) { await act(async () => { await vi.advanceTimersByTimeAsync(retryWindowMs); }); expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); } expect(requestCounts.get(frameObjectKeys[0])).toBe(5); expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(20_000); }); expect(requestCounts.get(frameObjectKeys[0])).toBe(5); expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); }); function loadMountedFrames(dialog: HTMLElement) { dialog .querySelectorAll('.admin-asset-query-sequence-frame') .forEach((frame) => fireEvent.load(frame)); } function resolveFrameByObjectKey(dialog: HTMLElement, objectKey: string) { return Array.from( dialog.querySelectorAll( '.admin-asset-query-sequence-frame', ), ).find((frame) => frame.src.endsWith(objectKey)); } function resolveVisibleFrameSrc(dialog: HTMLElement) { return Array.from( dialog.querySelectorAll( '.admin-asset-query-sequence-frame', ), ).find((frame) => frame.style.opacity === '1')?.src; }