feat(export): agenda-ordered playlist export
This commit is contained in:
parent
88661c6bef
commit
de431d29cc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue