Release 0.4.0

This commit is contained in:
hnzio committed 2026-10-21 23:52:05 +02:00
1 parent 7590be66a5
commit 4042fe6a64
24 files changed
+3570 -290

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
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,
getShow,
listShowTemplates,
movePieceUsage,
moveShowSection,
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,
intro: edited.intro,
research: edited.research,
moderation: edited.moderation,
transition: edited.transition,
presentation: edited.presentation,
ownNotes: edited.ownNotes,
durationSeconds: edited.durationSeconds,
status: edited.status
};
}
describe('show builder data layer', () => {
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('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: [] }]
});
});
});