Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
T
suzmii 2ea3bcbb07
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 19m24s
驱逐改为可见性优先,条目上限 48→72:图片不再自己消失又回来
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 LRU 插入顺序、且完全不看"是否仍然可见"**:缓存条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生新的请求 —— 于是恰恰是用户正看着的那几张排在队首被首选淘汰,`disposeCachedPreview` 释放 Blob 后图片凭空消失,再被兜底扫描重新读回来,表现为闪烁。上一轮「驱逐后补一次可见性复核」只是把"掉了不回来"变成"掉了再加载",是治标。

- `projectResourceCardPreviewEvictionIdentities` 新增 `visibleIdentities` 参数,淘汰改两轮:**第一轮只淘汰视口外的条目**(可见卡一律跳过);**第二轮才回退** —— 只有"剩余条目全部仍在视口内且依旧超预算"时才按全表 LRU 淘汰。回退不可省略,否则"可见即永不淘汰"会造成无界内存,该回退由用例钉住。
- `useProjectResourceCardPreviews` 在淘汰前按几何算出可见集合(复用既有的视口档位判据 `viewportBandOfElement <= 1`,经 ref 转发生效,避免定义顺序耦合)。
- `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT` 由 `48` 提到 `72`。依据是真机实测:该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目必然驱逐;取 72 覆盖 51 张并留约 40% 余量,按真机单张均值 591 KB 外推 ≈ **43 MiB**,**仍在既有 64 MiB 字节预算之内**(真机 52 张 blob 合计 29.32 MiB,仅用掉 45.8%)。**字节预算未动,也不是把上限放大到任意大。**

断言(`tests/useProjectResourceCardPreviews.test.ts`):
1. 「可见卡不得成为首选淘汰对象」:最老的 3 张都在屏幕上时,淘汰必须跳过它们、改淘汰视口外的第 4 张;对照用例同时钉住"不给可见信息时退化为纯 LRU";
2. 「全可见且超预算时仍必须淘汰」(防无界内存),条目上限与字节上限两侧各一条;
3. 「51 张整栏零淘汰」:真机栏目规模下 `projectResourceCardPreviewEvictionIdentities` 必须返回空数组,且断言字节侧余量。
另把原先守旧行为的用例改写为守新契约:可见卡被后续加载挤出缓存上限时**必须仍保持 `loaded` 且不产生第二次读取**(不再依赖"掉了再补读")。既有用例一条未放宽。

变异验证:
- 去掉可见性过滤(第一轮不再跳过可见卡)→ 断言 1 所属用例立即失败(`expected [ [ …(2) ], [ …(2) ] ] to have a length of 1 but got 2`,即目标卡被驱逐并重读);
- 去掉"全可见时回退全表 LRU"→ 断言 2 立即失败(`expected [] to deeply equal [ 'item-0' ]`,即缓存无界)。

验证:定向 `useProjectResourceCardPreviews` 31 passed;typecheck exit 0;prettier 干净。(全量 AGC 子集的前后对照见随后的回报。)
2026-09-11 20:39:59 +08:00

2130 lines
72 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);
}, 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 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('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,
),
{ timeout: 30000 },
);
// 核心契约:可见卡仍在缓存里、状态仍是 loaded、且**没有发生第二次读取**。
expect(result.current.previews.get(targetIdentity)?.status).toBe('loaded');
expect(targetReads()).toHaveLength(1);
}, 60000);
});
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,
);
});
});