refactor(php): rename SongGroup references throughout controllers/services/tests

Replace all SongGroup/SongArrangementGroup model usages with Label/SongArrangementLabel
after the schema migration to global labels. Updates 12 app files and 11 test files:

- SongService: createDefaultGroups now finds-or-creates global Labels by name
- ArrangementController: validates label_id (labels are global, no song-ownership)
- ProImportService: imports groups as Labels (firstOrCreate by name); does not
  overwrite existing label colors per spec
- ProExportService/SongPdfController/TranslationService/etc: traverse via
  arrangements -> arrangementLabels -> label -> songSlides chain
- All test factories and assertions adapted to label-based schema
This commit is contained in:
Thorsten Bus 2026-05-03 22:55:02 +02:00
parent a1612dc3ef
commit bdbf0c65e3
26 changed files with 573 additions and 573 deletions

View file

@ -2,12 +2,12 @@
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArrangementController extends Controller
{
@ -23,17 +23,24 @@ public function store(Request $request, Song $song): RedirectResponse
'is_default' => false,
]);
$groups = $song->groups()->orderBy('order')->get();
$rows = $groups->map(fn ($group, $index) => [
$defaultArr = $song->arrangements()->where('is_default', true)->first();
if ($defaultArr === null) {
return;
}
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $al->label_id,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
])->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
$arrangement->arrangementLabels()->insert($rows);
}
});
@ -47,14 +54,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR
]);
DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementGroups');
$arrangement->loadMissing('arrangementLabels');
$clone = $arrangement->song->arrangements()->create([
'name' => $data['name'],
'is_default' => false,
]);
$this->cloneGroups($arrangement, $clone);
$this->cloneArrangementLabels($arrangement, $clone);
});
return back()->with('success', 'Arrangement wurde geklont.');
@ -64,33 +71,22 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
{
$data = $request->validate([
'groups' => ['array'],
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'],
'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
$uniqueGroupIds = $groupIds->unique()->values();
$labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
$validGroupIds = $arrangement->song->groups()
->whereIn('id', $uniqueGroupIds)
->pluck('id');
DB::transaction(function () use ($arrangement, $labelIds, $data): void {
$arrangement->arrangementLabels()->delete();
if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
throw ValidationException::withMessages([
'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
]);
}
DB::transaction(function () use ($arrangement, $groupIds, $data): void {
$arrangement->arrangementGroups()->delete();
$rows = $groupIds
$rows = $labelIds
->values()
->map(fn (int $songGroupId, int $index) => [
->map(fn (int $labelId, int $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroupId,
'label_id' => $labelId,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
@ -98,14 +94,12 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
$arrangement->arrangementLabels()->insert($rows);
}
if (! empty($data['group_colors'])) {
foreach ($data['group_colors'] as $groupId => $color) {
$arrangement->song->groups()
->whereKey((int) $groupId)
->update(['color' => $color]);
foreach ($data['group_colors'] as $labelId => $color) {
Label::whereKey((int) $labelId)->update(['color' => $color]);
}
}
});
@ -136,28 +130,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
return back()->with('success', 'Arrangement wurde gelöscht.');
}
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void
{
if ($source === null) {
return;
}
$groups = $source->arrangementGroups
$arrangementLabels = $source->arrangementLabels
->sortBy('order')
->values();
$rows = $groups
->map(fn ($arrangementGroup) => [
$rows = $arrangementLabels
->map(fn ($arrangementLabel) => [
'song_arrangement_id' => $target->id,
'song_group_id' => $arrangementGroup->song_group_id,
'order' => $arrangementGroup->order,
'label_id' => $arrangementLabel->label_id,
'order' => $arrangementLabel->order,
'created_at' => now(),
'updated_at' => now(),
])
->all();
if ($rows !== []) {
$target->arrangementGroups()->insert($rows);
$target->arrangementLabels()->insert($rows);
}
}
}

View file

@ -49,7 +49,7 @@ public function importPro(Request $request): JsonResponse
public function downloadPro(Song $song): BinaryFileResponse
{
if ($song->groups()->count() === 0) {
if ($this->countSongLabels($song) === 0) {
abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.');
}
@ -60,4 +60,12 @@ public function downloadPro(Song $song): BinaryFileResponse
return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
}
private function countSongLabels(Song $song): int
{
return $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
}
}

View file

@ -129,15 +129,13 @@ public function edit(Service $service): Response
$service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song',
'serviceSongs.song.groups',
'serviceSongs.song.arrangements.arrangementGroups.group',
'serviceSongs.song.arrangements.arrangementLabels.label',
'serviceSongs.arrangement',
'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides',
'agendaItems.serviceSong.song.groups.slides',
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'agendaItems.serviceSong.arrangement.arrangementLabels.label',
]);
$songsCatalog = Song::query()
@ -253,15 +251,7 @@ public function edit(Service $service): Response
'title' => $ss->song->title,
'ccli_id' => $ss->song->ccli_id,
'has_translation' => $ss->song->has_translation,
'groups' => $ss->song->groups
->sortBy('order')
->values()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
]),
'groups' => $this->collectSongLabels($ss->song),
'arrangements' => $ss->song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->values()
@ -269,13 +259,13 @@ public function edit(Service $service): Response
'id' => $arrangement->id,
'name' => $arrangement->name,
'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementGroups
'groups' => $arrangement->arrangementLabels
->sortBy('order')
->values()
->map(fn ($arrangementGroup) => [
'id' => $arrangementGroup->group?->id,
'name' => $arrangementGroup->group?->name,
'color' => $arrangementGroup->group?->color,
->map(fn ($arrangementLabel) => [
'id' => $arrangementLabel->label?->id,
'name' => $arrangementLabel->label?->name,
'color' => $arrangementLabel->label?->color,
])
->filter(fn ($group) => $group['id'] !== null)
->values(),
@ -412,4 +402,25 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
)
->deleteFileAfterSend(true);
}
private function collectSongLabels(Song $song): \Illuminate\Support\Collection
{
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
if ($defaultArr === null) {
return collect();
}
return $defaultArr->arrangementLabels
->sortBy('order')
->values()
->map(fn ($arrangementLabel) => [
'id' => $arrangementLabel->label?->id,
'name' => $arrangementLabel->label?->name,
'color' => $arrangementLabel->label?->color,
'order' => $arrangementLabel->order,
])
->filter(fn ($group) => $group['id'] !== null)
->values();
}
}

View file

@ -15,9 +15,6 @@ public function __construct(
private readonly SongService $songService,
) {}
/**
* Alle Songs auflisten (paginiert, durchsuchbar).
*/
public function index(Request $request): JsonResponse
{
$query = Song::query();
@ -53,15 +50,11 @@ public function index(Request $request): JsonResponse
]);
}
/**
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
*/
public function store(SongRequest $request): JsonResponse
{
$song = DB::transaction(function () use ($request) {
$song = Song::create($request->validated());
$this->songService->createDefaultGroups($song);
$this->songService->createDefaultArrangement($song);
return $song;
@ -69,16 +62,13 @@ public function store(SongRequest $request): JsonResponse
return response()->json([
'message' => 'Song erfolgreich erstellt',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
], 201);
}
/**
* Song mit Gruppen, Slides und Arrangements anzeigen.
*/
public function show(int $id): JsonResponse
{
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
$song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
@ -89,9 +79,6 @@ public function show(int $id): JsonResponse
]);
}
/**
* Song-Metadaten aktualisieren.
*/
public function update(SongRequest $request, int $id): JsonResponse
{
$song = Song::find($id);
@ -104,13 +91,10 @@ public function update(SongRequest $request, int $id): JsonResponse
return response()->json([
'message' => 'Song erfolgreich aktualisiert',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
]);
}
/**
* Song soft-löschen.
*/
public function destroy(int $id): JsonResponse
{
$song = Song::find($id);
@ -126,11 +110,35 @@ public function destroy(int $id): JsonResponse
]);
}
/**
* Song-Detail formatieren.
*/
private function formatSongDetail(Song $song): array
{
$defaultArr = $song->arrangements->firstWhere('is_default', true);
$groupsPayload = [];
if ($defaultArr !== null) {
$groupsPayload = $defaultArr->arrangementLabels
->sortBy('order')
->values()
->map(fn ($al) => [
'id' => $al->label?->id,
'name' => $al->label?->name,
'color' => $al->label?->color,
'order' => $al->order,
'slides' => $al->label
? $al->label->songSlides
->sortBy('order')
->values()
->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
'notes' => $slide->notes,
])->toArray()
: [],
])->toArray();
}
return [
'id' => $song->id,
'title' => $song->title,
@ -144,27 +152,15 @@ private function formatSongDetail(Song $song): array
'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(),
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
'notes' => $slide->notes,
])->toArray(),
])->toArray(),
'groups' => $groupsPayload,
'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id,
'name' => $arr->name,
'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
'id' => $ag->id,
'song_group_id' => $ag->song_group_id,
'order' => $ag->order,
'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
'id' => $al->id,
'label_id' => $al->label_id,
'order' => $al->order,
])->toArray(),
])->toArray(),
];

