Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
T
suzmii 3de2ec734b 预览门禁去掉单点依赖:observer 必定建出,并加可见性兜底扫描
真机现场:整个「UI 交互」栏(51 项)全部只剩占位图标。根因链是**同一对文件里的两个缺陷叠加**,因此作为一个原子提交收口(拆成两半任一半都不足以独立成立:光修 observer 仍可能被回调时序漏掉,光加扫描则 observer 根本不存在时的判定精度无从恢复)。

一、确认 2a157ea6f 引入的回归:observer 可能永远不建
- 那次改动把「root 为 null」当成「不建 observer」,并指望「卡片注册」作为 root 就绪信号重建。
- 但那个 effect 每个 scope **实际只跑一次**(`requestPreview` 是稳定引用,其余依赖在 scope 内不变),一旦这一轮因 root 为 null 直接返回就再无第二次机会;
- 而「卡片注册」信号只在**后续还有新注册**时才来 —— 初次挂载时 51 张卡一次性注册完,之后再没有新注册,信号永不再来。
- 结果:observer 永不创建、所有卡永不被观察、永远停在 `idle`,整栏只剩占位图标(`categoryIcons['ui-interaction'] = LayoutGrid`)。
- 修复:**绝不拿"root 未就绪"当作"不建 observer"的理由**。拿到容器 root 最好;拿不到就退到 canvas、再退到视口(`root: null`)。视口判定严格优于"没有 observer"——屏幕上真实可见的卡一定落在视口矩形内;代价只是视口外但仍在画布内的卡晚一步由滚动触发。容器 root 迟到时再有界重试(5 次、退避到 512ms)重建一次,恢复按画本容器裁剪的精确判定。

二、系统性防线:可见性兜底扫描(去掉单点依赖)
- 门禁原先只有 `IntersectionObserver` 一条路,observer 没建出来 / 回调没送达 / 注册与创建交错,卡片就再无第二次机会。
- 新增 `sweepVisiblePreviews()`:按**与 observer 对齐的视口几何判据**(含 160px 余量)独立复核登记表,只对 `idle`(从未请求)的卡补一次 `visible`,`loading`/`queued`/`loaded`/`failed` 一律不碰。
- **不是第二条加载通路**:仍经 `requestPreview` 走同一条队列,受同一套去重、优先级、全局 3 槽与 `48` 项 / `64 MiB` 预算约束;**只补"可判定为可见"的卡,不退化成全量预读**(PRD §3.3.2 合同不变)。
- 触发点:scope 变化后 0 / 250 / 1000ms 各扫一次(覆盖晚挂载的卡)、observer 就绪后立即扫一次、每次卡片注册时扫一次、窗口 resize 与页面重新可见时各扫一次。

测试(`tests/useProjectResourceCardPreviews.test.ts`)
- 更新「等真正的 root 就绪」这条契约:它原先断言"root 为 null 时不得建 observer"——那正是本轮回归的语义,改为断言"**仍然必须建出 observer**(`root: null` 视口判定)",并要求容器 root 迟到后重建为容器 root;
- 新增「不在热预取窗口内的可见卡不得停在 idle」(20 张 / 热预取 12);
- 新增「没有任何 observer 回调时,可见卡也必须被放行」——完全不触发回调,只靠几何可见性,专门守住兜底扫描;
- 新增「视口外的已登记卡不得被请求」——守住"不退化成全量预读"。

变异验证:把兜底扫描改成直接 `return 0`(等价于去掉扫描)→ 「没有任何 observer 回调时可见卡也必须被放行」立即失败(`expected undefined to be 'loaded'`);恢复后 21/21 通过。断言不是恒真假守卫。

可观测性:`data-preview-status` / `data-preview-error` 已在位,因此"可见却 idle"这类问题以后读一个 DOM 属性即可判定,不再需要解码图片或猜调度。

验证(同一时刻、同一命令的前后对照;树上另有并发改动):改动前 `86 files / 1225 passed / 4 skipped / 0 failed`;改动后 `86 files / 1227 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)。`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4384 文件;prettier 与 eslint 干净;`git diff --check` 干净。
2026-09-11 16:49:09 +08:00

1433 lines
48 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**(退到视口判定)。
// 若这里因为 `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();
}
});
});
});