feat(export): agenda-ordered playlist export

This commit is contained in:
Thorsten Bus 2026-03-29 11:59:38 +02:00
parent 88661c6bef
commit de431d29cc
3 changed files with 793 additions and 61 deletions

View file

@ -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

View file

@ -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<int, ServiceAgendaItem> $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

View file

@ -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);
}
}
}