// @vitest-environment jsdom import { act, cleanup, render, renderHook, waitFor, } from '@testing-library/react'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; import { createResourceCanvasPrefetchScopeKey } from '../src/view/project-development/resourceCanvasController'; import { PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, projectResourceCardPreviewEvictionIdentities, } from '../src/view/project-development/resourceCardPreviewModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; import { nextPreviewJob, useProjectResourceCardPreviews as useProjectResourceCardPreviewsUnderTest, } from '../src/view/project-development/useProjectResourceCardPreviews'; type PreviewHookInput = Parameters< typeof useProjectResourceCardPreviewsUnderTest >[0]; /** * 生产里 `prefetchScopeKey` 是**必填**的视图标识,由 `index.tsx` 用 * `createResourceCanvasPrefetchScopeKey` 按「视图 + 栏目 + 排序模式」算出。 * * 绝大多数用例只关心同一条视图内的加载行为,所以这里统一补同一个标识 —— * 但**要断言"换视图取消上一视图排队预取"的用例必须直接用 * `useProjectResourceCardPreviewsUnderTest` 并显式传不同的标识**:那正是这条参数 * 存在的理由,用包装的常量会让用例失去判别力(换视图与不换视图表现完全一样)。 */ const TEST_PREFETCH_SCOPE_KEY = 'test:single-view|dependency'; function useProjectResourceCardPreviews( input: Omit & { prefetchScopeKey?: string; }, ) { return useProjectResourceCardPreviewsUnderTest({ prefetchScopeKey: TEST_PREFETCH_SCOPE_KEY, ...input, }); } const originalCreateObjectUrl = Object.getOwnPropertyDescriptor( URL, 'createObjectURL', ); const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor( URL, 'revokeObjectURL', ); function resource( id: string, input: Partial = {}, ): ProjectResource { return { id, category: 'unclassified', 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, // 线上合同的读取分支取值,不是资源所在的画布栏目:Rust 侧 // `read_local_project_media_preview_at` 只接受 art / audio,传栏目值 // (unclassified / ui-interaction / …)会被直接拒绝,卡片永远读不出预览。 category: 'art', }), ); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ pixelWidth: 640, pixelHeight: 360, }); }); it('sends the art read branch for extended images regardless of their canvas section', async () => { const art = resource('extended-art', { path: 'assets/extended-art.gif', mediaType: 'image/gif', // 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。 category: 'unclassified', }); 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'), ); const mediaCall = invoke.mock.calls.find( ([command]) => command === 'read_local_project_media_preview', ); expect(mediaCall?.[1]).toMatchObject({ relativePath: art.path, category: 'art', }); }); it('sends the audio read branch for a play request from an audio-section card', async () => { const audio = resource('play-audio', { path: 'assets/play-audio.mp3', mediaType: 'audio/mpeg', subtype: 'background-music', category: 'audio', }); const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg')); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-play-audio', projectId: 'preview-play-audio', mode: 'dependency', resources: [audio], canvasRef, // 音频不预读:没有播放意图前必须一次 IPC 都不发。 eagerPreviewLimit: 12, }), ); const identity = result.current.identityByResourceId.get(audio.id)!; expect(invoke).not.toHaveBeenCalled(); act(() => result.current.requestPreview(audio, identity, 'play')); await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(invoke).toHaveBeenCalledWith( 'read_local_project_media_preview', expect.objectContaining({ relativePath: audio.path, category: 'audio', }), ); }); 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, }); }); /** * 超时预算的声明与依据(不是"放宽时间门吸收并发负载")。 * * 本用例逐个 `waitFor` 等 `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 7 = 79` 张卡**串行** * 读完,共 79 轮读盘 + 状态提交;`2ea3bcbb0` 把条目上限从 48 提到 72,本用例的轮数因此由 * 55 轮机械地涨到 79 轮(+44%),与并发负载无关。实测本机耗时 **6164 ms**,已经超过 vitest * 默认的 5000 ms,所以这个预算是**结构性必需**的;30s 是给慢 CI 的余量,只服务这一条用例, * 不改全局 `testTimeout`。 */ 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); }, 30000); 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); }); it('observes cards against the provided intersection root and loads visible previews', async () => { const art = resource('intersection-root-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 intersectionRoot = document.createElement('div'); const callbacks: IntersectionObserverCallback[] = []; const roots: (Element | Document | null)[] = []; const observed = new Set(); const originalIntersectionObserver = Object.getOwnPropertyDescriptor( window, 'IntersectionObserver', ); class TestIntersectionObserver { readonly root = null; readonly rootMargin = '160px'; readonly thresholds = [0]; constructor( callback: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { callbacks.push(callback); roots.push(options?.root ?? null); } observe(element: Element) { observed.add(element); } unobserve(element: Element) { observed.delete(element); } disconnect() { observed.clear(); } takeRecords() { return []; } } Object.defineProperty(window, 'IntersectionObserver', { configurable: true, value: TestIntersectionObserver, }); try { const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-intersection-root', projectId: 'preview-intersection-root', mode: 'type', resources: [art], canvasRef, intersectionRootRef: { current: intersectionRoot }, eagerPreviewLimit: 0, }), ); const identity = result.current.identityByResourceId.get(art.id)!; const card = document.createElement('div'); act(() => { result.current.observePreview(card, art, identity); }); await waitFor(() => expect(callbacks.length).toBeGreaterThan(0)); expect(roots.at(-1)).toBe(intersectionRoot); expect(observed.has(card)).toBe(true); expect(previewReadCalls(invoke)).toHaveLength(0); act(() => { callbacks.at(-1)!( [ { target: card, isIntersecting: true, intersectionRatio: 1, } as IntersectionObserverEntry, ], {} as IntersectionObserver, ); }); await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(previewReadCalls(invoke)).toHaveLength(1); } finally { if (originalIntersectionObserver) { Object.defineProperty( window, 'IntersectionObserver', originalIntersectionObserver, ); } else { Reflect.deleteProperty(window, 'IntersectionObserver'); } } }); /** * 可见性门禁的时机契约:observer 必须**等 root 真正就绪**才建,且建好后要把 * 登记表里的卡一次性补挂齐。 * * 背景:root 是资源画本容器(卡片渲染在它内部)。若在 root 还是 null 时就建 observer, * 浏览器会退回按视口判定,被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行, * 卡片就停在 `idle`、卡面只剩占位图标 —— 真机现场出现的正是「同一屏里 idle 与 loaded 交错」。 */ describe('可见性门禁的 root 时机契约', () => { type ObserverHarness = { callbacks: IntersectionObserverCallback[]; roots: (Element | Document | null)[]; observed: Set; restore: () => void; }; function installIntersectionObserver(): ObserverHarness { const callbacks: IntersectionObserverCallback[] = []; const roots: (Element | Document | null)[] = []; const observed = new Set(); const original = Object.getOwnPropertyDescriptor( window, 'IntersectionObserver', ); class TestIntersectionObserver { readonly root = null; readonly rootMargin = '160px'; readonly thresholds = [0]; constructor( callback: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { callbacks.push(callback); roots.push(options?.root ?? null); } observe(element: Element) { observed.add(element); } unobserve(element: Element) { observed.delete(element); } disconnect() { observed.clear(); } takeRecords() { return []; } } Object.defineProperty(window, 'IntersectionObserver', { configurable: true, writable: true, value: TestIntersectionObserver, }); return { callbacks, roots, observed, restore: () => { if (original) { Object.defineProperty(window, 'IntersectionObserver', original); } else { Reflect.deleteProperty(window, 'IntersectionObserver'); } }, }; } function fireVisible(harness: ObserverHarness, targets: Iterable) { act(() => { harness.callbacks.at(-1)!( [...targets].map( (target) => ({ target, isIntersecting: true, intersectionRatio: 1, }) as IntersectionObserverEntry, ), {} as IntersectionObserver, ); }); } it('waits for a real root, then observes and loads the cards', async () => { const art = resource('late-root-art'); const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); window.__TAURI__ = { core: { invoke } }; // root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。 const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); try { const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-late-root', projectId: 'preview-late-root', mode: 'dependency', resources: [art], canvasRef, // 关掉热预取,逼这张卡只能靠可见性放行。 eagerPreviewLimit: 0, }), ); const identity = result.current.identityByResourceId.get(art.id)!; // 关键回归护栏:容器 root 尚未就绪时**仍然必须建出 observer**(退到视口判定)。 // 若这里因为 `root === null` 就不建,本 effect 每个 scope 只跑一次、 // `requestPreview` 又是稳定引用,就再也没有第二次机会 —— 所有卡片永远不被观察、 // 永远停在 `idle`,整个栏目只剩占位图标。真机整栏全空正是这个形状。 await waitFor(() => expect(harness.callbacks.length).toBeGreaterThan(0), ); expect(harness.roots.at(-1)).toBeNull(); const root = document.createElement('div'); const card = document.createElement('div'); // 故意让它落在视口之外:本用例要证明的是"observer 回调能放行"这条通路, // 兜底扫描不该抢先把可见卡加载掉(那由下面两条扫描专项用例覆盖)。 card.getBoundingClientRect = () => ({ top: 5000, left: 10, bottom: 5060, right: 60 }) as DOMRect; root.append(card); canvasRef.current = root; let unregister: () => void = () => undefined; act(() => { unregister = result.current.observePreview(card, art, identity); }); // 容器 root 迟到后要重建一次,把判定从视口换成画本容器。 await waitFor(() => expect(harness.roots.at(-1)).toBe(root)); expect(harness.observed.has(card)).toBe(true); expect(result.current.previews.get(identity)).toBeUndefined(); fireVisible(harness, [card]); await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); unregister(); } finally { harness.restore(); } }); it('re-observes cards registered before the observer existed', async () => { const resources = Array.from({ length: 3 }, (_, index) => resource(`early-register-${index + 1}`), ); const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); window.__TAURI__ = { core: { invoke } }; const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); try { const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-early-register', projectId: 'preview-early-register', mode: 'dependency', resources, canvasRef, eagerPreviewLimit: 0, }), ); const root = document.createElement('div'); canvasRef.current = root; const elements = resources.map(() => document.createElement('div')); act(() => { resources.forEach((item, index) => { result.current.observePreview( elements[index]!, item, result.current.identityByResourceId.get(item.id)!, ); }); }); // 登记与建 observer 的先后顺序无关:建好之后登记表里的每一张都必须被挂上。 await waitFor(() => expect(harness.observed.size).toBe(elements.length), ); for (const element of elements) { expect(harness.observed.has(element)).toBe(true); } fireVisible(harness, elements); await waitFor(() => { for (const item of resources) { const identity = result.current.identityByResourceId.get(item.id)!; expect(result.current.previews.get(identity)?.status).toBe( 'loaded', ); } }); } finally { harness.restore(); } }); it('does not leave a visible card idle when it is outside the eager window', async () => { // 真机现场:同一屏里 idle 与 loaded 交错,idle 的那批从未入队。 // 热预取只覆盖前 12 张,其余全部依赖可见性门禁 —— 所以这里必须证明 // 「注册进 observer 的卡,在回调报可见后会真的进入队列」,而不是停在 idle。 const resources = Array.from({ length: 20 }, (_, index) => resource(`visibility-${index + 1}`), ); const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const callbacks: IntersectionObserverCallback[] = []; const roots: (Element | Document | null)[] = []; const observed = new Set(); const originalIntersectionObserver = Object.getOwnPropertyDescriptor( window, 'IntersectionObserver', ); class TestIntersectionObserver { readonly root = null; readonly rootMargin = '160px'; readonly thresholds = [0]; constructor( callback: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { callbacks.push(callback); roots.push(options?.root ?? null); } observe(element: Element) { observed.add(element); } unobserve(element: Element) { observed.delete(element); } disconnect() { observed.clear(); } takeRecords() { return []; } } Object.defineProperty(window, 'IntersectionObserver', { configurable: true, writable: true, value: TestIntersectionObserver, }); try { const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-visibility', projectId: 'preview-visibility', mode: 'dependency', resources, canvasRef, // 热预取只覆盖前 12 张:第 13 张起完全依赖可见性门禁。 eagerPreviewLimit: 12, }), ); // 模拟卡片挂载后的注册:按计划顺序,前 12 张已进热预取窗口, // 从第 13 张起是「只能靠可见性放行」的那批。 const unregister: Array<() => void> = []; act(() => { for (const item of resources.slice(12)) { const element = document.createElement('div'); const identity = result.current.identityByResourceId.get(item.id)!; unregister.push( result.current.observePreview(element, item, identity), ); } }); // 注册的每个元素都必须真的进了 observer:漏 observe 的卡永远不会被放行。 expect(observed.size).toBe(unregister.length); expect(roots.at(-1)).toBe(canvasRef.current); await waitFor(() => expect( result.current.previews.get( result.current.identityByResourceId.get(resources[0]!.id)!, )?.status, ).toBe('loaded'), ); // 报可见之前,未进热预取窗口的卡应仍是「从未请求」。 const registerOrder: Array<[ProjectResource, string]> = resources .slice(12) .map((item) => [ item, result.current.identityByResourceId.get(item.id)!, ]); for (const [, identity] of registerOrder) { expect(result.current.previews.get(identity)?.status).toBeUndefined(); } act(() => { callbacks.at(-1)!( [...observed].map( (target) => ({ target, isIntersecting: true, intersectionRatio: 1, }) as IntersectionObserverEntry, ), {} as IntersectionObserver, ); }); await waitFor(() => { for (const [, identity] of registerOrder) { expect(result.current.previews.get(identity)?.status).toBe( 'loaded', ); } }); // 契约:可见卡不得停在 idle —— 每张注册过的卡都要落成 loaded。 expect( registerOrder.filter( ([, identity]) => result.current.previews.get(identity) === undefined, ), ).toEqual([]); } finally { if (originalIntersectionObserver) { Object.defineProperty( window, 'IntersectionObserver', originalIntersectionObserver, ); } else { Reflect.deleteProperty(window, 'IntersectionObserver'); } } }); it('requests a visible-but-never-observed card without any observer callback', async () => { // 兜底扫描的存在理由:门禁原先只有 observer 一条路,observer 回调没送达 // (没建出来、重建中、注册与创建交错)时卡就永远停在 idle。 // 这条用例**完全不触发 observer 回调**,只靠"已登记 + 几何上确实可见"就应被放行。 const art = resource('sweep-art'); const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); window.__TAURI__ = { core: { invoke } }; const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); try { const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-sweep', projectId: 'preview-sweep', mode: 'dependency', resources: [art], canvasRef, eagerPreviewLimit: 0, }), ); const identity = result.current.identityByResourceId.get(art.id)!; const root = document.createElement('div'); canvasRef.current = root; const card = document.createElement('div'); card.getBoundingClientRect = () => ({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect; act(() => { result.current.observePreview(card, art, identity); }); // 从未调用 harness.callbacks → 可见性只能来自兜底扫描。 await waitFor(() => expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(previewReadCalls(invoke)).toHaveLength(1); } finally { harness.restore(); } }); it('does not request a registered card that is outside the viewport', async () => { // 兜底扫描只能补"可判定为可见"的卡:不能退化成全量预读。 const art = resource('sweep-offscreen-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 harness = installIntersectionObserver(); try { const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-sweep-offscreen', projectId: 'preview-sweep-offscreen', mode: 'dependency', resources: [art], canvasRef, eagerPreviewLimit: 0, }), ); const identity = result.current.identityByResourceId.get(art.id)!; const card = document.createElement('div'); card.getBoundingClientRect = () => ({ top: 5000, left: 10, bottom: 5060, right: 60 }) as DOMRect; act(() => { result.current.observePreview(card, art, identity); }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(harness.observed.has(card)).toBe(true); expect(result.current.previews.get(identity)).toBeUndefined(); expect(previewReadCalls(invoke)).toHaveLength(0); } finally { harness.restore(); } }); }); it('lets the current prefetch scope jump ahead of the previous scope queue', () => { // 全局 3 槽跨视图共享:进入总览时队列里可能还压着上一视图的可见性预取。 // 若不插队,总览自己那几张"该出图"的卡会排在它们后面 —— 用户感知就是"进总览要等图"。 // 排序是纯函数,直接断言契约,避免依赖多微任务时序。 const job = ( identity: string, reason: 'visible' | 'detail' | 'play', scope: string, ) => ({ scopeKey: 'scope', scopeId: 'scope-id', scopeEpoch: 0, identity, resource: resource(identity), reason, prefetchScopeKey: scope, }); // 当前作用域的 visible 越过上一作用域排队的 visible。 expect( nextPreviewJob( [job('previous', 'visible', 'A'), job('current', 'visible', 'B')], 'B', )?.identity, ).toBe('current'); // 但不得越过 PRD 的固定优先级:当前作用域的 visible 仍排在 play 之后。 expect( nextPreviewJob( [job('playing', 'play', 'A'), job('current', 'visible', 'B')], 'B', )?.identity, ).toBe('playing'); // 同一作用域内仍是 detail > visible。 expect( nextPreviewJob( [job('listed', 'visible', 'B'), job('opened', 'detail', 'B')], 'B', )?.identity, ).toBe('opened'); }); it('cancels the previous view queued prefetch when only the view identity changes', async () => { // 修法 2 + 4:用户离开某个视图后,继续为它排队读图没有收益,而它会挡住新视图的按需请求。 // 只取消**排队中的** `visible`:`detail` / `play` 与在途请求一律保留。 // // 生产形状:`resources` **恒为全量投影**(`index.tsx` 的注释写明收窄它会让总览里其它 // 栏目只剩栏目标题栏),换栏目只改 `eagerResources` 与视图标识 —— 所以"切视图"这条 // 路径**不可能**靠资源集合变化触发,取消 effect 依赖的只能是显式传入的视图标识。 const full = [ ...Array.from({ length: 6 }, (_, index) => resource(`view-prev-${index + 1}`), ), resource('view-next-1'), ]; const previousEager = full.slice(0, 6); const nextEager = [full[6]!]; const previousScopeKey = createResourceCanvasPrefetchScopeKey({ view: 'child', target: 'scene', mode: 'dependency', }); const nextScopeKey = createResourceCanvasPrefetchScopeKey({ view: 'child', target: 'character', mode: 'dependency', }); // 判据前置:换栏目必须换标识。曾经这里没有这层保证 —— 标识由"喂进来的资源集合签名" // 兜底派生,而那个集合恒为全量、换栏目一字不改 ⇒ 标识不变 ⇒ 整段取消逻辑是 no-op。 expect(nextScopeKey).not.toBe(previousScopeKey); const held = deferred>(); const invoke = vi.fn(async () => held.promise); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( ({ scopeKey, eager }: { scopeKey: string; eager: ProjectResource[] }) => useProjectResourceCardPreviewsUnderTest({ projectPath: '/tmp/preview-cancel-visible', projectId: 'preview-cancel-visible', mode: 'dependency', resources: full, eagerResources: eager, prefetchScopeKey: scopeKey, canvasRef, eagerPreviewLimit: 0, }), { initialProps: { scopeKey: previousScopeKey, eager: previousEager } }, ); // 6 张全部请求:3 张在途占槽,3 张留在队列。其中一张用 detail 理由。 act(() => { previousEager.forEach((item, index) => { result.current.requestPreview( item, result.current.identityByResourceId.get(item.id)!, index === 5 ? 'detail' : 'visible', ); }); }); await waitFor(() => expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3), ); const before = result.current.previewQueueSnapshot().queue; expect(before).toHaveLength(3); expect(before.some((job) => job.reason === 'detail')).toBe(true); // 换栏目:全量资源集合一字未动,只有视图标识变。队列里上一视图的 visible // 必须被下掉,detail 必须留下。 rerender({ scopeKey: nextScopeKey, eager: nextEager }); await waitFor(() => expect(result.current.previewQueueSnapshot().queue).toHaveLength(1), ); const after = result.current.previewQueueSnapshot().queue; expect(after[0]!.reason).toBe('detail'); // 队列里的 job 带着它入队时那个视图的标识(这里是上一视图),不是资源集合签名。 expect(after[0]!.prefetchScopeKey).toBe(previousScopeKey); // 身份没有被整体清空:全量投影里的身份一直可用(未触发 disposeAllCachedPreviews)。 expect(result.current.identityByResourceId.has('view-next-1')).toBe(true); expect(result.current.identityByResourceId.size).toBe(full.length); // 在途请求不被取消:占槽数仍是 3(取消只针对"排队中"的预取)。 expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3); act(() => { held.resolve(preview(previousEager[0]!.path)); }); }); it('re-enqueues a cancelled prefetch when its card becomes visible again', async () => { // 硬要求:取消不得留下"永不重试"的死角。切回原视图时卡片重新挂载/注册, // 兜底扫描按几何判定它可见,必须重新入队。 // 同样用生产形状:全量资源集合不动,换视图只换标识。 const full = [ ...Array.from({ length: 6 }, (_, index) => resource(`retry-prev-${index + 1}`), ), resource('retry-next-1'), ]; const previousEager = full.slice(0, 6); const nextEager = [full[6]!]; const previousScopeKey = createResourceCanvasPrefetchScopeKey({ view: 'child', target: 'scene', mode: 'dependency', }); const nextScopeKey = createResourceCanvasPrefetchScopeKey({ view: 'child', target: 'character', mode: 'dependency', }); const held = deferred>(); const invoke = vi.fn(async () => held.promise); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( ({ scopeKey, eager }: { scopeKey: string; eager: ProjectResource[] }) => useProjectResourceCardPreviewsUnderTest({ projectPath: '/tmp/preview-cancel-retry', projectId: 'preview-cancel-retry', mode: 'dependency', resources: full, eagerResources: eager, prefetchScopeKey: scopeKey, canvasRef, eagerPreviewLimit: 0, }), { initialProps: { scopeKey: previousScopeKey, eager: previousEager } }, ); const target = previousEager[5]!; act(() => { previousEager.forEach((item) => { result.current.requestPreview( item, result.current.identityByResourceId.get(item.id)!, 'visible', ); }); }); await waitFor(() => expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3), ); expect( result.current .previewQueueSnapshot() .queue.some((job) => job.identity.includes(target.id)), ).toBe(true); // 换栏目:排队被下掉。 rerender({ scopeKey: nextScopeKey, eager: nextEager }); await waitFor(() => expect(result.current.previewQueueSnapshot().queue).toHaveLength(0), ); // 切回原视图:卡片重新注册,兜底扫描按几何判定可见后必须重新入队。 rerender({ scopeKey: previousScopeKey, eager: previousEager }); act(() => { const element = document.createElement('div'); element.getBoundingClientRect = () => ({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect; result.current.observePreview( element, target, result.current.identityByResourceId.get(target.id)!, ); }); await waitFor(() => expect( result.current .previewQueueSnapshot() .queue.some((job) => job.identity.includes(target.id)), ).toBe(true), ); act(() => { held.resolve(preview(previousEager[0]!.path)); }); }); }); describe('预览队列上限吞掉按需请求时的可观测性', () => { it('records an on-demand request that a full queue had to drop', async () => { // 现场:队列压满 96 条、又找不到可以顶掉的可见性预取时,`detail` / `play` 请求 // 被直接丢掉 —— 用户在可见卡上点播放,没有请求、没有报错,此前连痕迹都没有。 const resources = Array.from({ length: 101 }, (_, index) => resource(`queue-drop-${index.toString().padStart(3, '0')}`), ); // 所有读取都挂住:队列只进不出,才能在用例里稳定压满。 const invoke = vi.fn(() => new Promise(() => undefined)); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-queue-drop', projectId: 'preview-queue-drop', mode: 'dependency', resources, canvasRef, }), ); act(() => { for (const candidate of resources.slice(0, 99)) { result.current.requestPreview( candidate, result.current.identityByResourceId.get(candidate.id)!, 'detail', ); } }); expect(result.current.previewQueueSnapshot().queue).toHaveLength( PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, ); expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(0); expect(result.current.previewQueueSnapshot().lastDroppedRequest).toBeNull(); const dropped = resources[99]!; act(() => { result.current.requestPreview( dropped, result.current.identityByResourceId.get(dropped.id)!, 'play', ); }); expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(1); expect( result.current.previewQueueSnapshot().lastDroppedRequest, ).toMatchObject({ identity: result.current.identityByResourceId.get(dropped.id), reason: 'play', queueLength: PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, }); expect( result.current .previewQueueSnapshot() .queue.some( (job) => job.identity === result.current.identityByResourceId.get(dropped.id), ), ).toBe(false); expect( warn.mock.calls.some(([message]) => String(message).includes('[preview-queue]'), ), ).toBe(true); // 可见性预取撞上限是设计内的背压,不算"吞掉用户动作",不计数。 const backpressure = resources[100]!; act(() => { result.current.requestPreview( backpressure, result.current.identityByResourceId.get(backpressure.id)!, 'visible', ); }); expect(result.current.previewQueueSnapshot().droppedRequestCount).toBe(1); }); }); describe('冷启动首屏的放行范围与放行顺序', () => { type PreviewController = ReturnType; const VIEWPORT_MARGIN = 160; /** 只补用例关心的字段:几何判据只读 width/height/left/right/top/bottom。 */ function cardRect(input: { top: number; bottom: number; left?: number; right?: number; }): DOMRect { const left = input.left ?? 10; const right = input.right ?? 60; return { x: left, y: input.top, top: input.top, bottom: input.bottom, left, right, width: right - left, height: input.bottom - input.top, } as DOMRect; } /** 视口内(档 0) */ const IN_VIEWPORT_RECT = () => cardRect({ top: 10, bottom: 60 }); /** 只在 160px 余量圈里(档 1):整卡在视口上沿之外,但仍落在 rootMargin 内。 */ const IN_MARGIN_ONLY_RECT = () => cardRect({ top: -(VIEWPORT_MARGIN / 2), bottom: -(VIEWPORT_MARGIN / 2) + 50, }); /** 放行范围之外(档 2) */ const OFFSCREEN_RECT = () => cardRect({ top: 2000, bottom: 2060 }); function PreviewBandCard(input: { controller: PreviewController; resource: ProjectResource; rect: DOMRect; }) { const elementRef = React.useRef(null); const { identityByResourceId, observePreview } = input.controller; React.useLayoutEffect(() => { const element = elementRef.current; const identity = identityByResourceId.get(input.resource.id); if (!element || !identity) { return undefined; } element.getBoundingClientRect = () => input.rect; return observePreview(element, input.resource, identity); }, [identityByResourceId, input.rect, input.resource, observePreview]); return React.createElement('div', { ref: elementRef, 'data-preview-band-card': input.resource.id, }); } /** * 真机形状的最小复刻:卡片在子组件的 layout effect 里注册(早于本 hook 的被动 effect, * 与生产里"卡片是画本容器的子组件"同一时序)。 */ function PreviewBandHarness(input: { projectPath: string; resources: ProjectResource[]; rects: DOMRect[]; eagerPreviewLimit: number; controllerRef?: { current: PreviewController | null }; }) { const canvasRef = React.useRef(null); const controller = useProjectResourceCardPreviews({ projectPath: input.projectPath, projectId: input.projectPath, mode: 'dependency', resources: input.resources, canvasRef, eagerPreviewLimit: input.eagerPreviewLimit, }); if (input.controllerRef) { input.controllerRef.current = controller; } return React.createElement( 'div', { ref: canvasRef }, input.resources.map((item, index) => React.createElement(PreviewBandCard, { key: item.id, controller, resource: item, rect: input.rects[index]!, }), ), ); } function readPaths(invoke: ReturnType) { return previewReadCalls(invoke).map( ([, args]) => (args as { relativePath?: string } | undefined)?.relativePath ?? '', ); } function installRecordingIntersectionObserver() { const callbacks: IntersectionObserverCallback[] = []; const observed = new Set(); const original = Object.getOwnPropertyDescriptor( window, 'IntersectionObserver', ); class RecordingIntersectionObserver { readonly root = null; readonly rootMargin = '160px'; readonly thresholds = [0]; constructor(callback: IntersectionObserverCallback) { callbacks.push(callback); } observe(element: Element) { observed.add(element); } unobserve(element: Element) { observed.delete(element); } disconnect() { observed.clear(); } takeRecords() { return []; } } Object.defineProperty(window, 'IntersectionObserver', { configurable: true, writable: true, value: RecordingIntersectionObserver, }); return { callbacks, observed, fire: (targets: readonly Element[]) => { act(() => { callbacks.at(-1)!( targets.map( (target) => ({ target, isIntersecting: true, intersectionRatio: 1, }) as IntersectionObserverEntry, ), {} as IntersectionObserver, ); }); }, restore: () => { if (original) { Object.defineProperty(window, 'IntersectionObserver', original); } else { Reflect.deleteProperty(window, 'IntersectionObserver'); } }, }; } // 本用例不设显式测试超时:实测 **84 ms**,对 vitest 默认的 5 s 有约 60 倍余量,而且真正的时间门 // 是下面那个 `waitFor` 自带的 1 s。`cfd89ba57` 曾顺手给它加 `}, 30000)` —— 那只是给负载留余量, // 没有对应的结构性成本(对比 `releases Blob URLs …` 那条:79 轮串行读盘实测 6120 ms,超默认 5 s, // 那条的预算是必需的),故按实测收回。 it('prefetches only cards that are actually in view, and still fills the first screen', async () => { // A:热预取此前按投影顺序盲取前 12 张,条件里没有几何判断 —— 排在前面的视口外卡片 // 先占满 3 个读取槽,屏幕里的卡反而排队,首屏就是"等图片"。 const offscreen = Array.from({ length: 8 }, (_, index) => resource(`eager-offscreen-${index}`), ); const marginOnly = Array.from({ length: 4 }, (_, index) => resource(`eager-margin-${index}`), ); const inViewport = Array.from({ length: 8 }, (_, index) => resource(`eager-in-viewport-${index}`), ); // 投影顺序故意把"视口外"排在前面:旧的顺序预取会先读它们。 const resources = [...offscreen, ...marginOnly, ...inViewport]; const rects = [ ...offscreen.map(OFFSCREEN_RECT), ...marginOnly.map(IN_MARGIN_ONLY_RECT), ...inViewport.map(IN_VIEWPORT_RECT), ]; const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(PreviewBandHarness, { projectPath: '/tmp/preview-eager-visibility', resources, rects, eagerPreviewLimit: 12, }), ); // 首屏请求量:仍是 eagerPreviewLimit = 12 张,但 12 张全部落在可见卡上。 await waitFor(() => expect(new Set(readPaths(invoke)).size).toBe( resources.length - offscreen.length, ), ); const readPathsSet = new Set(readPaths(invoke)); for (const item of offscreen) { expect(readPathsSet.has(item.path)).toBe(false); } for (const item of [...inViewport, ...marginOnly]) { expect(readPathsSet.has(item.path)).toBe(true); } // 首屏要保证有图可读:可见的 12 张一张都不能少。 expect(readPathsSet.size).toBe(resources.length - offscreen.length); }); it('releases intersecting cards in two bands: viewport first, margin second', async () => { // B:一次 observer 回调可能同时报来 ~21 张相交卡,物理读取只有 3 个槽。 // 两档放行 = 先入队视口内、再入队余量圈,**总量不变**(不是丢掉第二档)。 const marginOnly = Array.from({ length: 3 }, (_, index) => resource(`band-margin-${index}`), ); const inViewport = Array.from({ length: 3 }, (_, index) => resource(`band-in-viewport-${index}`), ); // 登记顺序故意先余量圈、后视口内:单趟入队会把这个顺序原样写进队列。 const resources = [...marginOnly, ...inViewport]; const invoke = vi.fn(() => new Promise(() => undefined)); window.__TAURI__ = { core: { invoke } }; const harness = installRecordingIntersectionObserver(); const controllerRef: { current: PreviewController | null } = { current: null, }; try { render( React.createElement(PreviewBandHarness, { projectPath: '/tmp/preview-band-order', resources, rects: resources.map(OFFSCREEN_RECT), eagerPreviewLimit: 0, controllerRef, }), ); await waitFor(() => expect(harness.callbacks.length).toBeGreaterThan(0)); // 注册时全在放行范围之外:登记扫描一张都不该放行。 expect(previewReadCalls(invoke)).toHaveLength(0); const elements = Array.from( document.querySelectorAll('[data-preview-band-card]'), ); expect(elements).toHaveLength(resources.length); elements.forEach((element, index) => { element.getBoundingClientRect = index < marginOnly.length ? IN_MARGIN_ONLY_RECT : IN_VIEWPORT_RECT; }); harness.fire(elements); // 3 个槽先给视口内那批:一次回调里先看到的那几张先出图。 expect(readPaths(invoke)).toEqual(inViewport.map((item) => item.path)); // 余量圈那批没有被丢掉,只是排在后面(总量不变 = 6 张全部放行)。 const queuedIdentities = controllerRef .current!.previewQueueSnapshot() .queue.map((job) => job.identity); expect(queuedIdentities).toEqual( marginOnly.map((item) => controllerRef.current!.identityByResourceId.get(item.id), ), ); expect(readPaths(invoke).length + queuedIdentities.length).toBe( resources.length, ); } finally { harness.restore(); } }); }); describe('预览缓存驱逐必须避开可见卡', () => { /** * 外层 `}, 60000)` **保留**,但它不是"给负载留余量"的耗时预期:它的职责是**挂起护栏**, * 必须大于上面那个 `waitFor` 的 20 s 断言预算 —— 否则断言自己的失败信息永远不会出现 * (vitest 会先在测试超时处中止,只剩一句笼统的 "test timed out")。用例实测 150 ms, * 20 s 与 60 s 都不是耗时预期。 */ it('keeps a visible card cached when later loads push the cache over its limit', async () => { // 现场:缓存条目顺序是「最近一次被请求」,而停在屏幕上不动的卡不会产生新请求 —— // 纯 LRU 会首选淘汰用户正看着的那张,图片"消失又回来"。 // 现在淘汰第一轮只看视口外的条目,所以**可见的目标卡必须原封不动**: // 既不掉状态,也不产生第二次读取(不再需要"掉了再补读")。 const target = resource('visible-kept-art'); const fillers = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT }, (_, index) => resource(`eviction-filler-${index}`), ); const resources = [target, ...fillers]; const invoke = vi.fn( async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-keep-visible', projectId: 'preview-keep-visible', mode: 'dependency', resources, canvasRef, // 关掉热预取:这张卡只能靠可见性门禁放行。 eagerPreviewLimit: 0, }), ); const targetIdentity = result.current.identityByResourceId.get(target.id)!; const targetReads = () => previewReadCalls(invoke).filter( ([, args]) => (args as { relativePath?: string } | undefined)?.relativePath === target.path, ); // 目标卡落在视口里:注册即被兜底扫描放行,拿到图。 const card = document.createElement('div'); card.getBoundingClientRect = () => ({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect; act(() => { result.current.observePreview(card, target, targetIdentity); }); await waitFor(() => expect(result.current.previews.get(targetIdentity)?.status).toBe( 'loaded', ), ); expect(targetReads()).toHaveLength(1); // 灌满缓存:追加的条目把它们自己挤出去,但**不得动那张仍可见的卡**。 act(() => { for (const filler of fillers) { result.current.requestPreview( filler, result.current.identityByResourceId.get(filler.id)!, 'visible', ); } }); await waitFor( () => expect(previewReadCalls(invoke).length).toBeGreaterThanOrEqual( fillers.length + 1, ), // 这里等的是 `CACHE_LIMIT + 1 = 73` 次读盘落地,实测整条用例 **162 ms** 就跑完, // 与上限提到 72 前的 49 次同量级。`2ea3bcbb0` 曾把这里由 20s 提到 30s,那一次只是 // 给负载留余量的顺手放大、不是新出现的结构性成本,因此按实测回到原来的 20s // (162 ms 对 20 s,仍有约 120 倍余量)。 { timeout: 20000 }, ); // 核心契约:可见卡仍在缓存里、状态仍是 loaded、且**没有发生第二次读取**。 expect(result.current.previews.get(targetIdentity)?.status).toBe('loaded'); expect(targetReads()).toHaveLength(1); }, 60000); /** * 驱逐不得自激:这条用例是删掉"驱逐后立刻补一遍可见性扫描"的判据。 * * 那段补扫的触发条件是"本轮驱逐了任意条目"。当**全部缓存条目都还在视口内且仍超预算**时, * 淘汰会回退到第二轮全表 LRU(该回退由「全可见且超预算时仍必须淘汰」钉住),被驱逐的可见卡 * 状态回到 `undefined` ⇒ 补扫把它重新入队 ⇒ 读回来又超预算 ⇒ 再驱逐 ⇒ 再补扫……每个周期 * 跨一次异步读取,是一条**不收敛**的回路 —— 原注释写的"递归深度恒为 1、不自激"并不成立。 * 本用例钉住"必须收敛":读取次数在观察窗口内不再增长,且缓存稳定在上限。 */ it('settles instead of re-reading evicted visible cards forever', async () => { const resources = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, (_, index) => resource(`rollback-${index}`), ); const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); window.__TAURI__ = { core: { invoke } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/preview-eviction-settles', projectId: 'preview-eviction-settles', mode: 'dependency', resources, canvasRef, eagerPreviewLimit: 0, }), ); // 全部登记、且几何上都在视口内 ⇒ 第一轮"只淘汰视口外条目"无从下手, // 淘汰只剩第二轮回退可选。这正是"补扫自激"的现场。 act(() => { for (const item of resources) { const element = document.createElement('div'); element.getBoundingClientRect = () => ({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect; result.current.observePreview( element, item, result.current.identityByResourceId.get(item.id)!, ); } }); const reads = () => previewReadCalls(invoke).length; await waitFor( () => expect(reads()).toBeGreaterThanOrEqual(resources.length), { timeout: 30000 }, ); // 观察窗口:先跨过挂载时那几条**有界**兜底扫描(0 / 250 / 1000 ms),再看是否还在读。 await new Promise((resolve) => setTimeout(resolve, 1500)); const settled = reads(); await new Promise((resolve) => setTimeout(resolve, 500)); expect(reads()).toBe(settled); // 淘汰是有界的:稳定在上限,不是"永不淘汰",也不是"无限重读"。 expect(result.current.previews.size).toBe( PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, ); }, 30000); }); describe('预览缓存驱逐的判定契约', () => { it('never prefers evicting a visible preview while an off-screen one can go', () => { // 根因护栏:条目顺序是「最近一次被请求」,而停在屏幕上不动的卡不会产生新请求 // —— 纯 LRU 会首选淘汰用户正看着的那几张(图片"消失又回来")。 const entries = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, (_, index) => ({ identity: `item-${index}`, retainedBytes: 1 }), ); // 最老的三张恰好都还在屏幕上:它们必须全部豁免,改淘汰视口外的 item-3。 const visible = new Set(['item-0', 'item-1', 'item-2']); expect( projectResourceCardPreviewEvictionIdentities(entries, null, visible), ).toEqual(['item-3']); // 对照:不给可见信息时退化为纯 LRU(淘汰最老的 item-0)。 expect(projectResourceCardPreviewEvictionIdentities(entries, null)).toEqual( ['item-0'], ); }); it('still evicts when every candidate is visible so the cache stays bounded', () => { // 防无界内存:可见性过滤不是"永不淘汰"。全部条目都在屏幕上且超预算时, // 必须回退到对全表按 LRU 淘汰。 const entries = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, (_, index) => ({ identity: `item-${index}`, retainedBytes: 1 }), ); const allVisible = new Set(entries.map((entry) => entry.identity)); expect( projectResourceCardPreviewEvictionIdentities(entries, null, allVisible), ).toEqual(['item-0']); // 字节预算同理:全可见但超字节上限时也必须能淘汰。 const mebibyte = 1024 * 1024; expect( projectResourceCardPreviewEvictionIdentities( [ { identity: 'a', retainedBytes: 40 * mebibyte }, { identity: 'b', retainedBytes: 40 * mebibyte }, ], null, new Set(['a', 'b']), ), ).toEqual(['a']); }); it('fits a full 51-card panel without any eviction', () => { // 真机依据:「UI 交互」栏目有 51 张可预览卡,而原条目上限 48 小于一栏 ⇒ 滚满必然驱逐。 // 上限提到 72 后,一栏 51 张必须**零淘汰**。 const panelSize = 51; const entries = Array.from({ length: panelSize }, (_, index) => ({ identity: `panel-${index}`, // 真机单张均值 591 KB,取它来保证字节侧也不是绑定约束。 retainedBytes: 591 * 1024, })); const allVisible = new Set(entries.map((entry) => entry.identity)); expect(PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT).toBeGreaterThanOrEqual( panelSize, ); expect( projectResourceCardPreviewEvictionIdentities(entries, null, allVisible), ).toEqual([]); // 字节侧余量:51 张 ≈ 29.4 MiB,必须远低于 64 MiB。 expect(panelSize * 591 * 1024).toBeLessThan( PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT, ); }); });