From 03224ffa06c13028855724418df406359e8ab5ff Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 29 Mar 2026 11:37:06 +0200 Subject: [PATCH] feat(service): add AgendaMatcherService with wildcard namesmatching --- app/Services/AgendaMatcherService.php | 119 +++++++++++++++ tests/Feature/AgendaMatcherServiceTest.php | 159 +++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 app/Services/AgendaMatcherService.php create mode 100644 tests/Feature/AgendaMatcherServiceTest.php diff --git a/app/Services/AgendaMatcherService.php b/app/Services/AgendaMatcherService.php new file mode 100644 index 0000000..aeb6a06 --- /dev/null +++ b/app/Services/AgendaMatcherService.php @@ -0,0 +1,119 @@ +matches($itemTitle, $pattern)) { + return true; + } + } + + return false; + } + + /** + * Find the first ServiceAgendaItem whose title matches any of the patterns. + * + * @param array $agendaItems Array of ServiceAgendaItem + * @param string $patterns Comma-separated patterns (e.g. "Information*,Hinweis*") + */ + public function findFirstMatch(array $agendaItems, string $patterns): ?ServiceAgendaItem + { + $patternList = array_map(fn ($p) => trim($p), explode(',', $patterns)); + $patternList = array_filter($patternList, fn ($p) => $p !== ''); + + foreach ($agendaItems as $item) { + if ($this->matchesAny($item->title, $patternList)) { + return $item; + } + } + + return null; + } + + /** + * Filter array of ServiceAgendaItems to only those between start and end boundary items. + * Boundary items are identified by matching start/end patterns (comma-separated strings). + * The boundary items themselves are EXCLUDED from the result. + * + * If startPattern is empty/null: start from the first item + * If endPattern is empty/null: go to the last item + * + * @param array $items Array of ServiceAgendaItem + * @param string|null $startPattern Comma-separated start patterns or null + * @param string|null $endPattern Comma-separated end patterns or null + * @return array Filtered items (boundaries excluded) + */ + public function filterBetween(array $items, ?string $startPattern, ?string $endPattern): array + { + if (empty($items)) { + return []; + } + + $startIdx = 0; + $endIdx = count($items) - 1; + + // Find start boundary index + if ($startPattern !== null && $startPattern !== '') { + $startItem = $this->findFirstMatch($items, $startPattern); + if ($startItem === null) { + // No matching start pattern — return empty + return []; + } + // Find the index of the start item + foreach ($items as $index => $item) { + if ($item->id === $startItem->id) { + $startIdx = $index + 1; // Exclude the boundary item + break; + } + } + } + + // Find end boundary index + if ($endPattern !== null && $endPattern !== '') { + $endItem = $this->findFirstMatch($items, $endPattern); + if ($endItem === null) { + // No matching end pattern — return empty + return []; + } + // Find the index of the end item (search from end for clarity) + foreach (array_reverse($items, true) as $index => $item) { + if ($item->id === $endItem->id) { + $endIdx = $index - 1; // Exclude the boundary item + break; + } + } + } + + // Return slice + if ($startIdx > $endIdx) { + return []; + } + + return array_slice($items, $startIdx, $endIdx - $startIdx + 1); + } +} diff --git a/tests/Feature/AgendaMatcherServiceTest.php b/tests/Feature/AgendaMatcherServiceTest.php new file mode 100644 index 0000000..554d343 --- /dev/null +++ b/tests/Feature/AgendaMatcherServiceTest.php @@ -0,0 +1,159 @@ +service = new AgendaMatcherService; +}); + +// Exact match +test('matches exact title', function () { + expect($this->service->matches('Predigt', 'Predigt'))->toBeTrue(); +}); + +// Wildcard suffix +test('matches wildcard suffix pattern', function () { + expect($this->service->matches('Predigt – Thema XY', 'Predigt*'))->toBeTrue(); +}); + +// Wildcard prefix +test('matches wildcard prefix pattern', function () { + expect($this->service->matches('Die Predigt', '*Predigt'))->toBeTrue(); +}); + +// Wildcard both +test('matches wildcard both sides pattern', function () { + expect($this->service->matches('Die Predigt heute', '*Predigt*'))->toBeTrue(); +}); + +// Case insensitive +test('matches case insensitive', function () { + expect($this->service->matches('Predigt – Thema', 'predigt*'))->toBeTrue(); +}); + +// No match +test('does not match unrelated title', function () { + expect($this->service->matches('Lobpreis', 'Predigt*'))->toBeFalse(); +}); + +// Multiple patterns — matchesAny +test('matchesAny returns true if any pattern matches', function () { + $patterns = ['Predigt*', 'Sermon*']; + expect($this->service->matchesAny('Predigt – Thema', $patterns))->toBeTrue(); +}); + +test('matchesAny returns false if no pattern matches', function () { + $patterns = ['Predigt*', 'Sermon*']; + expect($this->service->matchesAny('Lobpreis', $patterns))->toBeFalse(); +}); + +// findFirstMatch returns matching item +test('findFirstMatch returns first matching ServiceAgendaItem', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Lobpreis']), + ServiceAgendaItem::factory()->create(['title' => 'Predigt – Thema']), + ServiceAgendaItem::factory()->create(['title' => 'Gebet']), + ]; + + $result = $this->service->findFirstMatch($items, 'Predigt*'); + + expect($result)->toBeInstanceOf(ServiceAgendaItem::class); + expect($result->title)->toBe('Predigt – Thema'); +}); + +test('findFirstMatch returns null when no match', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Lobpreis']), + ServiceAgendaItem::factory()->create(['title' => 'Gebet']), + ]; + + $result = $this->service->findFirstMatch($items, 'Predigt*'); + + expect($result)->toBeNull(); +}); + +// filterBetween with both start and end +test('filterBetween filters items between start and end (both excluded)', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Info']), + ServiceAgendaItem::factory()->create(['title' => 'Start']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 1']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 2']), + ServiceAgendaItem::factory()->create(['title' => 'End']), + ServiceAgendaItem::factory()->create(['title' => 'Segen']), + ]; + + $result = $this->service->filterBetween($items, 'Start', 'End'); + + expect($result)->toHaveCount(2); + expect($result[0]->title)->toBe('Lied 1'); + expect($result[1]->title)->toBe('Lied 2'); +}); + +// filterBetween with start only +test('filterBetween from start boundary to end of items', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Info']), + ServiceAgendaItem::factory()->create(['title' => 'Start']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 1']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 2']), + ]; + + $result = $this->service->filterBetween($items, 'Start', null); + + expect($result)->toHaveCount(2); + expect($result[0]->title)->toBe('Lied 1'); + expect($result[1]->title)->toBe('Lied 2'); +}); + +// filterBetween with neither +test('filterBetween with no start or end patterns returns all items', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Info']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 1']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 2']), + ]; + + $result = $this->service->filterBetween($items, null, null); + + expect($result)->toHaveCount(3); +}); + +// filterBetween with no matching start +test('filterBetween with no matching start pattern returns empty array', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Lied 1']), + ServiceAgendaItem::factory()->create(['title' => 'Lied 2']), + ]; + + $result = $this->service->filterBetween($items, 'Nonexistent*', null); + + expect($result)->toBeEmpty(); +}); + +// findFirstMatch with comma-separated patterns +test('findFirstMatch splits comma-separated patterns and matches', function () { + $items = [ + ServiceAgendaItem::factory()->create(['title' => 'Lobpreis']), + ServiceAgendaItem::factory()->create(['title' => 'Predigt – Thema']), + ]; + + $result = $this->service->findFirstMatch($items, 'Lied*,Predigt*'); + + expect($result)->toBeInstanceOf(ServiceAgendaItem::class); + expect($result->title)->toBe('Predigt – Thema'); +}); + +// Edge case: empty items array +test('findFirstMatch with empty items returns null', function () { + $result = $this->service->findFirstMatch([], 'Predigt*'); + + expect($result)->toBeNull(); +}); + +test('filterBetween with empty items returns empty array', function () { + $result = $this->service->filterBetween([], 'Start', 'End'); + + expect($result)->toBeEmpty(); +});