28e11c4718
新增项目封面 IndexedDB 本地缓存。 封面 Blob 生成后先写入本地缓存,上传失败仍可展示。 项目列表优先服务端封面,其次本地缓存,再回退画布图层。 补充封面兜底测试和项目记忆文档。
313 lines
9.0 KiB
TypeScript
313 lines
9.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
|
|
import {
|
|
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
|
resolveProjectCoverResource,
|
|
resolveProjectCoverSnapshotResource,
|
|
} from './ProjectCanvasCover';
|
|
|
|
function project(
|
|
resources: EditorProjectSnapshot['resources'],
|
|
layers: EditorProjectSnapshot['layers'] = [],
|
|
): EditorProjectSnapshot {
|
|
return {
|
|
projectId: 'editor-project-cover',
|
|
title: '封面项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers,
|
|
resources,
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
describe('resolveProjectCoverSnapshotResource', () => {
|
|
it('prefers the newest cover snapshot object timestamp', () => {
|
|
const cover = resolveProjectCoverSnapshotResource(
|
|
project([
|
|
{
|
|
resourceId: 'resource-newer-created-row',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/project-covers/editor-project-cover/1771400000000/cover.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/project-covers/editor-project-cover/1771400000000/cover.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'project-cover-snapshot',
|
|
updatedAt: '2026-06-12T09:10:00.000Z',
|
|
},
|
|
{
|
|
resourceId: 'resource-newer-snapshot',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/project-covers/editor-project-cover/1771400000100/cover.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/project-covers/editor-project-cover/1771400000100/cover.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'project-cover-snapshot',
|
|
updatedAt: '2026-06-12T09:00:00.000Z',
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-newer-snapshot');
|
|
});
|
|
|
|
it('ignores non-cover resources and empty cover snapshots', () => {
|
|
const cover = resolveProjectCoverSnapshotResource(
|
|
project([
|
|
{
|
|
resourceId: 'resource-image',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/source.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'character',
|
|
},
|
|
{
|
|
resourceId: 'resource-empty-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'project-cover-snapshot',
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(cover).toBeNull();
|
|
});
|
|
|
|
it('supports legacy editor object asset kind identifier', () => {
|
|
const cover = resolveProjectCoverSnapshotResource(
|
|
project([
|
|
{
|
|
resourceId: 'resource-legacy-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '',
|
|
objectKey:
|
|
'generated-character-drafts/editor/project-covers/editor-project-cover/1771400000200/cover.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'editor_project_cover_snapshot',
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-legacy-cover');
|
|
});
|
|
|
|
it('supports legacy snake-case project cover resource payload', () => {
|
|
const cover = resolveProjectCoverSnapshotResource(
|
|
project([
|
|
{
|
|
resourceId: 'resource-snake-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
asset_kind: 'project-cover-snapshot',
|
|
src: '/generated-character-drafts/editor/project-covers/editor-project-cover/1771400000300/cover.png',
|
|
} as EditorProjectSnapshot['resources'][number],
|
|
]),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-snake-cover');
|
|
});
|
|
});
|
|
|
|
describe('resolveProjectCoverResource', () => {
|
|
it('prefers the saved cover snapshot over canvas layer resources', () => {
|
|
const cover = resolveProjectCoverResource(
|
|
project(
|
|
[
|
|
{
|
|
resourceId: 'resource-layer',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/layer.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
resourceId: 'resource-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/cover.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'project-cover-snapshot',
|
|
},
|
|
],
|
|
[{ layerId: 'layer-top', resourceId: 'resource-layer', zIndex: 9 }],
|
|
),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-cover');
|
|
});
|
|
|
|
it('uses local cached covers only when no saved cover snapshot exists', () => {
|
|
const cover = resolveProjectCoverResource(
|
|
project([
|
|
{
|
|
resourceId: 'resource-local-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: 'blob:local-cover',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
|
updatedAt: '2026-06-12T09:10:00.000Z',
|
|
},
|
|
{
|
|
resourceId: 'resource-saved-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/cover.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'project-cover-snapshot',
|
|
updatedAt: '2026-06-12T09:00:00.000Z',
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-saved-cover');
|
|
|
|
const localCover = resolveProjectCoverResource(
|
|
project([
|
|
{
|
|
resourceId: 'resource-local-cover',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: 'blob:local-cover',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(localCover?.resourceId).toBe('resource-local-cover');
|
|
});
|
|
|
|
it('falls back to the top visible canvas layer resource', () => {
|
|
const cover = resolveProjectCoverResource(
|
|
project(
|
|
[
|
|
{
|
|
resourceId: 'resource-back',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/back.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
resourceId: 'resource-front',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/front.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
[
|
|
{ layerId: 'layer-back', resourceId: 'resource-back', zIndex: 1 },
|
|
{ layerId: 'layer-front', resourceId: 'resource-front', zIndex: 3 },
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-front');
|
|
});
|
|
|
|
it('ignores hidden and audio layers when falling back', () => {
|
|
const cover = resolveProjectCoverResource(
|
|
project(
|
|
[
|
|
{
|
|
resourceId: 'resource-hidden',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/hidden.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
resourceId: 'resource-audio',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/audio.mp3',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
resourceId: 'resource-visible',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/visible.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
[
|
|
{
|
|
layerId: 'layer-hidden',
|
|
resourceId: 'resource-hidden',
|
|
hidden: true,
|
|
zIndex: 9,
|
|
},
|
|
{
|
|
layerId: 'layer-audio',
|
|
resourceId: 'resource-audio',
|
|
mediaType: 'audio',
|
|
zIndex: 8,
|
|
},
|
|
{
|
|
layerId: 'layer-visible',
|
|
resourceId: 'resource-visible',
|
|
zIndex: 1,
|
|
},
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(cover?.resourceId).toBe('resource-visible');
|
|
});
|
|
|
|
it('returns null when the project has no visible layer image resource', () => {
|
|
const cover = resolveProjectCoverResource(
|
|
project(
|
|
[
|
|
{
|
|
resourceId: 'resource-hidden',
|
|
projectId: 'editor-project-cover',
|
|
imageSrc: '/generated/hidden.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
[
|
|
{
|
|
layerId: 'layer-hidden',
|
|
resourceId: 'resource-hidden',
|
|
hidden: true,
|
|
},
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(cover).toBeNull();
|
|
});
|
|
});
|