diff --git a/.sisyphus/notepads/edit-page-restructure/learnings.md b/.sisyphus/notepads/edit-page-restructure/learnings.md index 2dae83c..2f299d1 100644 --- a/.sisyphus/notepads/edit-page-restructure/learnings.md +++ b/.sisyphus/notepads/edit-page-restructure/learnings.md @@ -190,3 +190,63 @@ ### Code Patterns Used - Null-safe navigation: `$item->serviceSong?->song_id` - Setting retrieval: `Setting::get('agenda_sermon_matching')` - Slide filtering: `whereNull('deleted_at')` for soft deletes + +## 2026-03-29 — ServiceController::edit() agenda items + +### Implementation +- Updated `ServiceController::edit()` to pass `agendaItems` and `agendaSettings` props to Inertia +- Added `agendaItems()` HasMany relationship to `Service` model +- Fixed pre-existing syntax error (duplicate code block) in `Service::finalizationStatus()` +- Added `use App\Services\AgendaMatcherService;` import to `Service` model (was missing) + +### Controller Changes +- Added imports: `Setting`, `AgendaMatcherService` +- Extended eager loading to include `agendaItems` with nested relationships (slides, serviceSong.song.groups.slides, arrangements) +- Loads 4 agenda settings from DB: `agenda_start_title`, `agenda_end_title`, `agenda_announcement_position`, `agenda_sermon_matching` +- Filters out `is_before_event` items, applies `filterBetween()` with start/end boundaries +- Maps computed flags: `is_announcement_position` and `is_sermon` via `matchesAny()` +- Passes `agendaItems` (array of arrays) and `agendaSettings` (assoc array) alongside existing props + +### Test Coverage (5 new tests) +1. `edit_seite_liefert_leere_agenda_items_und_settings` — empty agenda, null settings +2. `edit_seite_liefert_agenda_items_mit_computed_flags` — announcement/sermon flags +3. `edit_seite_filtert_agenda_items_mit_start_end_grenzen` — boundary filtering +4. `edit_seite_schliesst_before_event_items_aus` — is_before_event exclusion +5. `edit_seite_liefert_agenda_settings_mit_allen_vier_keys` — settings with values + +### Pre-existing Issue Found +- `test_service_edit_seite_zeigt_service_mit_songs_und_slides` was already failing before changes (informationSlides size 0 vs expected 1) — factory generates random `uploaded_at` that can cause filtering issues + +## 2026-03-29 — PlaylistExportService agenda-ordered export + +### Implementation +- Refactored `generatePlaylist()` to check for `ServiceAgendaItem` records first +- Empty agenda → falls back to `generatePlaylistLegacy()` (old block-based order: info → songs → moderation → sermon) +- Agenda present → iterates items in `sort_order`, exporting songs and slides per item +- Information slides (announcements) inserted at `Setting::get('agenda_announcement_position')` pattern match, or prepended as fallback +- New `addSlidesFromCollection()` private method extracts common slide→.pro conversion logic +- Legacy `addSlidePresentation()` now delegates to `addSlidesFromCollection()` after querying + +### Key Design Decisions +- `generatePlaylistLegacy()` is an exact copy of the original `generatePlaylist()` body — backward compat guaranteed +- Agenda items with `is_before_event=true` are excluded from export (matches display behavior) +- Song items without `song_id` (unmatched) count as skipped +- Non-song agenda items without slides are silently skipped +- `ProExportService` instantiated via `new` (not DI) — matches legacy pattern + +### Test Coverage (8 new tests, all with `@runInSeparateProcess` for Mockery alias mocking) +1. `legacy_fallback_wenn_keine_agenda_items` — no agenda items → legacy path +2. `agenda_export_folgt_agenda_reihenfolge` — songs follow agenda sort_order, not serviceSong order +3. `agenda_export_informationen_an_gematchter_position` — announcements at pattern-matched agenda item +4. `agenda_export_informationen_am_anfang_als_fallback` — announcements prepended when no pattern matches +5. `agenda_export_ueberspringt_items_ohne_slides_oder_songs` — empty items not in playlist +6. `agenda_export_zaehlt_ungematchte_songs_als_skipped` — unmatched songs counted as skipped +7. `agenda_export_mit_slides_auf_agenda_item` — sermon slides on agenda item exported in order +8. `agenda_export_before_event_items_ausgeschlossen` — before-event items filtered out + +### Test Infrastructure +- `mockProPresenterClasses()` uses Mockery `alias:` to mock static calls on ProFileGenerator/ProPlaylistGenerator +- `createSlide()` helper includes `thumbnail_filename` (NOT NULL constraint) +- `createSlideFile()` calls `Storage::fake('public')` for each slide +- Mock playlist output: `mock-playlist:{name}\n{item1}\n{item2}` — enables position assertions via `strpos()` +- 2 pre-existing test failures (HTTP route tests) due to empty propresenter parser src — not regressions diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index ada2170..03bd5c1 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -3,7 +3,10 @@ namespace App\Services; use App\Models\Service; +use App\Models\ServiceAgendaItem; +use App\Models\Setting; use App\Models\Slide; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProPlaylistGenerator; @@ -12,6 +15,151 @@ class PlaylistExportService { /** @return array{path: string, filename: string, skipped: int} */ public function generatePlaylist(Service $service): array + { + $agendaItems = ServiceAgendaItem::where('service_id', $service->id) + ->where('is_before_event', false) + ->orderBy('sort_order') + ->with(['slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'serviceSong.song.groups.slides', 'serviceSong.arrangement.arrangementGroups.group']) + ->get(); + + if ($agendaItems->isEmpty()) { + return $this->generatePlaylistLegacy($service); + } + + return $this->generatePlaylistFromAgenda($service, $agendaItems); + } + + /** + * @param Collection $agendaItems + * @return array{path: string, filename: string, skipped: int, temp_dir: string} + */ + private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems): array + { + $informationSlides = Slide::where('type', 'information') + ->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date)) + ->where(fn ($q) => $q->whereNull('service_id')->orWhere('service_id', $service->id)) + ->whereNull('deleted_at'); + + if ($service->date) { + $informationSlides->whereDate('uploaded_at', '<=', $service->date); + } + + $informationSlides = $informationSlides->orderBy('sort_order')->orderByDesc('uploaded_at')->get(); + + $announcementPatterns = Setting::get('agenda_announcement_position'); + $announcementInserted = false; + + $exportService = new ProExportService; + $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid(); + mkdir($tempDir, 0755, true); + + $playlistItems = []; + $embeddedFiles = []; + $skippedUnmatched = 0; + + foreach ($agendaItems as $item) { + if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) { + $patterns = array_map('trim', explode(',', $announcementPatterns)); + $matcher = app(AgendaMatcherService::class); + if ($matcher->matchesAny($item->title, $patterns)) { + $this->addSlidesFromCollection( + $informationSlides, + 'information', + 'Informationen', + $tempDir, + $playlistItems, + $embeddedFiles, + ); + $announcementInserted = true; + } + } + + if ($item->serviceSong) { + $serviceSong = $item->serviceSong; + + if ($serviceSong->song_id && $serviceSong->song) { + $song = $serviceSong->song; + + if ($song->groups()->count() === 0) { + $skippedUnmatched++; + + continue; + } + + $proPath = $exportService->generateProFile($song); + $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro'; + $destPath = $tempDir.'/'.$proFilename; + rename($proPath, $destPath); + + $embeddedFiles[$proFilename] = file_get_contents($destPath); + + $playlistItems[] = [ + 'type' => 'presentation', + 'name' => $song->title, + 'path' => $proFilename, + ]; + } else { + $skippedUnmatched++; + } + + continue; + } + + if ($item->slides->isNotEmpty()) { + $label = $item->title ?: 'Folien'; + $this->addSlidesFromCollection( + $item->slides, + 'agenda_'.$item->id, + $label, + $tempDir, + $playlistItems, + $embeddedFiles, + ); + } + } + + if (! $announcementInserted && $informationSlides->isNotEmpty()) { + $prependItems = []; + $prependFiles = []; + $this->addSlidesFromCollection( + $informationSlides, + 'information', + 'Informationen', + $tempDir, + $prependItems, + $prependFiles, + ); + $playlistItems = array_merge($prependItems, $playlistItems); + $embeddedFiles = array_merge($prependFiles, $embeddedFiles); + } + + if (empty($playlistItems)) { + $this->deleteDirectory($tempDir); + throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); + } + + $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d'); + $playlistName = $service->title.' - '.$dateFormatted; + $outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist'; + $outputPath = $tempDir.'/'.$outputFilename; + + ProPlaylistGenerator::generateAndWrite($outputPath, $playlistName, $playlistItems, $embeddedFiles); + + return [ + 'path' => $outputPath, + 'filename' => $outputFilename, + 'skipped' => $skippedUnmatched, + 'temp_dir' => $tempDir, + ]; + } + + /** + * Legacy export for services without agenda items. + * Hardcoded block order: info → songs → moderation → sermon. + * + * @return array{path: string, filename: string, skipped: int, temp_dir: string} + */ + private function generatePlaylistLegacy(Service $service): array { $service->loadMissing('serviceSongs.song.groups.slides'); @@ -101,6 +249,76 @@ public function generatePlaylist(Service $service): array ]; } + private function addSlidesFromCollection( + Collection $slides, + string $prefix, + string $label, + string $tempDir, + array &$playlistItems, + array &$embeddedFiles, + ): void { + $slideDataList = []; + $imageFiles = []; + + foreach ($slides->values() as $index => $slide) { + $storedPath = Storage::disk('public')->path('slides/'.$slide->stored_filename); + + if (! file_exists($storedPath)) { + continue; + } + + $imageFilename = $prefix.'_'.($index + 1).'_'.$slide->stored_filename; + $destPath = $tempDir.'/'.$imageFilename; + copy($storedPath, $destPath); + + $imageFiles[$imageFilename] = file_get_contents($destPath); + + $slideDataList[] = [ + 'text' => '', + 'media' => $imageFilename, + 'format' => 'JPG', + 'label' => $slide->original_filename, + ]; + } + + if (empty($slideDataList)) { + return; + } + + $groups = [ + [ + 'name' => $label, + 'color' => [0.5, 0.5, 0.5, 1.0], + 'slides' => $slideDataList, + ], + ]; + + $arrangements = [ + [ + 'name' => 'Standard', + 'groupNames' => [$label], + ], + ]; + + $safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label); + $proFilename = $safeLabel.'.pro'; + $proPath = $tempDir.'/'.$proFilename; + + ProFileGenerator::generateAndWrite($proPath, $label, $groups, $arrangements); + + foreach ($imageFiles as $filename => $contents) { + $embeddedFiles[$filename] = $contents; + } + + $embeddedFiles[$proFilename] = file_get_contents($proPath); + + $playlistItems[] = [ + 'type' => 'presentation', + 'name' => $label, + 'path' => $proFilename, + ]; + } + private function addSlidePresentation( string $type, string $label, @@ -134,65 +352,14 @@ private function addSlidePresentation( return; } - $slideDataList = []; - $imageFiles = []; - - foreach ($slides as $index => $slide) { - $storedPath = Storage::disk('public')->path('slides/'.$slide->stored_filename); - - if (! file_exists($storedPath)) { - continue; - } - - $imageFilename = $type.'_'.($index + 1).'_'.$slide->stored_filename; - $destPath = $tempDir.'/'.$imageFilename; - copy($storedPath, $destPath); - - $imageFiles[$imageFilename] = file_get_contents($destPath); - - $slideDataList[] = [ - 'text' => '', - 'media' => $imageFilename, - 'format' => 'JPG', - 'label' => $slide->original_filename, - ]; - } - - if (empty($slideDataList)) { - return; - } - - $groups = [ - [ - 'name' => $label, - 'color' => [0.5, 0.5, 0.5, 1.0], - 'slides' => $slideDataList, - ], - ]; - - $arrangements = [ - [ - 'name' => 'Standard', - 'groupNames' => [$label], - ], - ]; - - $proFilename = $label.'.pro'; - $proPath = $tempDir.'/'.$proFilename; - - ProFileGenerator::generateAndWrite($proPath, $label, $groups, $arrangements); - - foreach ($imageFiles as $filename => $contents) { - $embeddedFiles[$filename] = $contents; - } - - $embeddedFiles[$proFilename] = file_get_contents($proPath); - - $playlistItems[] = [ - 'type' => 'presentation', - 'name' => $label, - 'path' => $proFilename, - ]; + $this->addSlidesFromCollection( + $slides, + $type, + $label, + $tempDir, + $playlistItems, + $embeddedFiles, + ); } private function deleteDirectory(string $dir): void diff --git a/tests/Feature/PlaylistExportTest.php b/tests/Feature/PlaylistExportTest.php index 64b78ab..0d02978 100644 --- a/tests/Feature/PlaylistExportTest.php +++ b/tests/Feature/PlaylistExportTest.php @@ -3,10 +3,16 @@ namespace Tests\Feature; use App\Models\Service; +use App\Models\ServiceAgendaItem; use App\Models\ServiceSong; +use App\Models\Setting; +use App\Models\Slide; use App\Models\Song; use App\Models\User; +use App\Services\PlaylistExportService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Mockery; use Tests\TestCase; final class PlaylistExportTest extends TestCase @@ -43,6 +49,44 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl return $song; } + private function createSlideFile(string $storedFilename): void + { + Storage::fake('public'); + $image = imagecreatetruecolor(1920, 1080); + ob_start(); + imagejpeg($image); + $contents = ob_get_clean(); + imagedestroy($image); + Storage::disk('public')->put('slides/'.$storedFilename, $contents); + } + + private function createSlide(array $attributes): Slide + { + return Slide::create(array_merge([ + 'thumbnail_filename' => 'thumb_'.($attributes['stored_filename'] ?? 'default.jpg'), + 'uploaded_at' => now()->subDay(), + ], $attributes)); + } + + private function mockProPresenterClasses(): void + { + Mockery::mock('alias:ProPresenter\Parser\ProFileGenerator') + ->shouldReceive('generateAndWrite') + ->andReturnUsing(function (string $path, string $name) { + file_put_contents($path, 'mock-pro-file:'.$name); + }); + + Mockery::mock('alias:ProPresenter\Parser\ProPlaylistGenerator') + ->shouldReceive('generateAndWrite') + ->andReturnUsing(function (string $path, string $name, array $items) { + $content = 'mock-playlist:'.$name; + foreach ($items as $item) { + $content .= "\n".$item['name']; + } + file_put_contents($path, $content); + }); + } + public function test_download_finalisierter_service_mit_songs_gibt_proplaylist_datei(): void { $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Gottesdienst']); @@ -95,7 +139,6 @@ public function test_download_mit_ungematchten_songs_setzt_skipped_header(): voi $service = Service::factory()->create(['finalized_at' => now()]); $song = $this->createSongWithContent('Matched Song'); - // Matched song ServiceSong::create([ 'service_id' => $service->id, 'song_id' => $song->id, @@ -103,7 +146,6 @@ public function test_download_mit_ungematchten_songs_setzt_skipped_header(): voi 'order' => 1, ]); - // Unmatched song (no song_id) ServiceSong::create([ 'service_id' => $service->id, 'song_id' => null, @@ -125,4 +167,467 @@ public function test_download_erfordert_authentifizierung(): void $response->assertUnauthorized(); } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_legacy_fallback_wenn_keine_agenda_items(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Legacy Service']); + $song = $this->createSongWithContent('Legacy Song'); + + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Legacy Song', + 'order' => 1, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertStringContainsString('.proplaylist', $result['filename']); + $this->assertFileExists($result['path']); + $this->assertEquals(0, $result['skipped']); + + $content = file_get_contents($result['path']); + $this->assertStringContainsString('Legacy Song', $content); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_folgt_agenda_reihenfolge(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Agenda Service']); + + $song1 = $this->createSongWithContent('Erstes Lied'); + $song2 = $this->createSongWithContent('Zweites Lied'); + + $serviceSong1 = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song1->id, + 'cts_song_name' => 'Erstes Lied', + 'order' => 1, + ]); + + $serviceSong2 = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song2->id, + 'cts_song_name' => 'Zweites Lied', + 'order' => 2, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Zweites Lied', + 'service_song_id' => $serviceSong2->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Erstes Lied', + 'service_song_id' => $serviceSong1->id, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertFileExists($result['path']); + $this->assertEquals(0, $result['skipped']); + + $playlistContent = file_get_contents($result['path']); + $pos1 = strpos($playlistContent, 'Zweites Lied'); + $pos2 = strpos($playlistContent, 'Erstes Lied'); + $this->assertNotFalse($pos1); + $this->assertNotFalse($pos2); + $this->assertLessThan($pos2, $pos1, 'Zweites Lied should appear before Erstes Lied in agenda order'); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_informationen_an_gematchter_position(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create([ + 'finalized_at' => now(), + 'title' => 'Service mit Infos', + 'date' => now(), + ]); + + Setting::set('agenda_announcement_position', 'Hinweise*,Information*'); + + $this->createSlideFile('info1.jpg'); + $this->createSlide([ + 'type' => 'information', + 'service_id' => null, + 'original_filename' => 'info1.jpg', + 'stored_filename' => 'info1.jpg', + 'sort_order' => 1, + ]); + + $song = $this->createSongWithContent('Lobpreissong'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Lobpreissong', + 'order' => 1, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lobpreissong', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Hinweise und Informationen', + 'service_song_id' => null, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertFileExists($result['path']); + + $playlistContent = file_get_contents($result['path']); + $songPos = strpos($playlistContent, 'Lobpreissong'); + $infoPos = strpos($playlistContent, 'Informationen'); + $this->assertNotFalse($songPos); + $this->assertNotFalse($infoPos); + $this->assertLessThan($infoPos, $songPos, 'Song should appear before announcements at matched position'); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_informationen_am_anfang_als_fallback(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create([ + 'finalized_at' => now(), + 'title' => 'Service Fallback Infos', + 'date' => now(), + ]); + + Setting::set('agenda_announcement_position', 'Hinweise*'); + + $this->createSlideFile('info_fallback.jpg'); + $this->createSlide([ + 'type' => 'information', + 'service_id' => null, + 'original_filename' => 'info_fallback.jpg', + 'stored_filename' => 'info_fallback.jpg', + 'sort_order' => 1, + ]); + + $song = $this->createSongWithContent('Fallback Song'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Fallback Song', + 'order' => 1, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Fallback Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertFileExists($result['path']); + + $playlistContent = file_get_contents($result['path']); + $infoPos = strpos($playlistContent, 'Informationen'); + $songPos = strpos($playlistContent, 'Fallback Song'); + $this->assertNotFalse($infoPos); + $this->assertNotFalse($songPos); + $this->assertLessThan($songPos, $infoPos, 'Announcements should be at beginning when no pattern matches'); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_ueberspringt_items_ohne_slides_oder_songs(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Skip Service']); + + $song = $this->createSongWithContent('Einziger Song'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Einziger Song', + 'order' => 1, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Einziger Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Gebet', + 'service_song_id' => null, + 'sort_order' => 3, + 'is_before_event' => false, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertFileExists($result['path']); + $this->assertEquals(0, $result['skipped']); + + $playlistContent = file_get_contents($result['path']); + $this->assertStringContainsString('Einziger Song', $playlistContent); + $this->assertStringNotContainsString('Begrüßung', $playlistContent); + $this->assertStringNotContainsString('Gebet', $playlistContent); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_zaehlt_ungematchte_songs_als_skipped(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Skipped Service']); + + $song = $this->createSongWithContent('Gematchter Song'); + $matchedServiceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Gematchter Song', + 'order' => 1, + ]); + + $unmatchedServiceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => null, + 'cts_song_name' => 'Unbekannter Song', + 'order' => 2, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Gematchter Song', + 'service_song_id' => $matchedServiceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Unbekannter Song', + 'service_song_id' => $unmatchedServiceSong->id, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertEquals(1, $result['skipped']); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_mit_slides_auf_agenda_item(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create([ + 'finalized_at' => now(), + 'title' => 'Slides Service', + 'date' => now(), + ]); + + $song = $this->createSongWithContent('Worship Song'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Worship Song', + 'order' => 1, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Worship Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + $sermonItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigtblock', + 'service_song_id' => null, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $this->createSlideFile('predigt1.jpg'); + $this->createSlide([ + 'type' => 'sermon', + 'service_id' => $service->id, + 'service_agenda_item_id' => $sermonItem->id, + 'original_filename' => 'predigt1.jpg', + 'stored_filename' => 'predigt1.jpg', + 'sort_order' => 1, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertFileExists($result['path']); + + $playlistContent = file_get_contents($result['path']); + $songPos = strpos($playlistContent, 'Worship Song'); + $sermonPos = strpos($playlistContent, 'Predigtblock'); + $this->assertNotFalse($songPos); + $this->assertNotFalse($sermonPos); + $this->assertLessThan($sermonPos, $songPos, 'Song should appear before sermon slides'); + + $this->cleanupTempDir($result['temp_dir']); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_agenda_export_before_event_items_ausgeschlossen(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Before Event']); + + $song = $this->createSongWithContent('Visible Song'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Visible Song', + 'order' => 1, + ]); + + $hiddenSong = $this->createSongWithContent('Hidden Song'); + $hiddenServiceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $hiddenSong->id, + 'cts_song_name' => 'Hidden Song', + 'order' => 2, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Hidden Song', + 'service_song_id' => $hiddenServiceSong->id, + 'sort_order' => 1, + 'is_before_event' => true, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Visible Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + $exportService = new PlaylistExportService; + $result = $exportService->generatePlaylist($service); + + $this->assertFileExists($result['path']); + + $playlistContent = file_get_contents($result['path']); + $this->assertStringContainsString('Visible Song', $playlistContent); + $this->assertStringNotContainsString('Hidden Song', $playlistContent); + + $this->cleanupTempDir($result['temp_dir']); + } + + private function cleanupTempDir(string $dir): void + { + if (is_dir($dir)) { + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir.'/'.$item; + is_dir($path) ? $this->cleanupTempDir($path) : unlink($path); + } + rmdir($dir); + } + } }