Files
openmaic/OpenMAIC/tests/export/pptx-media-fallback.test.ts
2026-08-16 14:58:47 +08:00

277 lines
8.7 KiB
TypeScript

import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Slide } from '@openmaic/dsl';
import type { Scene } from '@/lib/types/stage';
const mocks = vi.hoisted(() => ({
mediaGet: vi.fn(),
poolResolve: vi.fn(),
poolRelease: vi.fn(),
}));
vi.mock('@/lib/utils/database', () => ({
mediaFileKey: (stageId: string, ref: string) => `${stageId}:${ref}`,
db: { mediaFiles: { get: mocks.mediaGet } },
}));
vi.mock('@/lib/media/asset-pool', () => ({
getAssetPool: () => ({ resolve: mocks.poolResolve, release: mocks.poolRelease }),
}));
import { buildPptxBlob } from '@/lib/export/use-export-pptx';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const PNG_BYTES = Uint8Array.from(atob(PNG_BASE64), (char) => char.charCodeAt(0));
function baseSlide(element: Record<string, unknown>): Slide {
return {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
background: { type: 'solid', color: '#ffffff' },
theme: {
fontName: 'Arial',
fontColor: '#111111',
backgroundColor: '#ffffff',
themeColors: ['#111111'],
},
elements: [element],
} as unknown as Slide;
}
function sceneFor(slide: Slide): Scene {
return {
id: 'scene-1',
stageId: 'stage-1',
type: 'slide',
title: 'Scene',
order: 1,
content: { type: 'slide', canvas: slide },
} as Scene;
}
async function pptxMediaBytes(blob: Blob): Promise<Uint8Array[]> {
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
return Promise.all(
Object.values(zip.files)
.filter((entry) => !entry.dir && entry.name.startsWith('ppt/media/'))
.map((entry) => entry.async('uint8array')),
);
}
describe('PPTX media fallback chains', () => {
beforeEach(() => {
mocks.mediaGet.mockReset().mockResolvedValue(undefined);
mocks.poolResolve.mockReset().mockResolvedValue(null);
mocks.poolRelease.mockReset().mockResolvedValue(undefined);
useMediaGenerationStore.setState({ tasks: {} });
vi.stubGlobal(
'FileReader',
class TestFileReader {
result: string | null = null;
onloadend: (() => void) | null = null;
onerror: (() => void) | null = null;
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then(
(bytes) => {
this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(bytes).toString('base64')}`;
this.onloadend?.();
},
() => this.onerror?.(),
);
}
},
);
});
afterEach(() => vi.unstubAllGlobals());
it('embeds a concrete video src when its opaque mediaRef cannot be resolved', async () => {
const videoBytes = new TextEncoder().encode('direct-video-bytes');
const fetchSpy = vi.fn(async (url: string) => {
if (url === 'https://cdn.example/video.mp4') {
return new Response(new Blob([videoBytes], { type: 'video/mp4' }));
}
return new Response(null, { status: 404 });
});
vi.stubGlobal('fetch', fetchSpy);
const slide = baseSlide({
id: 'video-1',
type: 'video',
src: 'https://cdn.example/video.mp4',
mediaRef: 'ast_missing',
left: 0,
top: 0,
width: 640,
height: 360,
rotate: 0,
});
const blob = await buildPptxBlob([slide], [sceneFor(slide)], 0.5625, 1000, 100, 1, 'stage-1');
const media = await pptxMediaBytes(blob);
expect(fetchSpy).toHaveBeenCalledWith('https://cdn.example/video.mp4');
expect(mocks.mediaGet).not.toHaveBeenCalledWith('stage-1:ast_missing');
expect(media.some((bytes) => Buffer.from(bytes).equals(Buffer.from(videoBytes)))).toBe(true);
});
it('embeds an allocated generated poster from its own Dexie row', async () => {
mocks.mediaGet.mockImplementation(async (key: string) => {
if (key === 'stage-1:ast_video') {
return {
id: key,
stageId: 'stage-1',
type: 'video',
blob: new Blob(['video-bytes'], { type: 'video/mp4' }),
mimeType: 'video/mp4',
size: 11,
prompt: 'Clip',
params: '{}',
createdAt: 1,
};
}
if (key === 'stage-1:ast_poster') {
return {
id: key,
stageId: 'stage-1',
type: 'image',
blob: new Blob([PNG_BYTES], { type: 'image/png' }),
mimeType: 'image/png',
size: PNG_BYTES.byteLength,
prompt: 'Poster',
params: '{}',
createdAt: 1,
};
}
return undefined;
});
const slide = baseSlide({
id: 'video-1',
type: 'video',
src: 'ast_video',
mediaRef: 'ast_video',
poster: 'ast_poster',
left: 0,
top: 0,
width: 640,
height: 360,
rotate: 0,
});
const blob = await buildPptxBlob([slide], [sceneFor(slide)], 0.5625, 1000, 100, 1, 'stage-1');
const media = await pptxMediaBytes(blob);
expect(media.some((bytes) => Buffer.from(bytes).equals(Buffer.from(PNG_BYTES)))).toBe(true);
expect(mocks.mediaGet).toHaveBeenCalledWith('stage-1:ast_poster');
});
it('embeds an allocated slide background from its Dexie row', async () => {
mocks.mediaGet.mockImplementation(async (key: string) =>
key === 'stage-1:ast_background'
? {
id: key,
stageId: 'stage-1',
type: 'image',
blob: new Blob([PNG_BYTES], { type: 'image/png' }),
mimeType: 'image/png',
size: PNG_BYTES.byteLength,
prompt: 'Background',
params: '{}',
createdAt: 1,
}
: undefined,
);
const slide = {
...baseSlide({}),
background: { type: 'image', image: { src: 'ast_background', size: 'cover' } },
elements: [],
} as Slide;
const blob = await buildPptxBlob([slide], [sceneFor(slide)], 0.5625, 1000, 100, 1, 'stage-1');
const media = await pptxMediaBytes(blob);
expect(media.some((bytes) => Buffer.from(bytes).equals(Buffer.from(PNG_BYTES)))).toBe(true);
expect(mocks.mediaGet).toHaveBeenCalledWith('stage-1:ast_background');
});
it('prefers replaced pool bytes over a stale compatibility row', async () => {
const staleBytes = new TextEncoder().encode('dexie-old');
mocks.mediaGet.mockResolvedValue({
id: 'stage-1:ast_retried_background',
stageId: 'stage-1',
type: 'image',
blob: new Blob([staleBytes], { type: 'image/png' }),
mimeType: 'image/png',
size: staleBytes.byteLength,
prompt: 'Background',
params: '{}',
createdAt: 1,
});
mocks.poolResolve.mockResolvedValue('https://pool.test/retried-background');
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Blob([PNG_BYTES], { type: 'image/png' }))),
);
useMediaGenerationStore.setState({
tasks: {
ast_retried_background: {
elementId: 'ast_retried_background',
type: 'image',
status: 'failed',
prompt: 'Background',
params: {},
error: 'Compatibility write failed',
errorCode: 'MEDIA_COMPATIBILITY_STORE_LAGGED',
retryCount: 1,
stageId: 'stage-1',
},
},
});
const slide = {
...baseSlide({}),
background: {
type: 'image',
image: { src: 'ast_retried_background', size: 'cover' },
},
elements: [],
} as Slide;
const blob = await buildPptxBlob([slide], [sceneFor(slide)], 0.5625, 1000, 100, 1, 'stage-1');
const media = await pptxMediaBytes(blob);
expect(mocks.poolResolve).toHaveBeenCalledWith('ast_retried_background');
expect(media.some((bytes) => Buffer.from(bytes).equals(Buffer.from(PNG_BYTES)))).toBe(true);
expect(media.some((bytes) => Buffer.from(bytes).equals(Buffer.from(staleBytes)))).toBe(false);
});
it('falls back to the original image fetch when task, Dexie, and pool all miss', async () => {
const fetchSpy = vi.fn(async (url: string) => {
if (url === 'logo.png') {
return new Response(new Blob([PNG_BYTES], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
vi.stubGlobal('fetch', fetchSpy);
const slide = baseSlide({
id: 'image-1',
type: 'image',
src: 'logo.png',
left: 0,
top: 0,
width: 100,
height: 100,
rotate: 0,
fixedRatio: true,
});
const blob = await buildPptxBlob([slide], [sceneFor(slide)], 0.5625, 1000, 100, 1, 'stage-1');
const media = await pptxMediaBytes(blob);
expect(fetchSpy).toHaveBeenCalledWith('logo.png');
expect(media.some((bytes) => Buffer.from(bytes).equals(Buffer.from(PNG_BYTES)))).toBe(true);
});
});