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