diff --git a/.sisyphus/evidence/task-7-moderator.txt b/.sisyphus/evidence/task-7-moderator.txt new file mode 100644 index 0000000..2a17d42 --- /dev/null +++ b/.sisyphus/evidence/task-7-moderator.txt @@ -0,0 +1,16 @@ +Task T7 moderator evidence + +Implemented App\Services\NameTagResolver::moderatorFor(Service $service). + +Covered behavior: +- Non-empty services.moderator_name wins and is trimmed. +- Without override, first visible agenda item (is_before_event=false) ordered by sort_order then id is used. +- Multiple responsible names are joined with comma-space: "Anna Müller, Tom Klein". +- No override and no visible agenda item returns null. + +Verification: +- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist. +- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed. +- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions). +- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php. +- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php. diff --git a/.sisyphus/evidence/task-7-preacher.txt b/.sisyphus/evidence/task-7-preacher.txt new file mode 100644 index 0000000..f2bd0f7 --- /dev/null +++ b/.sisyphus/evidence/task-7-preacher.txt @@ -0,0 +1,28 @@ +Task T7 preacher evidence + +Implemented App\Services\NameTagResolver::preacherFor(Service $service). + +Covered behavior: +- Non-empty services.preacher_name_override wins and is trimmed. +- Without override, services.preacher_name (CTS Predigt role) is returned. +- Without both fields, first visible non-song sermon agenda item is resolved by agenda_sermon_matching patterns via AgendaMatcherService. +- Multiple responsible entries are supported with comma-space joining; empty/missing names return null. +- No override, no CTS preacher name, and no sermon responsible returns null. + +Responsible JSON findings: +- ServiceAgendaItem.responsible is cast to array. +- Existing sync test stores associative object shape: {"name":"Max Mustermann"}. +- New tests cover the expected multiple-person list shape: [{"name":"Anna Müller"},{"name":"Tom Klein"}]. +- Resolver also supports string entries and firstName/lastName or first_name/last_name fallbacks. + +Sermon detection used: +- Prefer configured Setting key agenda_sermon_matching, split by comma, matched with AgendaMatcherService::matchesAny(). +- Only visible non-song agenda items are considered (is_before_event=false, service_song_id IS NULL). +- If no setting exists, title/type substring fallback accepts predigt or sermon. + +Verification: +- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist. +- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed. +- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions). +- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php. +- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php. diff --git a/app/Services/NameTagResolver.php b/app/Services/NameTagResolver.php new file mode 100644 index 0000000..ed582a3 --- /dev/null +++ b/app/Services/NameTagResolver.php @@ -0,0 +1,130 @@ +filledString($service->moderator_name); + if ($override !== null) { + return $override; + } + + $firstAgendaItem = $service->agendaItems() + ->where('is_before_event', false) + ->orderBy('sort_order') + ->orderBy('id') + ->first(); + + return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null; + } + + public function preacherFor(Service $service): ?string + { + $override = $this->filledString($service->preacher_name_override); + if ($override !== null) { + return $override; + } + + $preacherName = $this->filledString($service->preacher_name); + if ($preacherName !== null) { + return $preacherName; + } + + $sermonItem = $service->agendaItems() + ->where('is_before_event', false) + ->whereNull('service_song_id') + ->orderBy('sort_order') + ->orderBy('id') + ->get() + ->first(fn (ServiceAgendaItem $item) => $this->isSermonItem($item)); + + return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null; + } + + private function filledString(?string $value): ?string + { + $trimmed = trim((string) $value); + + return $trimmed === '' ? null : $trimmed; + } + + private function namesFromResponsible(mixed $responsible): ?string + { + if (! is_array($responsible) || $responsible === []) { + return null; + } + + $people = Arr::isAssoc($responsible) ? [$responsible] : $responsible; + + $names = collect($people) + ->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person)) + ->filter() + ->values() + ->all(); + + return $names === [] ? null : implode(', ', $names); + } + + private function nameFromResponsiblePerson(mixed $person): ?string + { + if (is_string($person)) { + return $this->filledString($person); + } + + if (! is_array($person)) { + return null; + } + + $name = $this->filledString($person['name'] ?? null); + if ($name !== null) { + return $name; + } + + $firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? ''; + $lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? ''; + $fullName = trim($firstName.' '.$lastName); + + return $fullName === '' ? null : $fullName; + } + + private function isSermonItem(ServiceAgendaItem $item): bool + { + $configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching')); + if ($configuredPatterns !== []) { + return $this->agendaMatcherService->matchesAny($item->title, $configuredPatterns); + } + + $title = Str::lower($item->title); + $type = Str::lower($item->type ?? ''); + + return str_contains($title, 'predigt') + || str_contains($title, 'sermon') + || str_contains($type, 'predigt') + || str_contains($type, 'sermon'); + } + + /** @return array */ + private function patternsFromSetting(?string $patterns): array + { + if ($patterns === null || trim($patterns) === '') { + return []; + } + + return array_values(array_filter( + array_map(fn (string $pattern) => trim($pattern), explode(',', $patterns)), + fn (string $pattern) => $pattern !== '', + )); + } +} diff --git a/tests/Feature/NameTagResolverTest.php b/tests/Feature/NameTagResolverTest.php new file mode 100644 index 0000000..832e2a7 --- /dev/null +++ b/tests/Feature/NameTagResolverTest.php @@ -0,0 +1,140 @@ +create([ + 'moderator_name' => 'Override Mod', + ]); + + $name = app(NameTagResolver::class)->moderatorFor($service); + + expect($name)->toBe('Override Mod'); +}); + +test('moderator falls back to first visible agenda item responsibles', function () { + $service = Service::factory()->create([ + 'moderator_name' => null, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Vorprogramm', + 'is_before_event' => true, + 'responsible' => [['name' => 'Ignored Person']], + 'sort_order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'is_before_event' => false, + 'responsible' => [ + ['name' => 'Anna Müller'], + ['name' => 'Tom Klein'], + ], + 'sort_order' => 2, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Später', + 'is_before_event' => false, + 'responsible' => [['name' => 'Späte Person']], + 'sort_order' => 3, + ]); + + $name = app(NameTagResolver::class)->moderatorFor($service); + + expect($name)->toBe('Anna Müller, Tom Klein'); +}); + +test('moderator returns null without override or visible agenda item', function () { + $service = Service::factory()->create([ + 'moderator_name' => null, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'is_before_event' => true, + 'responsible' => [['name' => 'Ignored Person']], + 'sort_order' => 1, + ]); + + $name = app(NameTagResolver::class)->moderatorFor($service); + + expect($name)->toBeNull(); +}); + +test('preacher override wins', function () { + $service = Service::factory()->create([ + 'preacher_name_override' => 'Gast Sprecher', + 'preacher_name' => 'Pfr. Lang', + ]); + + $name = app(NameTagResolver::class)->preacherFor($service); + + expect($name)->toBe('Gast Sprecher'); +}); + +test('preacher falls back to cts preacher name', function () { + $service = Service::factory()->create([ + 'preacher_name_override' => null, + 'preacher_name' => 'Pfr. Lang', + ]); + + $name = app(NameTagResolver::class)->preacherFor($service); + + expect($name)->toBe('Pfr. Lang'); +}); + +test('preacher falls back to sermon agenda item responsibles', function () { + Setting::set('agenda_sermon_matching', 'Predigt*,Sermon*'); + $service = Service::factory()->create([ + 'preacher_name_override' => null, + 'preacher_name' => null, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Lied vor der Predigt', + 'service_song_id' => null, + 'responsible' => [['name' => 'Ignored Person']], + 'sort_order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'responsible' => [['name' => 'Diakon Bauer']], + 'sort_order' => 2, + ]); + + $name = app(NameTagResolver::class)->preacherFor($service); + + expect($name)->toBe('Diakon Bauer'); +}); + +test('preacher returns null without override cts name or sermon responsibles', function () { + Setting::set('agenda_sermon_matching', 'Predigt*'); + $service = Service::factory()->create([ + 'preacher_name_override' => null, + 'preacher_name' => null, + ]); + + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Begrüßung', + 'service_song_id' => null, + 'responsible' => [['name' => 'Moderation']], + 'sort_order' => 1, + ]); + + $name = app(NameTagResolver::class)->preacherFor($service); + + expect($name)->toBeNull(); +});