Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
T
suzmii 2a157ea6f8 预览可见性门禁等 root 就绪再建 observer,并把登记表补挂齐
真机现场:同一屏里 `idle` 与 `loaded` 交错,12 张相交卡从未入队(`idle` 的语义就是「从未请求」,不是被淘汰回退)。根因在 observer 的**创建时机**:`useProjectResourceCardPreviews` 建 IntersectionObserver 时把 `intersectionRootRef.current ?? canvasRef.current` 直接当 root 传下去,而 root 是资源画本容器、由被观察卡片所在的子树持有。创建那一刻 root 还是 `null` 时,浏览器会**退回按视口判定**,于是被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行它们 —— 卡面只剩占位图标,且没有任何错误提示。

- `useProjectResourceCardPreviews`:root 为 `null` 时**不建 observer**,等 root 就绪后由卡片注册触发的 `rootEpoch` 重建。判据是「卡片一定渲染在画本容器内部」,所以**有卡片注册本身就等价于 root 已就绪**,不需要新增跨组件契约、也不必改 `index.tsx`。
- 新增 `attachObservedCards()`:observer 就绪或重建后,按登记表把**每一张已注册卡片**补挂一遍。此前只在新 observer 创建时补挂一次,注册与创建分属不同 effect 存在时序窗口,错过那次补挂的卡会停在登记表里却从未被观察。
- root 未变且 observer 已在时只补挂、不重建,避免每次渲染重建观察器。
- `observePreview` 在没有 `IntersectionObserver` 的环境(如单测)保持静默,不制造多余渲染。

测试(`tests/useProjectResourceCardPreviews.test.ts` 新增 3 条契约):
- 「不在热预取窗口内的可见卡不得停在 idle」:注册 20 张、`eagerPreviewLimit: 12`,断言注册进 observer 的元素数等于注册数、回调报可见后全部落 `loaded`、且没有一张停留在未请求状态;
- 「等真正的 root 就绪后再观察并加载」:root 首次为 `null` 时**不得建 observer**(否则按视口判定),注册后必须以真正的 root 建出来并完成加载;
- 「建 observer 之前就注册的卡要被补挂」:断言 observer 就绪后登记表里的每一张都在观察集合内,且报可见后全部 `loaded`。

变异验证:把 root 守卫退回旧行为(允许 root 为 `null` 时照建 observer)后,「等真正的 root 就绪」这条立即失败(`expected [] to have a length of 0 but got 1`);恢复后 19/19 通过。断言不是恒真假守卫。

边界说明:只改预览 hook 与它的测试,未动 `index.tsx` / `styles.css` / `resourceBookLayout.ts`(均在他人手上),未放宽任何既有断言、未取消可见性门禁(仍是按需加载)。

验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1216 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4379 文件;prettier 与 eslint 干净;`git diff --check` 干净。
2026-09-11 15:31:29 +08:00

