// @vitest-environment jsdom import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; import { PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, projectResourceCardPreviewEvictionIdentities, } from '../src/view/project-development/resourceCardPreviewModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; import { useProjectResourceCardPreviews } from '../src/view/project-development/useProjectResourceCardPreviews'; const originalCreateObjectUrl = Object.getOwnPropertyDescriptor( URL, 'createObjectURL', ); const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor( URL, 'revokeObjectURL', ); function resource( id: string, input: Partial = {}, ): ProjectResource { return { id, category: 'art', subtype: 'image', label: id, path: `assets/${id}.png`, mediaType: 'image/png', sourceLabel: '测试', taskTitle: null, manifestAssetId: id, producerTaskId: null, externalResourceId: null, referenceResourceIds: [], dependencies: [], dependencyDepth: 0, ...input, }; } function preview(path: string, mediaType = 'image/png') { return { path, mediaType, byteLen: 4, ...(mediaType.startsWith('image/') ? { pixelWidth: 640, pixelHeight: 360 } : {}), dataUrl: `data:${mediaType};base64,dGVzdA==`, }; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((next) => { resolve = next; }); return { promise, resolve }; } function previewReadCalls(invoke: ReturnType) { return invoke.mock.calls.filter(([command]) => String(command).startsWith('read_local_project_'), ); } type DeferredPreview = ReturnType>>; beforeEach(() => { let nextObjectUrl = 0; Object.defineProperties(URL, { createObjectURL: { configurable: true, value: vi.fn(() => `blob:resource-preview-${nextObjectUrl++}`), }, revokeObjectURL: { configurable: true, value: vi.fn(), }, }); }); afterEach(() => { cleanup(); window.__TAURI__ = undefined; vi.restoreAllMocks(); if (originalCreateObjectUrl) { Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl); } else { Reflect.deleteProperty(URL, 'createObjectURL'); } if (originalRevokeObjectUrl) { Object.defineProperty(URL, 'revokeObjectURL', originalRevokeObjectUrl); } else { Reflect.deleteProperty(URL, 'revokeObjectURL'); } }); describe('useProjectResourceCardPreviews', () => { it('prefetches initial previewable resources without requiring a detail click', async () => { const art = resource('initial-art'); const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-prefetch', projectId: 'preview-prefetch', mode: 'dependency', resources: [art], canvasRef, eagerPreviewLimit: 12, }), ); const identity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(previewReadCalls(invoke)).toHaveLength(1); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, pixelHeight: 360, }); expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({ relativePath: art.path, }); }); it('uses dimensions returned by the media preview path for extended images', async () => { const art = resource('extended-art', { path: 'assets/extended-art.gif', mediaType: 'image/gif', }); const invoke = vi.fn(async () => preview(art.path, 'image/gif')); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-extended-art', projectId: 'preview-extended-art', mode: 'dependency', resources: [art], canvasRef, eagerPreviewLimit: 1, }), ); const identity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(invoke).toHaveBeenCalledWith( 'read_local_project_media_preview', expect.objectContaining({ relativePath: art.path, category: 'art' }), ); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, pixelHeight: 360, }); }); it('only treats the exact native cancellation category as cancellation', () => { const cancellation = 'project-resource-preview-scope-cancelled'; expect(isProjectResourcePreviewCancellation(cancellation)).toBe(true); expect(isProjectResourcePreviewCancellation(new Error(cancellation))).toBe( true, ); expect( isProjectResourcePreviewCancellation( `资源路径包含 ${cancellation} 但读取失败`, ), ).toBe(false); expect( isProjectResourcePreviewCancellation({ message: cancellation }), ).toBe(false); }); it('evicts by total retained bytes before the item limit and preserves an active preview', () => { const mebibyte = 1024 * 1024; expect( projectResourceCardPreviewEvictionIdentities( [ { identity: 'active', retainedBytes: 32 * mebibyte }, { identity: 'older', retainedBytes: 32 * mebibyte }, { identity: 'newer', retainedBytes: 32 * mebibyte }, ], 'active', ), ).toEqual(['older']); expect( projectResourceCardPreviewEvictionIdentities( Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, (_, index) => ({ identity: `item-${index}`, retainedBytes: 1 }), ), null, ), ).toEqual(['item-0']); expect(PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT).toBe(64 * mebibyte); expect( projectResourceCardPreviewEvictionIdentities( [ { identity: 'invalid', retainedBytes: Number.NaN }, { identity: 'older-valid', retainedBytes: 40 * mebibyte }, { identity: 'newer-valid', retainedBytes: 40 * mebibyte }, ], null, ), ).toEqual(['invalid', 'older-valid']); expect( projectResourceCardPreviewEvictionIdentities( [ { identity: 'invalid', retainedBytes: -1 }, { identity: 'valid', retainedBytes: 1 }, ], null, ), ).toEqual(['invalid']); expect( projectResourceCardPreviewEvictionIdentities( [ { identity: 'oversized-active', retainedBytes: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1, }, ], 'oversized-active', ), ).toEqual(['oversized-active']); }); it('retries transient failures only for explicit detail intent', async () => { const art = resource('retry-art'); const invoke = vi .fn() .mockRejectedValueOnce( new Error('读取项目媒体资源失败:temporarily busy'), ) .mockResolvedValueOnce(preview(art.path)); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-retry', projectId: 'preview-retry', mode: 'dependency', resources: [art], canvasRef, }), ); const identity = result.current.identityByResourceId.get(art.id)!; act(() => result.current.requestPreview(art, identity, 'visible')); await waitFor(() => expect(result.current.previews.get(identity)).toMatchObject({ status: 'failed', retryable: true, }), ); expect(result.current.previews.get(identity)).toMatchObject({ error: expect.stringContaining('关闭详情并重试'), }); act(() => result.current.requestPreview(art, identity, 'visible')); expect(invoke).toHaveBeenCalledTimes(1); act(() => result.current.requestPreview(art, identity, 'detail')); await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(invoke).toHaveBeenCalledTimes(2); }); it('keeps permanent decode and safety failures cached across user intent', async () => { const art = resource('broken-art'); const invoke = vi .fn() .mockRejectedValue( new Error('image.inspect 图片结构无效:assets/broken.png'), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-permanent', projectId: 'preview-permanent', mode: 'dependency', resources: [art], canvasRef, }), ); const identity = result.current.identityByResourceId.get(art.id)!; act(() => result.current.requestPreview(art, identity, 'visible')); await waitFor(() => expect(result.current.previews.get(identity)).toMatchObject({ status: 'failed', retryable: false, }), ); expect(result.current.previews.get(identity)).toMatchObject({ error: expect.not.stringContaining('关闭详情并重试'), }); act(() => result.current.requestPreview(art, identity, 'detail')); act(() => result.current.requestPreview(art, identity, 'play')); expect(invoke).toHaveBeenCalledTimes(1); }); it('reserves the bounded queue for intent and drains play before prefetch', async () => { const resources = Array.from({ length: 99 }, (_, index) => resource(`queue-${index.toString().padStart(3, '0')}`), ); const audio = resource('queue-audio', { category: 'audio', subtype: 'background-music', path: 'assets/queue-audio.mp3', mediaType: 'audio/mpeg', }); resources.push(audio); const firstRead = deferred>(); const secondRead = deferred>(); const thirdRead = deferred>(); const blockedReads = [firstRead, secondRead, thirdRead]; const invoke = vi.fn((_: string, args?: Record) => { const blocked = blockedReads[invoke.mock.calls.length - 1]; return blocked?.promise ?? new Promise(() => undefined); }); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-queue', projectId: 'preview-queue', mode: 'dependency', resources, canvasRef, }), ); act(() => { for (const candidate of resources.slice(0, 99)) { result.current.requestPreview( candidate, result.current.identityByResourceId.get(candidate.id)!, 'visible', ); } for (const candidate of resources.slice(96, 99)) { result.current.requestPreview( candidate, result.current.identityByResourceId.get(candidate.id)!, 'detail', ); } result.current.requestPreview( audio, result.current.identityByResourceId.get(audio.id)!, 'play', ); }); expect(invoke).toHaveBeenCalledTimes(3); await act(async () => { firstRead.resolve(preview(resources[0]!.path)); await Promise.resolve(); await Promise.resolve(); }); expect(invoke.mock.calls[3]?.[0]).toBe('read_local_project_media_preview'); expect(invoke.mock.calls[3]?.[1]).toMatchObject({ relativePath: audio.path, }); }); it('releases Blob URLs while continuously browsing beyond the cache limit', async () => { const resources = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 7 }, (_, index) => resource(`video-${index}`, { path: `assets/video-${index}.mp4`, mediaType: 'video/mp4', }), ); const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? ''), 'video/mp4'), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, unmount } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-continuous-media', projectId: 'preview-continuous-media', mode: 'dependency', resources, canvasRef, }), ); for (const candidate of resources) { const identity = result.current.identityByResourceId.get(candidate.id)!; act(() => result.current.requestPreview(candidate, identity, 'visible')); await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); } expect( Array.from(result.current.previews.values()).filter( (state) => state.status === 'loaded', ), ).toHaveLength(PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT); expect(URL.createObjectURL).toHaveBeenCalledTimes(resources.length); expect(URL.revokeObjectURL).toHaveBeenCalledTimes(7); unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledTimes(resources.length); }); it('releases each Blob URL once when identity changes and a retry replaces state', async () => { const original = resource('changing-art'); const replacement = resource('changing-art', { path: 'assets/changing-art-v2.png', }); const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (resources: ProjectResource[]) => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-identity-change', projectId: 'preview-identity-change', mode: 'dependency', resources, canvasRef, }), { initialProps: [original] }, ); const originalIdentity = result.current.identityByResourceId.get( original.id, )!; act(() => result.current.requestPreview(original, originalIdentity, 'visible'), ); await waitFor(() => expect(result.current.previews.get(originalIdentity)?.status).toBe( 'loaded', ), ); expect(URL.revokeObjectURL).not.toHaveBeenCalled(); rerender([replacement]); const replacementIdentity = result.current.identityByResourceId.get( replacement.id, )!; expect(replacementIdentity).not.toBe(originalIdentity); await waitFor(() => expect(result.current.previews.has(originalIdentity)).toBe(false), ); expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1); expect(URL.revokeObjectURL).toHaveBeenLastCalledWith( 'blob:resource-preview-0', ); act(() => result.current.requestPreview(replacement, replacementIdentity, 'detail'), ); await waitFor(() => expect(result.current.previews.get(replacementIdentity)?.status).toBe( 'loaded', ), ); act(() => result.current.failPreview(replacementIdentity, '暂时失败', true), ); expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2); expect(URL.revokeObjectURL).toHaveBeenLastCalledWith( 'blob:resource-preview-1', ); act(() => result.current.requestPreview(replacement, replacementIdentity, 'detail'), ); await waitFor(() => expect(result.current.previews.get(replacementIdentity)?.status).toBe( 'loaded', ), ); unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3); expect(URL.revokeObjectURL).toHaveBeenLastCalledWith( 'blob:resource-preview-2', ); }); it('cancels the old native scope before issuing a fresh scope and ignores an old finally', async () => { const scopeAResources = Array.from({ length: 3 }, (_, index) => resource(`scope-a-${index}`), ); const scopeBResources = Array.from({ length: 4 }, (_, index) => resource(`scope-b-${index}`), ); const pending: Array<{ path: string; read: DeferredPreview; }> = []; const invoke = vi.fn((command: string, args?: Record) => { if (command === 'cancel_local_project_resource_preview_scope') { return Promise.resolve(); } const read = deferred>(); pending.push({ path: String(args?.relativePath ?? ''), read }); return read.promise; }); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { projectPath: string; projectId: string; resources: ProjectResource[]; }) => useProjectResourceCardPreviews({ ...props, mode: 'dependency', canvasRef, }), { initialProps: { projectPath: '/tmp/preview-scope-a', projectId: 'preview-scope-a', resources: scopeAResources, }, }, ); act(() => { for (const candidate of scopeAResources) { result.current.requestPreview( candidate, result.current.identityByResourceId.get(candidate.id)!, 'visible', ); } }); expect(previewReadCalls(invoke)).toHaveLength(3); const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId; expect(firstScopeId).toEqual(expect.any(String)); expect(previewReadCalls(invoke).map((call) => call[1]?.scopeId)).toEqual([ firstScopeId, firstScopeId, firstScopeId, ]); expect( new Set(previewReadCalls(invoke).map((call) => call[1]?.requestId)).size, ).toBe(3); rerender({ projectPath: '/tmp/preview-scope-b', projectId: 'preview-scope-b', resources: scopeBResources, }); act(() => { for (const candidate of scopeBResources) { result.current.requestPreview( candidate, result.current.identityByResourceId.get(candidate.id)!, 'visible', ); } }); const cancelCallIndex = invoke.mock.calls.findIndex( ([command]) => command === 'cancel_local_project_resource_preview_scope', ); const firstScopeBReadIndex = invoke.mock.calls.findIndex( ([command, args]) => String(command).startsWith('read_local_project_') && args?.projectPath === '/tmp/preview-scope-b', ); expect(cancelCallIndex).toBeGreaterThanOrEqual(0); expect(cancelCallIndex).toBeLessThan(firstScopeBReadIndex); expect(invoke.mock.calls[cancelCallIndex]?.[1]).toEqual({ scopeId: firstScopeId, }); expect(previewReadCalls(invoke)).toHaveLength(6); const secondScopeId = previewReadCalls(invoke)[3]?.[1]?.scopeId; expect(secondScopeId).toEqual(expect.any(String)); expect(secondScopeId).not.toBe(firstScopeId); await act(async () => { pending[0]!.read.resolve(preview(pending[0]!.path)); await Promise.resolve(); await Promise.resolve(); }); expect(previewReadCalls(invoke)).toHaveLength(6); await act(async () => { pending[3]!.read.resolve(preview(pending[3]!.path)); await Promise.resolve(); await Promise.resolve(); }); await waitFor(() => expect(previewReadCalls(invoke)).toHaveLength(7)); }); it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => { const art = resource('mode-switch-art'); const invoke = vi.fn(async (command: string) => { if (command === 'cancel_local_project_resource_preview_scope') { return undefined; } return preview(art.path); }); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (mode: 'dependency' | 'type') => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-mode-switch', projectId: 'preview-mode-switch', mode, resources: [art], canvasRef, eagerPreviewLimit: 1, }), { initialProps: 'dependency' as const }, ); const firstIdentity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'), ); const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId; expect(firstScopeId).toEqual(expect.any(String)); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, pixelHeight: 360, }); rerender('type'); expect(result.current.identityByResourceId.get(art.id)).toBe(firstIdentity); expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, pixelHeight: 360, }); expect(previewReadCalls(invoke)).toHaveLength(1); expect( invoke.mock.calls.filter( ([command]) => command === 'cancel_local_project_resource_preview_scope', ), ).toHaveLength(0); unmount(); const cancelledScopeIds = invoke.mock.calls .filter( ([command]) => command === 'cancel_local_project_resource_preview_scope', ) .map((call) => call[1]?.scopeId); expect(cancelledScopeIds).toEqual([firstScopeId]); }); it('invalidates an in-place asset preview when its committed version changes', async () => { const art = resource('refined-art'); const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (previewVersion: string) => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-refine-version', projectId: 'preview-refine-version', mode: 'dependency', resources: [art], canvasRef, eagerPreviewLimit: 1, previewVersionByResourceId: new Map([[art.id, previewVersion]]), }), { initialProps: 'commit-1' }, ); const firstIdentity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'), ); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); rerender('commit-2'); const secondIdentity = result.current.identityByResourceId.get(art.id)!; expect(secondIdentity).not.toBe(firstIdentity); await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith( 'blob:resource-preview-0', ), ); await waitFor(() => expect(result.current.previews.get(secondIdentity)?.status).toBe( 'loaded', ), ); expect(previewReadCalls(invoke)).toHaveLength(2); expect(URL.createObjectURL).toHaveBeenCalledTimes(2); expect(result.current.previews.has(firstIdentity)).toBe(false); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, pixelHeight: 360, }); }); it('rejects an A-B-A late result even when scope and identity match again', async () => { const art = resource('aba-art'); const pending: DeferredPreview[] = []; const invoke = vi.fn((command: string) => { if (command === 'cancel_local_project_resource_preview_scope') { return Promise.resolve(); } const read = deferred>(); pending.push(read); return read.promise; }); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { projectPath: string; projectId: string }) => useProjectResourceCardPreviews({ ...props, mode: 'dependency', resources: [art], canvasRef, }), { initialProps: { projectPath: '/tmp/preview-aba-a', projectId: 'preview-aba-a', }, }, ); const firstIdentity = result.current.identityByResourceId.get(art.id)!; act(() => result.current.requestPreview(art, firstIdentity, 'visible')); rerender({ projectPath: '/tmp/preview-aba-b', projectId: 'preview-aba-b', }); rerender({ projectPath: '/tmp/preview-aba-a', projectId: 'preview-aba-a', }); const currentIdentity = result.current.identityByResourceId.get(art.id)!; expect(currentIdentity).toBe(firstIdentity); act(() => result.current.requestPreview(art, currentIdentity, 'visible')); const readCalls = previewReadCalls(invoke); expect(readCalls).toHaveLength(2); expect(readCalls[1]?.[1]?.scopeId).not.toBe(readCalls[0]?.[1]?.scopeId); const cancelledScopeIds = invoke.mock.calls .filter( ([command]) => command === 'cancel_local_project_resource_preview_scope', ) .map((call) => call[1]?.scopeId); expect(cancelledScopeIds).toEqual([ readCalls[0]?.[1]?.scopeId, expect.any(String), ]); expect(cancelledScopeIds[1]).not.toBe(cancelledScopeIds[0]); await act(async () => { pending[0]!.resolve(preview(art.path)); await Promise.resolve(); await Promise.resolve(); }); expect(result.current.previews.get(currentIdentity)?.status).toBe( 'loading', ); expect(URL.createObjectURL).not.toHaveBeenCalled(); await act(async () => { pending[1]!.resolve(preview(art.path)); await Promise.resolve(); await Promise.resolve(); }); await waitFor(() => expect(result.current.previews.get(currentIdentity)?.status).toBe( 'loaded', ), ); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); }); });