feat(service): add AgendaMatcherService with wildcard namesmatching

This commit is contained in:
Thorsten Bus 2026-03-29 11:37:06 +02:00
parent 31d7634dbf
commit 03224ffa06
2 changed files with 278 additions and 0 deletions

View file

@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Models\ServiceAgendaItem;
class AgendaMatcherService
{
/**
* Check if an item title matches a glob-style pattern.
* Supports * wildcard (matches any characters).
* Case-insensitive.
*/
public function matches(string $itemTitle, string $pattern): bool
{
$normalizedTitle = strtolower(trim($itemTitle));
$normalizedPattern = strtolower(trim($pattern));
return fnmatch($normalizedPattern, $normalizedTitle, FNM_CASEFOLD);
}
/**
* Check if an item title matches any of the patterns.
*
* @param array $patterns Array of glob-style patterns
*/
public function matchesAny(string $itemTitle, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($this->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);
}
}

View file

@ -0,0 +1,159 @@
<?php
use App\Models\ServiceAgendaItem;
use App\Services\AgendaMatcherService;
beforeEach(function () {
$this->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();
});