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

534 lines
19 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
StageAssetDocument,
StageAudioRow,
StageMediaRow,
} from '@/lib/media/collect-stage-asset-refs';
const mocks = vi.hoisted(() => ({
removeAsset: vi.fn(),
mediaRows: [] as Array<{ id: string; stageId: string }>,
audioRows: [] as Array<{ id: string; stageId?: string }>,
documents: new Map<string, StageAssetDocument>(),
listDocuments: vi.fn(),
loadDocument: vi.fn(),
poolBytes: new Map<string, Blob>(),
}));
vi.mock('@/lib/media/asset-pool', () => ({
removeAsset: mocks.removeAsset,
}));
vi.mock('@/lib/document-store', () => ({
getDocumentStore: () => ({
listDocuments: mocks.listDocuments,
loadDocument: mocks.loadDocument,
}),
}));
function indexedRows<T extends { id: string; stageId?: string }>(rows: T[]) {
return {
where: (field: keyof T) => ({
equals: (value: unknown) => ({
toArray: async () => rows.filter((row) => row[field] === value),
}),
}),
bulkDelete: async (ids: string[]) => {
const doomed = new Set(ids);
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (doomed.has(rows[index].id)) rows.splice(index, 1);
}
},
};
}
vi.mock('@/lib/utils/database', () => ({
db: {
mediaFiles: indexedRows(mocks.mediaRows),
audioFiles: indexedRows(mocks.audioRows),
},
}));
import {
collectPersistedDocumentAssetRefs,
collectStageAssetRefs,
} from '@/lib/media/collect-stage-asset-refs';
import {
buildStageAssetReclamationPlan,
executeStageAssetReclamation,
loadStageAssetInventory,
} from '@/lib/media/reclaim-stage-assets';
const stageId = 'stage-matrix';
function slide(id: string, elements: Array<Record<string, unknown>>) {
return {
id,
viewportSize: 1000,
viewportRatio: 0.5625,
background:
id === 'slide-exclusive'
? { type: 'image', image: { src: 'background-exclusive-ref' } }
: { type: 'solid', color: '#fff' },
elements,
};
}
function matrixDocument(): StageAssetDocument {
return {
stage: {
id: stageId,
name: 'Matrix',
createdAt: 1,
updatedAt: 1,
whiteboard: [
slide('stage-whiteboard', [
{ id: 'stage-whiteboard-image', type: 'image', src: 'stage-whiteboard-ref' },
]),
],
videoManifest: {
'video-media-exclusive': { type: 'video', prompt: 'Clip' },
'manifest-only': { type: 'video', prompt: 'Detached metadata' },
},
},
scenes: [
{
id: 'scene-exclusive',
stageId,
type: 'slide',
title: 'Exclusive',
order: 1,
content: {
type: 'slide',
canvas: slide('slide-exclusive', [
{ id: 'image-exclusive', type: 'image', src: 'image-exclusive-ref' },
{
id: 'video-exclusive',
type: 'video',
src: 'video-src-exclusive',
mediaRef: 'video-media-exclusive',
poster: 'poster-exclusive',
},
{ id: 'foreign-image', type: 'image', src: 'foreign-course-ref' },
]),
},
whiteboards: [
slide('scene-whiteboard', [
{ id: 'scene-whiteboard-image', type: 'image', src: 'scene-whiteboard-ref' },
]),
],
actions: [
{ id: 'speech-owned', type: 'speech', text: 'Owned', audioId: 'audio-exclusive' },
{ id: 'speech-legacy', type: 'speech', text: 'Legacy', audioId: 'tts_s1_action_1' },
],
},
],
} as unknown as StageAssetDocument;
}
function documentWithSpeechAudio(id: string, audioId: string): StageAssetDocument {
return {
stage: { id, name: id, createdAt: 1, updatedAt: 1 },
scenes: [
{
id: `${id}-scene`,
stageId: id,
type: 'slide',
title: id,
order: 1,
content: { type: 'slide', canvas: slide(`${id}-slide`, []) },
actions: [{ id: `${id}-speech`, type: 'speech', text: 'Shared', audioId }],
},
],
} as unknown as StageAssetDocument;
}
function documentWithImage(id: string, ref: string): StageAssetDocument {
return {
stage: { id, name: id, createdAt: 1, updatedAt: 1 },
scenes: [
{
id: `${id}-scene`,
stageId: id,
type: 'slide',
title: id,
order: 1,
content: {
type: 'slide',
canvas: slide(`${id}-slide`, [{ id: `${id}-image`, type: 'image', src: ref }]),
},
},
],
} as unknown as StageAssetDocument;
}
function documentWithManifestRef(id: string, ref: string): StageAssetDocument {
return {
stage: {
id,
name: id,
createdAt: 1,
updatedAt: 1,
videoManifest: { [ref]: { type: 'video', prompt: 'Finishing before insertion' } },
},
scenes: [],
} as unknown as StageAssetDocument;
}
async function resolveImageBytes(document: StageAssetDocument): Promise<string | undefined> {
const scene = document.scenes[0];
if (scene?.content.type !== 'slide') return undefined;
const element = scene.content.canvas.elements[0];
if (element?.type !== 'image') return undefined;
return mocks.poolBytes.get(element.src)?.text();
}
const mediaRefs = [
'stage-whiteboard-ref',
'scene-whiteboard-ref',
'image-exclusive-ref',
'video-src-exclusive',
'video-media-exclusive',
'poster-exclusive',
'manifest-only',
'media-orphan',
'background-exclusive-ref',
];
describe('stage asset reference and reclamation matrix', () => {
beforeEach(() => {
mocks.documents.clear();
mocks.poolBytes.clear();
mocks.removeAsset.mockReset().mockImplementation(async (ref: string) => {
mocks.poolBytes.delete(ref);
});
mocks.listDocuments
.mockReset()
.mockImplementation(async () => [...mocks.documents.keys()].map((id) => ({ id })));
mocks.loadDocument
.mockReset()
.mockImplementation(async (id: string) => mocks.documents.get(id) ?? null);
mocks.mediaRows.splice(
0,
mocks.mediaRows.length,
...mediaRefs.map((ref) => ({ id: `${stageId}:${ref}`, stageId })),
{ id: 'other-stage:foreign-course-ref', stageId: 'other-stage' },
);
mocks.audioRows.splice(
0,
mocks.audioRows.length,
{ id: 'audio-exclusive', stageId },
{ id: 'audio-orphan', stageId },
{ id: 'tts_s1_action_1' },
{ id: 'other-audio', stageId: 'other-stage' },
);
for (const ref of [...mediaRefs, 'audio-exclusive', 'audio-orphan']) {
mocks.poolBytes.set(ref, new Blob([`${ref}-bytes`]));
}
});
it('enumerates every document reference category', () => {
const refs = collectStageAssetRefs(matrixDocument(), { mediaRows: [], audioRows: [] });
expect(refs.imageSrc).toEqual(
new Set([
'stage-whiteboard-ref',
'image-exclusive-ref',
'foreign-course-ref',
'scene-whiteboard-ref',
]),
);
expect(refs.videoSrc).toEqual(new Set(['video-src-exclusive']));
expect(refs.videoMediaRef).toEqual(new Set(['video-media-exclusive']));
expect(refs.poster).toEqual(new Set(['poster-exclusive']));
expect(refs.backgroundImage).toEqual(new Set(['background-exclusive-ref']));
expect(refs.stageWhiteboard).toEqual(new Set(['stage-whiteboard-ref']));
expect(refs.sceneWhiteboard).toEqual(new Set(['scene-whiteboard-ref']));
expect(refs.speechAudioId).toEqual(new Set(['audio-exclusive', 'tts_s1_action_1']));
expect(refs.videoManifestKey).toEqual(new Set(['video-media-exclusive', 'manifest-only']));
});
it('builds a whole-stage plan from honestly stage-filtered rows', async () => {
const inventory = await loadStageAssetInventory(matrixDocument());
const plan = buildStageAssetReclamationPlan(
stageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
expect(new Set(plan.poolRefs)).toEqual(
new Set([...mediaRefs, 'audio-exclusive', 'audio-orphan']),
);
expect(new Set(plan.mediaRowIds)).toEqual(new Set(mediaRefs.map((ref) => `${stageId}:${ref}`)));
expect(new Set(plan.audioRowIds)).toEqual(new Set(['audio-exclusive', 'audio-orphan']));
expect(plan.poolRefs).not.toContain('foreign-course-ref');
expect(plan.poolRefs).not.toContain('tts_s1_action_1');
});
it('filters unscoped and foreign legacy rows inside both pure functions', () => {
const mediaRows = [
{ id: `${stageId}:image-exclusive-ref`, stageId },
{ id: 'other-stage:foreign-course-ref', stageId: 'other-stage' },
{ id: 'foreign-course-ref' } as unknown as StageMediaRow,
];
const audioRows: StageAudioRow[] = [
{ id: 'audio-exclusive', stageId },
{ id: 'tts_s1_action_1' },
{ id: 'audio-exclusive', stageId: 'other-stage' },
];
const refs = collectStageAssetRefs(matrixDocument(), { mediaRows, audioRows });
expect(refs.mediaRow).toEqual(new Set(['image-exclusive-ref']));
expect(refs.audioRow).toEqual(new Set(['audio-exclusive']));
expect(refs.poolOwned).not.toContain('foreign-course-ref');
expect(refs.poolOwned).not.toContain('tts_s1_action_1');
const plan = buildStageAssetReclamationPlan(stageId, refs, mediaRows, audioRows);
expect(plan.mediaRowIds).toEqual([`${stageId}:image-exclusive-ref`]);
expect(plan.audioRowIds).toEqual(['audio-exclusive']);
});
it('stage deletion reclaims matched rows while stage-less legacy rows survive', async () => {
const inventory = await loadStageAssetInventory(matrixDocument());
const plan = buildStageAssetReclamationPlan(
stageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
await executeStageAssetReclamation(plan, null);
expect(mocks.removeAsset).toHaveBeenCalledTimes(plan.poolRefs.length);
expect(mocks.mediaRows).toEqual([
{ id: 'other-stage:foreign-course-ref', stageId: 'other-stage' },
]);
expect(mocks.audioRows).toEqual([
{ id: 'tts_s1_action_1' },
{ id: 'other-audio', stageId: 'other-stage' },
]);
});
it('preserves a globally shared pool ref when one owning stage is deleted', async () => {
const sharedRef = 'ast_cross_document_alias';
const deletedStageId = 'stage-deleted';
const deletedDocument = documentWithImage(deletedStageId, sharedRef);
const survivingDocument = documentWithImage('stage-surviving', sharedRef);
mocks.mediaRows.splice(0, mocks.mediaRows.length, {
id: `${deletedStageId}:${sharedRef}`,
stageId: deletedStageId,
});
mocks.audioRows.splice(0, mocks.audioRows.length);
mocks.poolBytes.set(sharedRef, new Blob(['shared-surviving-bytes']));
mocks.documents.set(deletedDocument.stage.id, deletedDocument);
mocks.documents.set(survivingDocument.stage.id, survivingDocument);
expect(
collectPersistedDocumentAssetRefs([...mocks.documents.values()]).referenceCounts.get(
sharedRef,
),
).toBe(2);
const inventory = await loadStageAssetInventory(deletedDocument);
const plan = buildStageAssetReclamationPlan(
deletedStageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
mocks.documents.delete(deletedStageId);
await executeStageAssetReclamation(plan, null);
expect(mocks.removeAsset).not.toHaveBeenCalledWith(sharedRef);
expect(mocks.mediaRows).toEqual([]);
expect(await resolveImageBytes(survivingDocument)).toBe('shared-surviving-bytes');
});
it('preserves the compatibility row of an audio ref a surviving document shares', async () => {
const sharedAudioId = 'ast_shared_audio_alias';
const deletedStageId = 'stage-deleted';
const deletedDocument = documentWithSpeechAudio(deletedStageId, sharedAudioId);
const survivingDocument = documentWithSpeechAudio('stage-surviving', sharedAudioId);
mocks.mediaRows.splice(0, mocks.mediaRows.length);
// The row is keyed globally by audioId and only the deleted stage carries it.
mocks.audioRows.splice(0, mocks.audioRows.length, {
id: sharedAudioId,
stageId: deletedStageId,
});
mocks.poolBytes.set(sharedAudioId, new Blob(['shared-audio-bytes']));
mocks.documents.set(deletedDocument.stage.id, deletedDocument);
mocks.documents.set(survivingDocument.stage.id, survivingDocument);
const inventory = await loadStageAssetInventory(deletedDocument);
const plan = buildStageAssetReclamationPlan(
deletedStageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
expect(plan.audioRowIds).toContain(sharedAudioId);
mocks.documents.delete(deletedStageId);
await executeStageAssetReclamation(plan, null);
// Playback, classroom export and video export read this table directly, so
// the survivor keeps both the pool entry and its compatibility row.
expect(mocks.removeAsset).not.toHaveBeenCalledWith(sharedAudioId);
expect(mocks.audioRows.map((row) => row.id)).toContain(sharedAudioId);
});
it('keeps a shared audio row when surviving-document enumeration fails', async () => {
const sharedAudioId = 'ast_shared_audio_unknown_liveness';
const deletedStageId = 'stage-deleted';
const deletedDocument = documentWithSpeechAudio(deletedStageId, sharedAudioId);
const survivingDocument = documentWithSpeechAudio('stage-surviving', sharedAudioId);
mocks.mediaRows.splice(0, mocks.mediaRows.length);
mocks.audioRows.splice(0, mocks.audioRows.length, {
id: sharedAudioId,
stageId: deletedStageId,
});
mocks.poolBytes.set(sharedAudioId, new Blob(['shared-audio-bytes']));
mocks.documents.set(deletedDocument.stage.id, deletedDocument);
mocks.documents.set(survivingDocument.stage.id, survivingDocument);
const inventory = await loadStageAssetInventory(deletedDocument);
const plan = buildStageAssetReclamationPlan(
deletedStageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
mocks.documents.delete(deletedStageId);
mocks.listDocuments.mockRejectedValue(new Error('document repository unavailable'));
await executeStageAssetReclamation(plan, null);
// The pool entry survives, and so must the row the survivor plays from.
expect(mocks.removeAsset).not.toHaveBeenCalled();
expect(mocks.audioRows.map((row) => row.id)).toContain(sharedAudioId);
});
it('removes audio compatibility rows no surviving document references', async () => {
const exclusiveAudioId = 'ast_exclusive_audio';
const legacyAudioId = 'tts_s1_legacy';
const deletedStageId = 'stage-deleted';
const deletedDocument = documentWithSpeechAudio(deletedStageId, exclusiveAudioId);
mocks.mediaRows.splice(0, mocks.mediaRows.length);
mocks.audioRows.splice(
0,
mocks.audioRows.length,
{ id: exclusiveAudioId, stageId: deletedStageId },
// Legacy rows never had a pool entry; they are still removable.
{ id: legacyAudioId, stageId: deletedStageId },
);
mocks.poolBytes.set(exclusiveAudioId, new Blob(['exclusive-audio']));
mocks.documents.set(deletedDocument.stage.id, deletedDocument);
const inventory = await loadStageAssetInventory(deletedDocument);
const plan = buildStageAssetReclamationPlan(
deletedStageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
mocks.documents.delete(deletedStageId);
await executeStageAssetReclamation(plan, null);
expect(mocks.audioRows).toEqual([]);
});
it('preserves a ref owned by a surviving document only through its video manifest', async () => {
const sharedRef = 'ast_manifest_before_scene_insert';
const deletedDocument = documentWithImage('stage-deleted', sharedRef);
const survivingDocument = documentWithManifestRef('stage-surviving', sharedRef);
mocks.mediaRows.splice(0, mocks.mediaRows.length, {
id: `stage-deleted:${sharedRef}`,
stageId: 'stage-deleted',
});
mocks.audioRows.splice(0, mocks.audioRows.length);
mocks.documents.set(survivingDocument.stage.id, survivingDocument);
const inventory = await loadStageAssetInventory(deletedDocument);
const plan = buildStageAssetReclamationPlan(
deletedDocument.stage.id,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
await executeStageAssetReclamation(plan, null);
expect(mocks.removeAsset).not.toHaveBeenCalledWith(sharedRef);
expect(mocks.mediaRows).toEqual([]);
});
it('enumerates surviving documents once for every ref in a reclamation plan', async () => {
mocks.documents.set('stage-one', documentWithImage('stage-one', 'unrelated-one'));
mocks.documents.set('stage-two', documentWithManifestRef('stage-two', 'unrelated-two'));
const inventory = await loadStageAssetInventory(matrixDocument());
const plan = buildStageAssetReclamationPlan(
stageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
expect(plan.poolRefs.length).toBeGreaterThan(3);
await executeStageAssetReclamation(plan, null);
expect(mocks.listDocuments).toHaveBeenCalledTimes(1);
expect(mocks.loadDocument).toHaveBeenCalledTimes(2);
});
it('fails closed when surviving document enumeration fails', async () => {
const exclusiveRef = 'image-exclusive-ref';
const inventory = await loadStageAssetInventory(matrixDocument());
const plan = buildStageAssetReclamationPlan(
stageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
mocks.listDocuments.mockRejectedValue(new Error('document repository unavailable'));
await executeStageAssetReclamation(plan, null);
expect(mocks.removeAsset).not.toHaveBeenCalled();
expect(await mocks.poolBytes.get(exclusiveRef)?.text()).toBe(`${exclusiveRef}-bytes`);
expect(mocks.mediaRows.every((row) => row.stageId !== stageId)).toBe(true);
// Audio rows are globally keyed, so losing one is as irreversible for
// playback and the export paths as removing the pool entry: unknown
// liveness keeps them and leaves bounded garbage behind.
expect(mocks.audioRows.map((row) => row.id)).toEqual(
expect.arrayContaining([...plan.audioRowIds]),
);
});
it('continues row cleanup after one pool removal fails', async () => {
const inventory = await loadStageAssetInventory(matrixDocument());
const plan = buildStageAssetReclamationPlan(
stageId,
inventory.refs,
inventory.mediaRows,
inventory.audioRows,
);
mocks.removeAsset.mockRejectedValueOnce(new Error('broken entry'));
await executeStageAssetReclamation(plan, null);
expect(mocks.removeAsset).toHaveBeenCalledTimes(plan.poolRefs.length);
expect(mocks.mediaRows.every((row) => row.stageId !== stageId)).toBe(true);
expect(mocks.audioRows.every((row) => row.stageId !== stageId)).toBe(true);
});
it.each([
['canvas element deletion', 'lib/hooks/use-canvas-operations.ts'],
['slide-surface element deletion', 'components/edit/surfaces/slide/use-slide-surface.ts'],
['speech-cue deletion and audio supersession', 'components/edit/ActionsBar/ActionsBar.tsx'],
['scene deletion and undo', 'components/edit/SlideNavRail/SlideNavRail.tsx'],
])('%s cannot remove pool or Dexie assets', (_entryPoint, file) => {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/(?:reclaim-stage-assets|removeAsset|\.(?:audioFiles|mediaFiles)\.(?:delete|bulkDelete))/,
);
});
});