fix(export): copyright/blank slides, bundle-relative media, probundle injection, song links

- populate COPYRIGHT (title/author/copyright/CCLI) + blank slides on every song; songHasContent ignores locked sections
- foreground info/moderation images now bundle-relative (fixes blank images)
- pre-added .probundle injection: Zip64-fix + verbatim .pro extraction (fixes empty bundle)
- nametag subtitle split (text + subtitle); smaller non-bold render
- skip songs with no content slides at export with German warning
- link service agenda songs to SongDB edit modal via #song-<id>
- allow CCLI import of metadata-only songs (no lyric sections)
- expose has_content_slides on service songs; show "Keine Inhaltsfolien"
This commit is contained in:
Thorsten Bus 2026-06-21 09:58:55 +02:00
parent 6cd3dbbc4f
commit 42b8b5f428
24 changed files with 1512 additions and 80 deletions

View file

@ -134,6 +134,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
'serviceSongs.song.sections.label', 'serviceSongs.song.sections.label',
'serviceSongs.song.sections.slides', 'serviceSongs.song.sections.slides',
'serviceSongs.song.arrangements.arrangementSections.section.label', 'serviceSongs.song.arrangements.arrangementSections.section.label',
'serviceSongs.song.arrangements.arrangementSections.section.slides',
'serviceSongs.arrangement', 'serviceSongs.arrangement',
'slides', 'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
@ -308,6 +309,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
'song_arrangement_id' => $ss->song_arrangement_id, 'song_arrangement_id' => $ss->song_arrangement_id,
'matched_at' => $ss->matched_at?->toJSON(), 'matched_at' => $ss->matched_at?->toJSON(),
'request_sent_at' => $ss->request_sent_at?->toJSON(), 'request_sent_at' => $ss->request_sent_at?->toJSON(),
'has_content_slides' => $ss->song ? $this->defaultArrangementHasContentSlides($ss->song) : null,
'song' => $ss->song ? [ 'song' => $ss->song ? [
'id' => $ss->song->id, 'id' => $ss->song->id,
'title' => $ss->song->title, 'title' => $ss->song->title,
@ -537,4 +539,30 @@ private function collectSongLabels(Song $song): \Illuminate\Support\Collection
]) ])
->values(); ->values();
} }
/**
* Prueft, ob das Standard-Arrangement des Songs mindestens eine Inhaltsfolie hat.
* Inhaltsfolie = SongSlide in einer NICHT gesperrten Section (locked null = nicht gesperrt),
* die Teil des Standard-Arrangements ist. Relationen sind bereits eager-geladen.
*/
private function defaultArrangementHasContentSlides(Song $song): bool
{
$defaultArrangement = $song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->first();
if ($defaultArrangement === null) {
return false;
}
return $defaultArrangement->arrangementSections->contains(function ($arrangementSection): bool {
$section = $arrangementSection->section;
if ($section === null || (bool) ($section->locked ?? false)) {
return false;
}
return $section->slides->isNotEmpty();
});
}
} }

View file

@ -5,6 +5,7 @@
use App\Http\Requests\SongRequest; use App\Http\Requests\SongRequest;
use App\Models\Label; use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Services\SongPrefixPostfixService;
use App\Services\SongService; use App\Services\SongService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -14,6 +15,7 @@ class SongController extends Controller
{ {
public function __construct( public function __construct(
private readonly SongService $songService, private readonly SongService $songService,
private readonly SongPrefixPostfixService $songPrefixPostfixService,
) {} ) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
@ -98,6 +100,8 @@ public function update(SongRequest $request, int $id): JsonResponse
$song->update($request->validated()); $song->update($request->validated());
$this->songPrefixPostfixService->ensure($song);
return response()->json([ return response()->json([
'message' => 'Song erfolgreich aktualisiert', 'message' => 'Song erfolgreich aktualisiert',
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])), 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])),

View file

@ -169,7 +169,10 @@ private function upsertSong(ParsedCcliSong $parsed, ?string $sourceUrl, ?Song $s
private function songHasContent(Song $song): bool private function songHasContent(Song $song): bool
{ {
return $song->sections()->whereHas('slides')->exists(); return $song->sections()
->where(fn ($q) => $q->where('locked', false)->orWhereNull('locked'))
->whereHas('slides')
->exists();
} }
private function resolveLabel(ParsedCcliSection $section): Label private function resolveLabel(ParsedCcliSection $section): Label

View file

@ -38,7 +38,7 @@ public function parse(string $rawText): ParsedCcliSong
} }
if ($firstSectionIndex === null) { if ($firstSectionIndex === null) {
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.'); return $this->parseMetadataOnly($lines, $isMetadataLine);
} }
$headerLines = array_values(array_filter( $headerLines = array_values(array_filter(
@ -136,8 +136,10 @@ public function parse(string $rawText): ParsedCcliSong
$parsedSections = $this->mergeTranslatedSections($sections); $parsedSections = $this->mergeTranslatedSections($sections);
if ($parsedSections === []) { if ($parsedSections === []) {
if (trim($title) === '' && ($ccliId === null || trim($ccliId) === '')) {
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.'); throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
} }
}
return new ParsedCcliSong( return new ParsedCcliSong(
title: $title, title: $title,
@ -150,6 +152,68 @@ public function parse(string $rawText): ParsedCcliSong
); );
} }
/**
* Build a song from a paste that contains no lyric sections at all.
*
* Allowed as long as it is a real CCLI page (title + CCLI number present);
* otherwise there is genuinely no song and we throw.
*
* @param string[] $lines
* @param Closure(string): bool $isMetadataLine
*/
private function parseMetadataOnly(array $lines, Closure $isMetadataLine): ParsedCcliSong
{
$nonEmpty = array_values(array_filter(
$lines,
fn (string $line): bool => $line !== '',
));
$ccliId = null;
$year = null;
$copyrightText = null;
foreach ($nonEmpty as $line) {
if (! $isMetadataLine($line)) {
continue;
}
$extractedCcliId = CcliLabels::extractCcliId($line);
if ($extractedCcliId !== null) {
$ccliId = $extractedCcliId;
}
if (str_contains($line, '©')) {
$copyrightText = $line;
if (preg_match('/©\s*(\d{4})/u', $line, $matches)) {
$year = $matches[1];
}
}
}
$headerLines = array_values(array_filter(
$nonEmpty,
fn (string $line): bool => ! $isMetadataLine($line),
));
$title = $headerLines[0] ?? '';
$author = $headerLines[1] ?? null;
if (trim($title) === '' && ($ccliId === null || trim($ccliId) === '')) {
throw new InvalidArgumentException('Keine Sektionen erkannt — bitte vollständige Liedseite einfügen.');
}
return new ParsedCcliSong(
title: $title,
author: $author,
ccliId: $ccliId,
year: $year,
copyrightText: $copyrightText,
sourceUrl: null,
sections: [],
);
}
/** /**
* @param array<int, array{label: string, kind: string, rawKind: string, number: string|null, modifier: string|null, lines: string[]}> $sections * @param array<int, array{label: string, kind: string, rawKind: string, number: string|null, modifier: string|null, lines: string[]}> $sections
* @return ParsedCcliSection[] * @return ParsedCcliSection[]

View file

@ -27,20 +27,20 @@ public function build(string $name, string $title): ?array
if ($macro !== null) { if ($macro !== null) {
$collection = $macro->collections->first(); $collection = $macro->collections->first();
return [ return $this->withSubtitle([
'text' => $name."\n".$title, 'text' => $name,
'macro' => [ 'macro' => [
'name' => $macro->name, 'name' => $macro->name,
'uuid' => $macro->uuid, 'uuid' => $macro->uuid,
'collectionName' => $collection?->name ?? '--MAIN--', 'collectionName' => $collection?->name ?? '--MAIN--',
'collectionUuid' => $collection?->uuid ?? null, 'collectionUuid' => $collection?->uuid ?? null,
], ],
]; ], $title);
} }
// macro_id is configured but the Macro record is missing — still return a slide // macro_id is configured but the Macro record is missing — still return a slide
// without a macro so the name tag is not silently dropped. // without a macro so the name tag is not silently dropped.
return ['text' => $name."\n".$title]; return $this->withSubtitle(['text' => $name], $title);
} }
$macroName = Setting::get('namenseinblender_macro_name'); $macroName = Setting::get('namenseinblender_macro_name');
@ -49,14 +49,30 @@ public function build(string $name, string $title): ?array
return null; return null;
} }
return [ return $this->withSubtitle([
'text' => $name."\n".$title, 'text' => $name,
'macro' => [ 'macro' => [
'name' => $macroName, 'name' => $macroName,
'uuid' => Setting::get('namenseinblender_macro_uuid'), 'uuid' => Setting::get('namenseinblender_macro_uuid'),
'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'), 'collectionName' => Setting::get('namenseinblender_macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'), 'collectionUuid' => Setting::get('namenseinblender_macro_collection_uuid'),
], ],
]; ], $title);
}
/**
* Add the role line as a separate `subtitle` key so the generator can render
* it smaller and non-bold. Only set when the title is non-empty.
*
* @param array<string, mixed> $slideData
* @return array<string, mixed>
*/
private function withSubtitle(array $slideData, string $title): array
{
if (trim($title) !== '') {
$slideData['subtitle'] = $title;
}
return $slideData;
} }
} }

