781 lines
27 KiB
TypeScript
781 lines
27 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { getPiece } from './content.js';
|
|
import { closeDatabase, getDatabase } from './database.js';
|
|
import {
|
|
addPieceUsage,
|
|
addShowSection,
|
|
createShow,
|
|
createAndAddPieceUsage,
|
|
duplicatePieceUsage,
|
|
getPieceUsageContexts,
|
|
getShow,
|
|
listShowTemplates,
|
|
movePieceUsage,
|
|
moveShowSection,
|
|
refreshPieceUsageFromOriginal,
|
|
removePieceUsage,
|
|
removeShowSection,
|
|
updatePieceUsage,
|
|
updateShowSection,
|
|
type PieceUsage
|
|
} from './shows.js';
|
|
|
|
let directory: string;
|
|
|
|
beforeEach(() => {
|
|
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shows-test-'));
|
|
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
|
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
|
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
|
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeDatabase();
|
|
delete process.env.SENDEWERK_DB_PATH;
|
|
delete process.env.SENDEWERK_UPLOAD_DIR;
|
|
rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
|
const edited = { ...usage, ...overrides };
|
|
return {
|
|
title: edited.title,
|
|
outputTitle: edited.outputTitle,
|
|
intro: edited.intro,
|
|
research: edited.research,
|
|
moderation: edited.moderation,
|
|
transition: edited.transition,
|
|
presentation: edited.presentation,
|
|
presentationMode: edited.presentationMode,
|
|
presentationSourceLabel: edited.presentationSourceLabel,
|
|
presentationSourceUrl: edited.presentationSourceUrl,
|
|
presentationSourceTimecodeSeconds: edited.presentationSourceTimecodeSeconds,
|
|
presentationTitle: edited.presentationTitle,
|
|
presentationDescription: edited.presentationDescription,
|
|
presentationImageUrl: edited.presentationImageUrl,
|
|
presentationSiteName: edited.presentationSiteName,
|
|
presentationMediaId: edited.presentationMediaId,
|
|
ownNotes: edited.ownNotes,
|
|
durationSeconds: edited.durationSeconds
|
|
};
|
|
}
|
|
|
|
describe('show builder data layer', () => {
|
|
it('creates a fundus original and usage atomically and idempotently', () => {
|
|
const created = createShow({ title: 'Direkte Planung', startsAtUtc: null, templateId: null });
|
|
const section = addShowSection(created.id, created.revision, {
|
|
name: 'Ablauf',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const before = (
|
|
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
|
|
).count;
|
|
const first = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
|
|
title: 'Direkt geplantes Piece',
|
|
templateId: 'talk',
|
|
sectionId: section.id
|
|
});
|
|
const repeated = createAndAddPieceUsage(created.id, section.revision, 'new-piece-command', {
|
|
title: 'Darf nicht doppelt entstehen',
|
|
templateId: 'blank',
|
|
sectionId: section.id
|
|
});
|
|
expect(repeated).toEqual({ ...first, duplicate: true });
|
|
expect(getShow(created.id)?.sections[0].usages).toHaveLength(1);
|
|
expect(getPiece(first.pieceId)).toMatchObject({
|
|
title: 'Direkt geplantes Piece',
|
|
status: 'ready',
|
|
formatId: 'format-talk'
|
|
});
|
|
expect(
|
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
|
.count
|
|
).toBe(before + 1);
|
|
|
|
expect(() =>
|
|
createAndAddPieceUsage(created.id, first.revision, 'failing-command', {
|
|
title: 'Rollback',
|
|
templateId: 'blank',
|
|
sectionId: 'missing'
|
|
})
|
|
).toThrow('NOT_FOUND');
|
|
expect(
|
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
|
.count
|
|
).toBe(before + 1);
|
|
|
|
const removed = removePieceUsage(created.id, first.usageId, first.revision);
|
|
const afterRemoval = createAndAddPieceUsage(created.id, removed.revision, 'new-piece-command', {
|
|
title: 'Darf auch nach dem Entfernen nicht neu entstehen',
|
|
templateId: 'blank',
|
|
sectionId: section.id
|
|
});
|
|
expect(afterRemoval).toEqual({ ...first, duplicate: true });
|
|
expect(getShow(created.id)?.sections[0].usages).toHaveLength(0);
|
|
expect(
|
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
|
.count
|
|
).toBe(before + 1);
|
|
});
|
|
|
|
it('creates blank and templated shows with independent sections and reserve banks', () => {
|
|
const templatesBefore = listShowTemplates();
|
|
expect(
|
|
templatesBefore.find((template) => template.id === 'template-default')?.sections
|
|
).toHaveLength(5);
|
|
expect(templatesBefore.find((template) => template.id === 'template-games')?.name).toBe(
|
|
'Spieleabend'
|
|
);
|
|
|
|
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
|
|
expect(getShow(blank.id)).toMatchObject({
|
|
title: 'Neue Sendung',
|
|
startsAtUtc: null,
|
|
sections: [],
|
|
reserve: []
|
|
});
|
|
const reserveCount = getDatabase()
|
|
.prepare(
|
|
'SELECT count(*) AS count FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1'
|
|
)
|
|
.get(blank.id) as { count: number };
|
|
expect(reserveCount.count).toBe(1);
|
|
|
|
const templated = createShow({
|
|
title: 'Vorlagenkopie',
|
|
startsAtUtc: '2027-01-08T19:00:00Z',
|
|
templateId: 'template-default'
|
|
});
|
|
const copy = getShow(templated.id)!;
|
|
expect(copy.sections.map((section) => section.name)).toEqual([
|
|
'Start',
|
|
'JC / Reaction',
|
|
'Spiel 1',
|
|
'JC',
|
|
'Spiel 2'
|
|
]);
|
|
updateShowSection(templated.id, copy.sections[0].id, copy.revision, {
|
|
name: 'Eigener Start',
|
|
categoryId: 'cat-jc',
|
|
explicitDurationSeconds: 420
|
|
});
|
|
expect(listShowTemplates()).toEqual(templatesBefore);
|
|
});
|
|
|
|
it('snapshots a ready piece repeatedly and keeps usage edits independent from its original', () => {
|
|
const created = createShow({
|
|
title: 'Doppelte Verwendung',
|
|
startsAtUtc: null,
|
|
templateId: null
|
|
});
|
|
const section = addShowSection(created.id, created.revision, {
|
|
name: 'Fundstücke',
|
|
categoryId: 'cat-reaction',
|
|
explicitDurationSeconds: null
|
|
});
|
|
const first = addPieceUsage(created.id, section.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: section.id
|
|
});
|
|
const second = addPieceUsage(created.id, first.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: section.id
|
|
});
|
|
const usages = getShow(created.id)!.sections[0].usages;
|
|
const firstSaved = updatePieceUsage(
|
|
created.id,
|
|
first.id,
|
|
second.revision,
|
|
editedUsage(usages[0], {
|
|
title: 'Konsole als Auftakt',
|
|
durationSeconds: 300,
|
|
ownNotes: 'Kurbel zuerst zeigen',
|
|
research: 'Eigene Recherchekopie'
|
|
})
|
|
);
|
|
updatePieceUsage(
|
|
created.id,
|
|
second.id,
|
|
firstSaved.revision,
|
|
editedUsage(usages[1], { title: 'Konsole als Rückblick', durationSeconds: 900 })
|
|
);
|
|
|
|
const show = getShow(created.id)!;
|
|
expect(show.sections[0].usages).toMatchObject([
|
|
{
|
|
title: 'Konsole als Auftakt',
|
|
durationSeconds: 300,
|
|
ownNotes: 'Kurbel zuerst zeigen',
|
|
research: 'Eigene Recherchekopie'
|
|
},
|
|
{ title: 'Konsole als Rückblick', durationSeconds: 900 }
|
|
]);
|
|
expect(getPiece('piece-console')).toMatchObject({
|
|
title: 'Die kleine Konsole mit dem großen Dreh',
|
|
durationSeconds: 480,
|
|
researchText:
|
|
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten'
|
|
});
|
|
});
|
|
|
|
it('derives sent history and current planning only from qualifying usages by original id', () => {
|
|
const completed = createShow({
|
|
title: 'Abgeschlossene Sendung',
|
|
startsAtUtc: '2027-01-10T19:00:00Z',
|
|
templateId: null
|
|
});
|
|
const completedSection = addShowSection(completed.id, completed.revision, {
|
|
name: 'Ablauf',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const done = addPieceUsage(completed.id, completedSection.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: completedSection.id
|
|
});
|
|
const skipped = addPieceUsage(completed.id, done.revision, {
|
|
pieceId: 'piece-effects',
|
|
sectionId: completedSection.id
|
|
});
|
|
const reserve = addPieceUsage(completed.id, skipped.revision, {
|
|
pieceId: 'piece-pixels',
|
|
sectionId: null
|
|
});
|
|
getDatabase()
|
|
.prepare("UPDATE piece_usages SET status = 'done' WHERE id IN (?, ?)")
|
|
.run(done.id, reserve.id);
|
|
getDatabase()
|
|
.prepare("UPDATE piece_usages SET status = 'skipped' WHERE id = ?")
|
|
.run(skipped.id);
|
|
getDatabase()
|
|
.prepare(
|
|
"UPDATE shows SET status = 'completed', completed_at_utc = starts_at_utc WHERE id = ?"
|
|
)
|
|
.run(completed.id);
|
|
getDatabase()
|
|
.prepare(
|
|
`INSERT INTO show_finalizations
|
|
(show_id, command_id, source_revision, finalized_at_utc)
|
|
SELECT id, 'test-finalization', revision, starts_at_utc FROM shows WHERE id = ?`
|
|
)
|
|
.run(completed.id);
|
|
getDatabase()
|
|
.prepare(
|
|
`INSERT INTO piece_usage_history
|
|
(usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot,
|
|
used_at_utc, actual_duration_seconds)
|
|
SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot,
|
|
sh.starts_at_utc, 0 FROM piece_usages u
|
|
JOIN show_sections s ON s.id = u.show_section_id
|
|
JOIN shows sh ON sh.id = s.show_id WHERE u.id = ?`
|
|
)
|
|
.run(done.id);
|
|
|
|
const active = createShow({ title: 'Aktuelle Planung', startsAtUtc: null, templateId: null });
|
|
const activeSection = addShowSection(active.id, active.revision, {
|
|
name: 'Plan',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const planned = addPieceUsage(active.id, activeSection.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: activeSection.id
|
|
});
|
|
const plannedReserve = addPieceUsage(active.id, planned.revision, {
|
|
pieceId: 'piece-effects',
|
|
sectionId: null
|
|
});
|
|
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(planned.id);
|
|
|
|
let contexts = getPieceUsageContexts(
|
|
['piece-console', 'piece-effects', 'piece-pixels'],
|
|
active.id
|
|
);
|
|
expect(contexts['piece-console']).toMatchObject({
|
|
isPlannedInCurrentShow: true,
|
|
sentHistory: [
|
|
{
|
|
usageId: done.id,
|
|
showId: completed.id,
|
|
showTitle: 'Abgeschlossene Sendung',
|
|
usedAtUtc: '2027-01-10T19:00:00.000Z'
|
|
}
|
|
]
|
|
});
|
|
expect(contexts['piece-effects']).toEqual({
|
|
isPlannedInActiveShow: true,
|
|
isPlannedInCurrentShow: true,
|
|
sentHistory: []
|
|
});
|
|
expect(contexts['piece-pixels']).toEqual({
|
|
isPlannedInActiveShow: true,
|
|
isPlannedInCurrentShow: false,
|
|
sentHistory: []
|
|
});
|
|
|
|
removePieceUsage(active.id, plannedReserve.id, plannedReserve.revision);
|
|
contexts = getPieceUsageContexts(['piece-effects'], active.id);
|
|
expect(contexts['piece-effects'].isPlannedInCurrentShow).toBe(false);
|
|
});
|
|
|
|
it('duplicates an adjusted usage directly after its source without live measurements', () => {
|
|
const created = createShow({
|
|
title: 'Einplanungsduplikat',
|
|
startsAtUtc: null,
|
|
templateId: null
|
|
});
|
|
const section = addShowSection(created.id, created.revision, {
|
|
name: 'Ablauf',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const usage = addPieceUsage(created.id, section.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: section.id
|
|
});
|
|
const source = getShow(created.id)!.sections[0].usages[0];
|
|
const adjusted = updatePieceUsage(
|
|
created.id,
|
|
usage.id,
|
|
usage.revision,
|
|
editedUsage(source, {
|
|
title: 'Angepasste Einplanung',
|
|
durationSeconds: 777,
|
|
intro: 'Eigene Einleitung',
|
|
research: 'Eigene Recherche',
|
|
moderation: 'Eigene Moderation',
|
|
transition: 'Eigene Überleitung',
|
|
presentation: 'Eigene Präsentation',
|
|
ownNotes: 'Eigene Notizen'
|
|
})
|
|
);
|
|
getDatabase().prepare("UPDATE piece_usages SET status = 'done' WHERE id = ?").run(usage.id);
|
|
getDatabase()
|
|
.prepare(
|
|
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc, ended_at_utc)
|
|
VALUES ('source-run', ?, ?, '2027-01-01T10:00:00.000Z', '2027-01-01T10:10:00.000Z')`
|
|
)
|
|
.run(created.id, usage.id);
|
|
const pieceCountBefore = (
|
|
getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number }
|
|
).count;
|
|
|
|
const duplicate = duplicatePieceUsage(created.id, usage.id, adjusted.revision);
|
|
const usages = getShow(created.id)!.sections[0].usages;
|
|
expect(usages.map((item) => item.id)).toEqual([usage.id, duplicate.id]);
|
|
expect(usages[1]).toMatchObject({
|
|
originalPieceId: 'piece-console',
|
|
status: 'open',
|
|
durationSeconds: 777,
|
|
title: 'Angepasste Einplanung',
|
|
intro: 'Eigene Einleitung',
|
|
research: 'Eigene Recherche',
|
|
moderation: 'Eigene Moderation',
|
|
transition: 'Eigene Überleitung',
|
|
presentation: 'Eigene Präsentation',
|
|
ownNotes: 'Eigene Notizen'
|
|
});
|
|
expect(
|
|
getDatabase()
|
|
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
|
|
.get(duplicate.id)
|
|
).toEqual({ count: 0 });
|
|
expect(
|
|
(getDatabase().prepare('SELECT count(*) AS count FROM pieces').get() as { count: number })
|
|
.count
|
|
).toBe(pieceCountBefore);
|
|
|
|
getDatabase()
|
|
.prepare(
|
|
`UPDATE pieces SET title = 'Aktuelles Original', duration_seconds = 420,
|
|
intro = 'Neue Einleitung', research_text = 'Neue Recherche',
|
|
moderation_notes = 'Neue Moderation', transition_text = 'Neue Überleitung',
|
|
presentation_text = 'Neue Präsentation' WHERE id = 'piece-console'`
|
|
)
|
|
.run();
|
|
const refreshed = refreshPieceUsageFromOriginal(created.id, usage.id, duplicate.revision);
|
|
const refreshedUsages = getShow(created.id)!.sections[0].usages;
|
|
expect(refreshedUsages[0]).toMatchObject({
|
|
title: 'Aktuelles Original',
|
|
durationSeconds: 420,
|
|
intro: 'Neue Einleitung',
|
|
research: 'Neue Recherche',
|
|
moderation: 'Neue Moderation',
|
|
transition: 'Neue Überleitung',
|
|
presentation: 'Neue Präsentation',
|
|
ownNotes: 'Eigene Notizen',
|
|
status: 'done'
|
|
});
|
|
expect(refreshedUsages[1]).toMatchObject({
|
|
title: 'Angepasste Einplanung',
|
|
durationSeconds: 777,
|
|
status: 'open'
|
|
});
|
|
expect(
|
|
getDatabase()
|
|
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
|
|
.get(usage.id)
|
|
).toEqual({ count: 1 });
|
|
expect(() => duplicatePieceUsage(created.id, usage.id, adjusted.revision)).toThrow('CONFLICT');
|
|
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
|
|
getDatabase()
|
|
.prepare('UPDATE piece_usages SET original_piece_id = NULL WHERE id = ?')
|
|
.run(duplicate.id);
|
|
expect(() =>
|
|
refreshPieceUsageFromOriginal(created.id, duplicate.id, refreshed.revision)
|
|
).toThrow('ORIGINAL_NOT_FOUND');
|
|
expect(getShow(created.id)?.revision).toBe(refreshed.revision);
|
|
expect(getShow(created.id)!.sections[0].usages).toHaveLength(2);
|
|
});
|
|
|
|
it('snapshots presentation settings until an explicit refresh and copies them on duplication', () => {
|
|
getDatabase()
|
|
.prepare(
|
|
`UPDATE piece_sources SET label = 'YouTube', url = ?, timecode_seconds = 75
|
|
WHERE piece_id = 'piece-console' AND position = 0`
|
|
)
|
|
.run('https://youtu.be/dQw4w9WgXcQ');
|
|
getDatabase()
|
|
.prepare(
|
|
`UPDATE pieces SET output_title = 'Öffentlicher Ausgabetitel',
|
|
presentation_mode = 'video', presentation_source_position = 0,
|
|
presentation_title = 'Gespeicherte Vorschau', presentation_description = 'Erster Stand',
|
|
presentation_site_name = 'YouTube', presentation_provider = 'youtube',
|
|
presentation_provider_id = 'dQw4w9WgXcQ' WHERE id = 'piece-console'`
|
|
)
|
|
.run();
|
|
const show = createShow({ title: 'Snapshot-Test', startsAtUtc: null, templateId: null });
|
|
const section = addShowSection(show.id, show.revision, {
|
|
name: 'Videos',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const added = addPieceUsage(show.id, section.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: section.id
|
|
});
|
|
let usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage).toMatchObject({
|
|
outputTitle: 'Öffentlicher Ausgabetitel',
|
|
presentationMode: 'video',
|
|
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
|
presentationSourceTimecodeSeconds: 75,
|
|
presentationTitle: 'Gespeicherte Vorschau'
|
|
});
|
|
|
|
getDatabase()
|
|
.prepare(
|
|
`UPDATE pieces SET presentation_title = 'Neuer Originaltitel' WHERE id = 'piece-console'`
|
|
)
|
|
.run();
|
|
expect(getShow(show.id)!.sections[0].usages[0].presentationTitle).toBe('Gespeicherte Vorschau');
|
|
const refreshed = refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
|
|
usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage.presentationTitle).toBe('Neuer Originaltitel');
|
|
duplicatePieceUsage(show.id, usage.id, refreshed.revision);
|
|
expect(getShow(show.id)!.sections[0].usages[1]).toMatchObject({
|
|
presentationMode: 'video',
|
|
presentationSourceUrl: 'https://youtu.be/dQw4w9WgXcQ',
|
|
presentationTitle: 'Neuer Originaltitel',
|
|
status: 'open'
|
|
});
|
|
});
|
|
|
|
it('keeps private source, tool and topic snapshots stable until an explicit refresh', () => {
|
|
const database = getDatabase();
|
|
database
|
|
.prepare(
|
|
`INSERT INTO tools (id, name, start_url, description, is_active, created_at_utc, updated_at_utc)
|
|
VALUES ('tool-tierlist', 'Tierlist', 'https://example.com/start', '', 1, ?, ?)`
|
|
)
|
|
.run('2026-10-23T00:00:00.000Z', '2026-10-23T00:00:00.000Z');
|
|
database
|
|
.prepare(
|
|
`INSERT INTO piece_tools (id, piece_id, tool_id, work_url, note, position)
|
|
VALUES ('piece-tool-console', 'piece-console', 'tool-tierlist', 'https://example.com/board', 'Erster Stand', 0)`
|
|
)
|
|
.run();
|
|
const show = createShow({ title: 'Relationen', startsAtUtc: null, templateId: null });
|
|
const section = addShowSection(show.id, show.revision, {
|
|
name: 'Block',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const added = addPieceUsage(show.id, section.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: section.id
|
|
});
|
|
let usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage).toMatchObject({
|
|
sources: [{ label: 'Herstellerseite', url: 'https://example.com/console' }],
|
|
tools: [{ name: 'Tierlist', workUrl: 'https://example.com/board', note: 'Erster Stand' }],
|
|
topics: [{ name: 'Gaming', iconName: '' }]
|
|
});
|
|
|
|
database
|
|
.prepare("UPDATE piece_sources SET label = 'Neue Quelle' WHERE piece_id = 'piece-console'")
|
|
.run();
|
|
database
|
|
.prepare("UPDATE tools SET name = 'Neuer Katalogname' WHERE id = 'tool-tierlist'")
|
|
.run();
|
|
database
|
|
.prepare(
|
|
"UPDATE piece_tools SET work_url = 'https://example.com/new' WHERE id = 'piece-tool-console'"
|
|
)
|
|
.run();
|
|
database.prepare("DELETE FROM piece_topics WHERE piece_id = 'piece-console'").run();
|
|
database
|
|
.prepare(
|
|
"INSERT INTO piece_topics (piece_id, topic_id) VALUES ('piece-console', 'topic-internet')"
|
|
)
|
|
.run();
|
|
usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage.sources[0].label).toBe('Herstellerseite');
|
|
expect(usage.tools[0]).toMatchObject({
|
|
name: 'Tierlist',
|
|
workUrl: 'https://example.com/board'
|
|
});
|
|
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: '' }]);
|
|
|
|
refreshPieceUsageFromOriginal(show.id, usage.id, added.revision);
|
|
usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage.sources[0].label).toBe('Neue Quelle');
|
|
expect(usage.tools[0]).toMatchObject({
|
|
name: 'Neuer Katalogname',
|
|
workUrl: 'https://example.com/new'
|
|
});
|
|
expect(usage.topics).toEqual([{ name: 'Netzkultur', iconName: '' }]);
|
|
});
|
|
|
|
it('snapshots active media, their presentation choice and topic icons independently', () => {
|
|
const database = getDatabase();
|
|
database.prepare("UPDATE topics SET icon_name = 'gamepad' WHERE id = 'topic-gaming'").run();
|
|
const insertMedia = database.prepare(
|
|
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
|
|
byte_size, alt_text, position, created_at_utc, removed_at_utc)
|
|
VALUES (?, 'piece-console', ?, ?, 'image/png', 10, ?, ?, ?, ?)`
|
|
);
|
|
insertMedia.run(
|
|
'media-first',
|
|
'first.png',
|
|
'first-original.png',
|
|
'Erstes Bild',
|
|
0,
|
|
'2026-10-23T00:00:00.000Z',
|
|
null
|
|
);
|
|
insertMedia.run(
|
|
'media-second',
|
|
'second.png',
|
|
'second-original.png',
|
|
'Zweites Bild',
|
|
1,
|
|
'2026-10-23T00:00:00.000Z',
|
|
null
|
|
);
|
|
insertMedia.run(
|
|
'media-removed',
|
|
'removed.png',
|
|
'removed-original.png',
|
|
'Entferntes Bild',
|
|
2,
|
|
'2026-10-23T00:00:00.000Z',
|
|
'2026-10-23T01:00:00.000Z'
|
|
);
|
|
database
|
|
.prepare(
|
|
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-first')"
|
|
)
|
|
.run();
|
|
const show = createShow({ title: 'Medien', startsAtUtc: null, templateId: null });
|
|
const section = addShowSection(show.id, show.revision, {
|
|
name: 'Bilder',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const added = addPieceUsage(show.id, section.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: section.id
|
|
});
|
|
let usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage.topics).toEqual([{ name: 'Gaming', iconName: 'gamepad' }]);
|
|
expect(usage.media).toMatchObject([
|
|
{ originalMediaId: 'media-first', altText: 'Erstes Bild', position: 0, isPresentation: true },
|
|
{
|
|
originalMediaId: 'media-second',
|
|
altText: 'Zweites Bild',
|
|
position: 1,
|
|
isPresentation: false
|
|
}
|
|
]);
|
|
expect(usage.presentationMediaId).toBe(usage.media[0].id);
|
|
|
|
const selected = updatePieceUsage(show.id, usage.id, added.revision, {
|
|
...editedUsage(usage),
|
|
presentationMediaId: usage.media[1].id
|
|
});
|
|
usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage.media.map((item) => item.isPresentation)).toEqual([false, true]);
|
|
expect(() =>
|
|
updatePieceUsage(show.id, usage.id, selected.revision, {
|
|
...editedUsage(usage),
|
|
presentationMediaId: 'media-first'
|
|
})
|
|
).toThrow('INVALID_PRESENTATION_MEDIA');
|
|
expect(getShow(show.id)?.revision).toBe(selected.revision);
|
|
|
|
const duplicate = duplicatePieceUsage(show.id, usage.id, selected.revision);
|
|
const duplicateUsage = getShow(show.id)!.sections[0].usages[1];
|
|
expect(duplicateUsage.media.map((item) => item.originalMediaId)).toEqual([
|
|
'media-first',
|
|
'media-second'
|
|
]);
|
|
expect(duplicateUsage.presentationMediaId).toBe(duplicateUsage.media[1].id);
|
|
expect(duplicateUsage.media.map((item) => item.id)).not.toEqual(
|
|
usage.media.map((item) => item.id)
|
|
);
|
|
|
|
database
|
|
.prepare("UPDATE piece_media SET removed_at_utc = ? WHERE id = 'media-first'")
|
|
.run('2026-10-23T02:00:00.000Z');
|
|
database
|
|
.prepare("UPDATE piece_media SET alt_text = 'Neuer Alternativtext' WHERE id = 'media-second'")
|
|
.run();
|
|
database.prepare("DELETE FROM piece_presentation_media WHERE piece_id = 'piece-console'").run();
|
|
database
|
|
.prepare(
|
|
"INSERT INTO piece_presentation_media (piece_id, media_id) VALUES ('piece-console', 'media-second')"
|
|
)
|
|
.run();
|
|
refreshPieceUsageFromOriginal(show.id, usage.id, duplicate.revision);
|
|
usage = getShow(show.id)!.sections[0].usages[0];
|
|
expect(usage.media).toMatchObject([
|
|
{ originalMediaId: 'media-second', altText: 'Neuer Alternativtext', isPresentation: true }
|
|
]);
|
|
expect(duplicateUsage.media).toHaveLength(2);
|
|
});
|
|
|
|
it('persists normalized section and usage reordering, moving and removal', () => {
|
|
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
|
const firstSection = addShowSection(created.id, created.revision, {
|
|
name: 'Erster Block',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const secondSection = addShowSection(created.id, firstSection.revision, {
|
|
name: 'Zweiter Block',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
const firstUsage = addPieceUsage(created.id, secondSection.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: firstSection.id
|
|
});
|
|
const secondUsage = addPieceUsage(created.id, firstUsage.revision, {
|
|
pieceId: 'piece-effects',
|
|
sectionId: firstSection.id
|
|
});
|
|
const thirdUsage = addPieceUsage(created.id, secondUsage.revision, {
|
|
pieceId: 'piece-pixels',
|
|
sectionId: firstSection.id
|
|
});
|
|
const reordered = movePieceUsage(created.id, thirdUsage.id, thirdUsage.revision, {
|
|
sectionId: firstSection.id,
|
|
position: 0
|
|
});
|
|
const reserved = movePieceUsage(created.id, firstUsage.id, reordered.revision, {
|
|
sectionId: null,
|
|
position: 0
|
|
});
|
|
const plannedAgain = movePieceUsage(created.id, firstUsage.id, reserved.revision, {
|
|
sectionId: secondSection.id,
|
|
position: 0
|
|
});
|
|
const removedUsage = removePieceUsage(created.id, secondUsage.id, plannedAgain.revision);
|
|
const movedSection = moveShowSection(created.id, secondSection.id, 0, removedUsage.revision);
|
|
removeShowSection(created.id, firstSection.id, movedSection.revision);
|
|
|
|
const show = getShow(created.id)!;
|
|
expect(show.sections.map((section) => [section.name, section.position])).toEqual([
|
|
['Zweiter Block', 0]
|
|
]);
|
|
expect(show.sections[0].usages.map((usage) => [usage.id, usage.position])).toEqual([
|
|
[firstUsage.id, 0]
|
|
]);
|
|
expect(show.reserve).toEqual([]);
|
|
expect(getDatabase().pragma('foreign_key_check')).toEqual([]);
|
|
});
|
|
|
|
it('uses explicit section durations once and excludes the show-wide reserve', () => {
|
|
const created = createShow({ title: 'Dauer', startsAtUtc: null, templateId: null });
|
|
const section = addShowSection(created.id, created.revision, {
|
|
name: 'Fester Block',
|
|
categoryId: null,
|
|
explicitDurationSeconds: 600
|
|
});
|
|
const planned = addPieceUsage(created.id, section.revision, {
|
|
pieceId: 'piece-effects',
|
|
sectionId: section.id
|
|
});
|
|
addPieceUsage(created.id, planned.revision, {
|
|
pieceId: 'piece-pixels',
|
|
sectionId: null
|
|
});
|
|
|
|
const explicit = getShow(created.id)!;
|
|
expect(explicit.sections[0].durationSeconds).toBe(600);
|
|
expect(explicit.totalDurationSeconds).toBe(600);
|
|
expect(explicit.reserve[0].durationSeconds).toBe(540);
|
|
|
|
updateShowSection(created.id, section.id, explicit.revision, {
|
|
name: 'Summierter Block',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
expect(getShow(created.id)).toMatchObject({
|
|
totalDurationSeconds: 720,
|
|
sections: [{ durationSeconds: 720 }]
|
|
});
|
|
});
|
|
|
|
it('rolls stale and invalid aggregate mutations back and rejects completed shows', () => {
|
|
const created = createShow({ title: 'Konflikt', startsAtUtc: null, templateId: null });
|
|
const saved = addShowSection(created.id, created.revision, {
|
|
name: 'Gültig',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
});
|
|
expect(() =>
|
|
addShowSection(created.id, created.revision, {
|
|
name: 'Veraltet',
|
|
categoryId: null,
|
|
explicitDurationSeconds: null
|
|
})
|
|
).toThrow('CONFLICT');
|
|
expect(getShow(created.id)).toMatchObject({
|
|
revision: saved.revision,
|
|
sections: [{ name: 'Gültig' }]
|
|
});
|
|
|
|
expect(() => removeShowSection(created.id, 'missing', saved.revision)).toThrow('NOT_FOUND');
|
|
expect(getShow(created.id)?.revision).toBe(saved.revision);
|
|
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(created.id);
|
|
expect(() =>
|
|
addPieceUsage(created.id, saved.revision, {
|
|
pieceId: 'piece-console',
|
|
sectionId: saved.id
|
|
})
|
|
).toThrow('SHOW_COMPLETED');
|
|
expect(getShow(created.id)).toMatchObject({
|
|
revision: saved.revision,
|
|
sections: [{ usages: [] }]
|
|
});
|
|
});
|
|
});
|