Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
T
suzmii cfd89ba570 ③ 驱逐用例抗并发负载:一次灌满替代 48 轮逐张等待
- 现象:驱逐用例逐张 waitFor 48 张(3 个读取槽 + 48 轮 act/轮询)在同树多个写者并发跑测试时,会把用例 5s 超时耗光(Test timed out in 5000ms),红的是超时不是断言;
- 改法:48 张一次性入队,只等"读取都已发出"(read 次数 ≥ 49),不再逐张断言 48 个卡的状态;
- 原因说明:驱逐本来就会连锁(目标卡被驱逐 → 补扫描重读 → 再挤掉一张未被观察的填充卡),逐张断言会在链条行进中读到 undefined,红的是时序不是契约;
- 核心断言一条不动:目标卡必须被重新请求(读次数 1 → 2)且最终回到 loaded;本用例超时放宽到 60s、内部 waitFor 20s;
- 验证:改动前后各连跑 6 轮 + 4 路并发同时跑两个用例文件,全绿。
2026-09-11 20:23:10 +08:00

2068 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 {
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,
} 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> = {},
): 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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function previewReadCalls(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(([command]) =>
String(command).startsWith('read_local_project_'),
);
}
type DeferredPreview = ReturnType<typeof deferred<ReturnType<typeof preview>>>;
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<string, unknown>) =>
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<ReturnType<typeof preview>>();
const secondRead = deferred<ReturnType<typeof preview>>();
const thirdRead = deferred<ReturnType<typeof preview>>();
const blockedReads = [firstRead, secondRead, thirdRead];
const invoke = vi.fn((_: string, args?: Record<string, unknown>) => {
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<string, unknown>) =>
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<string, unknown>) =>
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<string, unknown>) => {
if (command === 'cancel_local_project_resource_preview_scope') {
return Promise.resolve();
}
const read = deferred<ReturnType<typeof preview>>();
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<string, unknown>) =>
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<ReturnType<typeof preview>>();
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<string, unknown>) =>
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<Element>();
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<Element>;
restore: () => void;
};
function installIntersectionObserver(): ObserverHarness {
const callbacks: IntersectionObserverCallback[] = [];
const roots: (Element | Document | null)[] = [];
const observed = new Set<Element>();
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<Element>) {
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<string, unknown>) =>
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<string, unknown>) =>
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<string, unknown>) =>
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<Element>();
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<string, unknown>) =>
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<string, unknown>) =>
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 only the previous scope queued prefetch when the view changes', async () => {
// 修法 2 + 4:用户离开某个视图后,继续为它排队读图没有收益,而它会挡住新视图的按需请求。
// 只取消**排队中的** `visible``detail` / `play` 与在途请求一律保留。
const previousScope = Array.from({ length: 6 }, (_, index) =>
resource(`view-prev-${index + 1}`),
);
const nextScope = [resource('view-next-1')];
const held = deferred<ReturnType<typeof preview>>();
const invoke = vi.fn(async () => held.promise);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
({ resources }: { resources: ProjectResource[] }) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-cancel-visible',
projectId: 'preview-cancel-visible',
mode: 'dependency',
resources,
canvasRef,
eagerPreviewLimit: 0,
}),
{ initialProps: { resources: previousScope } },
);
// 6 张全部请求:3 张在途占槽,3 张留在队列。其中一张用 detail 理由。
act(() => {
previousScope.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({ resources: nextScope });
await waitFor(() =>
expect(result.current.previewQueueSnapshot().queue).toHaveLength(1),
);
const after = result.current.previewQueueSnapshot().queue;
expect(after[0]!.reason).toBe('detail');
expect(after[0]!.prefetchScopeKey).toContain('view-prev-6');
// 身份没有被整体清空:新视图的 identity 立刻可用(未触发 disposeAllCachedPreviews)。
expect(result.current.identityByResourceId.has('view-next-1')).toBe(true);
expect(result.current.identityByResourceId.size).toBe(1);
// 在途请求不被取消:占槽数仍是 3(取消只针对"排队中"的预取)。
expect(result.current.previewQueueSnapshot().activeReadCount).toBe(3);
act(() => {
held.resolve(preview(previousScope[0]!.path));
});
});
it('re-enqueues a cancelled prefetch when its card becomes visible again', async () => {
// 硬要求:取消不得留下"永不重试"的死角。切回原视图时卡片重新挂载/注册,
// 兜底扫描按几何判定它可见,必须重新入队。
const previousScope = Array.from({ length: 6 }, (_, index) =>
resource(`retry-prev-${index + 1}`),
);
const nextScope = [resource('retry-next-1')];
const held = deferred<ReturnType<typeof preview>>();
const invoke = vi.fn(async () => held.promise);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
({ resources }: { resources: ProjectResource[] }) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-cancel-retry',
projectId: 'preview-cancel-retry',
mode: 'dependency',
resources,
canvasRef,
eagerPreviewLimit: 0,
}),
{ initialProps: { resources: previousScope } },
);
const target = previousScope[5]!;
act(() => {
previousScope.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({ resources: nextScope });
await waitFor(() =>
expect(result.current.previewQueueSnapshot().queue).toHaveLength(0),
);
// 切回原视图:卡片重新注册,兜底扫描按几何判定可见后必须重新入队。
rerender({ resources: previousScope });
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(previousScope[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<typeof useProjectResourceCardPreviews>;
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<HTMLDivElement | null>(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<HTMLDivElement | null>(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<typeof vi.fn>) {
return previewReadCalls(invoke).map(
([, args]) =>
(args as { relativePath?: string } | undefined)?.relativePath ?? '',
);
}
function installRecordingIntersectionObserver() {
const callbacks: IntersectionObserverCallback[] = [];
const observed = new Set<Element>();
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');
}
},
};
}
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<string, unknown>) =>
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);
}, 30000);
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<HTMLElement>('[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('预览缓存驱逐后的可见性复核', () => {
it('re-requests a card that is still visible right after the LRU evicts it', async () => {
// 现场:LRU 只按条数/字节驱逐,不看可见性 —— 当前屏幕上那张卡会被后加载的图挤掉,
// 图片凭空消失、退回占位图标,而且不会自己回来:`IntersectionObserver` 对一直相交的
// 元素没有二次回调,兜底扫描的下一个触发点要等 scope 变化 / resize / 切回页面。
const target = resource('evicted-visible-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<string, unknown>) =>
preview(String(args?.relativePath ?? '')),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-evict-visible',
projectId: 'preview-evict-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);
// 灌满缓存:第 49 张落地时把最早的、仍然可见的这张挤出去。
//
// 一次性把 48 张都排进队列、只等"读取都发出去了",**不逐张断言 48 个卡的状态**:
// 驱逐本来就会往后连锁(目标卡被驱逐 → 补扫描重读 → 再挤掉一张别人),
// 逐张断言会在链条行进中读到 `undefined`,红的是时序不是契约。
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,
),
{ timeout: 20000 },
);
// 驱逐后必须补一次可见性复核:仍可见的卡要重新入队并重新读到图。
await waitFor(() => expect(targetReads()).toHaveLength(2), {
timeout: 20000,
});
expect(result.current.previews.get(targetIdentity)?.status).toBe('loaded');
}, 60000);
});