Files
Genarrative/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
T
suzmii 2f8753f597 AGC 资源画布切换为 6 类资产分类加项目版本栏目(阶段二:投影轴与栏目表落地)
- 契约层把 ProjectResourceCanvasPosition.section 加宽为 PersistedProjectResourceCanvasSection 并删除旧 ProjectResourceCanvasSection 联合
- 七个分区类型消费点改用 ProjectResourceCanvasCategory:投影、布局模型、布局 Hook、分区高度、依赖图层与画布历史
- RESOURCE_CANVAS_SECTION_ORDER 改为 PROJECT_RESOURCE_CANVAS_SECTIONS,删除 RESOURCE_CANVAS_VISIBLE_SECTION_ORDER
- reconcileResourceCanvasLayout 接入读时归一化:旧栏目值按资源当前分区改写 section,坐标与手动标记原样保留
- 归并后的布局与原布局逐项不同,由既有协调路径判定 changed 并写回一次新分区,不引入迁移脚本
- 投影层五个资源入表点统一走 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类
- 新增 projectResourceDisplayKind 收敛 manifest 资产 subtype 即 kind 的显示类型口径,projectResourceTypeLabel 不再依赖旧栏目
- 卡片预览、编辑分流、媒体工具条、预览文案与画布历史快照改用显示类型与新分区,历史快照入栈时收窄到现行分区
- index.tsx 删除可见栏目导入与 visibleCategoryOrder,栏目标签由资源筛选唯一中文口径派生并补齐 7 栏
- index.tsx 补齐 7 栏默认视口与 7 个栏目图标(新增 LayoutGrid、Users、PackageOpen,移除已无用的 Code2)
- index.tsx 删除 canvasResources 的 code 过滤:只登记游戏代码的项目现在落在待归类栏目并正常显示卡片
- 新增只登记游戏代码项目的 UI 回归用例,覆盖栏目大纲、默认落地栏目与代码卡片渲染
- 测试夹具按新分区轴迁移,新增旧栏目 sidecar 读时归并用例与栏目顺序用例
- 共享测试 harness 的资源卡查询正则允许栏目标签自带空格(UI 交互)
- 同步 PRD 与共享记忆决策记录的栏目口径描述
2026-09-10 20:41:19 +08:00

914 lines
29 KiB
TypeScript

// @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,
category: 'unclassified',
}),
);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
});
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');
}
}
});
});