diff --git a/.sisyphus/notepads/edit-page-restructure/learnings.md b/.sisyphus/notepads/edit-page-restructure/learnings.md index 2f299d1..593aad0 100644 --- a/.sisyphus/notepads/edit-page-restructure/learnings.md +++ b/.sisyphus/notepads/edit-page-restructure/learnings.md @@ -250,3 +250,50 @@ ### Test Infrastructure - `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 + +## 2026-03-29 — Finalize → Download integration tests + +### Verification +- `ServiceController::download()` already calls `PlaylistExportService->generatePlaylist($service)` — no controller changes needed +- `PlaylistExportService::generatePlaylist()` already routes to agenda path or legacy fallback (Task 9) +- Main deliverable: integration tests proving full finalize → download flow + +### Integration Tests Added (PlaylistExportTest.php) +1. `test_finalize_und_download_flow_mit_agenda_items` — creates service with agenda items (2 songs + sermon with slides), finalizes, downloads, verifies playlist ordering (song1 → sermon → song2) +2. `test_finalize_und_download_flow_legacy_ohne_agenda` — creates service without agenda items, finalizes, downloads, verifies legacy path produces .proplaylist file + +### Test Patterns +- Both tests use `@runInSeparateProcess` + `@preserveGlobalState disabled` + `mockProPresenterClasses()` (Mockery alias) +- Full HTTP flow: POST finalize → refresh → GET download → assert response headers +- Agenda test verifies playlist content ordering via `strpos()` position comparison +- Legacy test verifies backward compat: no ServiceAgendaItems → uses `generatePlaylistLegacy()` + +## 2026-03-29 — Settings.vue Agenda Configuration UI + +### Implementation +- Updated `resources/js/Pages/Settings.vue` with new Agenda-Konfiguration section +- Added 4 new fields to the `fields` array with `section: 'agenda'` property: + 1. `agenda_start_title` — Ablauf-Start pattern + 2. `agenda_end_title` — Ablauf-Ende pattern + 3. `agenda_announcement_position` — Ankündigungen-Position with helpText + 4. `agenda_sermon_matching` — Predigt-Erkennung with helpText +- Added `section: 'macro'` to existing 4 macro fields for clear separation +- Updated macro section template: `fields.filter(f => f.section === 'macro')` +- Created new agenda section with identical field input structure +- Section displays: + - Header: "Agenda-Konfiguration" + - Subtitle: "Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird." + - Fields with auto-save on blur (same `saveField()` function) + - Conditional helpText display (only if `field.helpText` present) + - Identical saving/saved/error indicators as macro section + +### Build & Commit +- `npm run build` — ✓ 803 modules transformed, 17.94s gzip size for Edit component (expected growth) +- Committed: `feat(ui): add agenda settings to Settings page` + +### Vue Field Definition Pattern +- Using `section` property to group related fields +- Template: `fields.filter(f => f.section === '{section}')` +- Optional `helpText` property displayed below input (agenda fields only, not macro fields) +- All fields use same `form[key]`, `saving[key]`, `saved[key]`, `errors[key]` reactive objects +- On blur: `saveField(field.key)` triggers PATCH to `route('settings.update')` endpoint diff --git a/tests/Feature/PlaylistExportTest.php b/tests/Feature/PlaylistExportTest.php index 0d02978..d1ff8db 100644 --- a/tests/Feature/PlaylistExportTest.php +++ b/tests/Feature/PlaylistExportTest.php @@ -616,6 +616,197 @@ public function test_agenda_export_before_event_items_ausgeschlossen(): void $this->cleanupTempDir($result['temp_dir']); } + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_finalize_und_download_flow_mit_agenda_items(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create([ + 'finalized_at' => null, + 'title' => 'Integrations Service', + 'date' => now(), + ]); + + // Create songs with content + $song1 = $this->createSongWithContent('Lobpreis Lied'); + $song2 = $this->createSongWithContent('Abschluss Lied'); + $arrangement1 = $song1->arrangements()->first(); + $arrangement2 = $song2->arrangements()->first(); + + $serviceSong1 = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song1->id, + 'song_arrangement_id' => $arrangement1->id, + 'cts_song_name' => 'Lobpreis Lied', + 'order' => 1, + ]); + + $serviceSong2 = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song2->id, + 'song_arrangement_id' => $arrangement2->id, + 'cts_song_name' => 'Abschluss Lied', + 'order' => 2, + ]); + + // Agenda: Song → Sermon (non-song with slides) → Song + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lobpreis Lied', + 'service_song_id' => $serviceSong1->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + + $sermonItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Abschluss Lied', + 'service_song_id' => $serviceSong2->id, + 'sort_order' => 3, + 'is_before_event' => false, + ]); + + // Upload slides to sermon agenda item + $this->createSlideFile('sermon_slide1.jpg'); + $this->createSlide([ + 'type' => 'sermon', + 'service_id' => $service->id, + 'service_agenda_item_id' => $sermonItem->id, + 'original_filename' => 'sermon_slide1.jpg', + 'stored_filename' => 'sermon_slide1.jpg', + 'sort_order' => 1, + ]); + + // Add sermon slides for finalization check (legacy) + Slide::factory()->create([ + 'service_id' => $service->id, + 'type' => 'sermon', + ]); + + // Step 1: Finalize the service + $finalizeResponse = $this->actingAs($this->user) + ->postJson(route('services.finalize', $service), ['confirmed' => true]); + + $finalizeResponse->assertOk() + ->assertJson([ + 'needs_confirmation' => false, + 'success' => 'Service wurde abgeschlossen.', + ]); + + $service->refresh(); + $this->assertNotNull($service->finalized_at); + + // Step 2: Download the finalized service + $downloadResponse = $this->actingAs($this->user) + ->get(route('services.download', $service)); + + $downloadResponse->assertOk(); + + $disposition = $downloadResponse->headers->get('content-disposition'); + $this->assertNotNull($disposition); + $this->assertStringContainsString('.proplaylist', $disposition); + + // Verify playlist content includes songs and sermon in correct order + $tempPath = null; + foreach (glob(sys_get_temp_dir().'/playlist-export-*/*.proplaylist') as $file) { + $content = file_get_contents($file); + if (str_contains($content, 'Integrations Service')) { + $tempPath = dirname($file); + // Verify agenda ordering: song1 → sermon → song2 + $pos1 = strpos($content, 'Lobpreis Lied'); + $posSer = strpos($content, 'Predigt'); + $pos2 = strpos($content, 'Abschluss Lied'); + $this->assertNotFalse($pos1); + $this->assertNotFalse($posSer); + $this->assertNotFalse($pos2); + $this->assertLessThan($posSer, $pos1, 'Song 1 should be before sermon'); + $this->assertLessThan($pos2, $posSer, 'Sermon should be before Song 2'); + break; + } + } + + if ($tempPath) { + $this->cleanupTempDir($tempPath); + } + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_finalize_und_download_flow_legacy_ohne_agenda(): void + { + $this->mockProPresenterClasses(); + + $service = Service::factory()->create([ + 'finalized_at' => null, + 'title' => 'Legacy Ablauf', + 'date' => now(), + ]); + + $song = $this->createSongWithContent('Legacy Worship'); + $arrangement = $song->arrangements()->first(); + + ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => $arrangement->id, + 'cts_song_name' => 'Legacy Worship', + 'order' => 1, + ]); + + Slide::factory()->create([ + 'service_id' => $service->id, + 'type' => 'sermon', + ]); + + // No ServiceAgendaItems → will use legacy path + + // Step 1: Finalize + $finalizeResponse = $this->actingAs($this->user) + ->postJson(route('services.finalize', $service), ['confirmed' => false]); + + $finalizeResponse->assertOk() + ->assertJson([ + 'needs_confirmation' => false, + 'success' => 'Service wurde abgeschlossen.', + ]); + + $service->refresh(); + $this->assertNotNull($service->finalized_at); + + // Step 2: Download — uses legacy path (no agenda items) + $downloadResponse = $this->actingAs($this->user) + ->get(route('services.download', $service)); + + $downloadResponse->assertOk(); + + $disposition = $downloadResponse->headers->get('content-disposition'); + $this->assertNotNull($disposition); + $this->assertStringContainsString('.proplaylist', $disposition); + $this->assertStringContainsString('Legacy Ablauf', $disposition); + + // Cleanup temp files + foreach (glob(sys_get_temp_dir().'/playlist-export-*') as $dir) { + if (is_dir($dir)) { + $this->cleanupTempDir($dir); + } + } + } + private function cleanupTempDir(string $dir): void { if (is_dir($dir)) {