View file

@ -57,21 +57,25 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
private function buildGroupsInOrder(SongArrangement $arrangement): array
{
$arrangement->load([
'arrangementGroups' => fn ($query) => $query->orderBy('order'),
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
'arrangementLabels' => fn ($query) => $query->orderBy('order'),
'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'),
]);
return $arrangement->arrangementGroups->map(function ($arrangementGroup) {
$group = $arrangementGroup->group;
return $arrangement->arrangementLabels->map(function ($arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
return null;
}
return [
'name' => $group->name,
'color' => $group->color ?? '#6b7280',
'slides' => $group->slides->map(fn ($slide) => [
'name' => $label->name,
'color' => $label->color ?? '#6b7280',
'slides' => $label->songSlides->map(fn ($slide) => [
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values()->all(),
];
})->values()->all();
})->filter()->values()->all();
}
}

View file

@ -18,41 +18,48 @@ public function __construct(
public function page(Song $song): Response
{
$song->load([
'groups' => fn ($query) => $query
->orderBy('order')
->with([
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
]),
'arrangements' => fn ($q) => $q->where('is_default', true),
'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'),
'arrangements.arrangementLabels.label.songSlides',
]);
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
$groups = collect();
if ($defaultArr !== null) {
$groups = $defaultArr->arrangementLabels
->sortBy('order')
->values()
->map(fn ($al) => [
'id' => $al->label?->id,
'name' => $al->label?->name,
'color' => $al->label?->color,
'order' => $al->order,
'slides' => $al->label
? $al->label->songSlides
->sortBy('order')
->values()
->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values()
: collect(),
]);
}
return Inertia::render('Songs/Translate', [
'song' => [
'id' => $song->id,
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation,
'groups' => $song->groups->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
'slides' => $group->slides->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
])->values(),
])->values(),
'groups' => $groups,
],
]);
}
/**
* URL abrufen und Text zum Prüfen zurückgeben.
*
* Der Text wird NICHT automatisch gespeichert der Benutzer
* prüft ihn zuerst und importiert dann explizit.
*/
public function fetchUrl(Request $request): JsonResponse
{
$request->validate([
@ -72,11 +79,6 @@ public function fetchUrl(Request $request): JsonResponse
]);
}
/**
* Übersetzungstext für einen Song importieren.
*
* Verteilt den Text zeilenweise auf die Slides des Songs.
*/
public function import(int $songId, Request $request): JsonResponse
{
$song = Song::find($songId);
@ -98,9 +100,6 @@ public function import(int $songId, Request $request): JsonResponse
]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*/
public function destroy(int $songId): JsonResponse
{
$song = Song::find($songId);

View file

@ -28,8 +28,7 @@ public function __construct(
private readonly ?Closure $songFetcher = null,
private readonly ?Closure $agendaFetcher = null,
private readonly ?Closure $eventServiceFetcher = null,
) {
}
) {}
public function sync(): array
{

View file

@ -19,7 +19,11 @@ 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'])
->with([
'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'serviceSong.arrangement.arrangementLabels.label',
])
->get();
if ($agendaItems->isEmpty()) {
@ -80,7 +84,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if ($serviceSong->song_id && $serviceSong->song) {
$song = $serviceSong->song;
if ($song->groups()->count() === 0) {
if ($this->countSongLabels($song) === 0) {
$skippedUnmatched++;
continue;
@ -161,12 +165,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
*/
private function generatePlaylistLegacy(Service $service): array
{
$service->loadMissing('serviceSongs.song.groups.slides');
$service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
$matchedSongs = $service->serviceSongs()
->whereNotNull('song_id')
->orderBy('order')
->with('song.groups.slides')
->with('song.arrangements.arrangementLabels.label.songSlides')
->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
@ -191,7 +195,7 @@ private function generatePlaylistLegacy(Service $service): array
foreach ($matchedSongs as $serviceSong) {
$song = $serviceSong->song;
if (! $song || $song->groups()->count() === 0) {
if (! $song || $this->countSongLabels($song) === 0) {
$skippedEmpty++;
continue;
@ -366,6 +370,14 @@ protected function writeProFile(string $path, string $name, array $groups, array
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
}
private function countSongLabels(\App\Models\Song $song): int
{
return $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
}
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -35,8 +35,7 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
{
$agendaItem->loadMissing([
'slides',
'serviceSong.song.groups.slides',
'serviceSong.song.arrangements.arrangementGroups.group',
'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
]);
$title = $agendaItem->title ?: 'Ablauf-Element';
@ -44,7 +43,12 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
$song = $agendaItem->serviceSong->song;
if ($song->groups()->count() === 0) {
$labelCount = $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
if ($labelCount === 0) {
throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
}

View file

@ -25,7 +25,7 @@ public function generateProFile(Song $song): string
public function generateParserSong(Song $song): \ProPresenter\Parser\Song
{
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']);
$song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
return ProFileGenerator::generate(
$song->title,
@ -37,14 +37,34 @@ public function generateParserSong(Song $song): \ProPresenter\Parser\Song
private function buildGroups(Song $song): array
{
$groups = [];
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
if ($defaultArr === null) {
return [];
}
$defaultArr->loadMissing('arrangementLabels.label.songSlides');
$macroData = $this->buildMacroData();
$groups = [];
$seenLabelIds = [];
foreach ($song->groups->sortBy('order') as $group) {
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
continue;
}
if (in_array($label->id, $seenLabelIds, true)) {
continue;
}
$seenLabelIds[] = $label->id;
$isCopyrightGroup = strcasecmp($label->name, 'COPYRIGHT') === 0;
$slides = [];
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
foreach ($group->slides->sortBy('order') as $slide) {
foreach ($label->songSlides->sortBy('order') as $slide) {
$slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) {
@ -59,8 +79,8 @@ private function buildGroups(Song $song): array
}
$groups[] = [
'name' => $group->name,
'color' => ProImportService::hexToRgba($group->color),
'name' => $label->name,
'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
'slides' => $slides,
];
}
@ -88,12 +108,13 @@ private function buildMacroData(): ?array
private function buildArrangements(Song $song): array
{
$arrangements = [];
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
foreach ($song->arrangements as $arrangement) {
$groupNames = $arrangement->arrangementGroups
$arrangement->loadMissing('arrangementLabels.label');
$groupNames = $arrangement->arrangementLabels
->sortBy('order')
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null)
->map(fn ($al) => $al->label?->name)
->filter()
->values()
->toArray();

View file

@ -2,10 +2,11 @@
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Support\MacroColorConverter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\ProFileReader;
@ -103,28 +104,30 @@ private function upsertSong(ProSong $proSong): Song
}
$song->arrangements()->each(function (SongArrangement $arr) {
$arr->arrangementGroups()->delete();
$arr->arrangementLabels()->delete();
});
$song->arrangements()->delete();
$song->groups()->each(function (SongGroup $group) {
$group->slides()->delete();
});
$song->groups()->delete();
$hasTranslation = false;
$groupMap = [];
$labelsByName = [];
foreach ($proSong->getGroups() as $position => $proGroup) {
foreach ($proSong->getGroups() as $proGroup) {
$groupName = $proGroup->getName();
$existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
if ($existingLabel === null) {
$color = $proGroup->getColor();
$hexColor = $color ? self::rgbaToHex($color) : '#808080';
$hexColor = MacroColorConverter::fromRgba($color) ?? '#808080';
$songGroup = $song->groups()->create([
'name' => $proGroup->getName(),
$existingLabel = Label::create([
'name' => $groupName,
'color' => $hexColor,
'order' => $position,
]);
}
$groupMap[$proGroup->getName()] = $songGroup;
$labelsByName[$groupName] = $existingLabel;
$existingLabel->songSlides()->delete();
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
$translatedText = null;
@ -134,7 +137,7 @@ private function upsertSong(ProSong $proSong): Song
$hasTranslation = true;
}
$songGroup->slides()->create([
$existingLabel->songSlides()->create([
'order' => $slidePosition,
'text_content' => $proSlide->getPlainText(),
'text_content_translated' => $translatedText,
@ -153,19 +156,19 @@ private function upsertSong(ProSong $proSong): Song
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
foreach ($groupsInArrangement as $order => $proGroup) {
$songGroup = $groupMap[$proGroup->getName()] ?? null;
$label = $labelsByName[$proGroup->getName()] ?? null;
if ($songGroup) {
SongArrangementGroup::create([
if ($label) {
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroup->id,
'label_id' => $label->id,
'order' => $order,
]);
}
}
}
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']);
return $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
}
public static function rgbaToHex(array $rgba): string

View file

@ -2,36 +2,48 @@
namespace App\Services;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class SongService
{
/**
* Default-Gruppen für ein neues Lied erstellen.
* Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren.
*
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
* @return Collection<int, Label>
*/
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
public function createDefaultGroups(Song $song): Collection
{
$defaults = [
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
['name' => 'Strophe 1', 'color' => '#3B82F6'],
['name' => 'Refrain', 'color' => '#10B981'],
['name' => 'Bridge', 'color' => '#F59E0B'],
];
foreach ($defaults as $groupData) {
$song->groups()->create($groupData);
$labels = collect();
foreach ($defaults as $data) {
$existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first();
if ($existing === null) {
$existing = Label::create([
'name' => $data['name'],
'color' => $data['color'],
]);
}
return $song->groups()->orderBy('order')->get();
$labels->push($existing);
}
return $labels;
}
/**
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
* Standard "Normal"-Arrangement mit den Default-Labels erstellen.
*/
public function createDefaultArrangement(Song $song): SongArrangement
{
@ -40,16 +52,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
'is_default' => true,
]);
$groups = $song->groups()->orderBy('order')->get();
$labels = $this->createDefaultGroups($song);
foreach ($groups as $index => $group) {
$arrangement->arrangementGroups()->create([
'song_group_id' => $group->id,
foreach ($labels->values() as $index => $label) {
$arrangement->arrangementLabels()->create([
'label_id' => $label->id,
'order' => $index + 1,
]);
}
return $arrangement->load('arrangementGroups');
return $arrangement->load('arrangementLabels.label');
}
/**
@ -63,15 +75,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name)
$clone->is_default = false;
$clone->save();
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
SongArrangementGroup::create([
foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) {
SongArrangementLabel::create([
'song_arrangement_id' => $clone->id,
'song_group_id' => $group->song_group_id,
'order' => $group->order,
'label_id' => $arrangementLabel->label_id,
'order' => $arrangementLabel->order,
]);
}
return $clone->load('arrangementGroups');
return $clone->load('arrangementLabels.label');
});
}
}

View file

@ -8,12 +8,6 @@
class TranslationService
{
/**
* Text von einer URL abrufen (Best-Effort).
*
* HTML-Tags werden entfernt, nur reiner Text zurückgegeben.
* Bei Fehlern wird null zurückgegeben, ohne Exception.
*/
public function fetchFromUrl(string $url): ?string
{
try {
@ -33,29 +27,30 @@ public function fetchFromUrl(string $url): ?string
return null;
}
/**
* Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
*
* Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
* Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
*
* Beispiel:
* Slide 1 hat 4 Zeilen bekommt die nächsten 4 Zeilen der Übersetzung
* Slide 2 hat 2 Zeilen bekommt die nächsten 2 Zeilen
* Slide 3 hat 4 Zeilen bekommt die nächsten 4 Zeilen
*/
public function importTranslation(Song $song, string $text): void
{
$translatedLines = explode("\n", $text);
$offset = 0;
// Alle Gruppen nach order sortiert laden, mit Slides
$groups = $song->groups()->orderBy('order')->with([
'slides' => fn ($query) => $query->orderBy('order'),
])->get();
$defaultArr = $song->arrangements()
->where('is_default', true)
->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides'])
->first();
foreach ($groups as $group) {
foreach ($group->slides as $slide) {
if ($defaultArr === null) {
$this->markAsTranslated($song);
return;
}
foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) {
$label = $arrangementLabel->label;
if ($label === null) {
continue;
}
foreach ($label->songSlides->sortBy('order') as $slide) {
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
$offset += $originalLineCount;
@ -69,30 +64,25 @@ public function importTranslation(Song $song, string $text): void
$this->markAsTranslated($song);
}
/**
* Song als "hat Übersetzung" markieren.
*/
public function markAsTranslated(Song $song): void
{
$song->update(['has_translation' => true]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*
* Löscht alle text_content_translated Felder und setzt has_translation auf false.
*/
public function removeTranslation(Song $song): void
{
// Alle Slides des Songs über die Gruppen aktualisieren
$slideIds = SongSlide::whereIn(
'song_group_id',
$song->groups()->pluck('id')
)->pluck('id');
$labelIds = $song->arrangements()
->with('arrangementLabels')
->get()
->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
->unique()
->values();
SongSlide::whereIn('id', $slideIds)->update([
if ($labelIds->isNotEmpty()) {
SongSlide::whereIn('label_id', $labelIds)->update([
'text_content_translated' => null,
]);
}
$song->update(['has_translation' => false]);
}

View file

@ -2,12 +2,14 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -102,8 +104,18 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void
$service = Service::factory()->create();
$song = Song::factory()->create(['title' => 'Amazing Grace']);
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]);
SongSlide::factory()->create(['song_group_id' => $group->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
$label = Label::factory()->create(['name' => 'Verse 1']);
SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]);
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'is_default' => true,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => 1,
]);
$serviceSong = ServiceSong::create([
'service_id' => $service->id,

View file

@ -2,10 +2,10 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
@ -34,19 +34,19 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
$this->assertNotNull($newArrangement);
$defaultGroupOrder = SongGroup::query()
->where('song_id', $song->id)
$defaultLabelOrder = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('id')
->pluck('label_id')
->all();
$newGroups = SongArrangementGroup::query()
$newLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $newArrangement->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$this->assertSame($defaultGroupOrder, $newGroups);
$this->assertSame($defaultLabelOrder, $newLabels);
}
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void
@ -69,19 +69,19 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups():
$this->assertNotNull($clone);
$this->assertFalse($clone->is_default);
$originalGroups = SongArrangementGroup::query()
$originalLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$cloneGroups = SongArrangementGroup::query()
$cloneLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $clone->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$this->assertSame($originalGroups, $cloneGroups);
$this->assertSame($originalLabels, $cloneLabels);
}
public function test_update_arrangement_reorders_and_persists_groups(): void
@ -92,19 +92,19 @@ public function test_update_arrangement_reorders_and_persists_groups(): void
$response = $this->put(route('arrangements.update', $normal), [
'groups' => [
['song_group_id' => $chorus->id, 'order' => 1],
['song_group_id' => $bridge->id, 'order' => 2],
['song_group_id' => $verse->id, 'order' => 3],
['song_group_id' => $chorus->id, 'order' => 4],
['label_id' => $chorus->id, 'order' => 1],
['label_id' => $bridge->id, 'order' => 2],
['label_id' => $verse->id, 'order' => 3],
['label_id' => $chorus->id, 'order' => 4],
],
]);
$response->assertRedirect();
$updated = SongArrangementGroup::query()
$updated = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->pluck('label_id')
->all();
$this->assertSame([
@ -136,23 +136,9 @@ private function createSongWithDefaultArrangement(): array
{
$song = Song::factory()->create();
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Verse 1',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Chorus',
'order' => 2,
]);
$bridge = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Bridge',
'order' => 3,
]);
$verse = Label::factory()->create(['name' => 'Verse 1']);
$chorus = Label::factory()->create(['name' => 'Chorus']);
$bridge = Label::factory()->create(['name' => 'Bridge']);
$normal = SongArrangement::factory()->create([
'song_id' => $song->id,
@ -160,21 +146,21 @@ private function createSongWithDefaultArrangement(): array
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 2,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 3,
]);

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
@ -35,15 +36,21 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
'copyright_text' => 'Test Publisher',
]);
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse = Label::firstOrCreate(
['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'],
);
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$chorus = Label::firstOrCreate(
['name' => 'Chorus - '.$title],
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
return $song;
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -22,17 +23,23 @@ private function createSongWithContent(): Song
'publisher' => 'Test Publisher',
]);
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]);
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$verse = Label::firstOrCreate(
['name' => 'Verse 1 - Export Test Song'],
['color' => '#2196F3'],
);
$verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']);
$verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']);
$chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]);
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$chorus = Label::firstOrCreate(
['name' => 'Chorus - Export Test Song'],
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]);
return $song;
}
@ -82,7 +89,9 @@ public function test_download_pro_roundtrip_import_export(): void
$song = Song::find($songId);
$this->assertNotNull($song);
$this->assertGreaterThan(0, $song->groups()->count());
$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();
@ -92,7 +101,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
{
$user = User::factory()->create();
// 1. Import the reference .pro file
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
@ -100,43 +108,45 @@ public function test_download_pro_roundtrip_preserves_content(): void
$importResponse->assertOk();
$songId = $importResponse->json('songs.0.id');
$originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId);
$originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId);
$this->assertNotNull($originalSong);
// Snapshot original data
$originalGroups = $originalSong->groups->sortBy('order')->values();
$defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
$this->assertNotNull($defaultArr);
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements;
// 2. Export as .pro
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk();
// Save exported content to temp file — BinaryFileResponse delivers a real file
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
$baseResponse = $exportResponse->baseResponse;
copy($baseResponse->getFile()->getPathname(), $tempPath);
// 3. Re-import the exported file as a new song (different ccli to avoid upsert)
// Use the ProPresenter parser directly to read and verify
$reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
@unlink($tempPath);
// 4. Assert song name
$this->assertSame($originalSong->title, $reImported->getName());
// 5. Assert groups match (same names, same order)
$reImportedGroups = $reImported->getGroups();
$this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch');
foreach ($originalGroups as $index => $originalGroup) {
$uniqueOriginalLabels = $originalArrangementLabels
->map(fn ($al) => $al->label)
->filter()
->unique('id')
->values();
$this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch');
foreach ($uniqueOriginalLabels as $index => $originalLabel) {
$reImportedGroup = $reImportedGroups[$index];
$this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
$this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
// Assert slides within group
$originalSlides = $originalGroup->slides->sortBy('order')->values();
$originalSlides = $originalLabel->songSlides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'");
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'");
foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex];
@ -144,32 +154,30 @@ public function test_download_pro_roundtrip_preserves_content(): void
$this->assertSame(
$originalSlide->text_content,
$reImportedSlide->getPlainText(),
"Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
"Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
);
// Assert translation if present
if ($originalSlide->text_content_translated) {
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}");
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}");
$this->assertSame(
$originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(),
"Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
"Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}"
);
}
}
}
// 6. Assert arrangements match (same names, same group order)
$reImportedArrangements = $reImported->getArrangements();
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
foreach ($originalArrangements as $index => $originalArrangement) {
foreach ($originalArrangements as $originalArrangement) {
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementGroups
$originalGroupNames = $originalArrangement->arrangementLabels
->sortBy('order')
->map(fn ($ag) => $ag->group?->name)
->map(fn ($al) => $al->label?->name)
->filter()
->values()
->toArray();
@ -186,7 +194,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
);
}
// 7. Assert CCLI metadata
if ($originalSong->ccli_id) {
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
}

View file

@ -32,8 +32,10 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
$song = Song::where('title', 'Test')->first();
$this->assertNotNull($song);
$this->assertSame(4, $song->groups()->count());
$this->assertSame(5, $song->groups()->withCount('slides')->get()->sum('slides_count'));
$this->assertSame(4, \App\Models\Label::count());
$this->assertSame(5, \App\Models\SongSlide::count());
$this->assertSame(2, $song->arrangements()->count());
$this->assertTrue($song->has_translation);
}
@ -64,9 +66,18 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void
'title' => 'Old Title',
'ccli_id' => '999',
]);
$existingSong->groups()->create(['name' => 'Old Group', 'color' => '#FF0000', 'order' => 0]);
$this->assertSame(1, $existingSong->groups()->count());
$arrangement = $existingSong->arrangements()->create([
'name' => 'Normal',
'is_default' => true,
]);
$oldLabel = \App\Models\Label::firstOrCreate(['name' => 'Old Group'], ['color' => '#FF0000']);
$arrangement->arrangementLabels()->create([
'label_id' => $oldLabel->id,
'order' => 0,
]);
$this->assertSame(1, $arrangement->arrangementLabels()->count());
$existingSong->update(['ccli_id' => '999']);
$this->assertSame(1, Song::count());
@ -114,6 +125,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void
$this->assertNotNull($normalArrangement);
$this->assertTrue($normalArrangement->is_default);
$this->assertSame(5, $normalArrangement->arrangementGroups()->count());
$this->assertSame(5, $normalArrangement->arrangementLabels()->count());
}
}

View file

@ -1,25 +1,17 @@
<?php
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song CRUD API Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
// --- INDEX / LIST ---
test('songs index returns paginated list', function () {
Song::factory()->count(3)->create();
@ -75,8 +67,6 @@
$response->assertUnauthorized();
});
// --- STORE / CREATE ---
test('store creates song with default groups and arrangement', function () {
$response = $this->actingAs($this->user)
->postJson('/api/songs', [
@ -91,16 +81,12 @@
$song = Song::where('title', 'Neues Lied')->first();
expect($song)->not->toBeNull();
// Default groups: Strophe 1, Refrain, Bridge
expect($song->groups)->toHaveCount(3);
expect($song->groups->pluck('name')->toArray())
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
// Default "Normal" arrangement
$arrangement = $song->arrangements()->where('is_default', true)->first();
expect($arrangement)->not->toBeNull();
expect($arrangement->name)->toBe('Normal');
expect($arrangement->arrangementGroups)->toHaveCount(3);
expect($arrangement->arrangementLabels)->toHaveCount(3);
expect($arrangement->arrangementLabels->sortBy('order')->pluck('label.name')->toArray())
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
});
test('store validates required title', function () {
@ -136,12 +122,15 @@
expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull();
});
// --- SHOW ---
test('show returns song with groups slides and arrangements', function () {
$song = Song::factory()->create();
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']);
SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
$label = Label::factory()->create(['name' => 'Strophe 1']);
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => 1,
]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
@ -174,8 +163,6 @@
$response->assertNotFound();
});
// --- UPDATE ---
test('update modifies song metadata', function () {
$song = Song::factory()->create(['title' => 'Old Title']);
@ -197,7 +184,6 @@
$songA = Song::factory()->create(['ccli_id' => '111111']);
$songB = Song::factory()->create(['ccli_id' => '222222']);
// Try setting songB's ccli_id to songA's
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$songB->id}", [
'title' => $songB->title,
@ -220,8 +206,6 @@
$response->assertOk();
});
// --- DESTROY / SOFT DELETE ---
test('destroy soft-deletes a song', function () {
$song = Song::factory()->create();
@ -242,8 +226,6 @@
$response->assertNotFound();
});
// --- LAST USED IN SERVICE ---
test('last_used_in_service returns correct date from service_songs', function () {
$song = Song::factory()->create();
$serviceOld = Service::factory()->create(['date' => '2025-06-01']);
@ -275,26 +257,24 @@
expect($response->json('data.last_used_in_service'))->toBeNull();
});
// --- SONG SERVICE: DUPLICATE ARRANGEMENT ---
test('duplicate arrangement clones arrangement with groups', function () {
$song = Song::factory()->create();
$group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]);
$group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]);
$label1 = Label::factory()->create();
$label2 = Label::factory()->create();
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Original',
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id,
'label_id' => $label1->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id,
'label_id' => $label2->id,
'order' => 2,
]);
@ -303,7 +283,7 @@
expect($clone->name)->toBe('Klone');
expect($clone->is_default)->toBeFalse();
expect($clone->arrangementGroups)->toHaveCount(2);
expect($clone->arrangementGroups->pluck('song_group_id')->toArray())
->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray());
expect($clone->arrangementLabels)->toHaveCount(2);
expect($clone->arrangementLabels->pluck('label_id')->toArray())
->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray());
});

