Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()))`,
Expand Down Expand Up @@ -508,11 +516,6 @@ $checks = Understudy::verifyAll(); // int: заявления + strict-ста
объявляйте один раз через `times()`. Пересечение — не совпадение: широкий
запасной стаб под более узким ожиданием остаётся документированной слоёнкой.

Более поздний стаб на тот же вызов выигрывает, а ранние остаются запасными при
несовпадении аргументов. Ожидание с исчерпанным счётом продолжает отвечать на
совпавший вызов, поэтому для последующих вызовов с широким fallback используйте
непересекающийся матчер.

В Pest есть свой глобальный `expect()` — импортируйте наш как `expect as
expectCall` либо зовите `Understudy::expect()`.

Expand Down
7 changes: 4 additions & 3 deletions docs/src/guide/migrating-from-phpunit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions docs/src/guide/stubbing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
16 changes: 10 additions & 6 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()`

Expand Down
38 changes: 38 additions & 0 deletions src/Expectation/Expectation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -79,6 +90,7 @@ public function __construct(
$this->argumentCount = count($args);
$tailMatcher = null;
$capturing = false;
$generic = true;
$last = count($args) - 1;

/** @var mixed $argument */
Expand All @@ -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
Expand Down
52 changes: 32 additions & 20 deletions src/Runtime/DoubleState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<mixed>|null $args
* @return list<Expectation>
Expand All @@ -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;
Expand Down
Loading
Loading