mirror of
https://github.com/chenasraf/latch.git
synced 2026-05-17 17:28:08 +00:00
720 lines
22 KiB
PHP
720 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Latch\Exceptions\DuplicateHandlerException;
|
|
use Latch\Exceptions\DuplicateSourceException;
|
|
use Latch\Exceptions\HookPointNotFoundException;
|
|
use Latch\Exceptions\HookTypeMismatchException;
|
|
use Latch\Exceptions\ReservedTagException;
|
|
use Latch\Exceptions\SourceNotFoundException;
|
|
use Latch\HookRegistry;
|
|
use Latch\HookType;
|
|
|
|
beforeEach(function () {
|
|
$this->registry = new HookRegistry;
|
|
});
|
|
|
|
// --- Source Registration ---
|
|
|
|
it('registers a source', function () {
|
|
$source = $this->registry->registerSource('my-app');
|
|
|
|
expect($source->id)->toBe('my-app');
|
|
expect($this->registry->sources())->toHaveKey('my-app');
|
|
});
|
|
|
|
it('registers a source with a class', function () {
|
|
$this->registry->registerSource('my-app', 'App\\MyApp');
|
|
|
|
expect($this->registry->sources()['my-app']->class)->toBe('App\\MyApp');
|
|
});
|
|
|
|
it('throws when registering a source with the same name twice', function () {
|
|
$this->registry->registerSource('my-app');
|
|
$this->registry->registerSource('my-app');
|
|
})->throws(DuplicateSourceException::class);
|
|
|
|
it('declares filter, action, and collect points on a source', function () {
|
|
$this->registry->registerSource('my-app')
|
|
->filter('before-render', stdClass::class)
|
|
->action('user-created', stdClass::class)
|
|
->collect('menu-items', stdClass::class);
|
|
|
|
$source = $this->registry->sources()['my-app'];
|
|
|
|
expect($source->points())->toHaveCount(3);
|
|
expect($source->getPoint('before-render')->type)->toBe(HookType::Filter);
|
|
expect($source->getPoint('user-created')->type)->toBe(HookType::Action);
|
|
expect($source->getPoint('menu-items')->type)->toBe(HookType::Collect);
|
|
});
|
|
|
|
// --- Handler Registration ---
|
|
|
|
it('registers a handler on a hook point', function () {
|
|
$this->registry->registerSource('my-app')->filter('transform', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$handler->hook('my-app', 'transform')
|
|
->handle(fn (object $p) => $p);
|
|
|
|
$source = $this->registry->sources()['my-app'];
|
|
expect($source->getHandlers('transform'))->toHaveCount(1);
|
|
});
|
|
|
|
it('throws when hooking into a non-existent source', function () {
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$handler->hook('missing', 'anything');
|
|
})->throws(SourceNotFoundException::class);
|
|
|
|
it('throws when hooking into a non-existent point', function () {
|
|
$this->registry->registerSource('my-app');
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$handler->hook('my-app', 'missing');
|
|
})->throws(HookPointNotFoundException::class);
|
|
|
|
it('throws when registering a handler with the same name twice', function () {
|
|
$this->registry->registerHandler('seo');
|
|
$this->registry->registerHandler('seo');
|
|
})->throws(DuplicateHandlerException::class);
|
|
|
|
// --- Filter Chain ---
|
|
|
|
it('applies a filter chain in priority order', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
|
|
$handler->hook('app', 'transform')
|
|
->priority(20)
|
|
->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-second']);
|
|
|
|
$handler->hook('app', 'transform')
|
|
->priority(5)
|
|
->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-first']);
|
|
|
|
$result = $source->apply('transform', (object) ['value' => 'start']);
|
|
|
|
expect($result->value)->toBe('start-first-second');
|
|
});
|
|
|
|
it('throws type mismatch when applying filter on action point', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$source->apply('event', new stdClass);
|
|
})->throws(HookTypeMismatchException::class);
|
|
|
|
// --- Action Dispatch ---
|
|
|
|
it('dispatches an action to all handlers', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$calls = [];
|
|
|
|
$a = $this->registry->registerHandler('plugin-a');
|
|
$a->hook('app', 'event')
|
|
->handle(function (stdClass $p) use (&$calls) {
|
|
$calls[] = 'a';
|
|
});
|
|
|
|
$b = $this->registry->registerHandler('plugin-b');
|
|
$b->hook('app', 'event')
|
|
->handle(function (stdClass $p) use (&$calls) {
|
|
$calls[] = 'b';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass);
|
|
|
|
expect($calls)->toBe(['a', 'b']);
|
|
});
|
|
|
|
it('throws type mismatch when dispatching action on filter point', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$source->dispatch('transform', new stdClass);
|
|
})->throws(HookTypeMismatchException::class);
|
|
|
|
// --- Collect ---
|
|
|
|
it('collects contributions from all handlers', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->collect('items', stdClass::class);
|
|
|
|
$a = $this->registry->registerHandler('plugin-a');
|
|
$a->hook('app', 'items')
|
|
->handle(fn () => ['a', 'b']);
|
|
|
|
$b = $this->registry->registerHandler('plugin-b');
|
|
$b->hook('app', 'items')
|
|
->handle(fn () => ['c']);
|
|
|
|
$result = $source->collectFromHandlers('items');
|
|
|
|
expect($result)->toBe(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('passes context to collect handlers', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->collect('items', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$handler->hook('app', 'items')
|
|
->handle(fn (?stdClass $ctx) => [$ctx?->prefix.'-item']);
|
|
|
|
$result = $source->collectFromHandlers('items', (object) ['prefix' => 'admin']);
|
|
|
|
expect($result)->toBe(['admin-item']);
|
|
});
|
|
|
|
it('throws type mismatch when collecting on filter point', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$source->collectFromHandlers('transform');
|
|
})->throws(HookTypeMismatchException::class);
|
|
|
|
// --- Priority Ordering ---
|
|
|
|
it('runs handlers in priority order', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$order = [];
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
|
|
$handler->hook('app', 'event')
|
|
->priority(30)
|
|
->handle(function () use (&$order) {
|
|
$order[] = 'c';
|
|
});
|
|
|
|
$handler->hook('app', 'event')
|
|
->priority(10)
|
|
->handle(function () use (&$order) {
|
|
$order[] = 'a';
|
|
});
|
|
|
|
$handler->hook('app', 'event')
|
|
->priority(20)
|
|
->handle(function () use (&$order) {
|
|
$order[] = 'b';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass);
|
|
|
|
expect($order)->toBe(['a', 'b', 'c']);
|
|
});
|
|
|
|
// --- Exclusive Short-Circuit ---
|
|
|
|
it('stops after an exclusive handler', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$calls = [];
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
|
|
$handler->hook('app', 'event')
|
|
->priority(1)
|
|
->exclusive()
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'first';
|
|
});
|
|
|
|
$handler->hook('app', 'event')
|
|
->priority(20)
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'second';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass);
|
|
|
|
expect($calls)->toBe(['first']);
|
|
});
|
|
|
|
it('stops after an exclusive filter handler', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
|
|
$handler->hook('app', 'transform')
|
|
->priority(1)
|
|
->exclusive()
|
|
->handle(fn (stdClass $p) => (object) ['value' => 'exclusive']);
|
|
|
|
$handler->hook('app', 'transform')
|
|
->priority(20)
|
|
->handle(fn (stdClass $p) => (object) ['value' => 'should-not-run']);
|
|
|
|
$result = $source->apply('transform', (object) ['value' => 'start']);
|
|
|
|
expect($result->value)->toBe('exclusive');
|
|
});
|
|
|
|
it('stops after an exclusive collect handler', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->collect('items', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
|
|
$handler->hook('app', 'items')
|
|
->priority(1)
|
|
->exclusive()
|
|
->handle(fn () => ['only-this']);
|
|
|
|
$handler->hook('app', 'items')
|
|
->priority(20)
|
|
->handle(fn () => ['not-this']);
|
|
|
|
$result = $source->collectFromHandlers('items');
|
|
|
|
expect($result)->toBe(['only-this']);
|
|
});
|
|
|
|
// --- Conditional Skipping ---
|
|
|
|
it('skips handler when condition returns false', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$calls = [];
|
|
|
|
$a = $this->registry->registerHandler('plugin-a');
|
|
$a->hook('app', 'event')
|
|
->when(fn () => false)
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'skipped';
|
|
});
|
|
|
|
$b = $this->registry->registerHandler('plugin-b');
|
|
$b->hook('app', 'event')
|
|
->when(fn () => true)
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'ran';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass);
|
|
|
|
expect($calls)->toBe(['ran']);
|
|
});
|
|
|
|
it('skips conditional filter handler without breaking chain', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$a = $this->registry->registerHandler('plugin-a');
|
|
$a->hook('app', 'transform')
|
|
->priority(1)
|
|
->when(fn () => false)
|
|
->handle(fn (stdClass $p) => (object) ['value' => 'skipped']);
|
|
|
|
$b = $this->registry->registerHandler('plugin-b');
|
|
$b->hook('app', 'transform')
|
|
->priority(2)
|
|
->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-applied']);
|
|
|
|
$result = $source->apply('transform', (object) ['value' => 'start']);
|
|
|
|
expect($result->value)->toBe('start-applied');
|
|
});
|
|
|
|
// --- Tags ---
|
|
|
|
it('stores tags on handlers', function () {
|
|
$this->registry->registerSource('app')->action('event', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$hookHandler = $handler->hook('app', 'event')
|
|
->tag('admin', 'ui')
|
|
->handle(fn () => null);
|
|
|
|
expect($hookHandler->tags)->toBe(['handler:plugin', 'admin', 'ui']);
|
|
});
|
|
|
|
// --- Tag Filtering ---
|
|
|
|
it('filters action handlers by tag', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$calls = [];
|
|
|
|
$seo = $this->registry->registerHandler('seo-plugin');
|
|
$seo->hook('app', 'event')
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'seo-plugin';
|
|
});
|
|
|
|
$analytics = $this->registry->registerHandler('analytics-plugin');
|
|
$analytics->hook('app', 'event')
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'analytics-plugin';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass, ['handler:seo-plugin']);
|
|
|
|
expect($calls)->toBe(['seo-plugin']);
|
|
});
|
|
|
|
it('filters filter handlers by tag', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$seo = $this->registry->registerHandler('seo-plugin');
|
|
$seo->hook('app', 'transform')
|
|
->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-seo']);
|
|
|
|
$other = $this->registry->registerHandler('other');
|
|
$other->hook('app', 'transform')
|
|
->handle(fn (stdClass $p) => (object) ['value' => $p->value.'-other']);
|
|
|
|
$result = $source->apply('transform', (object) ['value' => 'start'], ['handler:seo-plugin']);
|
|
|
|
expect($result->value)->toBe('start-seo');
|
|
});
|
|
|
|
it('filters collect handlers by tag', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->collect('items', stdClass::class);
|
|
|
|
$seo = $this->registry->registerHandler('seo-plugin');
|
|
$seo->hook('app', 'items')
|
|
->handle(fn () => ['meta-tags', 'sitemap']);
|
|
|
|
$other = $this->registry->registerHandler('other');
|
|
$other->hook('app', 'items')
|
|
->handle(fn () => ['nope']);
|
|
|
|
$result = $source->collectFromHandlers('items', null, ['handler:seo-plugin']);
|
|
|
|
expect($result)->toBe(['meta-tags', 'sitemap']);
|
|
});
|
|
|
|
it('runs all handlers when no tag filter is given', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$calls = [];
|
|
|
|
$seo = $this->registry->registerHandler('seo-plugin');
|
|
$seo->hook('app', 'event')
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'seo-plugin';
|
|
});
|
|
|
|
$other = $this->registry->registerHandler('other');
|
|
$other->hook('app', 'event')
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'other';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass);
|
|
|
|
expect($calls)->toBe(['seo-plugin', 'other']);
|
|
});
|
|
|
|
// --- Registered Handlers ---
|
|
|
|
it('auto-tags hooks with handler name', function () {
|
|
$this->registry->registerSource('app')->action('event', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('seo');
|
|
$hookHandler = $handler->hook('app', 'event')
|
|
->handle(fn () => null);
|
|
|
|
expect($hookHandler->tags)->toBe(['handler:seo']);
|
|
});
|
|
|
|
it('auto-tags hooks with handler name and additional tags', function () {
|
|
$this->registry->registerSource('app')->action('event', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('seo', ['premium']);
|
|
$hookHandler = $handler->hook('app', 'event')
|
|
->handle(fn () => null);
|
|
|
|
expect($hookHandler->tags)->toBe(['handler:seo', 'premium']);
|
|
});
|
|
|
|
it('applies auto-tags to all hooks from a registered handler', function () {
|
|
$this->registry->registerSource('app')
|
|
->action('event-a', stdClass::class)
|
|
->action('event-b', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('analytics', ['tracking']);
|
|
|
|
$a = $handler->hook('app', 'event-a')->handle(fn () => null);
|
|
$b = $handler->hook('app', 'event-b')->handle(fn () => null);
|
|
|
|
expect($a->tags)->toBe(['handler:analytics', 'tracking']);
|
|
expect($b->tags)->toBe(['handler:analytics', 'tracking']);
|
|
});
|
|
|
|
it('merges auto-tags with per-hook tags', function () {
|
|
$this->registry->registerSource('app')->action('event', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('seo');
|
|
$hookHandler = $handler->hook('app', 'event')
|
|
->tag('extra')
|
|
->handle(fn () => null);
|
|
|
|
expect($hookHandler->tags)->toBe(['handler:seo', 'extra']);
|
|
});
|
|
|
|
it('adds global tags after construction', function () {
|
|
$this->registry->registerSource('app')
|
|
->action('event-a', stdClass::class)
|
|
->action('event-b', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('seo');
|
|
$a = $handler->hook('app', 'event-a')->handle(fn () => null);
|
|
|
|
$handler->globalTags('premium', 'v2');
|
|
$b = $handler->hook('app', 'event-b')->handle(fn () => null);
|
|
|
|
expect($a->tags)->toBe(['handler:seo']);
|
|
expect($b->tags)->toBe(['handler:seo', 'premium', 'v2']);
|
|
});
|
|
|
|
it('filters by handler auto-tag', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->action('event', stdClass::class);
|
|
|
|
$calls = [];
|
|
|
|
$seo = $this->registry->registerHandler('seo');
|
|
$seo->hook('app', 'event')
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'seo';
|
|
});
|
|
|
|
$analytics = $this->registry->registerHandler('analytics');
|
|
$analytics->hook('app', 'event')
|
|
->handle(function () use (&$calls) {
|
|
$calls[] = 'analytics';
|
|
});
|
|
|
|
$source->dispatch('event', new stdClass, ['handler:seo']);
|
|
|
|
expect($calls)->toBe(['seo']);
|
|
});
|
|
|
|
// --- Reserved Tag Protection ---
|
|
|
|
it('throws when manually using handler: tag prefix', function () {
|
|
$this->registry->registerSource('app')->action('event', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$handler->hook('app', 'event')
|
|
->tag('handler:fake')
|
|
->handle(fn () => null);
|
|
})->throws(ReservedTagException::class);
|
|
|
|
it('throws when passing handler: tag to registerHandler additional tags', function () {
|
|
$this->registry->registerHandler('seo', ['handler:spoof']);
|
|
})->throws(ReservedTagException::class);
|
|
|
|
it('throws when passing handler: tag to globalTags', function () {
|
|
$handler = $this->registry->registerHandler('seo');
|
|
$handler->globalTags('handler:spoof');
|
|
})->throws(ReservedTagException::class);
|
|
|
|
// --- Introspection ---
|
|
|
|
it('lists all sources', function () {
|
|
$this->registry->registerSource('app-a');
|
|
$this->registry->registerSource('app-b');
|
|
|
|
$sources = $this->registry->sources();
|
|
|
|
expect($sources)->toHaveCount(2);
|
|
expect(array_keys($sources))->toBe(['app-a', 'app-b']);
|
|
});
|
|
|
|
it('lists hook points per source', function () {
|
|
$this->registry->registerSource('app')
|
|
->filter('f1', stdClass::class)
|
|
->action('a1', stdClass::class);
|
|
|
|
$points = $this->registry->sources()['app']->points();
|
|
|
|
expect($points)->toHaveCount(2);
|
|
expect($points['f1']->type)->toBe(HookType::Filter);
|
|
expect($points['a1']->type)->toBe(HookType::Action);
|
|
});
|
|
|
|
it('lists handlers per point', function () {
|
|
$this->registry->registerSource('app')->filter('transform', stdClass::class);
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
|
|
$handler->hook('app', 'transform')
|
|
->priority(5)
|
|
->handle(fn ($p) => $p);
|
|
|
|
$handler->hook('app', 'transform')
|
|
->priority(15)
|
|
->handle(fn ($p) => $p);
|
|
|
|
$handlers = $this->registry->sources()['app']->getHandlers('transform');
|
|
|
|
expect($handlers)->toHaveCount(2);
|
|
expect($handlers[0]->priority)->toBe(5);
|
|
expect($handlers[1]->priority)->toBe(15);
|
|
});
|
|
|
|
// --- Existence Checks ---
|
|
|
|
it('reports whether a source exists', function () {
|
|
expect($this->registry->hasSource('app'))->toBeFalse();
|
|
|
|
$this->registry->registerSource('app');
|
|
|
|
expect($this->registry->hasSource('app'))->toBeTrue();
|
|
});
|
|
|
|
it('reports whether a point has handlers', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
expect($source->hasHandlers('transform'))->toBeFalse();
|
|
|
|
$handler = $this->registry->registerHandler('plugin');
|
|
$handler->hook('app', 'transform')
|
|
->handle(fn ($p) => $p);
|
|
|
|
expect($source->hasHandlers('transform'))->toBeTrue();
|
|
});
|
|
|
|
it('throws HookPointNotFoundException when checking handlers on missing point', function () {
|
|
$source = $this->registry->registerSource('app');
|
|
|
|
$source->hasHandlers('missing');
|
|
})->throws(HookPointNotFoundException::class);
|
|
|
|
// --- Exception Cases ---
|
|
|
|
it('throws HookPointNotFoundException for apply on missing point', function () {
|
|
$source = $this->registry->registerSource('app');
|
|
|
|
$source->apply('missing', new stdClass);
|
|
})->throws(HookPointNotFoundException::class);
|
|
|
|
it('returns payload unchanged when no handlers registered', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->filter('transform', stdClass::class);
|
|
|
|
$payload = (object) ['value' => 'unchanged'];
|
|
$result = $source->apply('transform', $payload);
|
|
|
|
expect($result->value)->toBe('unchanged');
|
|
});
|
|
|
|
it('returns empty array when no collect handlers registered', function () {
|
|
$source = $this->registry->registerSource('app')
|
|
->collect('items', stdClass::class);
|
|
|
|
$result = $source->collectFromHandlers('items');
|
|
|
|
expect($result)->toBe([]);
|
|
});
|
|
|
|
// --- Capability Discovery ---
|
|
|
|
it('lists all registered handlers', function () {
|
|
$this->registry->registerHandler('seo');
|
|
$this->registry->registerHandler('analytics', ['tracking']);
|
|
|
|
$handlers = $this->registry->handlers();
|
|
|
|
expect($handlers)->toHaveCount(2);
|
|
expect($handlers)->toHaveKeys(['seo', 'analytics']);
|
|
expect($handlers['seo']->name)->toBe('seo');
|
|
expect($handlers['analytics']->tags)->toBe(['tracking']);
|
|
});
|
|
|
|
it('finds handlers by capability tag', function () {
|
|
$this->registry->registerHandler('seo', ['content-enhancement']);
|
|
$this->registry->registerHandler('analytics', ['tracking']);
|
|
$this->registry->registerHandler('minifier', ['content-enhancement']);
|
|
|
|
$results = $this->registry->handlersByTag('content-enhancement');
|
|
|
|
expect($results)->toHaveCount(2);
|
|
expect($results[0]->name)->toBe('seo');
|
|
expect($results[1]->name)->toBe('minifier');
|
|
});
|
|
|
|
it('returns empty array when no handlers match tag', function () {
|
|
$this->registry->registerHandler('seo', ['content-enhancement']);
|
|
|
|
$results = $this->registry->handlersByTag('nonexistent');
|
|
|
|
expect($results)->toBe([]);
|
|
});
|
|
|
|
it('exposes hasTag on handler info', function () {
|
|
$this->registry->registerHandler('seo', ['content-enhancement', 'html-processing']);
|
|
|
|
$info = $this->registry->handlers()['seo'];
|
|
|
|
expect($info->hasTag('content-enhancement'))->toBeTrue();
|
|
expect($info->hasTag('html-processing'))->toBeTrue();
|
|
expect($info->hasTag('tracking'))->toBeFalse();
|
|
});
|
|
|
|
// --- Source Capability Discovery ---
|
|
|
|
it('registers a source with capability tags', function () {
|
|
$this->registry->registerSource('cms', null, ['content-management', 'publishing']);
|
|
|
|
$source = $this->registry->sources()['cms'];
|
|
|
|
expect($source->tags)->toBe(['content-management', 'publishing']);
|
|
});
|
|
|
|
it('finds sources by capability tag', function () {
|
|
$this->registry->registerSource('cms', null, ['content-management']);
|
|
$this->registry->registerSource('blog', null, ['content-management']);
|
|
$this->registry->registerSource('analytics', null, ['tracking']);
|
|
|
|
$results = $this->registry->sourcesByTag('content-management');
|
|
|
|
expect($results)->toHaveCount(2);
|
|
expect($results[0]->id)->toBe('cms');
|
|
expect($results[1]->id)->toBe('blog');
|
|
});
|
|
|
|
it('returns empty array when no sources match tag', function () {
|
|
$this->registry->registerSource('cms', null, ['content-management']);
|
|
|
|
$results = $this->registry->sourcesByTag('nonexistent');
|
|
|
|
expect($results)->toBe([]);
|
|
});
|
|
|
|
it('exposes hasTag on source store', function () {
|
|
$this->registry->registerSource('cms', null, ['content-management', 'publishing']);
|
|
|
|
$source = $this->registry->sources()['cms'];
|
|
|
|
expect($source->hasTag('content-management'))->toBeTrue();
|
|
expect($source->hasTag('publishing'))->toBeTrue();
|
|
expect($source->hasTag('tracking'))->toBeFalse();
|
|
});
|
|
|
|
it('defaults to empty tags on source', function () {
|
|
$this->registry->registerSource('cms');
|
|
|
|
$source = $this->registry->sources()['cms'];
|
|
|
|
expect($source->tags)->toBe([]);
|
|
});
|