feat(service): add AgendaMatcherService with wildcard namesmatching
This commit is contained in:
parent
31d7634dbf
commit
03224ffa06
119
app/Services/AgendaMatcherService.php
Normal file
119
app/Services/AgendaMatcherService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
159
tests/Feature/AgendaMatcherServiceTest.php
Normal file
159
tests/Feature/AgendaMatcherServiceTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue