Files
openmaic/OpenMAIC/tests/media/media-resolution-surface-matrix.test.ts
2026-08-16 14:58:47 +08:00

568 lines
18 KiB
TypeScript

import { createElement } from 'react';
import type { ComponentProps, ComponentType, ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PPTImageElement, PPTVideoElement, Slide } from '@openmaic/dsl';
import {
resolveImageSrc,
useResolvedImageSrc,
} from '@/components/slide-renderer/components/element/ImageElement/useResolvedImageSrc';
import { BaseVideoElement } from '@/components/slide-renderer/components/element/VideoElement/BaseVideoElement';
import { VideoElement } from '@/components/slide-renderer/components/element/VideoElement';
import { resolveSlideMediaState } from '@/components/slide-renderer/use-resolved-slide';
import { useResolvedVideoMedia } from '@/components/slide-renderer/components/element/VideoElement/useResolvedVideoMedia';
import { MediaStageProvider } from '@/lib/contexts/media-stage-context';
import { SceneProvider } from '@/lib/contexts/scene-context';
import { resolvePptxMediaBinding } from '@/lib/export/use-export-pptx';
import {
MISSING_ASSET_LEASE,
isConcreteMediaAddress,
mediaResolutionCanRetry,
renderableMediaUrl,
resolveMediaRef,
type MediaResolution,
type MediaTaskState,
} from '@/lib/media/resolve-media-ref';
import type { AssetUrlLeaseState } from '@/lib/media/use-asset-url';
import type { MediaTask } from '@/lib/store/media-generation';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useSettingsStore } from '@/lib/store/settings';
import { resolveThumbnailMediaValue } from '@/lib/utils/stage-storage';
import { resolveVideoExportMediaBinding } from '@/lib/video-export-app/collect';
import { resolveActionVideoMedia } from '@/lib/action/engine';
import type { StageStore } from '@/lib/api/stage-api';
const stageId = 'stage-matrix';
const posterRef = 'ast_video_poster';
const posterLease = { status: 'resolved', url: 'blob:poster' } satisfies AssetUrlLeaseState;
const hookLeases = vi.hoisted(() => ({
current: {} as Record<string, AssetUrlLeaseState>,
}));
const componentStores = vi.hoisted(() => ({
media: { tasks: {} as Record<string, MediaTask> },
settings: { imageGenerationEnabled: false, videoGenerationEnabled: false },
}));
vi.mock('@/lib/media/use-asset-url', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/media/use-asset-url')>();
return {
...actual,
useAssetUrlLease: (ref: string | undefined) =>
ref ? (hookLeases.current[ref] ?? { status: 'missing' }) : { status: 'pending' },
};
});
vi.mock('@/lib/hooks/use-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock('@/lib/store/media-generation', () => {
const useMediaGenerationStore = Object.assign(
(selector: (state: typeof componentStores.media) => unknown) => selector(componentStores.media),
{
getState: () => componentStores.media,
setState: (state: Partial<typeof componentStores.media>) =>
Object.assign(componentStores.media, state),
},
);
return { useMediaGenerationStore };
});
vi.mock('@/lib/store/settings', () => {
const useSettingsStore = Object.assign(
(selector: (state: typeof componentStores.settings) => unknown) =>
selector(componentStores.settings),
{
getState: () => componentStores.settings,
setState: (state: Partial<typeof componentStores.settings>) =>
Object.assign(componentStores.settings, state),
},
);
return { useSettingsStore };
});
const SceneProviderWithOptionalChildren = SceneProvider as ComponentType<
Omit<ComponentProps<typeof SceneProvider>, 'children'> & { children?: ReactNode }
>;
type Presentation = 'concrete' | 'empty' | 'skeleton' | 'disabled';
interface ResolutionCase {
readonly name: string;
readonly ref: string;
readonly task?: MediaTaskState;
readonly lease?: AssetUrlLeaseState;
readonly disabled?: boolean;
readonly expected: Presentation;
readonly retryable?: boolean;
}
const task = (
status: MediaTaskState['status'],
overrides: Omit<Partial<MediaTaskState>, 'status'> = {},
): MediaTaskState => ({ status, retryCount: 0, ...overrides });
const cases: readonly ResolutionCase[] = [
{
name: 'resolved allocation',
ref: 'ast_pool_ref',
lease: { status: 'resolved', url: 'blob:pool' },
expected: 'concrete',
},
{
name: 'direct address',
ref: 'https://example.test/media.png',
expected: 'concrete',
},
{
name: 'pending generation',
ref: 'gen_img_pending',
task: task('pending'),
expected: 'skeleton',
},
{
name: 'untracked placeholder',
ref: 'gen_img_placeholder',
expected: 'skeleton',
},
{
name: 'retryable failure without bytes',
ref: 'gen_img_retryable',
task: task('failed', { errorCode: 'UPSTREAM_TIMEOUT' }),
expected: 'empty',
retryable: true,
},
{
name: 'retryable failure with last-good bytes',
ref: 'ast_last_good',
task: task('failed', { errorCode: 'UPSTREAM_TIMEOUT', objectUrl: 'blob:last-good' }),
expected: 'concrete',
retryable: true,
},
{
name: 'generation disabled',
ref: 'gen_img_disabled',
task: task('pending'),
disabled: true,
expected: 'disabled',
},
{
name: 'missing opaque allocation',
ref: 'ast_missing_ref',
expected: 'skeleton',
},
];
function presentation(src: string, resolution: MediaResolution): Presentation {
if (src) return 'concrete';
if (resolution.kind === 'disabled') return 'disabled';
if (resolution.kind === 'pending' || resolution.kind === 'placeholder') return 'skeleton';
return 'empty';
}
function expectSafeBinding(
src: string,
resolution: MediaResolution,
expected: Presentation,
retryable = false,
): void {
expect(presentation(src, resolution)).toBe(expected);
expect(src === '' || isConcreteMediaAddress(src)).toBe(true);
expect(mediaResolutionCanRetry(resolution)).toBe(retryable);
}
function fullTask(ref: string, state: MediaTaskState | undefined, type: 'image' | 'video') {
if (!state) return undefined;
return {
elementId: ref,
type,
status: state.status,
prompt: '',
params: {},
objectUrl: state.objectUrl,
errorCode: state.errorCode,
retryCount: state.retryCount,
stageId,
} satisfies MediaTask;
}
function imageElement(ref: string): PPTImageElement {
return {
id: 'image-1',
type: 'image',
src: ref,
left: 0,
top: 0,
width: 100,
height: 100,
rotate: 0,
fixedRatio: true,
};
}
function videoElement(ref: string): PPTVideoElement {
return {
id: 'video-1',
type: 'video',
src: ref,
mediaRef: ref,
left: 0,
top: 0,
width: 100,
height: 56,
rotate: 0,
autoplay: false,
poster: posterRef,
};
}
function slideWith(element: PPTImageElement | PPTVideoElement): Slide {
return {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
background: { type: 'solid', color: '#fff' },
elements: [element],
} as Slide;
}
function stageStoreWith(element: PPTVideoElement): StageStore {
return {
getState: () => ({
stage: { id: stageId },
currentSceneId: 'scene-1',
mode: 'edit',
scenes: [
{
id: 'scene-1',
type: 'slide',
order: 0,
title: 'Slide',
content: { type: 'slide', canvas: slideWith(element) },
actions: [],
},
],
}),
setState: () => undefined,
subscribe: () => () => undefined,
} as unknown as StageStore;
}
function renderInMediaScene(
element: PPTImageElement | PPTVideoElement,
child: ReturnType<typeof createElement>,
): string {
const sceneData = { type: 'slide' as const, canvas: slideWith(element) };
return renderToStaticMarkup(
createElement(
MediaStageProvider,
{ value: stageId },
createElement(
SceneProviderWithOptionalChildren,
{
controller: {
sceneId: 'scene-1',
sceneType: 'slide',
getSnapshot: () => sceneData,
updateSceneData: () => undefined,
},
},
child,
),
),
);
}
/**
* Vitest runs this project in plain Node, so this matrix cannot assert DOM media
* attributes or browser playback. It executes each distinct production binding
* seam once: the shared image resolver, the direct video components' media-ref
* glue plus the useResolvedMediaRef state machine and renderable URL boundary,
* and the resolved-slide path shared by thumbnail and renderer consumers.
* Focused component tests remain responsible for renderer DOM structure.
*/
const componentSurfaces: readonly {
readonly name: string;
readonly resolvesPoster?: boolean;
readonly run: (entry: ResolutionCase) => {
readonly src: string;
readonly resolution: MediaResolution;
readonly poster?: string;
};
}[] = [
{
name: 'shared image-element binding (base and editor)',
run: (entry: ResolutionCase) => {
const binding = resolveImageSrc(
imageElement(entry.ref),
stageId,
fullTask(entry.ref, entry.task, 'image'),
entry.lease?.status === 'resolved' ? entry.lease.url : undefined,
entry.disabled,
);
return { src: binding.resolvedSrc, resolution: binding.resolution };
},
},
{
name: 'direct video-element binding (base and editor)',
resolvesPoster: true,
run: (entry: ResolutionCase) => {
const element = videoElement(entry.ref);
hookLeases.current = {
[entry.ref]: entry.lease ?? MISSING_ASSET_LEASE,
[posterRef]: posterLease,
};
function DirectVideoBindingProbe() {
const binding = useResolvedVideoMedia(
element,
entry.task ? { [entry.ref]: fullTask(entry.ref, entry.task, 'video')! } : {},
stageId,
entry.disabled ?? false,
);
const payload = Buffer.from(
JSON.stringify({
src: binding.resolvedSrc ?? '',
resolution: binding.resolution,
poster: binding.resolvedPoster,
} satisfies {
src: string;
resolution: MediaResolution;
poster?: string;
}),
).toString('base64url');
return createElement('div', { 'data-binding': payload });
}
const markup = renderToStaticMarkup(createElement(DirectVideoBindingProbe));
const encoded = /data-binding="([^"]+)"/.exec(markup)?.[1];
if (!encoded) throw new Error('Direct video binding did not render');
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as {
src: string;
resolution: MediaResolution;
poster?: string;
};
},
},
{
name: 'resolved-slide video binding (thumbnail and renderer)',
resolvesPoster: true,
run: (entry: ResolutionCase) => {
const element = videoElement(entry.ref);
const mediaTask = fullTask(entry.ref, entry.task, 'video');
const binding = resolveSlideMediaState(
slideWith(element),
stageId,
mediaTask ? { [entry.ref]: mediaTask } : {},
{
assetLeases: {
[entry.ref]: entry.lease ?? MISSING_ASSET_LEASE,
[posterRef]: posterLease,
},
videoGenerationDisabled: entry.disabled,
},
);
return {
src: (binding.slide.elements[0] as PPTVideoElement).src ?? '',
resolution: binding.byElementId['video-1'].resolution,
poster: (binding.slide.elements[0] as PPTVideoElement).poster,
};
},
},
{
name: 'action play_video binding',
run: (entry: ResolutionCase) => {
const element = videoElement(entry.ref);
const mediaTask = fullTask(entry.ref, entry.task, 'video');
const binding = resolveActionVideoMedia(
stageStoreWith(element),
mediaTask ? { [entry.ref]: mediaTask } : {},
element.id,
);
if (!binding) throw new Error('Action video binding did not resolve');
const resolution = resolveMediaRef(
binding.sourceRef,
binding.task,
entry.lease ?? MISSING_ASSET_LEASE,
entry.disabled,
);
return { src: renderableMediaUrl(resolution) ?? '', resolution };
},
},
];
describe('real media consumer matrix', () => {
afterEach(() => {
useMediaGenerationStore.setState({ tasks: {} });
useSettingsStore.setState({ imageGenerationEnabled: false, videoGenerationEnabled: false });
vi.unstubAllGlobals();
});
it('prefers a playable concrete src over an untracked opaque mediaRef', () => {
const reviewerRepro = { src: 'https://cdn.example/direct.mp4', mediaRef: 'ast_opaque' };
const element = { ...videoElement(reviewerRepro.src), ...reviewerRepro };
function DirectVideoBindingProbe() {
const binding = useResolvedVideoMedia(element, {}, stageId, false);
return createElement('div', {
'data-resolution': binding.resolution.kind,
'data-src': binding.resolvedSrc,
});
}
const markup = renderToStaticMarkup(createElement(DirectVideoBindingProbe));
expect(markup).toContain('data-resolution="raw"');
expect(markup).toContain('data-src="https://cdn.example/direct.mp4"');
expect(markup).not.toContain('placeholder');
});
it('useResolvedImageSrc prefers element-keyed fork progress over the shared source task', () => {
const ref = 'ast_shared_image';
const element = imageElement(ref);
const source = fullTask(ref, task('done', { objectUrl: 'blob:shared-image' }), 'image');
const targeted = fullTask(ref, task('pending'), 'image');
if (!source || !targeted) throw new Error('Expected media tasks');
useSettingsStore.setState({ imageGenerationEnabled: true });
useMediaGenerationStore.setState({
tasks: {
[ref]: source,
[element.id]: { ...targeted, elementId: element.id },
},
});
function ImageBindingProbe() {
const binding = useResolvedImageSrc(element);
return createElement('div', {
'data-resolution': binding.resolution.kind,
'data-src': binding.resolvedSrc,
});
}
const markup = renderToStaticMarkup(
createElement(MediaStageProvider, { value: stageId }, createElement(ImageBindingProbe)),
);
expect(markup).toContain('data-resolution="pending"');
expect(markup).not.toContain('blob:shared-image');
});
it('BaseVideoElement prefers element-keyed fork progress over the shared source task', () => {
const ref = 'ast_shared_base_video';
const element = videoElement(ref);
const source = fullTask(ref, task('done', { objectUrl: 'blob:shared-base-video' }), 'video');
const targeted = fullTask(ref, task('pending'), 'video');
if (!source || !targeted) throw new Error('Expected media tasks');
useSettingsStore.setState({ videoGenerationEnabled: true });
useMediaGenerationStore.setState({
tasks: {
[ref]: source,
[element.id]: { ...targeted, elementId: element.id },
},
});
const markup = renderInMediaScene(
element,
createElement(BaseVideoElement, { elementInfo: element }),
);
expect(markup).toContain('vid-pulse-ring');
expect(markup).not.toContain('blob:shared-base-video');
});
it('VideoElement prefers element-keyed fork progress over the shared source task', () => {
const ref = 'ast_shared_editor_video';
const element = videoElement(ref);
const source = fullTask(ref, task('done', { objectUrl: 'blob:shared-editor-video' }), 'video');
const targeted = fullTask(ref, task('pending'), 'video');
if (!source || !targeted) throw new Error('Expected media tasks');
useSettingsStore.setState({ videoGenerationEnabled: true });
useMediaGenerationStore.setState({
tasks: {
[ref]: source,
[element.id]: { ...targeted, elementId: element.id },
},
});
const markup = renderInMediaScene(
element,
createElement(VideoElement, { elementInfo: element }),
);
expect(markup).toContain('animate-pulse');
expect(markup).not.toContain('blob:shared-editor-video');
});
it('recovered legacy video tasks bind identically in playback and edit mode', () => {
const element = videoElement('gen_vid_1');
const recovered = fullTask(
'gen_vid_unique_legacy',
task('done', { objectUrl: 'blob:recovered-video' }),
'video',
);
if (!recovered) throw new Error('Expected recovered video task');
useSettingsStore.setState({ videoGenerationEnabled: true });
useMediaGenerationStore.setState({
tasks: {
gen_vid_unique_legacy: { ...recovered, placeholderRef: 'gen_vid_1' },
},
});
const playback = renderInMediaScene(
element,
createElement(BaseVideoElement, { elementInfo: element }),
);
const editor = renderInMediaScene(
element,
createElement(VideoElement, { elementInfo: element }),
);
expect(playback).toContain('src="blob:recovered-video"');
expect(editor).toContain('src="blob:recovered-video"');
expect(playback).not.toContain('vid-pulse-ring');
expect(editor).not.toContain('animate-pulse');
});
for (const surface of componentSurfaces) {
it.each(cases)(`${surface.name}: $name`, (entry) => {
const binding = surface.run(entry);
expectSafeBinding(binding.src, binding.resolution, entry.expected, entry.retryable);
if (surface.resolvesPoster) expect(binding.poster).toBe('blob:poster');
});
}
it.each(cases)('stage-storage hydration: $name', async (entry) => {
vi.stubGlobal('URL', {
createObjectURL: () => 'blob:hydrated',
revokeObjectURL: () => undefined,
});
const storedBlob =
entry.lease?.status === 'resolved' ? new Blob(['media'], { type: 'image/png' }) : undefined;
const src =
(await resolveThumbnailMediaValue(
entry.ref,
entry.task,
storedBlob,
'image/png',
entry.disabled,
)) ?? '';
const resolution = resolvePptxMediaBinding(
entry.ref,
entry.task,
storedBlob ? { status: 'resolved', url: src } : MISSING_ASSET_LEASE,
entry.disabled,
).resolution;
expectSafeBinding(src, resolution, entry.expected, entry.retryable);
});
it.each(cases)('PPTX exporter binding: $name', (entry) => {
const binding = resolvePptxMediaBinding(entry.ref, entry.task, entry.lease, entry.disabled);
expectSafeBinding(binding.src, binding.resolution, entry.expected, entry.retryable);
});
it.each(cases)('video exporter binding: $name', (entry) => {
const binding = resolveVideoExportMediaBinding(
entry.ref,
entry.task,
entry.lease,
entry.disabled,
);
expectSafeBinding(binding.src, binding.resolution, entry.expected, entry.retryable);
});
});