View file

@ -11,11 +11,12 @@
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistGenerator; use ProPresenter\Parser\ProPlaylistGenerator;
use ProPresenter\Parser\Zip64Fixer;
use ZipArchive; use ZipArchive;
class PlaylistExportService class PlaylistExportService
{ {
/** @return array{path: string, filename: string, skipped: int} */ /** @return array{path: string, filename: string, skipped: int, warnings: array<int, string>} */
public function generatePlaylist(Service $service, bool $preview = false): array public function generatePlaylist(Service $service, bool $preview = false): array
{ {
$agendaItems = ServiceAgendaItem::where('service_id', $service->id) $agendaItems = ServiceAgendaItem::where('service_id', $service->id)
@ -38,7 +39,7 @@ public function generatePlaylist(Service $service, bool $preview = false): array
/** /**
* @param Collection<int, ServiceAgendaItem> $agendaItems * @param Collection<int, ServiceAgendaItem> $agendaItems
* @return array{path: string, filename: string, skipped: int, temp_dir: string} * @return array{path: string, filename: string, skipped: int, warnings: array<int, string>, temp_dir: string}
*/ */
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array
{ {
@ -63,6 +64,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$playlistItems = []; $playlistItems = [];
$embeddedFiles = []; $embeddedFiles = [];
$skippedUnmatched = 0; $skippedUnmatched = 0;
$warnings = [];
$moderatorSlideData = $this->buildModeratorSlideData($service); $moderatorSlideData = $this->buildModeratorSlideData($service);
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id; $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
@ -105,8 +107,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if ($serviceSong->song_id && $serviceSong->song) { if ($serviceSong->song_id && $serviceSong->song) {
$song = $serviceSong->song; $song = $serviceSong->song;
if ($this->countSongLabels($song) === 0) { if ($this->countSongContentSlides($song) === 0) {
$skippedUnmatched++; $skippedUnmatched++;
$warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
continue; continue;
} }
@ -222,6 +225,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
'path' => $outputPath, 'path' => $outputPath,
'filename' => $outputFilename, 'filename' => $outputFilename,
'skipped' => $skippedUnmatched, 'skipped' => $skippedUnmatched,
'warnings' => $warnings,
'temp_dir' => $tempDir, 'temp_dir' => $tempDir,
]; ];
} }
@ -230,7 +234,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
* Legacy export for services without agenda items. * Legacy export for services without agenda items.
* Hardcoded block order: info songs moderation sermon. * Hardcoded block order: info songs moderation sermon.
* *
* @return array{path: string, filename: string, skipped: int, temp_dir: string} * @return array{path: string, filename: string, skipped: int, warnings: array<int, string>, temp_dir: string}
*/ */
private function generatePlaylistLegacy(Service $service, bool $preview = false): array private function generatePlaylistLegacy(Service $service, bool $preview = false): array
{ {
@ -244,6 +248,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
$skippedEmpty = 0; $skippedEmpty = 0;
$warnings = [];
$exportService = app(ProExportService::class); $exportService = app(ProExportService::class);
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid(); $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
@ -264,8 +269,11 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
foreach ($matchedSongs as $serviceSong) { foreach ($matchedSongs as $serviceSong) {
$song = $serviceSong->song; $song = $serviceSong->song;
if (! $song || $this->countSongLabels($song) === 0) { if (! $song || $this->countSongContentSlides($song) === 0) {
$skippedEmpty++; $skippedEmpty++;
if ($song !== null) {
$warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
}
continue; continue;
} }
@ -330,6 +338,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
'path' => $outputPath, 'path' => $outputPath,
'filename' => $outputFilename, 'filename' => $outputFilename,
'skipped' => $skippedUnmatched + $skippedEmpty, 'skipped' => $skippedUnmatched + $skippedEmpty,
'warnings' => $warnings,
'temp_dir' => $tempDir, 'temp_dir' => $tempDir,
]; ];
} }
@ -366,6 +375,7 @@ private function addSlidesFromCollection(
'media' => $imageFilename, 'media' => $imageFilename,
'format' => 'JPG', 'format' => 'JPG',
'label' => $slide->original_filename, 'label' => $slide->original_filename,
'bundleRelative' => true,
]; ];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
@ -561,45 +571,20 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri
if ($ext === 'probundle') { if ($ext === 'probundle') {
$storedAbsPath = Storage::disk('local')->path($file->stored_path); $storedAbsPath = Storage::disk('local')->path($file->stored_path);
$zip = new ZipArchive;
if ($zip->open($storedAbsPath) !== true) { $extracted = $this->extractProBundle($storedAbsPath);
if ($extracted === null) {
continue; continue;
} }
$proBytes = null; [$proBytes, $mediaFiles] = $extracted;
for ($i = 0; $i < $zip->numFiles; $i++) { foreach ($mediaFiles as $entryName => $entryBytes) {
$entryName = $zip->getNameIndex($i); $mediaName = basename((string) $entryName);
if ($entryName === false) {
continue;
}
if (str_ends_with($entryName, '/')) {
continue;
}
$entryExt = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
$entryBytes = $zip->getFromIndex($i);
if ($entryBytes === false) {
continue;
}
if ($entryExt === 'pro') {
$proBytes = $entryBytes;
} else {
$mediaName = basename($entryName);
if (! isset($embedded[$mediaName])) { if (! isset($embedded[$mediaName])) {
$embedded[$mediaName] = $entryBytes; $embedded[$mediaName] = $entryBytes;
} }
} }
}
$zip->close();
if ($proBytes === null) {
continue;
}
$embedded[$embeddedProName] = $proBytes; $embedded[$embeddedProName] = $proBytes;
$items[] = [ $items[] = [
@ -625,6 +610,90 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, stri
return $items; return $items;
} }
/**
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
*
* Uses raw ZipArchive extraction so the original .pro bytes are preserved exactly
* (no protobuf round-trip). ProPresenter-authored bundles use Zip64; Zip64Fixer is
* applied first so those archives open correctly. Normal (non-Zip64) archives pass
* through Zip64Fixer unchanged. Returns null when the bundle has no .pro entry.
*
* @return array{0: string, 1: array<string, string>}|null [proBytes, mediaFiles]
*/
private function extractProBundle(string $absPath): ?array
{
$rawBytes = @file_get_contents($absPath);
if ($rawBytes === false || $rawBytes === '') {
return null;
}
try {
$fixedBytes = Zip64Fixer::fix($rawBytes);
} catch (\Throwable) {
// Not Zip64-fixable: fall back to the original bytes.
$fixedBytes = $rawBytes;
}
$tempPath = tempnam(sys_get_temp_dir(), 'export-probundle-');
if ($tempPath === false) {
return null;
}
$zip = new ZipArchive;
$isOpen = false;
try {
if (@file_put_contents($tempPath, $fixedBytes) === false) {
return null;
}
if ($zip->open($tempPath) !== true) {
return null;
}
$isOpen = true;
$proFilename = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name !== false && str_ends_with(strtolower($name), '.pro')) {
$proFilename = $name;
break;
}
}
if ($proFilename === null) {
return null;
}
$proBytes = $zip->getFromName($proFilename);
if ($proBytes === false || $proBytes === '') {
return null;
}
$mediaFiles = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false || $name === $proFilename) {
continue;
}
$contents = $zip->getFromIndex($i);
if ($contents === false) {
continue;
}
$mediaFiles[$name] = $contents;
}
return [$proBytes, $mediaFiles];
} finally {
if ($isOpen) {
$zip->close();
}
@unlink($tempPath);
}
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{ {
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
@ -684,12 +753,45 @@ private function writeProAndEmbed(string $name, array $slideData, string $tempDi
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
} }
private function countSongLabels(\App\Models\Song $song): int /**
* Number of content slides in the song's default arrangement.
* Content = SongSlide rows belonging to NON-locked sections (locked false/null)
* that have a label. Locked prefix/postfix sections are ignored even if non-empty.
*/
private function countSongContentSlides(\App\Models\Song $song): int
{ {
return $song->arrangements() $song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label');
->withCount('arrangementLabels')
->get() $defaultArr = $song->arrangements->firstWhere('is_default', true)
->sum('arrangement_labels_count'); ?? $song->arrangements->first();
if ($defaultArr === null) {
return 0;
}
$count = 0;
$seenSectionIds = [];
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null || $section->label === null) {
continue;
}
if ($section->locked === true) {
continue;
}
if (in_array($section->id, $seenSectionIds, true)) {
continue;
}
$seenSectionIds[] = $section->id;
$count += $section->slides->count();
}
return $count;
} }
private function backgroundData(?Service $service): ?array private function backgroundData(?Service $service): ?array

View file

@ -52,13 +52,8 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) { if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
$song = $agendaItem->serviceSong->song; $song = $agendaItem->serviceSong->song;
$labelCount = $song->arrangements() if ($this->countSongContentSlides($song) === 0) {
->withCount('arrangementLabels') throw new RuntimeException('Lied "'.$song->title.'" übersprungen: keine Inhaltsfolien.');
->get()
->sum('arrangement_labels_count');
if ($labelCount === 0) {
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
} }
$parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service); $parserSong = app(ProExportService::class)->generateParserSong($song, $agendaItem->service);
@ -119,6 +114,7 @@ private function buildBundleFromSlides(
'media' => $imageFilename, 'media' => $imageFilename,
'format' => 'JPG', 'format' => 'JPG',
'label' => $slide->original_filename, 'label' => $slide->original_filename,
'bundleRelative' => true,
]; ];
if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) { if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
@ -173,6 +169,47 @@ private function buildBundleFromSlides(
return $bundlePath; return $bundlePath;
} }
/**
* Number of content slides in the song's default arrangement.
* Content = SongSlide rows belonging to NON-locked sections (locked false/null)
* that have a label. Locked prefix/postfix sections are ignored even if non-empty.
*/
private function countSongContentSlides(\App\Models\Song $song): int
{
$song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label');
$defaultArr = $song->arrangements->firstWhere('is_default', true)
?? $song->arrangements->first();
if ($defaultArr === null) {
return 0;
}
$count = 0;
$seenSectionIds = [];
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null || $section->label === null) {
continue;
}
if ($section->locked === true) {
continue;
}
if (in_array($section->id, $seenSectionIds, true)) {
continue;
}
$seenSectionIds[] = $section->id;
$count += $section->slides->count();
}
return $count;
}
private function backgroundData(?Service $service): ?array private function backgroundData(?Service $service): ?array
{ {
if ($this->backgroundSourcePath($service) === null) { if ($this->backgroundSourcePath($service) === null) {

View file

@ -18,8 +18,8 @@ public function ensure(Song $song): void
$prefixLabelId = $this->resolvePrefixLabelId(); $prefixLabelId = $this->resolvePrefixLabelId();
$postfixLabelId = $this->resolvePostfixLabelId(); $postfixLabelId = $this->resolvePostfixLabelId();
$prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0); $prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0, 'prefix');
$postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX); $postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX, 'postfix');
$arrangement = $this->resolveDefaultArrangement($song); $arrangement = $this->resolveDefaultArrangement($song);
@ -64,7 +64,10 @@ private function resolvePostfixLabelId(): int
return $label->id; return $label->id;
} }
private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection /**
* @param 'prefix'|'postfix' $kind
*/
private function ensureLockedSection(Song $song, int $labelId, int $orderHint, string $kind): SongSection
{ {
$section = SongSection::firstOrCreate( $section = SongSection::firstOrCreate(
['song_id' => $song->id, 'label_id' => $labelId], ['song_id' => $song->id, 'label_id' => $labelId],
@ -74,9 +77,43 @@ private function ensureLockedSection(Song $song, int $labelId, int $orderHint):
$section->update(['locked' => true]); $section->update(['locked' => true]);
$section->slides()->delete(); $section->slides()->delete();
$textContent = $kind === 'prefix'
? $this->buildCopyrightText($song)
: '';
$section->slides()->create([
'order' => 1,
'text_content' => $textContent,
]);
return $section; return $section;
} }
private function buildCopyrightText(Song $song): string
{
$title = trim((string) ($song->title ?? $song->name ?? ''));
$lines = [];
if ($title !== '') {
$lines[] = $title;
}
if (trim((string) $song->author) !== '') {
$lines[] = trim((string) $song->author);
}
if (trim((string) $song->copyright_text) !== '') {
$lines[] = trim((string) $song->copyright_text);
}
if (trim((string) $song->ccli_id) !== '') {
$lines[] = 'CCLI-Liednr. '.trim((string) $song->ccli_id);
}
return implode("\n", $lines);
}
private function resolveDefaultArrangement(Song $song): SongArrangement private function resolveDefaultArrangement(Song $song): SongArrangement
{ {
return SongArrangement::firstOrCreate( return SongArrangement::firstOrCreate(

View file

@ -1,6 +1,6 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3' import { Link, router } from '@inertiajs/vue3'
const props = defineProps({ const props = defineProps({
agendaItem: { type: Object, required: true }, agendaItem: { type: Object, required: true },
@ -14,6 +14,18 @@ const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaI
const isMatched = computed(() => serviceSong.value?.song_id != null) const isMatched = computed(() => serviceSong.value?.song_id != null)
// Matched song id for linking to the SongDB edit modal (Songs/Index reads #song-<id>).
const matchedSongId = computed(() => serviceSong.value?.song?.id ?? serviceSong.value?.song_id ?? null)
const songDetailHref = computed(() =>
matchedSongId.value != null ? `${route('songs.index')}#song-${matchedSongId.value}` : null,
)
// Matched song whose default arrangement has NO content slide -> not verified.
const missingContentSlides = computed(
() => isMatched.value && serviceSong.value?.has_content_slides === false,
)
const useTranslation = ref(false) const useTranslation = ref(false)
const downloading = ref(false) const downloading = ref(false)
const toastMessage = ref('') const toastMessage = ref('')
@ -206,8 +218,30 @@ async function downloadBundle() {
<td class="py-2.5 pr-3 align-top"> <td class="py-2.5 pr-3 align-top">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<!-- Song title --> <!-- Song title -->
<span class="font-medium text-gray-900" data-testid="song-agenda-title"> <Link
v-if="isMatched && songDetailHref"
:href="songDetailHref"
class="font-medium text-gray-900 underline decoration-gray-300 decoration-dotted underline-offset-2 transition hover:text-emerald-700 hover:decoration-emerald-400"
data-testid="song-agenda-title"
title="Song in der Song-Datenbank öffnen"
>
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }} {{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
</Link>
<span v-else class="font-medium text-gray-900" data-testid="song-agenda-title">
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
</span>
<!-- No content slides warning (matched but default arrangement empty) -->
<span
v-if="missingContentSlides"
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-700"
data-testid="song-no-content"
title="Standard-Arrangement enthält keine Inhaltsfolien"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Keine Inhaltsfolien
</span> </span>
<!-- CCLI pill --> <!-- CCLI pill -->
@ -232,7 +266,12 @@ async function downloadBundle() {
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<!-- Assign status --> <!-- Assign status -->
<span <span
v-if="isMatched" v-if="missingContentSlides"
class="inline-flex h-2 w-2 rounded-full bg-red-500"
title="Keine Inhaltsfolien"
></span>
<span
v-else-if="isMatched"
class="inline-flex h-2 w-2 rounded-full bg-emerald-500" class="inline-flex h-2 w-2 rounded-full bg-emerald-500"
title="Zugeordnet" title="Zugeordnet"
></span> ></span>

View file

@ -65,7 +65,19 @@ watch(search, () => {
watch(onlyWithContent, () => fetchSongs(1)) watch(onlyWithContent, () => fetchSongs(1))
onMounted(() => fetchSongs()) onMounted(async () => {
await fetchSongs()
openSongFromHash()
})
// When navigated from the service page (#song-<id>), open that song's edit modal.
function openSongFromHash() {
const match = window.location.hash.match(/^#song-(\d+)$/)
if (!match) return
const songId = Number(match[1])
const song = songs.value.find((s) => s.id === songId) ?? { id: songId }
openEditModal(song)
}
function goToPage(page) { function goToPage(page) {
if (page < 1 || page > meta.value.last_page) return if (page < 1 || page > meta.value.last_page) return

View file

@ -0,0 +1,87 @@
<?php
use App\Models\Setting;
use App\Models\Song;
use App\Models\SongSlide;
use App\Services\CcliImportService;
use App\Services\CcliPasteParser;
use App\Services\DTO\ParsedCcliSong;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* A real CCLI page that carries only metadata (title + CCLI number) but no
* lyric/section labels e.g. an instrumental or a stub song.
*/
function metadataOnlyPaste(): string
{
return <<<'TXT'
Instrumental Intro
© 2024 Test Publishing House
CCLI # 9999500
TXT;
}
test('parser akzeptiert Metadaten-only Paste ohne zu werfen und liefert leere Sektionen', function (): void {
$parser = new CcliPasteParser;
$result = $parser->parse(metadataOnlyPaste());
expect($result)->toBeInstanceOf(ParsedCcliSong::class)
->and($result->title)->toBe('Instrumental Intro')
->and($result->ccliId)->toBe('9999500')
->and($result->year)->toBe('2024')
->and($result->sections)->toBe([]);
});
test('parser wirft weiterhin wenn weder Titel noch CCLI vorhanden', function (): void {
$parser = new CcliPasteParser;
expect(fn () => $parser->parse(" \n \n"))
->toThrow(InvalidArgumentException::class);
});
test('import von Metadaten-only erzeugt Song ohne Inhalts-Sektionen aber mit Copyright+Blank', function (): void {
$service = app(CcliImportService::class);
$result = $service->import(metadataOnlyPaste());
$song = $result['song']->fresh();
expect($result['status'])->toBe('created')
->and($song->ccli_id)->toBe('9999500')
->and($song->has_translation)->toBeFalse();
// normal-Arrangement existiert trotz fehlender Inhalts-Sektionen.
$arrangement = $song->arrangements()->where('name', 'normal')->first();
expect($arrangement)->not->toBeNull()
->and($arrangement->is_default)->toBeTrue();
// Genau zwei Folien: eine COPYRIGHT-Folie, eine BLANK-Folie.
expect(SongSlide::count())->toBe(2);
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
$copyrightSlide = $song->sections()
->where('label_id', $prefixLabelId)
->first()
->slides()
->first();
expect($copyrightSlide->text_content)->toContain('Instrumental Intro')
->and($copyrightSlide->text_content)->toContain('CCLI-Liednr. 9999500');
});
test('songHasContent bleibt false fuer Metadaten-only Song (Duplikat-Guard behandelt als leer)', function (): void {
$service = app(CcliImportService::class);
// Erster Import legt den leeren Song an.
$service->import(metadataOnlyPaste());
// Zweiter Import darf NICHT als Duplikat geworfen werden, da kein Inhalt.
// Status 'restored' (Song existiert), aber keine DuplicateCcliSongException.
$result = $service->import(metadataOnlyPaste());
expect($result['status'])->toBe('restored')
->and(Song::where('ccli_id', '9999500')->count())->toBe(1);
});

View file

@ -45,7 +45,8 @@ function ccliFixture(string $name): string
expect($arrangement)->not->toBeNull() expect($arrangement)->not->toBeNull()
->and($arrangement->is_default)->toBeTrue() ->and($arrangement->is_default)->toBeTrue()
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7) ->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7)
->and(SongSlide::count())->toBe(5); // 5 Inhalts-Folien + COPYRIGHT-Folie + BLANK-Folie (Prefix/Postfix).
->and(SongSlide::count())->toBe(7);
}); });
test('imports english and german fixture and stores translated slide text', function () { test('imports english and german fixture and stores translated slide text', function () {
@ -94,7 +95,12 @@ function ccliFixture(string $name): string
->and($song->author)->toBe('Albert Frey') ->and($song->author)->toBe('Albert Frey')
->and($arrangement)->not->toBeNull() ->and($arrangement)->not->toBeNull()
->and($arrangement->arrangementSections)->toHaveCount(4) ->and($arrangement->arrangementSections)->toHaveCount(4)
->and(SongSlide::count())->toBe(7); // 7 Inhalts-Folien + COPYRIGHT-Folie + BLANK-Folie (Prefix/Postfix).
->and(SongSlide::count())->toBe(9);
// Gesperrte COPYRIGHT/BLANK-Sektionen tragen je eine eigene Folie.
$lockedSlides = SongSlide::whereHas('section', fn ($q) => $q->where('locked', true))->count();
expect($lockedSlides)->toBe(2);
}); });
test('uses distinct label colors for imported section kinds', function () { test('uses distinct label colors for imported section kinds', function () {

View file

@ -149,10 +149,22 @@ function ccliFixtureContent(string $filename): string
expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class); expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class);
}); });
test('parse throws InvalidArgumentException on text with no section labels', function (): void { test('parse erlaubt Metadaten-only Seite (Titel + CCLI, keine Sektionen)', function (): void {
$parser = new CcliPasteParser; $parser = new CcliPasteParser;
expect(fn () => $parser->parse('Just some random text without any section labels'))->toThrow(InvalidArgumentException::class); // Echte CCLI-Seite ohne Liedtext-Sektionen: Import muss möglich sein.
$result = $parser->parse("Instrumental\n\n© 2024 Verlag\nCCLI # 9999777");
expect($result->title)->toBe('Instrumental')
->and($result->ccliId)->toBe('9999777')
->and($result->sections)->toBe([]);
});
test('parse throws InvalidArgumentException when neither title nor ccli present', function (): void {
$parser = new CcliPasteParser;
// Reiner Whitespace -> kein Song -> Exception.
expect(fn () => $parser->parse(" \n \n "))->toThrow(InvalidArgumentException::class);
}); });
test('parse error messages are in German', function (): void { test('parse error messages are in German', function (): void {

View file

@ -0,0 +1,154 @@
<?php
namespace Tests\Feature;
use App\Models\Service;
use App\Models\Slide;
use App\Services\PlaylistExportService;
use App\Services\ProBundleExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use Tests\TestCase;
/**
* FIX 2: Foreground image slide data (information/moderation/sermon blocks) must
* carry 'bundleRelative' => true so the .pro references the embedded image by a
* bundle-relative resource URL instead of an absolute path. Otherwise the slide
* renders blank on the presenter PC.
*/
final class ExportBundleRelativeMediaTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
private function createSlideFile(string $storedFilename): void
{
$image = imagecreatetruecolor(1920, 1080);
ob_start();
imagejpeg($image);
$contents = ob_get_clean();
imagedestroy($image);
Storage::disk('public')->put($storedFilename, $contents);
}
private function createSlide(array $attributes): Slide
{
return Slide::create(array_merge([
'thumbnail_filename' => 'thumb.jpg',
'uploaded_at' => now()->subDay(),
], $attributes));
}
public function test_playlist_export_setzt_bundle_relative_auf_info_folien_slide_daten(): void
{
$service = Service::factory()->create(['finalized_at' => now(), 'date' => now()]);
$this->createSlideFile('slides/info1.jpg');
$this->createSlide([
'type' => 'information',
'service_id' => null,
'original_filename' => 'info1.jpg',
'stored_filename' => 'slides/info1.jpg',
'sort_order' => 1,
]);
$capturedGroups = [];
$exportService = new class($capturedGroups) extends PlaylistExportService
{
/** @var array<int, array> */
private array $captured;
public function __construct(array &$captured)
{
$this->captured = &$captured;
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{
$this->captured[] = $groups;
file_put_contents($path, 'mock-pro:'.$name);
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
file_put_contents($path, 'mock-playlist:'.$name);
}
};
$result = $exportService->generatePlaylist($service);
// Find the slide data array carrying our image media.
$found = false;
foreach ($capturedGroups as $groups) {
foreach ($groups as $group) {
foreach ($group['slides'] ?? [] as $slideData) {
if (isset($slideData['media'])) {
$found = true;
$this->assertArrayHasKey('bundleRelative', $slideData);
$this->assertTrue($slideData['bundleRelative']);
}
}
}
}
$this->assertTrue($found, 'Expected at least one media slide in captured pro groups.');
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_export_referenziert_vordergrund_medien_bundle_relativ(): void
{
$service = Service::factory()->create(['finalized_at' => now(), 'date' => now()]);
$this->createSlideFile('slides/mod1.jpg');
$this->createSlide([
'type' => 'moderation',
'service_id' => $service->id,
'original_filename' => 'mod1.jpg',
'stored_filename' => 'slides/mod1.jpg',
'sort_order' => 1,
]);
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'moderation');
$this->assertFileExists($bundlePath);
$bundle = ProBundleReader::read($bundlePath);
$slides = $bundle->getSong()->getSlides();
$mediaSlides = array_filter($slides, fn ($slide) => $slide->hasMedia());
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-media slide in the bundle.');
foreach ($mediaSlides as $slide) {
$url = $slide->getMediaUrl();
$this->assertNotNull($url);
// Bundle-relative resources use a bare basename (no absolute path / no slash).
$this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).');
}
@unlink($bundlePath);
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\PresentationBundle;
use ProPresenter\Parser\ProBundleWriter;
use ProPresenter\Parser\ProFileGenerator;
use Tests\TestCase;
/**
* FIX 3: A pre-added .probundle (prefix/postfix ExportProFile) must be unpacked
* with the parser's ProBundleReader (Zip64-aware) so the embedded .pro bytes are
* read correctly and the presentation appears in the exported playlist.
*/
final class ExportPreAddedProBundleTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
private function createSongWithContent(string $title = 'Test Song'): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Test Author',
]);
$label = Label::firstOrCreate(['name' => 'Verse 1 - '.$title], ['color' => '#2196F3']);
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Inhalt']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
/** Build a minimal valid .probundle and register it as a prefix ExportProFile on the local disk. */
private function registerPrefixBundle(string $proName = 'IntroBundle'): ExportProFile
{
$song = ProFileGenerator::generate(
$proName,
[[
'name' => 'Intro',
'color' => [0, 0, 0, 1],
'slides' => [['text' => 'Willkommen']],
]],
[['name' => 'normal', 'groupNames' => ['Intro']]],
);
$bundle = new PresentationBundle($song, $proName.'.pro', []);
$tmp = tempnam(sys_get_temp_dir(), 'fixture-').'.probundle';
ProBundleWriter::write($bundle, $tmp);
$bytes = file_get_contents($tmp);
@unlink($tmp);
$storedPath = 'export-pro-files/'.$proName.'.probundle';
Storage::disk('local')->put($storedPath, $bytes);
return ExportProFile::create([
'type' => 'prefix',
'original_name' => $proName.'.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
}
public function test_vorab_hinzugefuegtes_probundle_ergibt_nicht_leere_playlist(): void
{
$service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Mit Prefix']);
$song = $this->createSongWithContent('Hauptlied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Hauptlied',
'order' => 1,
]);
$this->registerPrefixBundle('IntroBundle');
$capturedItems = [];
$capturedFiles = [];
$exportService = new class($capturedItems, $capturedFiles) extends PlaylistExportService
{
/** @var array<int, array> */
private array $items;
/** @var array<string, string> */
private array $files;
public function __construct(array &$items, array &$files)
{
$this->items = &$items;
$this->files = &$files;
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{
file_put_contents($path, 'mock-pro:'.$name);
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
$this->items = $items;
$this->files = $embeddedFiles;
file_put_contents($path, 'mock-playlist:'.$name);
}
};
$result = $exportService->generatePlaylist($service);
$this->assertFileExists($result['path']);
// The prefix .pro must be present as a playlist item and embedded with non-empty bytes.
$prefixItem = collect($capturedItems)->first(
fn ($item) => str_starts_with($item['path'] ?? '', 'PREFIX_') && str_ends_with($item['path'], '.pro')
);
$this->assertNotNull($prefixItem, 'Pre-added .probundle should produce a PREFIX_*.pro playlist item.');
$embeddedKey = $prefixItem['path'];
$this->assertArrayHasKey($embeddedKey, $capturedFiles);
$this->assertNotSame('', $capturedFiles[$embeddedKey], 'Embedded .pro from .probundle must be non-empty.');
$this->assertGreaterThan(0, strlen($capturedFiles[$embeddedKey]));
$this->cleanupTempDir($result['temp_dir']);
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}

View file

@ -52,7 +52,7 @@ private function buildProbundleZip(string $proContent, array $mediaFiles = []):
{ {
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle'; $tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
$zip = new ZipArchive(); $zip = new ZipArchive;
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); $zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('song.pro', $proContent); $zip->addFromString('song.pro', $proContent);
@ -206,7 +206,7 @@ public function test_probundle_ohne_pro_eintrag_wird_uebersprungen(): void
]); ]);
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle'; $tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
$zip = new ZipArchive(); $zip = new ZipArchive;
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); $zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('media.jpg', 'media-only'); $zip->addFromString('media.jpg', 'media-only');
$zip->close(); $zip->close();

View file

@ -0,0 +1,199 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\PlaylistExportService;
use App\Services\ProBundleExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Tests\TestCase;
/**
* FIX 5: A song whose default arrangement contains only EMPTY locked prefix/postfix
* sections has zero content slides. It must be skipped (not exported as an empty .pro)
* and a German warning must be surfaced. A normal song still exports.
*/
final class ExportSkipEmptyLockedSongTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
/** Song whose default arrangement only references an empty, locked section. */
private function createEmptyLockedSong(string $title = 'Leeres Lied'): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Author',
]);
$label = Label::firstOrCreate(['name' => 'Intro - '.$title], ['color' => '#000000']);
// Locked section with NO slides.
$section = $song->sections()->create([
'label_id' => $label->id,
'order' => 0,
'locked' => true,
]);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
private function createNormalSong(string $title = 'Normales Lied'): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Author',
]);
$label = Label::firstOrCreate(['name' => 'Verse 1 - '.$title], ['color' => '#2196F3']);
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0, 'locked' => false]);
$section->slides()->create(['order' => 0, 'text_content' => 'Strophentext']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
private function testable_export_service(): PlaylistExportService
{
return new class extends PlaylistExportService
{
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{
file_put_contents($path, 'mock-pro:'.$name);
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
$content = 'mock-playlist:'.$name;
foreach ($items as $item) {
$content .= "\n".($item['name'] ?? '');
}
file_put_contents($path, $content);
}
};
}
public function test_playlist_legacy_ueberspringt_leeres_locked_lied_mit_warnung(): void
{
$service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Mix Service']);
$emptySong = $this->createEmptyLockedSong('Leeres Lied');
$normalSong = $this->createNormalSong('Normales Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $emptySong->id,
'cts_song_name' => 'Leeres Lied',
'order' => 1,
]);
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $normalSong->id,
'cts_song_name' => 'Normales Lied',
'order' => 2,
]);
$result = $this->testable_export_service()->generatePlaylist($service);
$content = file_get_contents($result['path']);
// Empty locked song skipped, normal song exported.
$this->assertStringNotContainsString('Leeres Lied', $content);
$this->assertStringContainsString('Normales Lied', $content);
// German warning present.
$this->assertContains(
"Lied 'Leeres Lied' übersprungen: keine Inhaltsfolien.",
$result['warnings'],
);
$this->assertGreaterThanOrEqual(1, $result['skipped']);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_agenda_song_wirft_fehler_bei_leerem_locked_lied(): void
{
$service = Service::factory()->create(['finalized_at' => now()]);
$emptySong = $this->createEmptyLockedSong('Leeres Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $emptySong->id,
'cts_song_name' => 'Leeres Lied',
'order' => 1,
]);
$agendaItem = \App\Models\ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Leeres Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('keine Inhaltsfolien');
app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
}
public function test_probundle_agenda_song_exportiert_normales_lied(): void
{
$service = Service::factory()->create(['finalized_at' => now()]);
$normalSong = $this->createNormalSong('Normales Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $normalSong->id,
'cts_song_name' => 'Normales Lied',
'order' => 1,
]);
$agendaItem = \App\Models\ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Normales Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
$this->assertFileExists($bundlePath);
$this->assertGreaterThan(0, filesize($bundlePath));
@unlink($bundlePath);
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}

View file

@ -20,13 +20,14 @@
$slide = app(NameTagSlideBuilder::class)->build('Anna Müller', 'Moderation'); $slide = app(NameTagSlideBuilder::class)->build('Anna Müller', 'Moderation');
expect($slide)->toBe([ expect($slide)->toBe([
'text' => "Anna Müller\nModeration", 'text' => 'Anna Müller',
'macro' => [ 'macro' => [
'name' => 'Namenseinblender', 'name' => 'Namenseinblender',
'uuid' => '11111111-1111-1111-1111-111111111111', 'uuid' => '11111111-1111-1111-1111-111111111111',
'collectionName' => 'Service Macros', 'collectionName' => 'Service Macros',
'collectionUuid' => '22222222-2222-2222-2222-222222222222', 'collectionUuid' => '22222222-2222-2222-2222-222222222222',
], ],
'subtitle' => 'Moderation',
]); ]);
}); });
@ -38,8 +39,13 @@
$builder = app(NameTagSlideBuilder::class); $builder = app(NameTagSlideBuilder::class);
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration") $moderatorSlide = $builder->buildModeratorSlide('Max Mustermann');
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt"); $preacherSlide = $builder->buildPreacherSlide('Erika Beispiel');
expect($moderatorSlide['text'])->toBe('Max Mustermann')
->and($moderatorSlide['subtitle'])->toBe('Moderation')
->and($preacherSlide['text'])->toBe('Erika Beispiel')
->and($preacherSlide['subtitle'])->toBe('Predigt');
}); });
test('build returns slide with macro when macro_id points to existing macro', function () { test('build returns slide with macro when macro_id points to existing macro', function () {
@ -51,7 +57,8 @@
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation'); $slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
expect($slide)->toHaveKey('text', "Lea Leiter\nModeration") expect($slide)->toHaveKey('text', 'Lea Leiter')
->and($slide)->toHaveKey('subtitle', 'Moderation')
->and($slide)->toHaveKey('macro') ->and($slide)->toHaveKey('macro')
->and($slide['macro']['name'])->toBe('Namenseinblender') ->and($slide['macro']['name'])->toBe('Namenseinblender')
->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'); ->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
@ -62,5 +69,5 @@
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation'); $slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
expect($slide)->toBe(['text' => "Lea Leiter\nModeration"]); expect($slide)->toBe(['text' => 'Lea Leiter', 'subtitle' => 'Moderation']);
}); });

