532c6d51fd
## 背景 本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。 当前远端比较基线: - base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d` - head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472` - 相对 base:28 commits、33 files(`+3746 / -1333`) ## 主要改动 ### 资源管理分页自由画布 - 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。 - `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。 - 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。 - 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。 - 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。 - 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。 ### 图片持续精修与生成事务 - 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。 - 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。 - 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。 - 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。 - 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。 ### 合同与文档 - `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。 - 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。 - 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。 ## 验证 CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功: - [x] Repository checks - [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests) - [x] Backend tests - [x] Native shell tests 另有本地定向检查: - [x] `npm run agc:typecheck` - [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归 - [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试 - [x] `cargo fmt --check` - [x] `npm run check:encoding` - [x] `git diff --check` ## 评审 provenance - review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。 - review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。 - 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。 - 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。 ## 影响范围 - 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。 - 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。 - 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
808 lines
26 KiB
TypeScript
808 lines
26 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: 'art',
|
|
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: 'art' }),
|
|
);
|
|
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);
|
|
});
|
|
});
|