c3ccae75af
- `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 都不是耗时预期;硬收会让内层预算变成不可达配置,是更差的诊断而不是更紧的门禁。 - 无功能改动。
2285 lines
80 KiB
TypeScript
2285 lines
80 KiB
TypeScript
// @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,
|
||
);
|
||
});
|
||
});
|