View file

@ -66,7 +66,11 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]); $nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
$this->assertCount(1, $nameTagSlides); $this->assertCount(1, $nameTagSlides);
$this->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText()); // Name and role are now split: the name is the main (\fs84) run and the
// role is a separate smaller, non-bold (\b0\fs50) subtitle run.
[$name, $subtitle] = $this->nameTagNameAndSubtitle($nameTagSlides[0]);
$this->assertSame('Erika Predigt', $name);
$this->assertSame('Predigt', $subtitle);
$this->assertTrue($nameTagSlides[0]->hasMacro()); $this->assertTrue($nameTagSlides[0]->hasMacro());
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]); $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
@ -116,7 +120,10 @@ public function test_moderator_nametag_is_first_presentation_for_first_visible_a
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist)); $this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]); $moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
$this->assertCount(1, $moderatorSlides); $this->assertCount(1, $moderatorSlides);
$this->assertSame("Max Moderation\nModeration", $moderatorSlides[0]->getPlainText()); // Name and role are split: the name is the main run, the role the subtitle run.
[$name, $subtitle] = $this->nameTagNameAndSubtitle($moderatorSlides[0]);
$this->assertSame('Max Moderation', $name);
$this->assertSame('Moderation', $subtitle);
$this->assertTrue($moderatorSlides[0]->hasMacro()); $this->assertTrue($moderatorSlides[0]->hasMacro());
$this->cleanupTempDir($result['temp_dir']); $this->cleanupTempDir($result['temp_dir']);
@ -252,6 +259,28 @@ private function slidesForEntry(PlaylistArchive $playlist, $entry): array
return $this->allParserSlides($playlist->getEmbeddedSong($filename)); return $this->allParserSlides($playlist->getEmbeddedSong($filename));
} }
/**
* Split a name-tag slide into its [name, subtitle] parts. The generator emits
* the name as the main (\fs84) run and the role as a separate smaller,
* non-bold (\b0\fs50) subtitle run on a new line inside the same RTF element.
*
* @return array{0: string, 1: string}
*/
private function nameTagNameAndSubtitle(\ProPresenter\Parser\Slide $slide): array
{
$elements = $slide->getTextElements();
$this->assertNotEmpty($elements);
$rtf = $elements[0]->getRtfData();
$this->assertStringContainsString('\b0\fs50', $rtf, 'Name-tag slide is missing the subtitle run');
// getPlainText() returns both runs joined by a newline ("Name\nRole").
$plain = $slide->getPlainText();
$parts = explode("\n", $plain, 2);
$this->assertCount(2, $parts, 'Name-tag slide does not contain a name and a subtitle line');
return [trim($parts[0]), trim($parts[1])];
}
private function allParserSlides(?\ProPresenter\Parser\Song $parserSong): array private function allParserSlides(?\ProPresenter\Parser\Song $parserSong): array
{ {
$this->assertNotNull($parserSong); $this->assertNotNull($parserSong);

View file

@ -35,7 +35,15 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
$this->assertNotNull($song); $this->assertNotNull($song);
$this->assertSame(6, \App\Models\Label::count()); $this->assertSame(6, \App\Models\Label::count());
$this->assertSame(5, \App\Models\SongSlide::count());
// The imported lyric content is 5 slides. SongPrefixPostfixService::ensure()
// now always adds a COPYRIGHT slide + a BLANK slide in locked sections, so
// the 5 content slides live in non-locked sections and the total is 7.
$contentSlideCount = \App\Models\SongSlide::whereHas('section', function ($query): void {
$query->where('locked', false)->orWhereNull('locked');
})->count();
$this->assertSame(5, $contentSlideCount);
$this->assertSame(7, \App\Models\SongSlide::count());
$this->assertSame(2, $song->arrangements()->count()); $this->assertSame(2, $song->arrangements()->count());
$this->assertTrue($song->has_translation); $this->assertTrue($song->has_translation);

View file

@ -0,0 +1,184 @@
<?php
namespace Tests\Feature;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementSection;
use App\Models\SongSection;
use App\Models\SongSlide;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ServiceSongContentSlidesTest extends TestCase
{
use RefreshDatabase;
private function makeServiceWithSong(callable $buildSong): array
{
$service = Service::factory()->create([
'date' => Carbon::today(),
'finalized_at' => null,
]);
$song = Song::factory()->create();
$arrangement = $buildSong($song);
$serviceSong = ServiceSong::factory()->create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'order' => 1,
]);
return [$service, $serviceSong];
}
private function defaultArrangementWithContentSlide(Song $song): SongArrangement
{
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'is_default' => true,
]);
$section = SongSection::factory()->create([
'song_id' => $song->id,
'locked' => false,
]);
SongSlide::factory()->create(['song_section_id' => $section->id]);
SongArrangementSection::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 0,
]);
return $arrangement;
}
public function test_has_content_slides_ist_true_wenn_standard_arrangement_inhaltsfolie_hat(): void
{
Carbon::setTestNow('2026-03-01 10:00:00');
$this->withoutVite();
[$service, $serviceSong] = $this->makeServiceWithSong(
fn (Song $song) => $this->defaultArrangementWithContentSlide($song)
);
$response = $this->actingAs(User::factory()->create())
->get(route('services.edit', $service->id));
$response->assertInertia(
fn ($page) => $page
->component('Services/Edit')
->where('serviceSongs.0.has_content_slides', true)
);
}
public function test_has_content_slides_ist_false_wenn_section_gesperrt(): void
{
Carbon::setTestNow('2026-03-01 10:00:00');
$this->withoutVite();
[$service, $serviceSong] = $this->makeServiceWithSong(function (Song $song): SongArrangement {
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'is_default' => true,
]);
// Locked section with slides -> not a content slide.
$section = SongSection::factory()->create([
'song_id' => $song->id,
'locked' => true,
]);
SongSlide::factory()->create(['song_section_id' => $section->id]);
SongArrangementSection::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 0,
]);
return $arrangement;
});
$response = $this->actingAs(User::factory()->create())
->get(route('services.edit', $service->id));
$response->assertInertia(
fn ($page) => $page
->component('Services/Edit')
->where('serviceSongs.0.has_content_slides', false)
);
}
public function test_has_content_slides_ist_false_wenn_section_keine_folien_hat(): void
{
Carbon::setTestNow('2026-03-01 10:00:00');
$this->withoutVite();
[$service, $serviceSong] = $this->makeServiceWithSong(function (Song $song): SongArrangement {
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'is_default' => true,
]);
// Non-locked section but NO slides -> not a content slide.
$section = SongSection::factory()->create([
'song_id' => $song->id,
'locked' => false,
]);
SongArrangementSection::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 0,
]);
return $arrangement;
});
$response = $this->actingAs(User::factory()->create())
->get(route('services.edit', $service->id));
$response->assertInertia(
fn ($page) => $page
->component('Services/Edit')
->where('serviceSongs.0.has_content_slides', false)
);
}
public function test_has_content_slides_ist_null_wenn_kein_song_zugeordnet(): void
{
Carbon::setTestNow('2026-03-01 10:00:00');
$this->withoutVite();
$service = Service::factory()->create([
'date' => Carbon::today(),
'finalized_at' => null,
]);
ServiceSong::create([
'service_id' => $service->id,
'song_id' => null,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Unmatched Song',
'cts_ccli_id' => '999999',
]);
$response = $this->actingAs(User::factory()->create())
->get(route('services.edit', $service->id));
$response->assertInertia(
fn ($page) => $page
->component('Services/Edit')
->where('serviceSongs.0.has_content_slides', null)
);
}
}

