Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
T
suzmii c3ccae75af 按实测收回 cfd89ba57 顺手加宽的一处测试超时,并写明另一处保留的理由
- `prefetches only cards that are actually in view, and still fills the first screen` 的 `}, 30000)` 收回(回到 vitest 默认 5 s):实测 93 ms,约 54 倍余量,而且真正的时间门是内层 `waitFor` 自带的 1 s。cfd89ba57 那次只是给负载留余量,没有对应的结构性成本 —— 对照 `releases Blob URLs while continuously browsing beyond the cache limit`(79 轮串行读盘、实测 6120 ms、超默认 5 s)那条的预算属结构性必需,保持不动。
- `keeps a visible card cached when later loads push the cache over its limit` 的 `}, 60000)` **保留**并写明理由:它的职责是挂起护栏,必须大于内层 `waitFor` 的 20 s 断言预算,否则内层断言自己的失败信息永远不出现(vitest 会先在测试超时处中止,只剩笼统的 "test timed out")。用例实测 120 ms,20 s 与 60 s 都不是耗时预期;硬收会让内层预算变成不可达配置,是更差的诊断而不是更紧的门禁。
- 无功能改动。
2026-09-11 22:18:40 +08:00

2285 lines
80 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 { 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<PreviewHookInput, 'prefetchScopeKey'> & {
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> = {},
): 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,
});
});
/**
* 超时预算的声明与依据(不是"放宽时间门吸收并发负载")。
*
* 本用例逐个 `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<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);
}, 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<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 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<ReturnType<typeof preview>>();
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<ReturnType<typeof preview>>();
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<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');
}
},
};
}
// 本用例不设显式测试超时:实测 **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<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);
});
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('预览缓存驱逐必须避开可见卡', () => {
/**
* 外层 `}, 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<string, unknown>) =>
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<string, unknown>) =>
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,
);
});
});