Refactor lyric storage so each song owns its sections instead of sharing global labels. Adds song_sections (per song+label) owning song_slides; labels stay global ProPresenter group tags (name/color/macro). Arrangements now reference sections, so editing/importing one song no longer corrupts others that share a label name. - New: song_sections table + migration with safe backfill; SongSection, SongArrangementSection models; SongSectionController (edit/add/delete sections, immediate persistence) wired into SongEditModal. - Refactor writers/readers: CcliImport, ProImport, SongService, ArrangementController, SongController, ProExport, PDF, Translation (translation reset now section-scoped), CCLI pairing. - CCLI import fixes: parse SongSelect copy-icon format (German "Vers" abbrev + trailing author), fill empty CTS-synced songs instead of blocking as duplicate, distinct label colors per section kind, import&edit/existing-song open the edit modal (no 404/405), teleport paste dialog above assign dialog, preview shows section content, correct SongSelect search URL, copy-icon instructions. - Bookmarklet clicks #generalCopyLyricsButton and captures clipboard; serves correct host from request. - Export: embed key-visual/background under fixed bundle-relative names. - Tests updated for the section model; new section + isolation coverage.
525 lines
20 KiB
PHP
525 lines
20 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Label;
|
|
use App\Models\Macro;
|
|
use App\Models\MacroAssignment;
|
|
use App\Models\MacroCollection;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceAgendaItem;
|
|
use App\Models\Slide;
|
|
use App\Models\Song;
|
|
use App\Models\User;
|
|
use App\Services\PlaylistExportService;
|
|
use App\Services\ProBundleExportService;
|
|
use App\Services\ProExportService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use ProPresenter\Parser\ProBundleReader;
|
|
use ProPresenter\Parser\ProPlaylistReader;
|
|
use Tests\TestCase;
|
|
|
|
final class ProFileExportTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function createSongWithContent(): Song
|
|
{
|
|
$song = Song::create([
|
|
'title' => 'Export Test Song',
|
|
'ccli_id' => '54321',
|
|
'author' => 'Test Author',
|
|
'copyright_text' => 'Test Publisher',
|
|
'copyright_year' => 2024,
|
|
'publisher' => 'Test Publisher',
|
|
]);
|
|
|
|
$verse = Label::firstOrCreate(
|
|
['name' => 'Verse 1 - Export Test Song'],
|
|
['color' => '#2196F3'],
|
|
);
|
|
$verseSection = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
|
|
$verseSection->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
|
|
$verseSection->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
|
|
|
|
$chorus = Label::firstOrCreate(
|
|
['name' => 'Chorus - Export Test Song'],
|
|
['color' => '#F44336'],
|
|
);
|
|
$chorusSection = $song->sections()->create(['label_id' => $chorus->id, 'order' => 1]);
|
|
$chorusSection->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
|
|
|
|
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
|
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 0]);
|
|
$arrangement->arrangementSections()->create(['song_section_id' => $chorusSection->id, 'order' => 1]);
|
|
$arrangement->arrangementSections()->create(['song_section_id' => $verseSection->id, 'order' => 2]);
|
|
|
|
return $song;
|
|
}
|
|
|
|
public function test_download_pro_gibt_datei_zurueck(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$song = $this->createSongWithContent();
|
|
|
|
$response = $this->actingAs($user)->get("/api/songs/{$song->id}/download-pro");
|
|
|
|
$response->assertOk();
|
|
$response->assertHeader('content-disposition');
|
|
$this->assertStringContains('Export Test Song.pro', $response->headers->get('content-disposition'));
|
|
}
|
|
|
|
public function test_download_pro_song_ohne_gruppen_gibt_422(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$song = Song::factory()->create();
|
|
|
|
$response = $this->actingAs($user)->get("/api/songs/{$song->id}/download-pro");
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
public function test_download_pro_erfordert_authentifizierung(): void
|
|
{
|
|
$song = Song::factory()->create();
|
|
|
|
$response = $this->getJson("/api/songs/{$song->id}/download-pro");
|
|
|
|
$response->assertUnauthorized();
|
|
}
|
|
|
|
public function test_download_pro_roundtrip_import_export(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
|
|
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
|
|
|
|
$importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]);
|
|
$importResponse->assertOk();
|
|
|
|
$songId = $importResponse->json('songs.0.id');
|
|
$song = Song::find($songId);
|
|
|
|
$this->assertNotNull($song);
|
|
|
|
$labelCount = $song->arrangements()->withCount('arrangementLabels')->get()->sum('arrangement_labels_count');
|
|
$this->assertGreaterThan(0, $labelCount);
|
|
|
|
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
|
|
$exportResponse->assertOk();
|
|
}
|
|
|
|
public function test_download_pro_roundtrip_preserves_content(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
|
|
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
|
|
|
|
$importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]);
|
|
$importResponse->assertOk();
|
|
|
|
$songId = $importResponse->json('songs.0.id');
|
|
$originalSong = Song::with(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label'])->find($songId);
|
|
$this->assertNotNull($originalSong);
|
|
|
|
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
|
|
$this->assertNotNull($defaultArr);
|
|
|
|
$originalArrangementSections = $defaultArr->arrangementSections->sortBy('order')->values();
|
|
$originalArrangements = $originalSong->arrangements;
|
|
|
|
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
|
|
$exportResponse->assertOk();
|
|
|
|
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
|
|
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
|
|
$baseResponse = $exportResponse->baseResponse;
|
|
copy($baseResponse->getFile()->getPathname(), $tempPath);
|
|
|
|
$reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
|
|
@unlink($tempPath);
|
|
|
|
$this->assertSame($originalSong->title, $reImported->getName());
|
|
|
|
$reImportedGroups = $reImported->getGroups();
|
|
|
|
$uniqueOriginalSections = $originalArrangementSections
|
|
->map(fn ($arrangementSection) => $arrangementSection->section)
|
|
->filter()
|
|
->unique('id')
|
|
->values();
|
|
|
|
$this->assertCount($uniqueOriginalSections->count(), $reImportedGroups, 'Group count mismatch');
|
|
|
|
foreach ($uniqueOriginalSections as $index => $originalSection) {
|
|
$reImportedGroup = $reImportedGroups[$index];
|
|
$this->assertSame($originalSection->label->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
|
|
|
|
$originalSlides = $originalSection->slides->sortBy('order')->values();
|
|
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
|
|
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalSection->label->name}'");
|
|
|
|
foreach ($originalSlides as $slideIndex => $originalSlide) {
|
|
$reImportedSlide = $reImportedSlides[$slideIndex];
|
|
|
|
$this->assertSame(
|
|
$originalSlide->text_content,
|
|
$reImportedSlide->getPlainText(),
|
|
"Slide text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
|
|
);
|
|
|
|
if ($originalSlide->text_content_translated) {
|
|
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalSection->label->name}' slide {$slideIndex}");
|
|
$this->assertSame(
|
|
$originalSlide->text_content_translated,
|
|
$reImportedSlide->getTranslation()?->getPlainText(),
|
|
"Translation text mismatch for group '{$originalSection->label->name}' slide {$slideIndex}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
$reImportedArrangements = $reImported->getArrangements();
|
|
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
|
|
|
|
foreach ($originalArrangements as $originalArrangement) {
|
|
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
|
|
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
|
|
|
|
$originalGroupNames = $originalArrangement->arrangementSections
|
|
->sortBy('order')
|
|
->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name)
|
|
->filter()
|
|
->values()
|
|
->toArray();
|
|
|
|
$reImportedGroupNames = array_map(
|
|
fn ($group) => $group->getName(),
|
|
$reImported->getGroupsForArrangement($reImportedArrangement)
|
|
);
|
|
|
|
$this->assertSame(
|
|
$originalGroupNames,
|
|
$reImportedGroupNames,
|
|
"Arrangement '{$originalArrangement->name}' group order mismatch"
|
|
);
|
|
}
|
|
|
|
if ($originalSong->ccli_id) {
|
|
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
|
|
}
|
|
if ($originalSong->author) {
|
|
$this->assertSame($originalSong->author, $reImported->getCcliAuthor());
|
|
}
|
|
}
|
|
|
|
public function test_export_ohne_service_context_enthaelt_keine_macros(): void
|
|
{
|
|
$song = $this->createSongWithContent();
|
|
$macro = $this->createMacroForExport('Service Macro');
|
|
MacroAssignment::create([
|
|
'part_type' => 'song',
|
|
'macro_id' => $macro->id,
|
|
'position' => 'all_slides',
|
|
'order' => 0,
|
|
]);
|
|
|
|
$parserSong = app(ProExportService::class)->generateParserSong($song);
|
|
|
|
foreach ($this->allParserSlides($parserSong) as $slide) {
|
|
$this->assertFalse($slide->hasMacro());
|
|
}
|
|
}
|
|
|
|
public function test_export_mit_globaler_song_zuweisung_enthaelt_macro_auf_allen_slides(): void
|
|
{
|
|
$service = Service::factory()->create();
|
|
$song = $this->createSongWithContent();
|
|
$macro = $this->createMacroForExport('Alle Folien Macro');
|
|
MacroAssignment::create([
|
|
'part_type' => 'song',
|
|
'macro_id' => $macro->id,
|
|
'position' => 'all_slides',
|
|
'order' => 0,
|
|
]);
|
|
|
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
|
$slides = $this->allParserSlides($parserSong);
|
|
|
|
$this->assertNotEmpty($slides);
|
|
foreach ($slides as $slide) {
|
|
$this->assertTrue($slide->hasMacro());
|
|
$this->assertSame('Alle Folien Macro', $slide->getMacroName());
|
|
$this->assertSame($macro->uuid, $slide->getMacroUuid());
|
|
$this->assertSame('Export Collection', $slide->getMacroCollectionName());
|
|
}
|
|
}
|
|
|
|
public function test_export_mit_ausgeblendeter_macro_enthaelt_keine_macro(): void
|
|
{
|
|
$service = Service::factory()->create();
|
|
$song = $this->createSongWithContent();
|
|
$macro = $this->createMacroForExport('Ausgeblendete Macro', ['hidden_at' => now()]);
|
|
MacroAssignment::create([
|
|
'part_type' => 'song',
|
|
'macro_id' => $macro->id,
|
|
'position' => 'all_slides',
|
|
'order' => 0,
|
|
]);
|
|
|
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
|
|
|
foreach ($this->allParserSlides($parserSong) as $slide) {
|
|
$this->assertFalse($slide->hasMacro());
|
|
}
|
|
}
|
|
|
|
public function test_export_mit_service_background_enthaelt_background_auf_allen_song_folien(): void
|
|
{
|
|
Storage::fake('public');
|
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
|
|
|
$service = Service::factory()->create([
|
|
'background_filename' => 'slides/background.jpg',
|
|
]);
|
|
$song = $this->createSongWithContent();
|
|
|
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
|
$slides = $this->allParserSlides($parserSong);
|
|
|
|
$this->assertNotEmpty($slides);
|
|
foreach ($slides as $slide) {
|
|
$this->assertTrue($slide->hasBackgroundMedia());
|
|
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
|
|
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
|
|
}
|
|
}
|
|
|
|
public function test_export_ohne_background_enthaelt_keine_background_actions(): void
|
|
{
|
|
Storage::fake('public');
|
|
|
|
$service = Service::factory()->create();
|
|
$song = $this->createSongWithContent();
|
|
|
|
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
|
|
|
|
foreach ($this->allParserSlides($parserSong) as $slide) {
|
|
$this->assertFalse($slide->hasBackgroundMedia());
|
|
}
|
|
}
|
|
|
|
public function test_sermon_export_ueberspringt_background_bei_full_cover_folien(): void
|
|
{
|
|
Storage::fake('public');
|
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
|
Storage::disk('public')->put('slides/contain.jpg', 'contain-image');
|
|
Storage::disk('public')->put('slides/cover.jpg', 'cover-image');
|
|
|
|
$service = Service::factory()->create([
|
|
'background_filename' => 'slides/background.jpg',
|
|
]);
|
|
|
|
Slide::factory()->create([
|
|
'service_id' => $service->id,
|
|
'type' => 'sermon',
|
|
'original_filename' => 'contain.jpg',
|
|
'stored_filename' => 'slides/contain.jpg',
|
|
'cover_mode' => false,
|
|
'sort_order' => 0,
|
|
]);
|
|
Slide::factory()->create([
|
|
'service_id' => $service->id,
|
|
'type' => 'sermon',
|
|
'original_filename' => 'cover.jpg',
|
|
'stored_filename' => 'slides/cover.jpg',
|
|
'cover_mode' => true,
|
|
'sort_order' => 1,
|
|
]);
|
|
|
|
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, 'sermon');
|
|
$bundle = ProBundleReader::read($bundlePath);
|
|
$slides = $this->allParserSlides($bundle->getSong());
|
|
|
|
$this->assertCount(2, $slides);
|
|
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
|
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
|
|
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
|
$this->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name');
|
|
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
|
|
|
|
@unlink($bundlePath);
|
|
}
|
|
|
|
public function test_information_und_moderation_exports_erhalten_keinen_background(): void
|
|
{
|
|
Storage::fake('public');
|
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
|
Storage::disk('public')->put('slides/information.jpg', 'information-image');
|
|
Storage::disk('public')->put('slides/moderation.jpg', 'moderation-image');
|
|
|
|
$service = Service::factory()->create([
|
|
'background_filename' => 'slides/background.jpg',
|
|
]);
|
|
|
|
Slide::factory()->create([
|
|
'service_id' => $service->id,
|
|
'type' => 'information',
|
|
'original_filename' => 'information.jpg',
|
|
'stored_filename' => 'slides/information.jpg',
|
|
'sort_order' => 0,
|
|
]);
|
|
Slide::factory()->create([
|
|
'service_id' => $service->id,
|
|
'type' => 'moderation',
|
|
'original_filename' => 'moderation.jpg',
|
|
'stored_filename' => 'slides/moderation.jpg',
|
|
'sort_order' => 0,
|
|
]);
|
|
|
|
foreach (['information', 'moderation'] as $blockType) {
|
|
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
|
|
|
|
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
|
|
$this->assertFalse($slide->hasBackgroundMedia());
|
|
}
|
|
|
|
@unlink($bundlePath);
|
|
}
|
|
}
|
|
|
|
public function test_playlist_export_setzt_background_auf_sermon_folien_und_nicht_auf_informationen(): void
|
|
{
|
|
Storage::fake('public');
|
|
Storage::disk('public')->put('slides/background.jpg', 'background-image');
|
|
Storage::disk('public')->put('slides/information.jpg', 'information-image');
|
|
Storage::disk('public')->put('slides/sermon-contain.jpg', 'sermon-contain-image');
|
|
Storage::disk('public')->put('slides/sermon-cover.jpg', 'sermon-cover-image');
|
|
|
|
$service = Service::factory()->create([
|
|
'title' => 'Playlist Background',
|
|
'date' => now(),
|
|
'background_filename' => 'slides/background.jpg',
|
|
]);
|
|
|
|
Slide::factory()->create([
|
|
'service_id' => null,
|
|
'type' => 'information',
|
|
'original_filename' => 'information.jpg',
|
|
'stored_filename' => 'slides/information.jpg',
|
|
'uploaded_at' => now()->subDay(),
|
|
'sort_order' => 0,
|
|
]);
|
|
|
|
$sermonItem = ServiceAgendaItem::factory()->create([
|
|
'service_id' => $service->id,
|
|
'title' => 'Predigt',
|
|
'service_song_id' => null,
|
|
'sort_order' => 1,
|
|
'is_before_event' => false,
|
|
]);
|
|
|
|
Slide::factory()->create([
|
|
'service_id' => $service->id,
|
|
'service_agenda_item_id' => $sermonItem->id,
|
|
'type' => 'sermon',
|
|
'original_filename' => 'sermon-contain.jpg',
|
|
'stored_filename' => 'slides/sermon-contain.jpg',
|
|
'cover_mode' => false,
|
|
'sort_order' => 0,
|
|
]);
|
|
Slide::factory()->create([
|
|
'service_id' => $service->id,
|
|
'service_agenda_item_id' => $sermonItem->id,
|
|
'type' => 'sermon',
|
|
'original_filename' => 'sermon-cover.jpg',
|
|
'stored_filename' => 'slides/sermon-cover.jpg',
|
|
'cover_mode' => true,
|
|
'sort_order' => 1,
|
|
]);
|
|
|
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
|
$playlist = ProPlaylistReader::read($result['path']);
|
|
$informationSong = $playlist->getEmbeddedSong('Informationen.pro');
|
|
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
|
|
|
$this->assertNotNull($informationSong);
|
|
$this->assertNotNull($sermonSong);
|
|
|
|
foreach ($this->allParserSlides($informationSong) as $slide) {
|
|
$this->assertFalse($slide->hasBackgroundMedia());
|
|
}
|
|
|
|
$sermonSlides = $this->allParserSlides($sermonSong);
|
|
$this->assertCount(2, $sermonSlides);
|
|
$this->assertTrue($sermonSlides[0]->hasBackgroundMedia());
|
|
$this->assertFalse($sermonSlides[1]->hasBackgroundMedia());
|
|
|
|
$this->cleanupTempDir($result['temp_dir']);
|
|
}
|
|
|
|
private function createMacroForExport(string $name, array $attributes = []): Macro
|
|
{
|
|
$macro = Macro::factory()->create(array_merge([
|
|
'uuid' => '11111111-2222-4333-8444-555555555555',
|
|
'name' => $name,
|
|
], $attributes));
|
|
|
|
$collection = MacroCollection::create([
|
|
'uuid' => '99999999-8888-4777-8666-555555555555',
|
|
'name' => 'Export Collection',
|
|
]);
|
|
$collection->macros()->attach($macro->id, ['order' => 0]);
|
|
|
|
return $macro;
|
|
}
|
|
|
|
private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array
|
|
{
|
|
$slides = [];
|
|
|
|
foreach ($parserSong->getGroups() as $group) {
|
|
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
|
|
$slides[] = $slide;
|
|
}
|
|
}
|
|
|
|
return $slides;
|
|
}
|
|
|
|
private function assertStringContains(string $needle, ?string $haystack): void
|
|
{
|
|
$this->assertNotNull($haystack);
|
|
$this->assertTrue(
|
|
str_contains($haystack, $needle),
|
|
"Failed asserting that '{$haystack}' contains '{$needle}'"
|
|
);
|
|
}
|
|
|
|
private function cleanupTempDir(string $dir): void
|
|
{
|
|
if (! is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$items = scandir($dir);
|
|
if ($items === false) {
|
|
return;
|
|
}
|
|
|
|
foreach ($items as $item) {
|
|
if ($item === '.' || $item === '..') {
|
|
continue;
|
|
}
|
|
|
|
$path = $dir.'/'.$item;
|
|
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
|
|
}
|
|
|
|
rmdir($dir);
|
|
}
|
|
}
|