View file

@ -181,6 +181,33 @@
expect($song->author)->toBe('New Author'); expect($song->author)->toBe('New Author');
}); });
test('update aktualisiert COPYRIGHT-Folie ueber Prefix/Postfix-Service', function () {
$song = Song::factory()->create([
'title' => 'Old Title',
'author' => 'Old Author',
'ccli_id' => '7654321',
]);
$this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => 'Neuer Titel',
'author' => 'Neuer Autor',
'ccli_id' => '7654321',
])->assertOk();
$prefixLabelId = (int) \App\Models\Setting::get('song_prefix_label_id');
$copyrightSlide = \App\Models\SongSection::where('song_id', $song->id)
->where('label_id', $prefixLabelId)
->first()
->slides()
->first();
expect($copyrightSlide)->not->toBeNull()
->and($copyrightSlide->text_content)->toContain('Neuer Titel')
->and($copyrightSlide->text_content)->toContain('Neuer Autor')
->and($copyrightSlide->text_content)->toContain('CCLI-Liednr. 7654321');
});
test('update validates unique ccli_id excluding self', function () { test('update validates unique ccli_id excluding self', function () {
$songA = Song::factory()->create(['ccli_id' => '111111']); $songA = Song::factory()->create(['ccli_id' => '111111']);
$songB = Song::factory()->create(['ccli_id' => '222222']); $songB = Song::factory()->create(['ccli_id' => '222222']);

View file

@ -0,0 +1,108 @@
<?php
use App\Models\Setting;
use App\Models\Song;
use App\Models\SongSection;
use App\Models\SongSlide;
use App\Services\SongPrefixPostfixService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('ensure populiert COPYRIGHT-Folie mit Attribution inkl. CCLI-Nummer', function (): void {
$song = Song::create([
'title' => 'Mein Lied',
'author' => 'Max Mustermann',
'copyright_text' => '© 2024 Verlag',
'ccli_id' => '1234567',
]);
app(SongPrefixPostfixService::class)->ensure($song);
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
$prefixSection = SongSection::where('song_id', $song->id)
->where('label_id', $prefixLabelId)
->first();
expect($prefixSection)->not->toBeNull()
->and($prefixSection->locked)->toBeTrue();
$slides = $prefixSection->slides()->get();
expect($slides)->toHaveCount(1);
$expected = implode("\n", [
'Mein Lied',
'Max Mustermann',
'© 2024 Verlag',
'CCLI-Liednr. 1234567',
]);
expect($slides->first()->text_content)->toBe($expected);
});
test('ensure laesst leere Felder in der Attribution weg', function (): void {
$song = Song::create([
'title' => 'Nur Titel',
]);
app(SongPrefixPostfixService::class)->ensure($song);
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
$slide = SongSection::where('song_id', $song->id)
->where('label_id', $prefixLabelId)
->first()
->slides()
->first();
expect($slide->text_content)->toBe('Nur Titel');
});
test('ensure erstellt eine leere BLANK-Folie in gesperrter Postfix-Sektion', function (): void {
$song = Song::create(['title' => 'Mein Lied', 'ccli_id' => '999']);
app(SongPrefixPostfixService::class)->ensure($song);
$postfixLabelId = (int) Setting::get('song_postfix_label_id');
$postfixSection = SongSection::where('song_id', $song->id)
->where('label_id', $postfixLabelId)
->first();
expect($postfixSection)->not->toBeNull()
->and($postfixSection->locked)->toBeTrue();
$slides = $postfixSection->slides()->get();
expect($slides)->toHaveCount(1)
->and($slides->first()->text_content)->toBe('');
});
test('default-Arrangement hat COPYRIGHT zuerst und BLANK zuletzt', function (): void {
$song = Song::create(['title' => 'Mein Lied', 'ccli_id' => '999']);
app(SongPrefixPostfixService::class)->ensure($song);
$prefixLabelId = (int) Setting::get('song_prefix_label_id');
$postfixLabelId = (int) Setting::get('song_postfix_label_id');
$arrangement = $song->arrangements()->where('name', 'normal')->first();
$ordered = $arrangement->arrangementSections()
->with('section')
->get()
->sortBy('order')
->values();
expect($ordered->first()->section->label_id)->toBe($prefixLabelId)
->and($ordered->last()->section->label_id)->toBe($postfixLabelId);
});
test('ensure ist idempotent und erzeugt keine doppelten Folien', function (): void {
$song = Song::create(['title' => 'Mein Lied', 'ccli_id' => '999']);
$service = app(SongPrefixPostfixService::class);
$service->ensure($song);
$service->ensure($song);
expect(SongSlide::count())->toBe(2);
});

View file

@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
// FIX 6: matched song title in the service agenda links to the SongDB edit modal
// (route('songs.index')#song-<id>); FIX 5: songs whose default arrangement has no
// content slides show a "Keine Inhaltsfolien" indicator.
//
// These tests depend on synced service data. When no editable service / matched song
// exists, they skip gracefully (same pattern as service-edit-songs.spec.ts).
async function navigateToEditPage(page): Promise<boolean> {
await page.goto('/services');
await page.waitForLoadState('networkidle');
const editButton = page.getByTestId('service-list-edit-button').first();
const hasEditableService = await editButton.isVisible().catch(() => false);
if (!hasEditableService) {
return false;
}
await editButton.click();
await page.waitForLoadState('networkidle');
return true;
}
test('matched song title links to SongDB edit hash', async ({ page }) => {
const navigated = await navigateToEditPage(page);
if (!navigated) {
test.skip();
}
// A matched title renders as an Inertia <Link> (an <a> with data-testid).
const titleLink = page.locator('a[data-testid="song-agenda-title"]').first();
const hasMatched = await titleLink.isVisible().catch(() => false);
if (!hasMatched) {
test.skip();
}
const href = await titleLink.getAttribute('href');
expect(href).toBeTruthy();
// route('songs.index') + '#song-<id>'
expect(href).toMatch(/\/songs(\/index)?#song-\d+$/);
});
test('clicking matched song title opens the edit modal on the Songs page', async ({ page }) => {
const navigated = await navigateToEditPage(page);
if (!navigated) {
test.skip();
}
const titleLink = page.locator('a[data-testid="song-agenda-title"]').first();
const hasMatched = await titleLink.isVisible().catch(() => false);
if (!hasMatched) {
test.skip();
}
await titleLink.click();
await page.waitForLoadState('networkidle');
// We land on the Songs page with #song-<id> and the edit modal auto-opens.
expect(new URL(page.url()).pathname).toContain('/songs');
expect(page.url()).toMatch(/#song-\d+$/);
const editModal = page.getByTestId('song-list-edit-modal');
await expect(editModal).toBeVisible({ timeout: 5000 });
});
test('unmatched song title stays plain text (no link)', async ({ page }) => {
const navigated = await navigateToEditPage(page);
if (!navigated) {
test.skip();
}
// An unmatched row has a request-creation button; its title is a <span>, not <a>.
const requestButton = page.getByTestId('song-request-creation').first();
const hasUnmatched = await requestButton.isVisible().catch(() => false);
if (!hasUnmatched) {
test.skip();
}
const unmatchedRow = page
.getByTestId('song-agenda-item')
.filter({ has: page.getByTestId('song-request-creation') })
.first();
const plainTitle = unmatchedRow.locator('span[data-testid="song-agenda-title"]');
await expect(plainTitle).toBeVisible();
await expect(unmatchedRow.locator('a[data-testid="song-agenda-title"]')).toHaveCount(0);
});
test('song without content slides shows "Keine Inhaltsfolien" indicator', async ({ page }) => {
const navigated = await navigateToEditPage(page);
if (!navigated) {
test.skip();
}
const noContent = page.getByTestId('song-no-content').first();
const hasNoContent = await noContent.isVisible().catch(() => false);
if (!hasNoContent) {
test.skip();
}
await expect(noContent).toBeVisible();
await expect(noContent).toContainText('Keine Inhaltsfolien');
});