View file

@ -1,28 +1,15 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song Edit Modal Tests
|--------------------------------------------------------------------------
|
| Tests verifying the API endpoints used by SongEditModal.vue:
| - GET /api/songs/{id} (show with groups + arrangements)
| - PUT /api/songs/{id} (auto-save metadata)
|
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
// --- Modal Data Loading ---
test('show returns song with full detail for modal', function () {
$song = Song::factory()->create([
'title' => 'Großer Gott wir loben Dich',
@ -30,18 +17,14 @@
'copyright_text' => '© Public Domain',
]);
$group1 = SongGroup::factory()->create([
'song_id' => $song->id,
$label1 = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
'order' => 1,
]);
$group2 = SongGroup::factory()->create([
'song_id' => $song->id,
$label2 = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
'order' => 2,
]);
$arrangement = SongArrangement::factory()->create([
@ -50,15 +33,15 @@
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id,
'label_id' => $label1->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id,
'label_id' => $label2->id,
'order' => 2,
]);
@ -77,17 +60,13 @@
],
]);
// Groups in correct order
expect($response->json('data.groups.0.name'))->toBe('Strophe 1');
expect($response->json('data.groups.1.name'))->toBe('Refrain');
// Arrangement with arrangement_groups
expect($response->json('data.arrangements.0.name'))->toBe('Normal');
expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2);
});
// --- Metadata Auto-Save ---
test('update saves title via auto-save', function () {
$song = Song::factory()->create(['title' => 'Original Title']);
@ -155,7 +134,6 @@
test('update returns full song detail with arrangements', function () {
$song = Song::factory()->create();
SongGroup::factory()->create(['song_id' => $song->id]);
SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]);
$response = $this->actingAs($this->user)

View file

@ -1,18 +1,12 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song PDF Download Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
@ -24,22 +18,20 @@
'name' => 'Normal',
]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Verse 1',
'color' => '#3B82F6',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Amazing grace how sweet the sound',
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);
@ -73,42 +65,37 @@
'name' => 'Normal',
]);
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
$verse = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
$chorus = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
'order' => 2,
]);
SongSlide::factory()->create([
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
'text_content' => 'Großer Gott wir loben dich',
]);
SongSlide::factory()->create([
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 1,
'text_content' => 'Heilig heilig heilig',
]);
// Arrangement: Strophe 1 -> Refrain
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 2,
]);
@ -130,22 +117,20 @@
'name' => 'Normal',
]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Verse 1',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Amazing grace how sweet the sound',
'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang',
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);
@ -210,21 +195,19 @@
'name' => 'Übung',
]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Strophe 1',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Großer Gott wir loben dich',
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);
@ -234,7 +217,6 @@
$response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf');
// Filename should contain slug with umlauts handled
$contentDisposition = $response->headers->get('Content-Disposition');
expect($contentDisposition)->toContain('.pdf');
});
@ -260,28 +242,24 @@
'ccli_id' => '123456',
]);
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
$verse = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3b82f6',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
$chorus = Label::factory()->create([
'name' => 'Refrain',
'color' => '#ef4444',
'order' => 2,
]);
SongSlide::factory()->create([
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
'text_content' => 'Strophe Text',
]);
SongSlide::factory()->create([
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 1,
'text_content' => 'Refrain Text',
]);
@ -291,16 +269,15 @@
'name' => 'Normal',
]);
// Order: Chorus first, then Verse
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 2,
]);
@ -321,7 +298,6 @@
],
]);
// Chorus should be first (order=1), Verse second (order=2)
$data = $response->json();
expect($data['groups'][0]['name'])->toBe('Refrain');
expect($data['groups'][1]['name'])->toBe('Strophe 1');
@ -331,14 +307,12 @@
test('song preview includes translation text when slides have translations', function () {
$song = Song::factory()->create(['title' => 'Lied mit Übersetzung']);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
$label = Label::factory()->create([
'name' => 'Verse',
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Original Text',
'text_content_translated' => 'Translated Text',
@ -348,9 +322,9 @@
'song_id' => $song->id,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
]);

View file

@ -2,12 +2,12 @@
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongArrangementLabel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
@ -79,18 +79,14 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'has_translation' => true,
]);
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
$verse = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
$chorus = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
'order' => 2,
]);
$normal = SongArrangement::factory()->create([
@ -99,15 +95,15 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'label_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id,
'label_id' => $chorus->id,
'order' => 2,
]);

