2a157ea6f8
真机现场:同一屏里 `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` 干净。
1338 lines
44 KiB
TypeScript
1338 lines
44 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,
|
||
// 线上合同的读取分支取值,不是资源所在的画布栏目: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');
|
||
}
|
||
}
|
||
});
|
||
});
|