diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2fbc2..9390c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## 1.1.0 — Unreleased +- **Changed.** Dispatch between overlapping specifications now runs in two + tiers: one that pins at least one argument to something narrower than + "anything" — a literal, any matcher except `Arg::any()`, or a tail pinned + empty by `Arg::none()` — is tried before one that says nothing about any + argument (`Arg::any()` in every position, an unspelled optional parameter, + `Arg::rest()`/`remaining()`), whichever order the two were declared in. + Within a tier, most-recently-registered still wins, and two specifics are + never compared for narrowness. The trap this removes (#136): a catch-all + registered after a specific stub used to take every call, leaving the + specific one unreachable and undiagnosed — that ordering mistake can no + longer be written. This is a behaviour change to the documented layering + semantics: a suite that relied on a catch-all overriding a narrower stub + declared earlier now sees the narrow one answer. No name, signature or + readonly field of the public API changed. (#152) - **Added.** `where()`: a predicate over the whole argument list, narrowing a specification by a constraint no positional matcher can express — `when(fn () => $ledger->transfer(Arg::any(), Arg::any()))->where(fn ($from, diff --git a/MIGRATION.md b/MIGRATION.md index 30d2fdb..079c9ae 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -205,9 +205,10 @@ when(fn () => $repo->find(2))->returns($b); when(fn () => $repo->find(Arg::any()))->returns(null); // the fallback ``` -A later stub wins, and earlier ones remain reachable for arguments the later -one does not match — so declare the broad fallback **first** or give it a -non-overlapping matcher. See [Stubbing](https://rasuvaeff.github.io/understudy/guide/stubbing/index#which-stub-wins). +A specification that pins an argument beats a catch-all whichever order the +two were declared in, and within a tier a later stub wins — so the fallback +above answers everything but `find(1)` and `find(2)` however it is ordered. +See [Stubbing](https://rasuvaeff.github.io/understudy/guide/stubbing/index#which-stub-wins). Where the mapping is genuinely tabular, one `answers()` with a `match` is closer to the original. diff --git a/README.md b/README.md index 550a339..ff5f807 100644 --- a/README.md +++ b/README.md @@ -282,12 +282,17 @@ when(fn () => $repository->find(Arg::any()))->answers( when(fn () => $repository->mode())->returns('fast', 'slow'); ``` -A later stub for the same call wins; earlier ones stay reachable as fallbacks -when their arguments do not match. Register the broad stub first and the -specific one after it: a catch-all registered *after* a specific stub takes -every call, and the specific one is not diagnosed — unreachable is not the -same as uncalled, so not even `strictStubs` reports it. An exhausted -call-count expectation keeps answering the matching call, so use a +Two tiers, whichever order they were declared in: a specification that pins +at least one argument to something narrower than "anything" — a literal, any +matcher except `Arg::any()`, or a tail pinned empty by `Arg::none()` — is +tried before one that says nothing about any argument (`Arg::any()` in every +position, an unspelled optional, `Arg::rest()`/`remaining()` standing for +"the rest does not matter"). A catch-all registered *after* a specific stub +therefore steals nothing: the specific one answers its own calls, and the +catch-all takes the rest — the ordering mistake cannot be written. Within a +tier, the layering below stands. Two specifics are never compared against +each other for narrowness; registration order decides between them. An +exhausted call-count expectation keeps answering the matching call, so use a non-overlapping matcher when a broad fallback should handle later calls. The closure makes exactly one call on a double, and runs exactly once. Two diff --git a/README.ru.md b/README.ru.md index 612a5f2..e8bcc72 100644 --- a/README.ru.md +++ b/README.ru.md @@ -283,11 +283,19 @@ when(fn () => $repository->find(Arg::any()))->answers( when(fn () => $repository->mode())->returns('fast', 'slow'); ``` -Более поздний стаб на тот же вызов выигрывает, а ранние остаются запасными — -поэтому широкий стаб с `Arg::any()` может лежать под точечным. Именно под: -catch-all, зарегистрированный *после* точечного, забирает каждый вызов, а -точечный не диагностируется — недостижимый не значит невызванный, и даже -`strictStubs` его не покажет. +Два тира, в каком бы порядке их ни объявили: спецификация, пинящая хотя бы +один аргумент чем-то уже «чего угодно» — литералом, любым матчером кроме +`Arg::any()`, либо хвостом, закреплённым пустым через `Arg::none()` — +пробуется раньше спецификации, которая ни о каком аргументе ничего не говорит +(`Arg::any()` в каждой позиции, необъявленный опциональный параметр, +`Arg::rest()`/`remaining()` в значении «остальное не важно»). Catch-all, +зарегистрированный *после* точечного стаба, поэтому ничего не крадёт: +точечный отвечает на свои вызовы, catch-all берёт остальные — ошибку порядка +просто невозможно написать. Внутри тира действует слоёнка ниже. Две точные +спецификации никогда не сравниваются между собой по узости — порядок +объявления решает между ними. Ожидание с исчерпанным счётом продолжает +отвечать на совпавший вызов, поэтому для последующих вызовов с широким +fallback используйте непересекающийся матчер. Замыкание делает ровно один вызов на дубле и выполняется ровно один раз. Два вызова — вложенный в аргументы другого, `when(fn () => $repo->find($repo->count()))`, @@ -508,11 +516,6 @@ $checks = Understudy::verifyAll(); // int: заявления + strict-ста объявляйте один раз через `times()`. Пересечение — не совпадение: широкий запасной стаб под более узким ожиданием остаётся документированной слоёнкой. -Более поздний стаб на тот же вызов выигрывает, а ранние остаются запасными при -несовпадении аргументов. Ожидание с исчерпанным счётом продолжает отвечать на -совпавший вызов, поэтому для последующих вызовов с широким fallback используйте -непересекающийся матчер. - В Pest есть свой глобальный `expect()` — импортируйте наш как `expect as expectCall` либо зовите `Understudy::expect()`. diff --git a/docs/src/guide/migrating-from-phpunit.md b/docs/src/guide/migrating-from-phpunit.md index 1b2ed58..a9fa27f 100644 --- a/docs/src/guide/migrating-from-phpunit.md +++ b/docs/src/guide/migrating-from-phpunit.md @@ -54,9 +54,10 @@ when(fn () => $repo->find(2))->returns($b); when(fn () => $repo->find(Arg::any()))->returns(null); // the fallback ``` -A later stub wins, and earlier ones remain reachable for arguments the later -one does not match — so declare the broad fallback **first** or give it a -non-overlapping matcher. See [Stubbing](/guide/stubbing/index#which-stub-wins). +A specification that pins an argument beats a catch-all whichever order the +two were declared in, and within a tier a later stub wins — so the fallback +above answers everything but `find(1)` and `find(2)` however it is ordered. +See [Stubbing](/guide/stubbing/index#which-stub-wins). Where the mapping is genuinely tabular, one `answers()` with a `match` is closer to the original. diff --git a/docs/src/guide/stubbing/index.md b/docs/src/guide/stubbing/index.md index 4ab0353..26ec829 100644 --- a/docs/src/guide/stubbing/index.md +++ b/docs/src/guide/stubbing/index.md @@ -53,8 +53,17 @@ parameter name. ## Which stub wins -A later stub for the same call wins. Earlier ones stay reachable as fallbacks -for calls whose arguments the later one does not match. +Two tiers, whichever order they were declared in: a specification that pins at +least one argument to something narrower than "anything" — a literal, any +matcher except `Arg::any()`, or a tail pinned empty by `Arg::none()` — is +tried before one that says nothing about any argument. A catch-all registered +after a specific stub therefore steals nothing: the specific one answers its +own calls, and the catch-all takes the rest. Within a tier, a later stub for +the same call wins; earlier ones stay reachable as fallbacks for calls whose +arguments the later one does not match. + +Two specifics are never compared against each other for narrowness — +registration order decides between them. Two consequences worth holding on to: diff --git a/llms.txt b/llms.txt index 4bb3a2a..d9c68a6 100644 --- a/llms.txt +++ b/llms.txt @@ -202,12 +202,16 @@ when(fn () => $repo->find(Arg::any()))->answers( ); ``` -Matching: most recently registered wins, earlier stubs remain as fallbacks on -argument mismatch — a matching call-count expectation keeps answering after -its count is exhausted. Register the broad stub FIRST and the specific one -after it: a catch-all registered after a specific stub takes every call and -the specific one is not diagnosed (unreachable ≠ uncalled, so `strictStubs` -stays quiet). Arguments match by `===`, or by matcher. +Matching: two tiers, whichever order they were declared in — a specification +that pins at least one argument (a literal, any matcher except `Arg::any()`, +or a tail pinned empty by `Arg::none()`) is tried before one that says +nothing about any argument (`Arg::any()` everywhere, an unspelled optional, +`Arg::rest()`/`remaining()`); within a tier most recently registered wins and +earlier stubs remain as fallbacks on argument mismatch — a catch-all +registered after a specific stub steals nothing. A matching call-count +expectation keeps answering after its count is exhausted. Two specifics are +never compared for narrowness; registration order decides. Arguments match +by `===`, or by matcher. ### Joint constraints — `where()` diff --git a/src/Expectation/Expectation.php b/src/Expectation/Expectation.php index 875d8ec..9be07ee 100644 --- a/src/Expectation/Expectation.php +++ b/src/Expectation/Expectation.php @@ -8,9 +8,12 @@ use Rasuvaeff\Understudy\Cardinality; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Invocation; +use Rasuvaeff\Understudy\Matcher\AnyArgument; use Rasuvaeff\Understudy\Matcher\ArgumentMatcher; use Rasuvaeff\Understudy\Matcher\Capturing; +use Rasuvaeff\Understudy\Matcher\EmptyTail; use Rasuvaeff\Understudy\Matcher\TailMatcher; +use Rasuvaeff\Understudy\Matcher\Unspelled; /** * One configured call: which method with which arguments, and what happens @@ -53,6 +56,14 @@ final class Expectation */ private readonly bool $capturing; + /** + * Whether this expectation says nothing about any argument: every + * position is `Arg::any()`, an unspelled optional, or a tail that stands + * for "the rest does not matter". Precomputed, because dispatch reads it + * per candidate to order specific expectations before such catch-alls. + */ + private readonly bool $generic; + /** * A constraint over the whole argument list, from `where()`. Null for * every expectation the test did not narrow this way. @@ -79,6 +90,7 @@ public function __construct( $this->argumentCount = count($args); $tailMatcher = null; $capturing = false; + $generic = true; $last = count($args) - 1; /** @var mixed $argument */ @@ -101,10 +113,36 @@ public function __construct( if ($argument instanceof Capturing) { $capturing = true; } + + // Specificity, decided here once rather than per dispatch: an + // argument that says something — a literal, any narrowing + // matcher, or a tail pinned empty by Arg::none() — makes the + // whole expectation specific. The ones that say nothing: + // Arg::any(), an unspelled optional, and rest()/remaining(), + // which stand for "the rest of the arity does not matter". + if ($argument instanceof AnyArgument || $argument instanceof Unspelled) { + continue; + } + + if ($argument instanceof EmptyTail || !$argument instanceof TailMatcher) { + $generic = false; + } } $this->tailMatcher = $tailMatcher; $this->capturing = $capturing; + $this->generic = $generic; + } + + /** + * Whether no position of this expectation narrows the call — the tier it + * dispatches in. A generic expectation is tried only after every + * specific one for the same method refused the call, whichever order + * the two were declared in. + */ + public function isGeneric(): bool + { + return $this->generic; } public function hasCaptors(): bool diff --git a/src/Runtime/DoubleState.php b/src/Runtime/DoubleState.php index 41f5289..a3001fc 100644 --- a/src/Runtime/DoubleState.php +++ b/src/Runtime/DoubleState.php @@ -202,14 +202,17 @@ public function referenceSlot(string $method, mixed $seed, bool $replace): Refer public function hasActionFor(string $method, array $args): bool { // The same walk the dispatcher makes, in the same order, stopping at - // the same place. Scanning declaration order and answering "some - // matching expectation has an action" would disagree with it exactly - // when a newer, narrower `expect(...)->times(1)` shadows an older - // broad stub for the same call — and the slot would then be replaced - // by a value dispatch never returned. (An identical specification - // cannot layer like that any more — assertComposes() refuses it — but - // an overlapping one still does.) - foreach ($this->expectationsFor($method) as $expectation) { + // the same place — tier order included, which is why the args are + // passed rather than walking every expectation of the method: the + // tier a call's candidates come in is part of what "the same order" + // means. Scanning declaration order and answering "some matching + // expectation has an action" would disagree with it exactly when a + // newer generic stub shadows an older specific one for the same + // call — and the slot would then be replaced by a value dispatch + // never returned. (An identical specification cannot layer like + // that any more — assertComposes() refuses it — but an overlapping + // one still does.) + foreach ($this->expectationsFor($method, $args) as $expectation) { if ($expectation->matchesArguments($args)) { return $expectation->hasAction(); } @@ -304,6 +307,16 @@ public function expectations(): array * that has one expectation would tax every call in every suite to speed up * the rare one. * + * Two tiers, whichever order they were declared in: an expectation that + * pins at least one argument to something narrower than "anything" is + * tried before one that says nothing about any argument, so a catch-all + * registered after a specific stub cannot steal the calls the specific + * one describes — the specific one is never unreachable, and the trap + * needs no diagnosis because it cannot be written. Within a tier the + * documented layering stands: most recently registered first, earlier + * ones as fallbacks. Two specifics are never compared against each other + * for narrowness; registration order decides between them. + * * @param non-empty-string $method * @param list|null $args * @return list @@ -329,22 +342,21 @@ public function expectationsFor(string $method, ?array $args = null): array ? [] : ($this->matchingByMethodWithoutFirstLiteral[$method] ?? []); - if ($fallback === []) { - return array_reverse($indexed); - } - - if ($indexed === []) { - return array_reverse($fallback); - } - - // Both kinds can answer this call, so the declared order decides - // between them — the same rule the unindexed walk followed. + // The same tier order both lists answer in — a tier crossing inside + // the index (a literal against a catch-all) and one inside the walked + // list (a range matcher against a catch-all) are the same rule, so + // they get the same sort. $candidates = [...$indexed, ...$fallback]; usort( $candidates, - static fn(Expectation $left, Expectation $right): int - => $right->declarationOrder() <=> $left->declarationOrder(), + static function (Expectation $left, Expectation $right): int { + $tier = $left->isGeneric() <=> $right->isGeneric(); + + return $tier !== 0 + ? $tier + : $right->declarationOrder() <=> $left->declarationOrder(); + }, ); return $candidates; diff --git a/tests/DispatchIndexPropertyTest.php b/tests/DispatchIndexPropertyTest.php index 788817e..d61debd 100644 --- a/tests/DispatchIndexPropertyTest.php +++ b/tests/DispatchIndexPropertyTest.php @@ -27,8 +27,8 @@ * * That is an optimisation, and an optimisation of a lookup is exactly where a * subtle ordering bug hides — the wrong stub answering is silent, and a suite - * built on it stays green. So the property is stated against the thing the - * index replaced: whatever a plain most-recent-first walk would have + * built on it stays green. So the property is stated against the walk the + * index replaced: whatever a plain tiered most-recent-first walk would have * answered, the index answers too. * * @internal @@ -52,7 +52,7 @@ public function tearDown(): void * @param list $encoded */ #[Property(runs: 300, timeoutMs: 10_000)] - public function theIndexAnswersWhatALinearWalkWouldHave(array $encoded, int $call): void + public function theIndexAnswersWhatATieredWalkWouldHave(array $encoded, int $call): void { Classify::cover($encoded === [], 'no expectations', 3.0); Classify::cover(count($encoded) > 5, 'many expectations', 15.0); @@ -67,6 +67,7 @@ public function theIndexAnswersWhatALinearWalkWouldHave(array $encoded, int $cal 30.0, ); Classify::cover($this->modelAnswer($encoded, $call) === null, 'nothing matched', 5.0); + Classify::cover($this->modelAnswerIsGeneric($encoded, $call), 'the catch-all answered', 10.0); $double = Understudy::for(Tally::class); @@ -81,21 +82,22 @@ public function theIndexAnswersWhatALinearWalkWouldHave(array $encoded, int $cal /** * Cases the random phase would reach only by luck, and each of which - * ordering bugs actually produce: a literal shadowed by a later catch-all, - * a catch-all shadowed by a later literal, and the same literal twice. + * ordering bugs actually produce: the tier crossing in both directions, + * the same literal twice, and the misses. */ - public static function theIndexAnswersWhatALinearWalkWouldHaveExamples(): iterable + public static function theIndexAnswersWhatATieredWalkWouldHaveExamples(): iterable { - yield 'a later catch-all shadows an earlier literal' => [[2, 12], 2]; - yield 'a later literal shadows an earlier catch-all' => [[12, 2], 2]; + yield 'a literal beats a later catch-all' => [[2, 12], 2]; + yield 'a literal beats an earlier catch-all' => [[12, 2], 2]; yield 'the same literal twice, the later one answers' => [[2, 2], 2]; yield 'a range shadowed by a literal outside it' => [[5, 3], 6]; + yield 'only the catch-all accepts the call' => [[0, 11], 4]; yield 'nothing matches the call' => [[0, 1], 4]; yield 'no expectations at all' => [[], 3]; } /** @return array */ - public static function theIndexAnswersWhatALinearWalkWouldHaveGenerators(): array + public static function theIndexAnswersWhatATieredWalkWouldHaveGenerators(): array { return [ 'encoded' => Gen::arrayOf(Gen::intBetween(0, 12), 0, 8), @@ -204,13 +206,20 @@ private function identityAnswer(array $armed, int $called): ?int } /** - * What the dispatcher did before the index existed: the most recently - * registered expectation that matches, and no other. + * The tiered walk: the most recently registered expectation that pins the + * argument and matches, and — only when no pinning one does — the most + * recent catch-all. Literals and ranges pin; a catch-all says nothing. * * @param list $encoded */ private function modelAnswer(array $encoded, int $call): ?int { + for ($position = count($encoded) - 1; $position >= 0; --$position) { + if ($encoded[$position] <= self::RANGE_MAX && $this->codeMatches($encoded[$position], $call)) { + return $position; + } + } + for ($position = count($encoded) - 1; $position >= 0; --$position) { if ($this->codeMatches($encoded[$position], $call)) { return $position; @@ -220,6 +229,19 @@ private function modelAnswer(array $encoded, int $call): ?int return null; } + /** + * Whether the model's answer came from a catch-all rather than a pinning + * expectation — the branch the tier exists to make last. + * + * @param list $encoded + */ + private function modelAnswerIsGeneric(array $encoded, int $call): bool + { + $answer = $this->modelAnswer($encoded, $call); + + return $answer !== null && $encoded[$answer] > self::RANGE_MAX; + } + private function codeMatches(int $code, int $call): bool { return match (true) { diff --git a/tests/DispatchTierTest.php b/tests/DispatchTierTest.php new file mode 100644 index 0000000..b626549 --- /dev/null +++ b/tests/DispatchTierTest.php @@ -0,0 +1,137 @@ + $repository->find(1))->returns($book); + when(fn() => $repository->find(Arg::any()))->returns(null); + + // The trap #136 asked about, now unwriteable: whichever order the two + // were declared in, the specific stub answers its own call. + Assert::same($repository->find(1), $book); + Assert::null($repository->find(2)); + } + + public function aSpecificMatcherBeatsALaterCatchAll(): void + { + $repository = Understudy::for(BookRepository::class); + + // The crossing does not need a literal: a narrowing matcher in the + // walked list is the same tier rule as a literal in the index. + when(fn() => $repository->find(Arg::int(min: 1, max: 5)))->returns(new Book('narrow')); + when(fn() => $repository->find(Arg::any()))->returns(new Book('wide')); + + Assert::same($repository->find(3)?->title, 'narrow'); + Assert::same($repository->find(9)?->title, 'wide'); + } + + public function anUnspelledOptionalParameterIsTheGenericTier(): void + { + $repository = Understudy::for(BookRepository::class); + + // tag(Arg::any()) leaves the optional weight unspelled: no position + // pins anything, so the specification dispatches as a catch-all. The + // literal in the other one pins — and answers its calls even though + // the catch-all was declared after it. + when(fn() => $repository->tag(Arg::any()))->returns('wide'); + when(fn() => $repository->tag('alpha'))->returns('pinned'); + + Assert::same($repository->tag('alpha', 3), 'pinned'); + Assert::same($repository->tag('beta'), 'wide'); + } + + public function aTailLeftUnpinnedIsTheGenericTier(): void + { + $sink = Understudy::for(VariadicSink::class); + + // In the first specification every position says something: any() + // says nothing about the channel, but none() pins the tail empty. + // In the second, rest() stands for "the rest does not matter" — a + // catch-all, so the pinned tail answers the empty write even though + // the catch-all was declared after it. + when(fn() => $sink->write(Arg::any(), Arg::none()))->returns(true); + when(fn() => $sink->write(Arg::rest()))->returns(false); + + Assert::true($sink->write('log')); + Assert::false($sink->write('log', 1)); + } + + public function twoSpecificStubsKeepTheDeclaredLayering(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->find(Arg::int(min: 1, max: 5)))->returns(new Book('range')); + when(fn() => $repository->find(Arg::int(min: 3, max: 9)))->returns(new Book('later')); + + // Both pin the argument: registration order decides between them, + // and matchers are never compared for narrowness. + Assert::same($repository->find(4)?->title, 'later'); + Assert::same($repository->find(1)?->title, 'range'); + } + + public function twoCatchAllsKeepTheDeclaredLayering(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->find(Arg::any()))->returns(new Book('first')); + when(fn() => $repository->find(Arg::any()))->returns(new Book('second')); + + Assert::same($repository->find(7)?->title, 'second'); + } + + public function theByReferenceSlotWalkAppliesTheSameTiers(): void + { + $registry = Understudy::for(\Rasuvaeff\Understudy\Tests\Fixture\Ref\Registry::class); + + // A generic stub with an action, declared after an action-less + // specific claim: dispatch hands the call to the claim, which has + // nothing to answer with, so the mode's own value comes back — and + // the slot decision has to agree, or the reference would be seeded + // with a value dispatch never returned. + when(fn() => $registry->row(Arg::any()))->returns(['generic' => true]); + expect(fn() => $registry->row(1)); + + $row = &$registry->row(1); + $row['written'] = true; + + Assert::same($row, ['written' => true]); + } +} diff --git a/tests/EnginePropertyTest.php b/tests/EnginePropertyTest.php index 6d5afa8..6b1a657 100644 --- a/tests/EnginePropertyTest.php +++ b/tests/EnginePropertyTest.php @@ -136,7 +136,7 @@ public static function anOmittedArgumentIsLoggedAsItsDefaultGenerators(): array * and earlier matching ones stay reachable underneath. */ #[Property(runs: 300)] - public function theAnsweringStubIsTheLastMatchingOne(array $specs, int $id): void + public function theMostSpecificMatchingStubAnswers(array $specs, int $id): void { $repository = Understudy::for(BookRepository::class); @@ -144,20 +144,36 @@ public function theAnsweringStubIsTheLastMatchingOne(array $specs, int $id): voi EngineGenerators::registerSpec($repository, $spec, new Book('spec-' . $index)); } - // The oracle walks the specifications backwards, deciding acceptance - // without the matcher implementation under test. + // The oracle decides acceptance and tier without the matcher + // implementation under test: a literal and a range pin the argument, + // a catch-all says nothing. Among the specifications that accept the + // call, the most recently registered pinning one answers; only when + // none does, the most recent catch-all. $expected = null; + $answeredByACatchAll = false; foreach (array_reverse($specs, preserve_keys: true) as $index => $spec) { - if (EngineGenerators::specAccepts($spec, $id)) { + if (EngineGenerators::specAccepts($spec, $id) && !EngineGenerators::specIsGeneric($spec)) { $expected = 'spec-' . $index; break; } } + if ($expected === null) { + foreach (array_reverse($specs, preserve_keys: true) as $index => $spec) { + if (EngineGenerators::specAccepts($spec, $id)) { + $expected = 'spec-' . $index; + $answeredByACatchAll = true; + + break; + } + } + } + Classify::cover($expected !== null, 'a specification answered', 30); Classify::cover($expected === null, 'no specification matched', 5); + Classify::cover($answeredByACatchAll, 'the catch-all answered', 10); Assert::same($repository->find($id)?->title, $expected); } @@ -165,7 +181,7 @@ public function theAnsweringStubIsTheLastMatchingOne(array $specs, int $id): voi /** * @return array */ - public static function theAnsweringStubIsTheLastMatchingOneGenerators(): array + public static function theMostSpecificMatchingStubAnswersGenerators(): array { return [ 'specs' => Gen::nonEmptyArrayOf(EngineGenerators::specification(), 5), @@ -178,10 +194,12 @@ public static function theAnsweringStubIsTheLastMatchingOneGenerators(): array * * @return iterable, int}> */ - public static function theAnsweringStubIsTheLastMatchingOneExamples(): iterable + public static function theMostSpecificMatchingStubAnswersExamples(): iterable { yield 'a literal over a catch-all' => [[['any', 0, 0], ['literal', 3, 3]], 3]; - yield 'a catch-all over a literal' => [[['literal', 3, 3], ['any', 0, 0]], 3]; + yield 'a literal under a later catch-all' => [[['literal', 3, 3], ['any', 0, 0]], 3]; + yield 'a range under a later catch-all' => [[['range', 2, 5], ['any', 0, 0]], 3]; + yield 'a catch-all under a later range' => [[['any', 0, 0], ['range', 2, 5]], 3]; yield 'a literal that misses' => [[['literal', 3, 3]], 4]; yield 'a range that contains the id' => [[['range', 2, 5]], 3]; yield 'a range that misses the id' => [[['range', 2, 5]], 9]; diff --git a/tests/Support/EngineGenerators.php b/tests/Support/EngineGenerators.php index f4df7aa..582e963 100644 --- a/tests/Support/EngineGenerators.php +++ b/tests/Support/EngineGenerators.php @@ -104,4 +104,16 @@ public static function specAccepts(array $spec, int $id): bool default => true, }; } + + /** + * The dispatch tier: a catch-all says nothing about the argument, a + * literal and a range pin it. Decided from the spec's own shape, the way + * the engine decides it from the arguments. + * + * @param array{string, int, int} $spec + */ + public static function specIsGeneric(array $spec): bool + { + return $spec[0] === 'any'; + } } diff --git a/tests/Support/EngineState.php b/tests/Support/EngineState.php index 7257702..54f73e5 100644 --- a/tests/Support/EngineState.php +++ b/tests/Support/EngineState.php @@ -12,8 +12,9 @@ * Immutable by construction — every transition returns a fresh instance — so * the state machine runner can replay a shrunk sequence deterministically. * - * The oracle walks the specifications backwards (most recently registered - * first) without using any matcher implementation of the engine's. + * The oracle walks the specifications by tier (specific ones first, then + * catch-alls), most recently registered first within a tier, without using + * any matcher implementation of the engine's. */ final readonly class EngineState { @@ -30,29 +31,49 @@ public function __construct( /** * The specification that would answer `find($id)`: the last registered - * one whose predicate accepts the call. + * pinning one whose predicate accepts the call — and, only when no + * pinning one does, the last registered catch-all. */ public function answerFor(int $id): ?EngineSpec { - foreach (array_reverse($this->specs) as $spec) { - if ($spec->accepts($id)) { - return $spec; - } - } + $index = $this->answeringIndex($id); - return null; + return $index === null ? null : $this->specs[$index]; } - public function afterDispatch(int $id): self + /** + * The registration index of the specification the tiered walk hands the + * call to, or null when nothing accepts it. + */ + private function answeringIndex(int $id): ?int { - $answering = null; + $fallback = null; + + for ($index = count($this->specs) - 1; $index >= 0; --$index) { + $spec = $this->specs[$index]; - foreach ($this->specs as $index => $spec) { - if ($spec->accepts($id)) { - $answering = $index; + if (!$spec->accepts($id)) { + continue; } + + // A literal pins the argument — the specific tier, whichever + // order the two were declared in, most recent first inside the + // tier. A catch-all is the fallback tier: remembered, and + // answered only if nothing pins. + if (!$spec->anyArgument) { + return $index; + } + + $fallback ??= $index; } + return $fallback; + } + + public function afterDispatch(int $id): self + { + $answering = $this->answeringIndex($id); + $specs = $this->specs; if ($answering !== null) {