1338 lines
44 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, renderHook, waitFor } from '@testing-library/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,
projectResourceCardPreviewEvictionIdentities,
} from '../src/view/project-development/resourceCardPreviewModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
import { useProjectResourceCardPreviews } from '../src/view/project-development/useProjectResourceCardPreviews';
const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'createObjectURL',
);
const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'revokeObjectURL',
);
function resource(
id: string,
input: Partial<ProjectResource> = {},
): ProjectResource {
return {
id,
category: 'unclassified',
subtype: 'image',
label: id,
path: `assets/${id}.png`,
mediaType: 'image/png',
sourceLabel: '测试',
taskTitle: null,
manifestAssetId: id,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...input,
};
}
function preview(path: string, mediaType = 'image/png') {
return {
path,
mediaType,
byteLen: 4,
...(mediaType.startsWith('image/')
? { pixelWidth: 640, pixelHeight: 360 }
: {}),
dataUrl: `data:${mediaType};base64,dGVzdA==`,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function previewReadCalls(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(([command]) =>
String(command).startsWith('read_local_project_'),
);
}
type DeferredPreview = ReturnType<typeof deferred<ReturnType<typeof preview>>>;
beforeEach(() => {
let nextObjectUrl = 0;
Object.defineProperties(URL, {
createObjectURL: {
configurable: true,
value: vi.fn(() => `blob:resource-preview-${nextObjectUrl++}`),
},
revokeObjectURL: {
configurable: true,
value: vi.fn(),
},
});
});
afterEach(() => {
cleanup();
window.__TAURI__ = undefined;
vi.restoreAllMocks();
if (originalCreateObjectUrl) {
Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl);
} else {
Reflect.deleteProperty(URL, 'createObjectURL');
}
if (originalRevokeObjectUrl) {
Object.defineProperty(URL, 'revokeObjectURL', originalRevokeObjectUrl);
} else {
Reflect.deleteProperty(URL, 'revokeObjectURL');
}
});
describe('useProjectResourceCardPreviews', () => {
it('prefetches initial previewable resources without requiring a detail click', async () => {
const art = resource('initial-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-prefetch',
projectId: 'preview-prefetch',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 12,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(previewReadCalls(invoke)).toHaveLength(1);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({
relativePath: art.path,
});
});
it('uses dimensions returned by the media preview path for extended images', async () => {
const art = resource('extended-art', {
path: 'assets/extended-art.gif',
mediaType: 'image/gif',
});
const invoke = vi.fn(async () => preview(art.path, 'image/gif'));
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-extended-art',
projectId: 'preview-extended-art',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(invoke).toHaveBeenCalledWith(
'read_local_project_media_preview',
expect.objectContaining({
relativePath: art.path,
// 线上合同的读取分支取值,不是资源所在的画布栏目:Rust 侧
// `read_local_project_media_preview_at` 只接受 art / audio,传栏目值
// unclassified / ui-interaction / …)会被直接拒绝,卡片永远读不出预览。
category: 'art',
}),
);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
});
it('sends the art read branch for extended images regardless of their canvas section', async () => {
const art = resource('extended-art', {
path: 'assets/extended-art.gif',
mediaType: 'image/gif',
// 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。
category: 'unclassified',
});
const invoke = vi.fn(async () => preview(art.path, 'image/gif'));
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-extended-art',
projectId: 'preview-extended-art',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
const mediaCall = invoke.mock.calls.find(
([command]) => command === 'read_local_project_media_preview',
);
expect(mediaCall?.[1]).toMatchObject({
relativePath: art.path,
category: 'art',
});
});
it('sends the audio read branch for a play request from an audio-section card', async () => {
const audio = resource('play-audio', {
path: 'assets/play-audio.mp3',
mediaType: 'audio/mpeg',
subtype: 'background-music',
category: 'audio',
});
const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg'));
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-play-audio',
projectId: 'preview-play-audio',
mode: 'dependency',
resources: [audio],
canvasRef,
// 音频不预读:没有播放意图前必须一次 IPC 都不发。
eagerPreviewLimit: 12,
}),
);
const identity = result.current.identityByResourceId.get(audio.id)!;
expect(invoke).not.toHaveBeenCalled();
act(() => result.current.requestPreview(audio, identity, 'play'));
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(invoke).toHaveBeenCalledWith(
'read_local_project_media_preview',
expect.objectContaining({
relativePath: audio.path,
category: 'audio',
}),
);
});
it('only treats the exact native cancellation category as cancellation', () => {
const cancellation = 'project-resource-preview-scope-cancelled';
expect(isProjectResourcePreviewCancellation(cancellation)).toBe(true);
expect(isProjectResourcePreviewCancellation(new Error(cancellation))).toBe(
true,
);
expect(
isProjectResourcePreviewCancellation(
`资源路径包含 ${cancellation} 但读取失败`,
),
).toBe(false);
expect(
isProjectResourcePreviewCancellation({ message: cancellation }),
).toBe(false);
});
it('evicts by total retained bytes before the item limit and preserves an active preview', () => {
const mebibyte = 1024 * 1024;
expect(
projectResourceCardPreviewEvictionIdentities(
[
{ identity: 'active', retainedBytes: 32 * mebibyte },
{ identity: 'older', retainedBytes: 32 * mebibyte },
{ identity: 'newer', retainedBytes: 32 * mebibyte },
],
'active',
),
).toEqual(['older']);
expect(
projectResourceCardPreviewEvictionIdentities(
Array.from(
{ length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 },
(_, index) => ({ identity: `item-${index}`, retainedBytes: 1 }),
),
null,
),
).toEqual(['item-0']);
expect(PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT).toBe(64 * mebibyte);
expect(
projectResourceCardPreviewEvictionIdentities(
[
{ identity: 'invalid', retainedBytes: Number.NaN },
{ identity: 'older-valid', retainedBytes: 40 * mebibyte },
{ identity: 'newer-valid', retainedBytes: 40 * mebibyte },
],
null,
),
).toEqual(['invalid', 'older-valid']);
expect(
projectResourceCardPreviewEvictionIdentities(
[
{ identity: 'invalid', retainedBytes: -1 },
{ identity: 'valid', retainedBytes: 1 },
],
null,
),
).toEqual(['invalid']);
expect(
projectResourceCardPreviewEvictionIdentities(
[
{
identity: 'oversized-active',
retainedBytes: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1,
},
],
'oversized-active',
),
).toEqual(['oversized-active']);
});
it('retries transient failures only for explicit detail intent', async () => {
const art = resource('retry-art');
const invoke = vi
.fn()
.mockRejectedValueOnce(
new Error('读取项目媒体资源失败:temporarily busy'),
)
.mockResolvedValueOnce(preview(art.path));
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-retry',
projectId: 'preview-retry',
mode: 'dependency',
resources: [art],
canvasRef,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
act(() => result.current.requestPreview(art, identity, 'visible'));
await waitFor(() =>
expect(result.current.previews.get(identity)).toMatchObject({
status: 'failed',
retryable: true,
}),
);
expect(result.current.previews.get(identity)).toMatchObject({
error: expect.stringContaining('关闭详情并重试'),
});
act(() => result.current.requestPreview(art, identity, 'visible'));
expect(invoke).toHaveBeenCalledTimes(1);
act(() => result.current.requestPreview(art, identity, 'detail'));
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(invoke).toHaveBeenCalledTimes(2);
});
it('keeps permanent decode and safety failures cached across user intent', async () => {
const art = resource('broken-art');
const invoke = vi
.fn()
.mockRejectedValue(
new Error('image.inspect 图片结构无效:assets/broken.png'),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-permanent',
projectId: 'preview-permanent',
mode: 'dependency',
resources: [art],
canvasRef,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
act(() => result.current.requestPreview(art, identity, 'visible'));
await waitFor(() =>
expect(result.current.previews.get(identity)).toMatchObject({
status: 'failed',
retryable: false,
}),
);
expect(result.current.previews.get(identity)).toMatchObject({
error: expect.not.stringContaining('关闭详情并重试'),
});
act(() => result.current.requestPreview(art, identity, 'detail'));
act(() => result.current.requestPreview(art, identity, 'play'));
expect(invoke).toHaveBeenCalledTimes(1);
});
it('reserves the bounded queue for intent and drains play before prefetch', async () => {
const resources = Array.from({ length: 99 }, (_, index) =>
resource(`queue-${index.toString().padStart(3, '0')}`),
);
const audio = resource('queue-audio', {
category: 'audio',
subtype: 'background-music',
path: 'assets/queue-audio.mp3',
mediaType: 'audio/mpeg',
});
resources.push(audio);
const firstRead = deferred<ReturnType<typeof preview>>();
const secondRead = deferred<ReturnType<typeof preview>>();
const thirdRead = deferred<ReturnType<typeof preview>>();
const blockedReads = [firstRead, secondRead, thirdRead];
const invoke = vi.fn((_: string, args?: Record<string, unknown>) => {
const blocked = blockedReads[invoke.mock.calls.length - 1];
return blocked?.promise ?? new Promise(() => undefined);
});
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-queue',
projectId: 'preview-queue',
mode: 'dependency',
resources,
canvasRef,
}),
);
act(() => {
for (const candidate of resources.slice(0, 99)) {
result.current.requestPreview(
candidate,
result.current.identityByResourceId.get(candidate.id)!,
'visible',
);
}
for (const candidate of resources.slice(96, 99)) {
result.current.requestPreview(
candidate,
result.current.identityByResourceId.get(candidate.id)!,
'detail',
);
}
result.current.requestPreview(
audio,
result.current.identityByResourceId.get(audio.id)!,
'play',
);
});
expect(invoke).toHaveBeenCalledTimes(3);
await act(async () => {
firstRead.resolve(preview(resources[0]!.path));
await Promise.resolve();
await Promise.resolve();
});
expect(invoke.mock.calls[3]?.[0]).toBe('read_local_project_media_preview');
expect(invoke.mock.calls[3]?.[1]).toMatchObject({
relativePath: audio.path,
});
});
it('releases Blob URLs while continuously browsing beyond the cache limit', async () => {
const resources = Array.from(
{ length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 7 },
(_, index) =>
resource(`video-${index}`, {
path: `assets/video-${index}.mp4`,
mediaType: 'video/mp4',
}),
);
const invoke = vi.fn(async (_: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? ''), 'video/mp4'),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, unmount } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-continuous-media',
projectId: 'preview-continuous-media',
mode: 'dependency',
resources,
canvasRef,
}),
);
for (const candidate of resources) {
const identity = result.current.identityByResourceId.get(candidate.id)!;
act(() => result.current.requestPreview(candidate, identity, 'visible'));
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
}
expect(
Array.from(result.current.previews.values()).filter(
(state) => state.status === 'loaded',
),
).toHaveLength(PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT);
expect(URL.createObjectURL).toHaveBeenCalledTimes(resources.length);
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(7);
unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(resources.length);
});
it('releases each Blob URL once when identity changes and a retry replaces state', async () => {
const original = resource('changing-art');
const replacement = resource('changing-art', {
path: 'assets/changing-art-v2.png',
});
const invoke = vi.fn(async (_: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? '')),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender, unmount } = renderHook(
(resources: ProjectResource[]) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-identity-change',
projectId: 'preview-identity-change',
mode: 'dependency',
resources,
canvasRef,
}),
{ initialProps: [original] },
);
const originalIdentity = result.current.identityByResourceId.get(
original.id,
)!;
act(() =>
result.current.requestPreview(original, originalIdentity, 'visible'),
);
await waitFor(() =>
expect(result.current.previews.get(originalIdentity)?.status).toBe(
'loaded',
),
);
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
rerender([replacement]);
const replacementIdentity = result.current.identityByResourceId.get(
replacement.id,
)!;
expect(replacementIdentity).not.toBe(originalIdentity);
await waitFor(() =>
expect(result.current.previews.has(originalIdentity)).toBe(false),
);
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith(
'blob:resource-preview-0',
);
act(() =>
result.current.requestPreview(replacement, replacementIdentity, 'detail'),
);
await waitFor(() =>
expect(result.current.previews.get(replacementIdentity)?.status).toBe(
'loaded',
),
);
act(() =>
result.current.failPreview(replacementIdentity, '暂时失败', true),
);
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith(
'blob:resource-preview-1',
);
act(() =>
result.current.requestPreview(replacement, replacementIdentity, 'detail'),
);
await waitFor(() =>
expect(result.current.previews.get(replacementIdentity)?.status).toBe(
'loaded',
),
);
unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith(
'blob:resource-preview-2',
);
});
it('cancels the old native scope before issuing a fresh scope and ignores an old finally', async () => {
const scopeAResources = Array.from({ length: 3 }, (_, index) =>
resource(`scope-a-${index}`),
);
const scopeBResources = Array.from({ length: 4 }, (_, index) =>
resource(`scope-b-${index}`),
);
const pending: Array<{
path: string;
read: DeferredPreview;
}> = [];
const invoke = vi.fn((command: string, args?: Record<string, unknown>) => {
if (command === 'cancel_local_project_resource_preview_scope') {
return Promise.resolve();
}
const read = deferred<ReturnType<typeof preview>>();
pending.push({ path: String(args?.relativePath ?? ''), read });
return read.promise;
});
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
(props: {
projectPath: string;
projectId: string;
resources: ProjectResource[];
}) =>
useProjectResourceCardPreviews({
...props,
mode: 'dependency',
canvasRef,
}),
{
initialProps: {
projectPath: '/tmp/preview-scope-a',
projectId: 'preview-scope-a',
resources: scopeAResources,
},
},
);
act(() => {
for (const candidate of scopeAResources) {
result.current.requestPreview(
candidate,
result.current.identityByResourceId.get(candidate.id)!,
'visible',
);
}
});
expect(previewReadCalls(invoke)).toHaveLength(3);
const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId;
expect(firstScopeId).toEqual(expect.any(String));
expect(previewReadCalls(invoke).map((call) => call[1]?.scopeId)).toEqual([
firstScopeId,
firstScopeId,
firstScopeId,
]);
expect(
new Set(previewReadCalls(invoke).map((call) => call[1]?.requestId)).size,
).toBe(3);
rerender({
projectPath: '/tmp/preview-scope-b',
projectId: 'preview-scope-b',
resources: scopeBResources,
});
act(() => {
for (const candidate of scopeBResources) {
result.current.requestPreview(
candidate,
result.current.identityByResourceId.get(candidate.id)!,
'visible',
);
}
});
const cancelCallIndex = invoke.mock.calls.findIndex(
([command]) => command === 'cancel_local_project_resource_preview_scope',
);
const firstScopeBReadIndex = invoke.mock.calls.findIndex(
([command, args]) =>
String(command).startsWith('read_local_project_') &&
args?.projectPath === '/tmp/preview-scope-b',
);
expect(cancelCallIndex).toBeGreaterThanOrEqual(0);
expect(cancelCallIndex).toBeLessThan(firstScopeBReadIndex);
expect(invoke.mock.calls[cancelCallIndex]?.[1]).toEqual({
scopeId: firstScopeId,
});
expect(previewReadCalls(invoke)).toHaveLength(6);
const secondScopeId = previewReadCalls(invoke)[3]?.[1]?.scopeId;
expect(secondScopeId).toEqual(expect.any(String));
expect(secondScopeId).not.toBe(firstScopeId);
await act(async () => {
pending[0]!.read.resolve(preview(pending[0]!.path));
await Promise.resolve();
await Promise.resolve();
});
expect(previewReadCalls(invoke)).toHaveLength(6);
await act(async () => {
pending[3]!.read.resolve(preview(pending[3]!.path));
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() => expect(previewReadCalls(invoke)).toHaveLength(7));
});
it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => {
const art = resource('mode-switch-art');
const invoke = vi.fn(async (command: string) => {
if (command === 'cancel_local_project_resource_preview_scope') {
return undefined;
}
return preview(art.path);
});
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender, unmount } = renderHook(
(mode: 'dependency' | 'type') =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-mode-switch',
projectId: 'preview-mode-switch',
mode,
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
}),
{ initialProps: 'dependency' as const },
);
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
);
const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId;
expect(firstScopeId).toEqual(expect.any(String));
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
rerender('type');
expect(result.current.identityByResourceId.get(art.id)).toBe(firstIdentity);
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded');
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
expect(previewReadCalls(invoke)).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'cancel_local_project_resource_preview_scope',
),
).toHaveLength(0);
unmount();
const cancelledScopeIds = invoke.mock.calls
.filter(
([command]) =>
command === 'cancel_local_project_resource_preview_scope',
)
.map((call) => call[1]?.scopeId);
expect(cancelledScopeIds).toEqual([firstScopeId]);
});
it('invalidates an in-place asset preview when its committed version changes', async () => {
const art = resource('refined-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
(previewVersion: string) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-refine-version',
projectId: 'preview-refine-version',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
previewVersionByResourceId: new Map([[art.id, previewVersion]]),
}),
{ initialProps: 'commit-1' },
);
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
rerender('commit-2');
const secondIdentity = result.current.identityByResourceId.get(art.id)!;
expect(secondIdentity).not.toBe(firstIdentity);
await waitFor(() =>
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'blob:resource-preview-0',
),
);
await waitFor(() =>
expect(result.current.previews.get(secondIdentity)?.status).toBe(
'loaded',
),
);
expect(previewReadCalls(invoke)).toHaveLength(2);
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
expect(result.current.previews.has(firstIdentity)).toBe(false);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
});
it('rejects an A-B-A late result even when scope and identity match again', async () => {
const art = resource('aba-art');
const pending: DeferredPreview[] = [];
const invoke = vi.fn((command: string) => {
if (command === 'cancel_local_project_resource_preview_scope') {
return Promise.resolve();
}
const read = deferred<ReturnType<typeof preview>>();
pending.push(read);
return read.promise;
});
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
(props: { projectPath: string; projectId: string }) =>
useProjectResourceCardPreviews({
...props,
mode: 'dependency',
resources: [art],
canvasRef,
}),
{
initialProps: {
projectPath: '/tmp/preview-aba-a',
projectId: 'preview-aba-a',
},
},
);
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
act(() => result.current.requestPreview(art, firstIdentity, 'visible'));
rerender({
projectPath: '/tmp/preview-aba-b',
projectId: 'preview-aba-b',
});
rerender({
projectPath: '/tmp/preview-aba-a',
projectId: 'preview-aba-a',
});
const currentIdentity = result.current.identityByResourceId.get(art.id)!;
expect(currentIdentity).toBe(firstIdentity);
act(() => result.current.requestPreview(art, currentIdentity, 'visible'));
const readCalls = previewReadCalls(invoke);
expect(readCalls).toHaveLength(2);
expect(readCalls[1]?.[1]?.scopeId).not.toBe(readCalls[0]?.[1]?.scopeId);
const cancelledScopeIds = invoke.mock.calls
.filter(
([command]) =>
command === 'cancel_local_project_resource_preview_scope',
)
.map((call) => call[1]?.scopeId);
expect(cancelledScopeIds).toEqual([
readCalls[0]?.[1]?.scopeId,
expect.any(String),
]);
expect(cancelledScopeIds[1]).not.toBe(cancelledScopeIds[0]);
await act(async () => {
pending[0]!.resolve(preview(art.path));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.previews.get(currentIdentity)?.status).toBe(
'loading',
);
expect(URL.createObjectURL).not.toHaveBeenCalled();
await act(async () => {
pending[1]!.resolve(preview(art.path));
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() =>
expect(result.current.previews.get(currentIdentity)?.status).toBe(
'loaded',
),
);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
});
it('observes cards against the provided intersection root and loads visible previews', async () => {
const art = resource('intersection-root-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const intersectionRoot = document.createElement('div');
const callbacks: IntersectionObserverCallback[] = [];
const roots: (Element | Document | null)[] = [];
const observed = new Set<Element>();
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
class TestIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
callbacks.push(callback);
roots.push(options?.root ?? null);
}
observe(element: Element) {
observed.add(element);
}
unobserve(element: Element) {
observed.delete(element);
}
disconnect() {
observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: TestIntersectionObserver,
});
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-intersection-root',
projectId: 'preview-intersection-root',
mode: 'type',
resources: [art],
canvasRef,
intersectionRootRef: { current: intersectionRoot },
eagerPreviewLimit: 0,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
const card = document.createElement('div');
act(() => {
result.current.observePreview(card, art, identity);
});
await waitFor(() => expect(callbacks.length).toBeGreaterThan(0));
expect(roots.at(-1)).toBe(intersectionRoot);
expect(observed.has(card)).toBe(true);
expect(previewReadCalls(invoke)).toHaveLength(0);
act(() => {
callbacks.at(-1)!(
[
{
target: card,
isIntersecting: true,
intersectionRatio: 1,
} as IntersectionObserverEntry,
],
{} as IntersectionObserver,
);
});
await waitFor(() =>
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
);
expect(previewReadCalls(invoke)).toHaveLength(1);
} finally {
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
}
});
/**
* 可见性门禁的时机契约:observer 必须**等 root 真正就绪**才建,且建好后要把
* 登记表里的卡一次性补挂齐。
*
* 背景:root 是资源画本容器(卡片渲染在它内部)。若在 root 还是 null 时就建 observer
* 浏览器会退回按视口判定,被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行,
* 卡片就停在 `idle`、卡面只剩占位图标 —— 真机现场出现的正是「同一屏里 idle 与 loaded 交错」。
*/
describe('可见性门禁的 root 时机契约', () => {
type ObserverHarness = {
callbacks: IntersectionObserverCallback[];
roots: (Element | Document | null)[];
observed: Set<Element>;
restore: () => void;
};
function installIntersectionObserver(): ObserverHarness {
const callbacks: IntersectionObserverCallback[] = [];
const roots: (Element | Document | null)[] = [];
const observed = new Set<Element>();
const original = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
class TestIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
callbacks.push(callback);
roots.push(options?.root ?? null);
}
observe(element: Element) {
observed.add(element);
}
unobserve(element: Element) {
observed.delete(element);
}
disconnect() {
observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
writable: true,
value: TestIntersectionObserver,
});
return {
callbacks,
roots,
observed,
restore: () => {
if (original) {
Object.defineProperty(window, 'IntersectionObserver', original);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
},
};
}
function fireVisible(harness: ObserverHarness, targets: Iterable<Element>) {
act(() => {
harness.callbacks.at(-1)!(
[...targets].map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
{} as IntersectionObserver,
);
});
}
it('waits for a real root, then observes and loads the cards', async () => {
const art = resource('late-root-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
// root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。
const canvasRef: { current: HTMLDivElement | null } = { current: null };
const harness = installIntersectionObserver();
try {
const { result } = renderHook(() =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-late-root',
projectId: 'preview-late-root',
mode: 'dependency',
resources: [art],
canvasRef,
// 关掉热预取,逼这张卡只能靠可见性放行。
eagerPreviewLimit: 0,
}),
);
const identity = result.current.identityByResourceId.get(art.id)!;
// root 还没就绪时不得建 observer —— 否则会按视口判定,被容器裁掉的卡永远不可见。
expect(harness.callbacks).toHaveLength(0);
const root = document.createElement('div');
const card = document.createElement('div');
root.append(card);
canvasRef.current = root;
let unregister: () => void = () => undefined;
act(() => {
unregister = result.current.observePreview(card, art, identity);
});
// 注册本身就是「root 已就绪」的信号:observer 必须以真正的 root 建出来。
await waitFor(() =>
expect(harness.callbacks.length).toBeGreaterThan(0),
);
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');
}
}
});
});