View file

@ -3,8 +3,10 @@
namespace Tests\Feature;
use App\Http\Controllers\TranslationController;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\JsonResponse;
@ -21,36 +23,50 @@ public function test_translate_page_response_contains_ordered_groups_and_slides(
'title' => 'Grosser Gott',
]);
$groupLater = SongGroup::factory()->create([
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain',
'color' => '#22c55e',
'order' => 2,
'name' => 'Normal',
'is_default' => true,
]);
$groupFirst = SongGroup::factory()->create([
'song_id' => $song->id,
$labelLater = Label::factory()->create([
'name' => 'Refrain',
'color' => '#22c55e',
]);
$labelFirst = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#0ea5e9',
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelFirst->id,
'order' => 1,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelLater->id,
'order' => 2,
]);
SongSlide::factory()->create([
'song_group_id' => $groupFirst->id,
'label_id' => $labelFirst->id,
'order' => 2,
'text_content' => "Zeile A\nZeile B",
'text_content_translated' => "Line A\nLine B",
]);
SongSlide::factory()->create([
'song_group_id' => $groupFirst->id,
'label_id' => $labelFirst->id,
'order' => 1,
'text_content' => "Zeile C\nZeile D\nZeile E",
'text_content_translated' => null,
]);
SongSlide::factory()->create([
'song_group_id' => $groupLater->id,
'label_id' => $labelLater->id,
'order' => 1,
'text_content' => 'Refrain',
'text_content_translated' => 'Chorus',

View file

@ -1,25 +1,19 @@
<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide;
use App\Models\User;
use App\Services\TranslationService;
use Illuminate\Support\Facades\Http;
/*
|--------------------------------------------------------------------------
| Translation Service Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
$this->service = app(TranslationService::class);
});
// --- URL FETCH ---
test('fetchFromUrl returns text from successful HTTP response', function () {
Http::fake([
'https://example.com/lyrics' => Http::response('<html><body><p>Zeile 1</p><p>Zeile 2</p></body></html>', 200),
@ -30,7 +24,6 @@
expect($result)->not->toBeNull();
expect($result)->toContain('Zeile 1');
expect($result)->toContain('Zeile 2');
// HTML tags should be stripped
expect($result)->not->toContain('<p>');
expect($result)->not->toContain('<html>');
});
@ -65,34 +58,56 @@
expect($result)->toBeNull();
});
// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) ---
test('importTranslation distributes lines by slide line counts', function () {
function makeSongWithDefaultArrangement(): array
{
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1',
'order' => 1,
'name' => 'Normal',
'is_default' => true,
]);
// Slide 1: 4 lines
return [$song, $arrangement];
}
function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): Label
{
$label = Label::firstOrCreate(['name' => $labelName]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $label->id,
'order' => $arrangementOrder,
]);
foreach ($slides as $slide) {
SongSlide::factory()->create(array_merge(
['label_id' => $label->id],
$slide,
));
}
return $label;
}
test('importTranslation distributes lines by slide line counts', function () {
[$song, $arrangement] = makeSongWithDefaultArrangement();
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
]);
// Slide 2: 2 lines
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 2,
'text_content' => "Original 5\nOriginal 6",
]);
// Slide 3: 4 lines
$slide3 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 3,
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
]);
@ -105,37 +120,25 @@
$slide2->refresh();
$slide3->refresh();
// Slide 1 gets lines 1-4
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4");
// Slide 2 gets lines 5-6
expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6");
// Slide 3 gets lines 7-10
expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10");
});
test('importTranslation distributes across multiple groups', function () {
$song = Song::factory()->create(['has_translation' => false]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$group1 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1',
'order' => 1,
]);
$group2 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain',
'order' => 2,
]);
$label1 = attachLabelWithSlides($arrangement, 'Strophe 1 - multi', [], 1);
$label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group1->id,
'label_id' => $label1->id,
'order' => 1,
'text_content' => "Line A\nLine B",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group2->id,
'label_id' => $label2->id,
'order' => 1,
'text_content' => "Line C\nLine D\nLine E",
]);
@ -152,26 +155,22 @@
});
test('importTranslation handles fewer translation lines than original', function () {
$song = Song::factory()->create(['has_translation' => false]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => "Line 1\nLine 2\nLine 3",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 2,
'text_content' => "Line 4\nLine 5",
]);
// Only 3 lines for 5 lines total
$translatedText = "Zeile 1\nZeile 2\nZeile 3";
$this->service->importTranslation($song, $translatedText);
@ -179,22 +178,16 @@
$slide1->refresh();
$slide2->refresh();
// Slide 1 gets all 3 available lines
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3");
// Slide 2 gets empty (no lines left)
expect($slide2->text_content_translated)->toBe('');
});
test('importTranslation marks song as translated', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Line 1',
]);
@ -205,8 +198,6 @@
expect($song->has_translation)->toBeTrue();
});
// --- MARK AS TRANSLATED ---
test('markAsTranslated sets has_translation to true', function () {
$song = Song::factory()->create(['has_translation' => false]);
@ -216,25 +207,21 @@
expect($song->has_translation)->toBeTrue();
});
// --- REMOVE TRANSLATION ---
test('removeTranslation clears all translated text and sets flag to false', function () {
$song = Song::factory()->create(['has_translation' => true]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$song->update(['has_translation' => true]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Original',
'text_content_translated' => 'Übersetzt',
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 2,
'text_content' => 'Original 2',
'text_content_translated' => 'Übersetzt 2',
@ -251,8 +238,6 @@
expect($slide2->text_content_translated)->toBeNull();
});
// --- CONTROLLER ENDPOINTS ---
test('POST translation/fetch-url returns scraped text', function () {
Http::fake([
'https://lyrics.example.com/song' => Http::response('<div>Liedtext Zeile 1</div>', 200),
@ -292,15 +277,12 @@
});
test('POST songs/{song}/translation/import distributes and saves translation', function () {
$song = Song::factory()->create(['has_translation' => false]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1);
$slide = SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => "Line 1\nLine 2",
]);
@ -340,15 +322,13 @@
});
test('DELETE songs/{song}/translation removes translation', function () {
$song = Song::factory()->create(['has_translation' => true]);
[$song, $arrangement] = makeSongWithDefaultArrangement();
$song->update(['has_translation' => true]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'label_id' => $label->id,
'order' => 1,
'text_content' => 'Original',
'text_content_translated' => 'Übersetzt',

View file

@ -8,7 +8,7 @@
function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
{
$color = new Color();
$color = new Color;
$color->setRed($r);
$color->setGreen($g);
$color->setBlue($b);
@ -17,7 +17,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
return $color;
}
$doc = new ProLabelsDocument();
$doc = new ProLabelsDocument;
$labels = [
['name' => 'Copyright', 'r' => 0.8, 'g' => 0.2, 'b' => 0.2],
@ -28,7 +28,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
$labelObjects = [];
foreach ($labels as $data) {
$label = new Label();
$label = new Label;
$label->setText($data['name']);
$label->setColor(makeColor($data['r'], $data['g'], $data['b']));
$labelObjects[] = $label;

View file

@ -11,7 +11,7 @@
function makeUuid(string $value): UUID
{
$uuid = new UUID();
$uuid = new UUID;
$uuid->setString(strtoupper($value));
return $uuid;
@ -19,7 +19,7 @@ function makeUuid(string $value): UUID
function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
{
$color = new Color();
$color = new Color;
$color->setRed($r);
$color->setGreen($g);
$color->setBlue($b);
@ -28,7 +28,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
return $color;
}
$doc = new MacrosDocument();
$doc = new MacrosDocument;
$macros = [
['uuid' => 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Copyright Makro', 'r' => 1.0, 'g' => 0.5, 'b' => 0.0],
@ -38,7 +38,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
$macroObjects = [];
foreach ($macros as $data) {
$macro = new Macro();
$macro = new Macro;
$macro->setUuid(makeUuid($data['uuid']));
$macro->setName($data['name']);
$macro->setColor(makeColor($data['r'], $data['g'], $data['b']));
@ -48,13 +48,13 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
}
$doc->setMacros($macroObjects);
$collection = new MacroCollection();
$collection = new MacroCollection;
$collection->setUuid(makeUuid('8D02FC57-83F8-4042-9B90-81C229728426'));
$collection->setName('--MAIN--');
$items = [];
foreach ($macros as $data) {
$item = new Item();
$item = new Item;
$item->setMacroId(makeUuid($data['uuid']));
$items[] = $item;
}