Files
sendewerk/src/lib/server/shows.spec.ts
T
2026-10-25 09:45:31 +01:00

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: [] }]
});
});
});