From c99aec99e511f686cf4ecca37bc8be7b346dd06c Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 11 Oct 2026 09:35:06 +0300 Subject: [PATCH] where(): a joint constraint over the whole argument list Fixes #151 --- CHANGELOG.md | 11 + MIGRATION.md | 1 + README.md | 35 ++ README.ru.md | 34 ++ composer.json | 3 +- docs/scripts/api-snapshot.json | 398 +++++++++++------- docs/src/api/classes/Arg.md | 2 +- docs/src/api/classes/Captor.md | 2 +- .../Exception/AmbiguousDefaultFactory.md | 2 +- .../classes/Exception/BypassUnavailable.md | 2 +- docs/src/api/classes/Exception/CannotWire.md | 2 +- .../Exception/ConflictingExpectation.md | 2 +- .../Exception/ContextOwnershipViolation.md | 2 +- .../api/classes/Exception/ForgottenDouble.md | 2 +- .../Exception/ForwardingTargetMismatch.md | 2 +- .../Exception/InvalidCallSpecification.md | 17 +- .../classes/Exception/InvalidDefaultValue.md | 2 +- .../Exception/InvalidSpecificationArgument.md | 2 +- .../api/classes/Exception/MatcherLeaked.md | 2 +- .../classes/Exception/NeverMethodCalled.md | 2 +- .../api/classes/Exception/NoDefaultValue.md | 2 +- .../api/classes/Exception/NothingCaptured.md | 2 +- .../Exception/OriginalCallUnavailable.md | 2 +- .../Exception/OriginalReturnTypeViolation.md | 2 +- .../classes/Exception/OutcomeUnavailable.md | 2 +- .../classes/Exception/StrictModeViolation.md | 2 +- .../api/classes/Exception/UnderstudyError.md | 2 +- .../classes/Exception/UnsupportedTarget.md | 2 +- .../classes/Exception/VerificationFailed.md | 2 +- docs/src/api/classes/ExpectBuilder.md | 2 +- docs/src/api/classes/FailureKind.md | 2 +- docs/src/api/classes/Invocation.md | 2 +- docs/src/api/classes/Understudy.md | 9 +- docs/src/api/classes/VerificationFailure.md | 2 +- docs/src/api/classes/WhenBuilder.md | 39 +- docs/src/api/functions.md | 8 +- docs/src/guide/migrating-from-mockery.md | 1 + docs/src/guide/stubbing/matchers.md | 32 ++ examples/README.md | 1 + examples/where-guard.php | 83 ++++ llms.txt | 20 + src/Exception/InvalidCallSpecification.php | 18 + src/Expectation/Expectation.php | 119 +++++- src/Understudy.php | 11 + src/WhenBuilder.php | 39 ++ src/functions.php | 7 +- tests/WhereTest.php | 244 +++++++++++ 47 files changed, 997 insertions(+), 185 deletions(-) create mode 100644 examples/where-guard.php create mode 100644 tests/WhereTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7412785..2b2fbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## 1.1.0 — Unreleased +- **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, + $to) => $from <= $to)->returns(true)`. The closure receives the arguments + positionally and is asked only after every position matched; a call it + refuses falls through to broader expectations. `expect()` takes one through + the same verb, `verify()` through its `where:` parameter. One guard per + expectation (a second is refused), `description:` names it in failure + messages, and a guard that refused a positionally-matching call is marked + as the refusing part. The Mockery migration table gains its + `withArgs(closure)` row. (#151) - **Added.** `Understudy::verifyAll()` returns `int<0, max>` — how many checks it verified — instead of `void`, so runner adapters count real assertions instead of a flat one per test. One check is: an expectation carrying a diff --git a/MIGRATION.md b/MIGRATION.md index cb7d1c0..30d2fdb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -37,6 +37,7 @@ here, and the sections after it cover what a mechanical translation gets wrong. | `->andReturn($a, $b)` | `->returns($a, $b)` | one per call, last repeats | | `->with(123, Mockery::any())` | inside the closure: `find(123, Arg::any())` | | | `Mockery::on(fn ($x) => …)` | `Arg::satisfies(fn ($x) => …)` | | +| `->withArgs(fn ($from, $to) => …)` | `->where(fn ($from, $to) => …)` | a joint constraint over the whole argument list | | `Mockery::type(Foo::class)` | `Arg::instanceOf(Foo::class)` | | | `->withAnyArgs()` | inside the closure: `method(Arg::rest())` | also the escape from spelling a wide signature: `method('svc', Arg::rest())` | | `Mockery::capture($x)` | `Arg::captor(X::class)` + `$captor->capture()` | `last()` / `all()` are typed through the class-string — no `instanceof` at the read site | diff --git a/README.md b/README.md index d7a2cdd..550a339 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ here. Two rows are traps, marked ⚠. | `->andThrow(new NotFound())` | `->throws(new NotFound())` | | | `->with(123, Mockery::any())` | inside the closure: `find(123, Arg::any())` | | | `Mockery::on(fn ($x) => ...)` | `Arg::satisfies(fn ($x) => ...)` | | +| `->withArgs(fn ($from, $to) => ...)` | `->where(fn ($from, $to) => ...)` | a joint constraint over the whole argument list — see Stubbing | | `$mock->shouldNotHaveReceived('save')` | `verify(fn () => $mock->save(...), never: true)` | `Understudy::unused($mock)` when the double must not have been touched at all | | `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(...))` | after the fact; add `nothingElse()` — see below | | `Mockery::close()` | adapter's `reset()`, or your own teardown | | @@ -394,6 +395,40 @@ A getter that throws counts as a mismatch, never as an error — matching runs while the code under test is executing, and a matcher must not be the thing that breaks it. +A positional matcher sees one argument. A constraint over two or more of them +together — `transfer($from, $to)` where `$from <= $to` must hold, a resize +whose aspect ratio matters — is what `where()` is: + +```php +when(fn () => $ledger->transfer(Arg::any(), Arg::any())) + ->where(fn ($from, $to) => $from <= $to) + ->returns(true); + +verify( + fn () => $ledger->transfer(Arg::any(), Arg::any()), + times: 1, + where: fn ($from, $to) => $from <= $to, +); +``` + +The closure receives the arguments the call carried, positionally, so it reads +like the method's own signature; it is asked only after every position already +matched, and a call it refuses simply does not match — a broader expectation +can still answer it. `expect()` takes one through the same `->where()`. The +guard runs wherever matching runs, which is inside the code under test: it is +the test's own code, and an exception it raises is the test's own error. One +guard per expectation — a second `where()` is refused with +`InvalidCallSpecification` rather than silently replacing the first. In a +failure message the guard renders as `where(…)`, or as the `description` given: + +```php +->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)') +``` + +and when the positions accepted a call but the guard refused it, the report +marks the guard itself — `transfer(any(), any()) *where(…)*` — rather than +blaming a position that did accept. + ### Capturing an argument `Arg::captor()` is the typed replacement for reading `args[N]` out of the call diff --git a/README.ru.md b/README.ru.md index 2a9e07e..612a5f2 100644 --- a/README.ru.md +++ b/README.ru.md @@ -58,6 +58,7 @@ when(fn () => $repository->find(123))->returns($book); | `->andThrow(new NotFound())` | `->throws(new NotFound())` | | | `->with(123, Mockery::any())` | в замыкании: `find(123, Arg::any())` | | | `Mockery::on(fn ($x) => ...)` | `Arg::satisfies(fn ($x) => ...)` | | +| `->withArgs(fn ($from, $to) => ...)` | `->where(fn ($from, $to) => ...)` | совместное ограничение на весь список аргументов — см. «Стаббинг» | | `$mock->shouldNotHaveReceived('save')` | `verify(fn () => $mock->save(...), never: true)` | `Understudy::unused($mock)` — когда дубль не должны были трогать вообще | | `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(...))` | пост-фактум; добавьте `nothingElse()` — см. ниже | | `Mockery::close()` | reset() адаптера или свой teardown | | @@ -393,6 +394,39 @@ teardown: `Arg::int(min: 5, max: 1)` и его собратья ошибкой: сопоставление идёт во время работы тестируемого кода, и матчер не должен быть тем, что его ломает. +Позиционный матчер видит один аргумент. Ограничение на два и более аргументов +вместе — `transfer($from, $to)`, где должно выполняться `$from <= $to`, — это +то, для чего есть `where()`: + +```php +when(fn () => $ledger->transfer(Arg::any(), Arg::any())) + ->where(fn ($from, $to) => $from <= $to) + ->returns(true); + +verify( + fn () => $ledger->transfer(Arg::any(), Arg::any()), + times: 1, + where: fn ($from, $to) => $from <= $to, +); +``` + +Замыкание получает аргументы вызова позиционно и читается как собственная +сигнатура метода; его спрашивают, только когда все позиции уже совпали, и +вызов, который он отверг, просто не совпадает — более широкое ожидание всё +ещё может ответить на него. `expect()` принимает его тем же `->where()`. +Guard работает везде, где идёт сопоставление — то есть внутри тестируемого +кода: это код самого теста, и исключение из него — ошибка самого теста. Один +guard на ожидание: второй `where()` отвергается с `InvalidCallSpecification`, +а не молча заменяет первый. В сообщении о провале guard читается как +`where(…)` либо как переданный `description`: + +```php +->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)') +``` + +а когда позиции приняли вызов, а guard — нет, отчёт помечает сам guard — +`transfer(any(), any()) *where(…)*` — а не обвиняет принявшую позицию. + ### Захват аргумента `Arg::captor()` — типизированная замена чтению `args[N]` из лога вызовов: без diff --git a/composer.json b/composer.json index d7c0aff..6f2f425 100644 --- a/composer.json +++ b/composer.json @@ -92,7 +92,8 @@ "@php examples/wiring.php", "@php examples/protocol.php", "@php examples/defaults-registry.php", - "@php examples/structured-failures.php" + "@php examples/structured-failures.php", + "@php examples/where-guard.php" ], "mutation": "infection --threads=max", "psalm": "psalm --no-cache", diff --git a/docs/scripts/api-snapshot.json b/docs/scripts/api-snapshot.json index 41df47a..7f0dc0a 100644 --- a/docs/scripts/api-snapshot.json +++ b/docs/scripts/api-snapshot.json @@ -2,7 +2,7 @@ "classes": [ { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Arg", "kind": "class", @@ -437,7 +437,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FileWrapper", "kind": "class", @@ -1099,7 +1099,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FinalStripper", "kind": "class", @@ -1159,7 +1159,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Captor", "kind": "class", @@ -1287,7 +1287,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Cardinality", "kind": "class", @@ -1480,7 +1480,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\Blueprint", "kind": "class", @@ -1617,7 +1617,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\DoubleFactory", "kind": "class", @@ -1746,7 +1746,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\MethodSignature", "kind": "class", @@ -1940,7 +1940,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertyDefaults", "kind": "class", @@ -1993,7 +1993,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertySignature", "kind": "class", @@ -2083,7 +2083,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TargetUnifier", "kind": "class", @@ -2136,7 +2136,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TypeRenderer", "kind": "class", @@ -2256,7 +2256,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\DefaultFactories", "kind": "class", @@ -2361,7 +2361,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\TypeDefaultResolver", "kind": "class", @@ -2500,7 +2500,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\AmbiguousDefaultFactory", "kind": "class", @@ -2560,7 +2560,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\BypassUnavailable", "kind": "class", @@ -2666,7 +2666,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\CannotWire", "kind": "class", @@ -2911,7 +2911,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ConflictingExpectation", "kind": "class", @@ -3031,7 +3031,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ContextOwnershipViolation", "kind": "class", @@ -3076,7 +3076,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForgottenDouble", "kind": "class", @@ -3236,7 +3236,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", "kind": "class", @@ -3326,7 +3326,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "kind": "class", @@ -3588,6 +3588,36 @@ "attributes": [], "startLine": 166 }, + { + "name": "guardAlreadyNarrowed", + "static": true, + "params": [ + { + "name": "method", + "type": "non-empty-string", + "description": "", + "default": null, + "variadic": false + }, + { + "name": "description", + "type": "non-empty-string|null", + "description": "how the first guard reads", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", + "summary": "Builds the error for a second `where()` on one expectation. Replacing\nthe first guard silently would narrow the call by the last sentence\nanyone wrote, which is not what a fluent chain reads like.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 184 + }, { "name": "tailMatcherInCombinator", "static": true, @@ -3616,7 +3646,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 182 + "startLine": 200 }, { "name": "matcherInsideArray", @@ -3653,7 +3683,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 200 + "startLine": 218 }, { "name": "captorInCombinator", @@ -3676,7 +3706,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 218 + "startLine": 236 }, { "name": "emptySequence", @@ -3691,7 +3721,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 233 + "startLine": 251 }, { "name": "protocolAlreadyArmed", @@ -3721,7 +3751,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 247 + "startLine": 265 }, { "name": "incompleteSpecification", @@ -3758,7 +3788,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 265 + "startLine": 283 }, { "name": "omittedBeforeSpecified", @@ -3795,7 +3825,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 284 + "startLine": 302 }, { "name": "omittedTailNeedsRest", @@ -3825,7 +3855,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 302 + "startLine": 320 }, { "name": "closureFailed", @@ -3848,7 +3878,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 317 + "startLine": 335 }, { "name": "staticMethodCalled", @@ -3871,7 +3901,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 331 + "startLine": 349 } ], "constants": [], @@ -3881,7 +3911,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidDefaultValue", "kind": "class", @@ -3941,7 +3971,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", "kind": "class", @@ -4210,7 +4240,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\MatcherLeaked", "kind": "class", @@ -4277,7 +4307,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NeverMethodCalled", "kind": "class", @@ -4397,7 +4427,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NoDefaultValue", "kind": "class", @@ -4464,7 +4494,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NothingCaptured", "kind": "class", @@ -4517,7 +4547,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", "kind": "class", @@ -4600,7 +4630,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", "kind": "class", @@ -4667,7 +4697,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OutcomeUnavailable", "kind": "class", @@ -4750,7 +4780,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\StrictModeViolation", "kind": "class", @@ -4817,7 +4847,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnderstudyError", "kind": "interface", @@ -4866,7 +4896,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnsupportedTarget", "kind": "class", @@ -4986,7 +5016,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\VerificationFailed", "kind": "class", @@ -5073,7 +5103,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\ExpectBuilder", "kind": "class", @@ -5121,7 +5151,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Action", "kind": "interface", @@ -5179,7 +5209,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ArgumentFormatter", "kind": "class", @@ -5262,7 +5292,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ComputeAnswer", "kind": "class", @@ -5329,7 +5359,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Expectation", "kind": "class", @@ -5383,7 +5413,37 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 95 + "startLine": 110 + }, + { + "name": "setGuard", + "static": false, + "params": [ + { + "name": "guard", + "type": "callable", + "description": "", + "default": null, + "variadic": false + }, + { + "name": "description", + "type": "non-empty-string", + "description": "how the guard reads in a failure message", + "default": null, + "variadic": false + } + ], + "returnType": "void", + "summary": "Attaches the `where()` guard: a predicate over the whole argument list,\nasked after every position already matched.", + "description": "Evaluated inside \\Rasuvaeff\\Understudy\\Expectation\\matchesArguments() on purpose: that is the one\nplace every walk asks an expectation whether a call is its — dispatch,\nthe by-reference slot walk (`hasActionFor()`), a `verify()` probe — so\na guard either narrows everywhere or nowhere, and no walk can disagree\nwith the dispatcher about which expectation answers.\n\nOne guard per expectation; a second `where()` is refused where it is\nwritten rather than silently replacing the first.", + "throws": [], + "throwsInBody": true, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 131 }, { "name": "captureFrom", @@ -5406,7 +5466,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 113 + "startLine": 174 }, { "name": "setAction", @@ -5436,7 +5496,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 134 + "startLine": 195 }, { "name": "hasAction", @@ -5451,7 +5511,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 149 + "startLine": 210 }, { "name": "cardinality", @@ -5466,7 +5526,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 158 + "startLine": 219 }, { "name": "setCardinality", @@ -5489,7 +5549,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 163 + "startLine": 224 }, { "name": "declareClaim", @@ -5504,7 +5564,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 173 + "startLine": 234 }, { "name": "isClaim", @@ -5519,7 +5579,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 178 + "startLine": 239 }, { "name": "setDeclarationOrder", @@ -5542,7 +5602,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 189 + "startLine": 250 }, { "name": "declarationOrder", @@ -5557,7 +5617,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 197 + "startLine": 258 }, { "name": "matchedSequences", @@ -5572,7 +5632,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 205 + "startLine": 266 }, { "name": "requireOrder", @@ -5587,7 +5647,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 214 + "startLine": 275 }, { "name": "isOrdered", @@ -5602,7 +5662,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 219 + "startLine": 280 }, { "name": "matchCount", @@ -5617,7 +5677,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 227 + "startLine": 288 }, { "name": "matches", @@ -5647,7 +5707,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 236 + "startLine": 297 }, { "name": "matchesArguments", @@ -5670,7 +5730,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 251 + "startLine": 312 }, { "name": "specEquals", @@ -5693,7 +5753,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 311 + "startLine": 377 }, { "name": "recordMatch", @@ -5716,7 +5776,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 346 + "startLine": 412 }, { "name": "performAction", @@ -5739,7 +5799,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 360 + "startLine": 426 }, { "name": "describeAgainst", @@ -5762,7 +5822,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 384 + "startLine": 450 }, { "name": "describe", @@ -5777,7 +5837,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 438 + "startLine": 547 } ], "constants": [], @@ -5787,7 +5847,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnContract", "kind": "class", @@ -5877,7 +5937,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnValue", "kind": "class", @@ -5944,7 +6004,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowComputed", "kind": "class", @@ -6011,7 +6071,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowError", "kind": "class", @@ -6078,7 +6138,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureKind", "kind": "enum", @@ -6167,7 +6227,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureReport", "kind": "class", @@ -6352,7 +6412,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Invocation", "kind": "class", @@ -6745,7 +6805,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AllOf", "kind": "class", @@ -6829,7 +6889,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyArgument", "kind": "class", @@ -6903,7 +6963,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyOf", "kind": "class", @@ -6987,7 +7047,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyRest", "kind": "class", @@ -7087,7 +7147,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyTail", "kind": "class", @@ -7187,7 +7247,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", "kind": "interface", @@ -7277,7 +7337,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArrayContaining", "kind": "class", @@ -7361,7 +7421,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\BooleanValue", "kind": "class", @@ -7435,7 +7495,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Bounds", "kind": "class", @@ -7495,7 +7555,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Capturing", "kind": "class", @@ -7588,7 +7648,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\CountBetween", "kind": "class", @@ -7681,7 +7741,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\EmptyTail", "kind": "class", @@ -7781,7 +7841,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\FloatInRange", "kind": "class", @@ -7874,7 +7934,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IdenticalTo", "kind": "class", @@ -7958,7 +8018,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\InstanceOfType", "kind": "class", @@ -8042,7 +8102,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IntInRange", "kind": "class", @@ -8135,7 +8195,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Negated", "kind": "class", @@ -8219,7 +8279,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Operand", "kind": "class", @@ -8302,7 +8362,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\QueryEquals", "kind": "class", @@ -8395,7 +8455,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Satisfying", "kind": "class", @@ -8488,7 +8548,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\StringMatching", "kind": "class", @@ -8572,7 +8632,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", "kind": "interface", @@ -8632,7 +8692,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Unspelled", "kind": "class", @@ -8706,7 +8766,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\UnspelledTail", "kind": "class", @@ -8806,7 +8866,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Outcome", "kind": "class", @@ -12379,7 +12439,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Absent", "kind": "enum", @@ -12422,7 +12482,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ArmedSequence", "kind": "class", @@ -12628,7 +12688,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\DoubleState", "kind": "class", @@ -13132,7 +13192,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\InvocationSignal", "kind": "class", @@ -13208,7 +13268,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Mode", "kind": "enum", @@ -13271,7 +13331,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ReferenceSlot", "kind": "class", @@ -13307,7 +13367,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Runtime", "kind": "class", @@ -13909,7 +13969,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\RuntimeContext", "kind": "class", @@ -14294,7 +14354,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\SequenceVerdict", "kind": "enum", @@ -14503,7 +14563,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Understudy", "kind": "class", @@ -14655,21 +14715,28 @@ { "name": "never", "type": "bool", - "description": "", + "description": "the call must not have happened at all; refused beside a count", "default": "false", "variadic": false + }, + { + "name": "where", + "type": "callable|null", + "description": "a guard over the whole argument list", + "default": "NULL", + "variadic": false } ], "returnType": "void", "summary": "Asserts, after the fact, how many times a call was made.", - "description": "", + "description": "A `where:` predicate narrows the claim the same way `->where()` narrows\na specification: only calls whose whole argument list the closure\naccepts are counted.", "throws": [], "throwsInBody": true, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 489 + "startLine": 495 }, { "name": "calls", @@ -14692,7 +14759,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 573 + "startLine": 584 }, { "name": "lastCall", @@ -14715,7 +14782,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 595 + "startLine": 606 }, { "name": "strict", @@ -14738,7 +14805,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 625 + "startLine": 636 }, { "name": "lean", @@ -14761,7 +14828,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 650 + "startLine": 661 }, { "name": "forwarding", @@ -14791,7 +14858,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 668 + "startLine": 679 }, { "name": "delegate", @@ -14821,7 +14888,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 719 + "startLine": 730 }, { "name": "wire", @@ -14851,7 +14918,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 751 + "startLine": 762 }, { "name": "bypassFinals", @@ -14879,7 +14946,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 788 + "startLine": 799 }, { "name": "defaults", @@ -14909,7 +14976,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 869 + "startLine": 880 }, { "name": "label", @@ -14939,7 +15006,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 888 + "startLine": 899 }, { "name": "unused", @@ -14962,7 +15029,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 898 + "startLine": 909 }, { "name": "forget", @@ -14985,7 +15052,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 938 + "startLine": 949 }, { "name": "nothingElse", @@ -15015,7 +15082,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 958 + "startLine": 969 }, { "name": "allVerified", @@ -15038,7 +15105,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1001 + "startLine": 1012 }, { "name": "expectSequence", @@ -15061,7 +15128,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1084 + "startLine": 1095 }, { "name": "verifySequence", @@ -15084,7 +15151,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1124 + "startLine": 1135 }, { "name": "transcript", @@ -15107,7 +15174,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1150 + "startLine": 1161 }, { "name": "scope", @@ -15137,7 +15204,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1286 + "startLine": 1297 }, { "name": "checkpoint", @@ -15160,7 +15227,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1319 + "startLine": 1330 }, { "name": "reset", @@ -15175,7 +15242,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1346 + "startLine": 1357 }, { "name": "idle", @@ -15190,7 +15257,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1358 + "startLine": 1369 } ], "constants": [], @@ -15200,7 +15267,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\VerificationFailure", "kind": "class", @@ -15307,7 +15374,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\WhenBuilder", "kind": "class", @@ -15455,6 +15522,36 @@ "attributes": [], "startLine": 173 }, + { + "name": "where", + "static": false, + "params": [ + { + "name": "guard", + "type": "callable", + "description": "accepts the argument list the call carried, or refuses it", + "default": null, + "variadic": false + }, + { + "name": "description", + "type": "non-empty-string", + "description": "how the guard reads in a failure message", + "default": "'where(…)'", + "variadic": false + } + ], + "returnType": "static", + "summary": "Narrows the specification by a predicate over the whole argument list —\nthe one shape a positional matcher cannot express: a constraint over\ntwo or more arguments together.", + "description": "```php\nwhen(fn () => $ledger->transfer($from, Arg::any()))\n ->where(fn ($from, $to) => $from <= $to)\n ->returns(true);\n```\n\nThe closure receives the arguments the call carried, positionally, so\nit reads like the method's own signature; it runs wherever matching\nruns — dispatch, `verify()`, the by-reference slot decision — and a\ncall the positions accept but the guard refuses simply does not match:\na broader expectation may still answer it. That also means the guard\nruns inside the code under test, and an exception it raises is the\ntest's own error.\n\nWorks on `expect()` the same way, and `verify()` takes one through its\n`where:` parameter. One guard per expectation; a second `where()` is\nrefused. In a failure message the guard renders as `where(…)` —\nor as the `$description` given, which is how a test names what the\nconstraint means:\n\n```php\n->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)')\n```", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 212 + }, { "name": "times", "static": false, @@ -15483,7 +15580,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 190 + "startLine": 229 } ], "constants": [], @@ -15493,7 +15590,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Wiring\\Wire", "kind": "class", @@ -15558,7 +15655,7 @@ "functions": [ { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expect", @@ -15582,7 +15679,7 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expectSequence", @@ -15606,13 +15703,13 @@ }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\verify", "isApi": true, "summary": "Asserts, after the fact, how many times a call was made.", - "description": "```php\nverify(fn () => $repository->recordView($book), times: 2);\n```", + "description": "```php\nverify(fn () => $repository->recordView($book), times: 2);\n```\n\nA `where:` predicate narrows the claim to calls whose whole argument list\nthe closure accepts — the `verify()` counterpart of `->where()`.", "deprecated": null, "see": [], "params": [ @@ -15650,15 +15747,22 @@ "description": "the call must not have happened at all; refused beside a count", "default": "false", "variadic": false + }, + { + "name": "where", + "type": "callable|null", + "description": "a guard over the whole argument list", + "default": "NULL", + "variadic": false } ], "returnType": "void", "throws": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/functions.php#L84" + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/functions.php#L88" }, { "root": "core", - "rootVersion": "v1.0.0-3-gb1f9107", + "rootVersion": "v1.0.1-1-g9f93626", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\when", diff --git a/docs/src/api/classes/Arg.md b/docs/src/api/classes/Arg.md index f79cb29..506970f 100644 --- a/docs/src/api/classes/Arg.md +++ b/docs/src/api/classes/Arg.md @@ -9,7 +9,7 @@ description: "Argument matchers, usable only inside a specification closure:" `Rasuvaeff\Understudy\Arg` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v1.0.1-1-g9f93626 Argument matchers, usable only inside a specification closure: diff --git a/docs/src/api/classes/Captor.md b/docs/src/api/classes/Captor.md index 56b087c..07cffcd 100644 --- a/docs/src/api/classes/Captor.md +++ b/docs/src/api/classes/Captor.md @@ -9,7 +9,7 @@ description: "A typed argument captor, built by Arg::captor()." `Rasuvaeff\Understudy\Captor` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v1.0.1-1-g9f93626 **Type parameters:** diff --git a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md index 4011719..abd19ee 100644 --- a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md +++ b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md @@ -9,7 +9,7 @@ description: "Two registered default factories are equally close to the requeste `Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/BypassUnavailable.md b/docs/src/api/classes/Exception/BypassUnavailable.md index 69753a0..99ce16a 100644 --- a/docs/src/api/classes/Exception/BypassUnavailable.md +++ b/docs/src/api/classes/Exception/BypassUnavailable.md @@ -9,7 +9,7 @@ description: "`bypassFinals()` cannot do what was asked of it." `Rasuvaeff\Understudy\Exception\BypassUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/CannotWire.md b/docs/src/api/classes/Exception/CannotWire.md index 4d95f6d..a7525b7 100644 --- a/docs/src/api/classes/Exception/CannotWire.md +++ b/docs/src/api/classes/Exception/CannotWire.md @@ -9,7 +9,7 @@ description: "`wire()` cannot build the subject, or cannot decide what to pass i `Rasuvaeff\Understudy\Exception\CannotWire` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/ConflictingExpectation.md b/docs/src/api/classes/Exception/ConflictingExpectation.md index 5ebff20..c3616f1 100644 --- a/docs/src/api/classes/Exception/ConflictingExpectation.md +++ b/docs/src/api/classes/Exception/ConflictingExpectation.md @@ -9,7 +9,7 @@ description: "A `when()` or `expect()` names a call another registration already `Rasuvaeff\Understudy\Exception\ConflictingExpectation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ContextOwnershipViolation.md b/docs/src/api/classes/Exception/ContextOwnershipViolation.md index ddae078..5832c68 100644 --- a/docs/src/api/classes/Exception/ContextOwnershipViolation.md +++ b/docs/src/api/classes/Exception/ContextOwnershipViolation.md @@ -9,7 +9,7 @@ description: "Configuration and verification belong to the context that created `Rasuvaeff\Understudy\Exception\ContextOwnershipViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForgottenDouble.md b/docs/src/api/classes/Exception/ForgottenDouble.md index 7f9a6d8..1065ffe 100644 --- a/docs/src/api/classes/Exception/ForgottenDouble.md +++ b/docs/src/api/classes/Exception/ForgottenDouble.md @@ -9,7 +9,7 @@ description: "A double outlived the context that created it — almost always a `Rasuvaeff\Understudy\Exception\ForgottenDouble` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md index 32f69ff..61f11e2 100644 --- a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md +++ b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md @@ -9,7 +9,7 @@ description: "The instance offered as a forwarding target does not satisfy what `Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v1.0.1-1-g9f93626 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/InvalidCallSpecification.md b/docs/src/api/classes/Exception/InvalidCallSpecification.md index e7b889e..b0b4370 100644 --- a/docs/src/api/classes/Exception/InvalidCallSpecification.md +++ b/docs/src/api/classes/Exception/InvalidCallSpecification.md @@ -9,7 +9,7 @@ description: "A specification of the wrong SHAPE: the closure handed to when()/v `Rasuvaeff\Understudy\Exception\InvalidCallSpecification` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` @@ -143,6 +143,21 @@ static emptyCombinator(non-empty-string $matcher): Exception\InvalidCallSpecific Builds the error for an empty matcher combinator. +### guardAlreadyNarrowed() + +```php +static guardAlreadyNarrowed( + non-empty-string $method, + non-empty-string|null $description, +): Exception\InvalidCallSpecification +``` + +Builds the error for a second `where()` on one expectation. Replacing +the first guard silently would narrow the call by the last sentence +anyone wrote, which is not what a fluent chain reads like. + +- `$description` — how the first guard reads + ### tailMatcherInCombinator() ```php diff --git a/docs/src/api/classes/Exception/InvalidDefaultValue.md b/docs/src/api/classes/Exception/InvalidDefaultValue.md index 7141f12..ec4d70d 100644 --- a/docs/src/api/classes/Exception/InvalidDefaultValue.md +++ b/docs/src/api/classes/Exception/InvalidDefaultValue.md @@ -9,7 +9,7 @@ description: "A registered default factory produced a value the contract cannot `Rasuvaeff\Understudy\Exception\InvalidDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md index e129271..b82ef44 100644 --- a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md +++ b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md @@ -9,7 +9,7 @@ description: "A VALUE inside a specification that no run could act on: a maximum `Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v1.0.1-1-g9f93626 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/MatcherLeaked.md b/docs/src/api/classes/Exception/MatcherLeaked.md index b1cdb7c..786a3d2 100644 --- a/docs/src/api/classes/Exception/MatcherLeaked.md +++ b/docs/src/api/classes/Exception/MatcherLeaked.md @@ -9,7 +9,7 @@ description: "A matcher reached a real call instead of a specification closure." `Rasuvaeff\Understudy\Exception\MatcherLeaked` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/NeverMethodCalled.md b/docs/src/api/classes/Exception/NeverMethodCalled.md index f0ea9b6..b0c2fde 100644 --- a/docs/src/api/classes/Exception/NeverMethodCalled.md +++ b/docs/src/api/classes/Exception/NeverMethodCalled.md @@ -9,7 +9,7 @@ description: "A method declared `: never` was called without an expectation that `Rasuvaeff\Understudy\Exception\NeverMethodCalled` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v1.0.1-1-g9f93626 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NoDefaultValue.md b/docs/src/api/classes/Exception/NoDefaultValue.md index d0e3c94..9167035 100644 --- a/docs/src/api/classes/Exception/NoDefaultValue.md +++ b/docs/src/api/classes/Exception/NoDefaultValue.md @@ -9,7 +9,7 @@ description: "A loose understudy had to answer a call, but the declared return t `Rasuvaeff\Understudy\Exception\NoDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v1.0.1-1-g9f93626 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NothingCaptured.md b/docs/src/api/classes/Exception/NothingCaptured.md index cb1ef13..661f69b 100644 --- a/docs/src/api/classes/Exception/NothingCaptured.md +++ b/docs/src/api/classes/Exception/NothingCaptured.md @@ -9,7 +9,7 @@ description: "`Captor::last()` was read before any matched call carried a value `Rasuvaeff\Understudy\Exception\NothingCaptured` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalCallUnavailable.md b/docs/src/api/classes/Exception/OriginalCallUnavailable.md index 9f4548a..51904ff 100644 --- a/docs/src/api/classes/Exception/OriginalCallUnavailable.md +++ b/docs/src/api/classes/Exception/OriginalCallUnavailable.md @@ -9,7 +9,7 @@ description: "`callOriginal()` was asked to delegate, and there is nothing to de `Rasuvaeff\Understudy\Exception\OriginalCallUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md index bf6a91a..51da8fe 100644 --- a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md +++ b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md @@ -9,7 +9,7 @@ description: "A forwarded call returned an object the double cannot stand in for `Rasuvaeff\Understudy\Exception\OriginalReturnTypeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/OutcomeUnavailable.md b/docs/src/api/classes/Exception/OutcomeUnavailable.md index bd509ac..6155203 100644 --- a/docs/src/api/classes/Exception/OutcomeUnavailable.md +++ b/docs/src/api/classes/Exception/OutcomeUnavailable.md @@ -9,7 +9,7 @@ description: "An invocation's outcome was read as the wrong kind: a returned val `Rasuvaeff\Understudy\Exception\OutcomeUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/StrictModeViolation.md b/docs/src/api/classes/Exception/StrictModeViolation.md index fd70bb2..9f0a235 100644 --- a/docs/src/api/classes/Exception/StrictModeViolation.md +++ b/docs/src/api/classes/Exception/StrictModeViolation.md @@ -9,7 +9,7 @@ description: "A strict understudy received a call no expectation matched." `Rasuvaeff\Understudy\Exception\StrictModeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v1.0.1-1-g9f93626 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/UnderstudyError.md b/docs/src/api/classes/Exception/UnderstudyError.md index f20680e..e0e4907 100644 --- a/docs/src/api/classes/Exception/UnderstudyError.md +++ b/docs/src/api/classes/Exception/UnderstudyError.md @@ -9,7 +9,7 @@ description: "Implemented by every exception this library throws, so a test can `Rasuvaeff\Understudy\Exception\UnderstudyError` -**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v1.0.0-3-gb1f9107 +**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v1.0.1-1-g9f93626 **Implements:** `Throwable`, `Stringable` diff --git a/docs/src/api/classes/Exception/UnsupportedTarget.md b/docs/src/api/classes/Exception/UnsupportedTarget.md index abad93c..edb1351 100644 --- a/docs/src/api/classes/Exception/UnsupportedTarget.md +++ b/docs/src/api/classes/Exception/UnsupportedTarget.md @@ -9,7 +9,7 @@ description: "The requested target cannot be doubled, and no option would make i `Rasuvaeff\Understudy\Exception\UnsupportedTarget` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v1.0.1-1-g9f93626 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/VerificationFailed.md b/docs/src/api/classes/Exception/VerificationFailed.md index 36974c5..38ae159 100644 --- a/docs/src/api/classes/Exception/VerificationFailed.md +++ b/docs/src/api/classes/Exception/VerificationFailed.md @@ -9,7 +9,7 @@ description: "A verification about what the code under test did (or did not) do `Rasuvaeff\Understudy\Exception\VerificationFailed` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v1.0.1-1-g9f93626 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/ExpectBuilder.md b/docs/src/api/classes/ExpectBuilder.md index 7777ab8..eb1f19b 100644 --- a/docs/src/api/classes/ExpectBuilder.md +++ b/docs/src/api/classes/ExpectBuilder.md @@ -9,7 +9,7 @@ description: "Configures a call the code under test is expected to make." `Rasuvaeff\Understudy\ExpectBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v1.0.1-1-g9f93626 **Extends:** [`WhenBuilder`](/api/classes/WhenBuilder) diff --git a/docs/src/api/classes/FailureKind.md b/docs/src/api/classes/FailureKind.md index eb473c1..42a186b 100644 --- a/docs/src/api/classes/FailureKind.md +++ b/docs/src/api/classes/FailureKind.md @@ -9,7 +9,7 @@ description: "What kind of verification claim a VerificationFailure reports." `Rasuvaeff\Understudy\FailureKind` -**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v1.0.0-3-gb1f9107 +**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v1.0.1-1-g9f93626 **Implements:** `UnitEnum` diff --git a/docs/src/api/classes/Invocation.md b/docs/src/api/classes/Invocation.md index 80a4cd4..66f78b1 100644 --- a/docs/src/api/classes/Invocation.md +++ b/docs/src/api/classes/Invocation.md @@ -9,7 +9,7 @@ description: "One recorded call on an understudy." `Rasuvaeff\Understudy\Invocation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v1.0.1-1-g9f93626 One recorded call on an understudy. diff --git a/docs/src/api/classes/Understudy.md b/docs/src/api/classes/Understudy.md index 2c8574d..c4aa8be 100644 --- a/docs/src/api/classes/Understudy.md +++ b/docs/src/api/classes/Understudy.md @@ -9,7 +9,7 @@ description: "The whole public surface, as static methods so that an understudy `Rasuvaeff\Understudy\Understudy` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L41) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L41) — **Version:** v1.0.1-1-g9f93626 The whole public surface, as static methods so that an understudy itself can stay free of service members: every one of them would be a name the doubled @@ -85,12 +85,19 @@ static verify( int<0, max>|null $minimum = NULL, int<0, max>|null $maximum = NULL, bool $never = false, + callable|null $where = NULL, ): void ``` Asserts, after the fact, how many times a call was made. - `$times` — exact count; defaults to at least one +- `$never` — the call must not have happened at all; refused beside a count +- `$where` — a guard over the whole argument list + +A `where:` predicate narrows the claim the same way `->where()` narrows +a specification: only calls whose whole argument list the closure +accepts are counted. ### calls() diff --git a/docs/src/api/classes/VerificationFailure.md b/docs/src/api/classes/VerificationFailure.md index c86a7b3..d9f32a1 100644 --- a/docs/src/api/classes/VerificationFailure.md +++ b/docs/src/api/classes/VerificationFailure.md @@ -9,7 +9,7 @@ description: "The structured half of one verification failure — the same facts `Rasuvaeff\Understudy\VerificationFailure` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v1.0.1-1-g9f93626 The structured half of one verification failure — the same facts the rendered message states, addressable by field. diff --git a/docs/src/api/classes/WhenBuilder.md b/docs/src/api/classes/WhenBuilder.md index f5d9705..859c74a 100644 --- a/docs/src/api/classes/WhenBuilder.md +++ b/docs/src/api/classes/WhenBuilder.md @@ -9,7 +9,7 @@ description: "Configures what a stubbed call does." `Rasuvaeff\Understudy\WhenBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L39) — **Version:** v1.0.0-3-gb1f9107 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L39) — **Version:** v1.0.1-1-g9f93626 **Type parameters:** @@ -115,6 +115,43 @@ when(fn () => $breaker->call($operation)) Once the chain runs out, its last link keeps answering. +### where() + +```php +where(callable $guard, non-empty-string $description = 'where(…)'): static +``` + +Narrows the specification by a predicate over the whole argument list — +the one shape a positional matcher cannot express: a constraint over +two or more arguments together. + +- `$guard` — accepts the argument list the call carried, or refuses it +- `$description` — how the guard reads in a failure message + +```php +when(fn () => $ledger->transfer($from, Arg::any())) + ->where(fn ($from, $to) => $from <= $to) + ->returns(true); +``` + +The closure receives the arguments the call carried, positionally, so +it reads like the method's own signature; it runs wherever matching +runs — dispatch, `verify()`, the by-reference slot decision — and a +call the positions accept but the guard refuses simply does not match: +a broader expectation may still answer it. That also means the guard +runs inside the code under test, and an exception it raises is the +test's own error. + +Works on `expect()` the same way, and `verify()` takes one through its +`where:` parameter. One guard per expectation; a second `where()` is +refused. In a failure message the guard renders as `where(…)` — +or as the `$description` given, which is how a test names what the +constraint means: + +```php +->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)') +``` + ### times() ```php diff --git a/docs/src/api/functions.md b/docs/src/api/functions.md index ec14aa6..29114a2 100644 --- a/docs/src/api/functions.md +++ b/docs/src/api/functions.md @@ -64,10 +64,10 @@ expectSequence( ## verify() -`Rasuvaeff\Understudy\verify()` · [Source](https://github.com/rasuvaeff/understudy/blob/master/src/functions.php#L84) +`Rasuvaeff\Understudy\verify()` · [Source](https://github.com/rasuvaeff/understudy/blob/master/src/functions.php#L88) ```php -verify(callable $call, int<0, max>|null $times = NULL, int<0, max>|null $minimum = NULL, int<0, max>|null $maximum = NULL, bool $never = false): void +verify(callable $call, int<0, max>|null $times = NULL, int<0, max>|null $minimum = NULL, int<0, max>|null $maximum = NULL, bool $never = false, callable|null $where = NULL): void ``` Asserts, after the fact, how many times a call was made. @@ -76,6 +76,9 @@ Asserts, after the fact, how many times a call was made. verify(fn () => $repository->recordView($book), times: 2); ``` +A `where:` predicate narrows the claim to calls whose whole argument list +the closure accepts — the `verify()` counterpart of `->where()`. + | Parameter | Type | Description | |---|---|---| | `$call` | `callable` | | @@ -83,6 +86,7 @@ verify(fn () => $repository->recordView($book), times: 2); | `$minimum = NULL` | `int<0, max>\|null` | | | `$maximum = NULL` | `int<0, max>\|null` | | | `$never = false` | `bool` | the call must not have happened at all; refused beside a count | +| `$where = NULL` | `callable\|null` | a guard over the whole argument list | ## when() diff --git a/docs/src/guide/migrating-from-mockery.md b/docs/src/guide/migrating-from-mockery.md index 3a8d97d..9219a18 100644 --- a/docs/src/guide/migrating-from-mockery.md +++ b/docs/src/guide/migrating-from-mockery.md @@ -28,6 +28,7 @@ the site still gets them. | `->andReturn($a, $b)` | `->returns($a, $b)` | one per call, last repeats | | `->with(123, Mockery::any())` | inside the closure: `find(123, Arg::any())` | | | `Mockery::on(fn ($x) => …)` | `Arg::satisfies(fn ($x) => …)` | | +| `->withArgs(fn ($from, $to) => …)` | `->where(fn ($from, $to) => …)` | a joint constraint over the whole argument list | | `Mockery::type(Foo::class)` | `Arg::instanceOf(Foo::class)` | | | `->withAnyArgs()` | inside the closure: `method(Arg::rest())` | also the escape from spelling a wide signature: `method('svc', Arg::rest())` | | `Mockery::capture($x)` | `Arg::captor(X::class)` + `$captor->capture()` | `last()` / `all()` are typed through the class-string — no `instanceof` at the read site | diff --git a/docs/src/guide/stubbing/matchers.md b/docs/src/guide/stubbing/matchers.md index 50e4856..aa23b31 100644 --- a/docs/src/guide/stubbing/matchers.md +++ b/docs/src/guide/stubbing/matchers.md @@ -132,6 +132,38 @@ A getter that throws counts as a **mismatch**, never as an error. Matching runs while the code under test is executing, and a matcher must not be the thing that breaks it. +## `where()`: a constraint over several arguments + +A positional matcher sees one argument. A constraint over two or more of them +together — `transfer($from, $to)` where `$from <= $to` must hold — is what +`where()` is: + +```php +when(fn () => $ledger->transfer(Arg::any(), Arg::any())) + ->where(fn ($from, $to) => $from <= $to) + ->returns(true); + +verify( + fn () => $ledger->transfer(Arg::any(), Arg::any()), + times: 1, + where: fn ($from, $to) => $from <= $to, +); +``` + +The closure receives the arguments the call carried, positionally, so it reads +like the method's own signature. It is asked only after every position already +matched, and a call it refuses simply does not match — a broader expectation +can still answer it. `expect()` takes one through the same `->where()`. + +The guard runs wherever matching runs, which is inside the code under test: it +is the test's own code, and an exception it raises is the test's own error. +One guard per expectation — a second `where()` is refused rather than silently +replacing the first. In a failure message the guard renders as `where(…)`, or +as the `description` given, and when the positions accepted a call but the +guard refused it, the report marks the guard itself — +`transfer(any(), any()) *where(…)*` — rather than blaming a position that did +accept. + ## A matcher that never reaches a specification Building a matcher and not passing it to a specification is reported, not diff --git a/examples/README.md b/examples/README.md index 24a8e4e..57879aa 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ demonstrate changes. | `protocol.php` | `expect()->ordered()`, `Understudy::verifySequence()` across two doubles, and `Understudy::nothingElse()` | no | | `defaults-registry.php` | what an unconfigured call answers: nested doubles one level deep, `Understudy::defaults()`, the nullable-return rule, and the refusals | no | | `structured-failures.php` | reading `VerificationFailed::failures()` as data — the path an adapter or reporter takes instead of parsing the message | no | +| `where-guard.php` | `->where()`: a joint constraint over several arguments — narrowing a stub, counting in `verify(where:)`, and the failure message that marks the guard | no | `_check.php` is the shared assertion helper the scripts include; the leading underscore is what marks it an include rather than a script of its own. diff --git a/examples/where-guard.php b/examples/where-guard.php new file mode 100644 index 0000000..c934709 --- /dev/null +++ b/examples/where-guard.php @@ -0,0 +1,83 @@ +where()\n"; + +$ledger = Understudy::for(Ledger::class); +Understudy::label($ledger, 'ledger'); + +when(fn() => $ledger->transfer(Arg::any(), Arg::any())) + ->where(fn(int $from, int $to) => $from <= $to, description: 'where(from <= to)') + ->returns(true); + +check($ledger->transfer(3, 7) === true, 'an ordered transfer answers the stub'); + +// 9 > 4: the positions matched, the joint constraint refused — the stub does +// not answer and loose mode\'s bool default does. +check($ledger->transfer(9, 4) === false, 'a refused transfer falls to the default'); + +Understudy::reset(); + +// --- verify() counts only the calls the guard accepted -------------------------- + +echo "verify(where:)\n"; + +$ledger = Understudy::for(Ledger::class); +Understudy::label($ledger, 'ledger'); + +$ledger->transfer(9, 4); +$ledger->transfer(2, 5); +$ledger->transfer(1, 6); + +verify( + fn() => $ledger->transfer(Arg::any(), Arg::any()), + times: 2, + where: fn(int $from, int $to) => $from <= $to, +); + +check(true, 'two of the three calls satisfied the joint constraint'); + +// And the refusal names the guard — verify() renders it with its default +// description, which is what a reader of the report sees: +try { + verify( + fn() => $ledger->transfer(Arg::any(), Arg::any()), + times: 5, + where: fn(int $from, int $to) => $from <= $to, + ); +} catch (VerificationFailed $failure) { + $message = $failure->getMessage(); + + check(str_contains($message, 'where(…)'), 'the failure renders the guard'); +} + +Understudy::reset(); diff --git a/llms.txt b/llms.txt index 76ee353..4bb3a2a 100644 --- a/llms.txt +++ b/llms.txt @@ -209,6 +209,26 @@ 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. +### Joint constraints — `where()` + +```php +when(fn () => $ledger->transfer(Arg::any(), Arg::any())) + ->where(fn ($from, $to) => $from <= $to) + ->returns(true); + +verify(fn () => $ledger->transfer(Arg::any(), Arg::any()), + times: 1, where: fn ($from, $to) => $from <= $to); +``` + +`->where($guard, $description = 'where(…)')` narrows a specification by a +predicate over the WHOLE argument list, positionally — the Mockery +`withArgs(closure)` row. Asked only after every position matched; a refused +call falls through to broader expectations. `expect()` takes one too. One +guard per expectation (a second is refused); the guard runs inside the code +under test and its exceptions are the test's own. When positions matched but +the guard refused, the failure marks the guard: `transfer(any(), any()) +*where(…)*`. + ### Matchers — `Arg` | Matcher | Matches | diff --git a/src/Exception/InvalidCallSpecification.php b/src/Exception/InvalidCallSpecification.php index 33dd6b1..d00bdc4 100644 --- a/src/Exception/InvalidCallSpecification.php +++ b/src/Exception/InvalidCallSpecification.php @@ -173,6 +173,24 @@ public static function emptyCombinator(string $matcher): self )); } + /** + * Builds the error for a second `where()` on one expectation. Replacing + * the first guard silently would narrow the call by the last sentence + * anyone wrote, which is not what a fluent chain reads like. + * + * @param non-empty-string $method + * @param non-empty-string|null $description how the first guard reads + */ + public static function guardAlreadyNarrowed(string $method, ?string $description): self + { + return new self(sprintf( + "`%s` already carries a `where()` guard%s. One call, one guard: put both " + . 'conditions in the one closure.', + $method, + $description === null ? '' : " (`$description`)", + )); + } + /** * Builds the error for putting a tail matcher inside a combinator. * diff --git a/src/Expectation/Expectation.php b/src/Expectation/Expectation.php index b07279b..875d8ec 100644 --- a/src/Expectation/Expectation.php +++ b/src/Expectation/Expectation.php @@ -53,6 +53,21 @@ final class Expectation */ private readonly bool $capturing; + /** + * A constraint over the whole argument list, from `where()`. Null for + * every expectation the test did not narrow this way. + * + * @var \Closure(mixed...): bool|null + */ + private ?\Closure $guard = null; + + /** + * How the guard reads in a failure message. + * + * @var non-empty-string|null + */ + private ?string $guardDescription = null; + /** * @param non-empty-string $method * @param list $args literal values and/or ArgumentMatcher instances @@ -97,6 +112,52 @@ public function hasCaptors(): bool return $this->capturing; } + /** + * Attaches the `where()` guard: a predicate over the whole argument list, + * asked after every position already matched. + * + * Evaluated inside {@see matchesArguments()} on purpose: that is the one + * place every walk asks an expectation whether a call is its — dispatch, + * the by-reference slot walk (`hasActionFor()`), a `verify()` probe — so + * a guard either narrows everywhere or nowhere, and no walk can disagree + * with the dispatcher about which expectation answers. + * + * One guard per expectation; a second `where()` is refused where it is + * written rather than silently replacing the first. + * + * @param callable(mixed...): bool $guard + * @param non-empty-string $description how the guard reads in a failure message + */ + public function setGuard(callable $guard, string $description): void + { + if ($this->guard !== null) { + throw InvalidCallSpecification::guardAlreadyNarrowed($this->method, $this->guardDescription); + } + + $this->guard = $guard(...); + $this->guardDescription = $description; + } + + /** + * Whether a call the positions already accepted passes the `where()` + * guard, when there is one. + * + * The guard is the test's own code and runs inside the code under test; + * an exception it raises is the test's own error and propagates — unlike + * a matcher, which must survive hostile arguments, a predicate that + * breaks is a bug in the sentence that names it. + * + * @param list $args + */ + private function acceptsGuard(array $args): bool + { + if ($this->guard === null) { + return true; + } + + return ($this->guard)(...$args); + } + /** * Records each capturing argument's value from a call this expectation * matched, and answers the captors that recorded so the caller can tie @@ -258,15 +319,20 @@ public function matchesArguments(array $args): bool return false; } - return $this->tailMatcher->matchesTail(array_slice($args, $fixed)); + if (!$this->tailMatcher->matchesTail(array_slice($args, $fixed))) { + return false; + } + + return $this->acceptsGuard($args); } if ($this->argumentCount === 0) { - return $args === []; + return $args === [] && $this->acceptsGuard($args); } return count($args) === $this->argumentCount - && $this->matchesPositions($args, $this->argumentCount); + && $this->matchesPositions($args, $this->argumentCount) + && $this->acceptsGuard($args); } /** @@ -397,10 +463,53 @@ public function describeAgainst(array $args): string : '*' . $rendered . '*'; } - return $this->method . '(' . implode(', ', $parts) . ')'; + return $this->method + . '(' . implode(', ', $parts) . ')' + . $this->describeGuardAgainst($args); }); } + /** + * The `where()` segment of a rendered line: plain when the guard accepted + * the call (or there is none), marked like a rejecting argument when the + * positions matched and the guard did not — which is the sentence a + * joint constraint refused, not any one position. + * + * @param list $args + */ + private function describeGuardAgainst(array $args): string + { + if ($this->guard === null) { + return ''; + } + + \assert($this->guardDescription !== null); + + return $this->acceptsGuardDefensively($args) + ? ' ' . $this->guardDescription + : ' *' . $this->guardDescription . '*'; + } + + /** + * The guard re-asked while a failure message is rendered — defensively, + * exactly like a matcher is: this runs during dispatch, inside the code + * under test, and a guard that throws on the way to a message would + * replace the refusal with its own exception. A guard that breaks counts + * as one that did not accept. + * + * @param list $args + */ + private function acceptsGuardDefensively(array $args): bool + { + \assert($this->guard !== null); + + try { + return ($this->guard)(...$args); + } catch (\Throwable) { + return false; + } + } + /** * Whether one declared argument accepts what arrived in its place. * @@ -443,7 +552,7 @@ public function describe(): string ? $arg->describe() : ArgumentFormatter::format($arg), $this->args, - )) . ')', + )) . ')' . ($this->guardDescription === null ? '' : ' ' . $this->guardDescription), ); } } diff --git a/src/Understudy.php b/src/Understudy.php index b06aefe..6ff5fe3 100644 --- a/src/Understudy.php +++ b/src/Understudy.php @@ -481,10 +481,16 @@ private static function checkExpectation(DoubleState $state, Expectation $expect /** * Asserts, after the fact, how many times a call was made. * + * A `where:` predicate narrows the claim the same way `->where()` narrows + * a specification: only calls whose whole argument list the closure + * accepts are counted. + * * @param callable(): mixed $call * @param int<0, max>|null $times exact count; defaults to at least one * @param int<0, max>|null $minimum * @param int<0, max>|null $maximum + * @param bool $never the call must not have happened at all; refused beside a count + * @param callable(mixed...): bool|null $where a guard over the whole argument list */ public static function verify( callable $call, @@ -492,11 +498,16 @@ public static function verify( ?int $minimum = null, ?int $maximum = null, bool $never = false, + ?callable $where = null, ): void { $signal = self::record($call); $state = self::stateOf($signal->double, 'verify'); $probe = new Expectation($signal->method, $signal->args); + if ($where !== null) { + $probe->setGuard($where, 'where(…)'); + } + $matches = 0; $matched = []; diff --git a/src/WhenBuilder.php b/src/WhenBuilder.php index 26ccbbc..be4baea 100644 --- a/src/WhenBuilder.php +++ b/src/WhenBuilder.php @@ -177,6 +177,45 @@ public function then(): static return $this; } + /** + * Narrows the specification by a predicate over the whole argument list — + * the one shape a positional matcher cannot express: a constraint over + * two or more arguments together. + * + * ```php + * when(fn () => $ledger->transfer($from, Arg::any())) + * ->where(fn ($from, $to) => $from <= $to) + * ->returns(true); + * ``` + * + * The closure receives the arguments the call carried, positionally, so + * it reads like the method's own signature; it runs wherever matching + * runs — dispatch, `verify()`, the by-reference slot decision — and a + * call the positions accept but the guard refuses simply does not match: + * a broader expectation may still answer it. That also means the guard + * runs inside the code under test, and an exception it raises is the + * test's own error. + * + * Works on `expect()` the same way, and `verify()` takes one through its + * `where:` parameter. One guard per expectation; a second `where()` is + * refused. In a failure message the guard renders as `where(…)` — + * or as the `$description` given, which is how a test names what the + * constraint means: + * + * ```php + * ->where(fn ($w, $h) => $w / $h === 2, description: 'where(16:9)') + * ``` + * + * @param callable(mixed...): bool $guard accepts the argument list the call carried, or refuses it + * @param non-empty-string $description how the guard reads in a failure message + */ + public function where(callable $guard, string $description = 'where(…)'): static + { + $this->expectation->setGuard($guard, $description); + + return $this; + } + /** * Makes the call count part of what `verifyAll()` checks. Without it a * stub is permission, not a claim. diff --git a/src/functions.php b/src/functions.php index 4718111..3e938a2 100644 --- a/src/functions.php +++ b/src/functions.php @@ -73,11 +73,15 @@ function expectSequence(callable ...$calls): void * verify(fn () => $repository->recordView($book), times: 2); * ``` * + * A `where:` predicate narrows the claim to calls whose whole argument list + * the closure accepts — the `verify()` counterpart of `->where()`. + * * @param callable(): mixed $call * @param int<0, max>|null $times * @param int<0, max>|null $minimum * @param int<0, max>|null $maximum * @param bool $never the call must not have happened at all; refused beside a count + * @param callable(mixed...): bool|null $where a guard over the whole argument list * * @api */ @@ -87,6 +91,7 @@ function verify( ?int $minimum = null, ?int $maximum = null, bool $never = false, + ?callable $where = null, ): void { - Understudy::verify($call, $times, $minimum, $maximum, $never); + Understudy::verify($call, $times, $minimum, $maximum, $never, $where); } diff --git a/tests/WhereTest.php b/tests/WhereTest.php new file mode 100644 index 0000000..90411f4 --- /dev/null +++ b/tests/WhereTest.php @@ -0,0 +1,244 @@ + $repository->tag('alpha', Arg::any())) + ->where(fn(string $name, int $weight) => $weight >= 10) + ->returns('heavy'); + + Assert::same($repository->tag('alpha', 12), 'heavy'); + + // The positions matched and the joint constraint did not: the stub + // does not answer, and loose mode's own default does. + Assert::same($repository->tag('alpha', 2), ''); + } + + public function aRefusedGuardFallsThroughToABroaderStub(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->tag(Arg::any(), Arg::any()))->returns('any'); + when(fn() => $repository->tag('alpha', Arg::any())) + ->where(fn(string $name, int $weight) => $weight > 5) + ->returns('heavy-alpha'); + + Assert::same($repository->tag('alpha', 9), 'heavy-alpha'); + Assert::same($repository->tag('beta', 9), 'any'); + + // The narrow registration exists and its positions accepted, but its + // guard refused — the broad fallback answers, as layering promises. + Assert::same($repository->tag('alpha', 1), 'any'); + } + + #[ExpectNoAssertions] + public function expectHonoursTheGuard(): void + { + $repository = Understudy::for(BookRepository::class); + + expect(fn() => $repository->tag('alpha', Arg::any())) + ->where(fn(string $name, int $weight) => $weight >= 10) + ->times(1); + + $repository->tag('alpha', 2); + $repository->tag('alpha', 12); + + // One call satisfied the guard; the refused one was never the + // expected call, so the claim is met. + Understudy::verifyAll(); + } + + public function anExpectationOnlyTheGuardRefusedIsUnmet(): void + { + $repository = Understudy::for(BookRepository::class); + + expect(fn() => $repository->tag('alpha', Arg::any())) + ->where(fn(string $name, int $weight) => $weight >= 10) + ->times(1); + + Expect::exception(VerificationFailed::class)->withMessageContaining('where(…)'); + + Understudy::verifyAll(); + } + + #[ExpectNoAssertions] + public function verifyCountsOnlyTheCallsTheGuardAccepted(): void + { + $repository = Understudy::for(BookRepository::class); + + $repository->tag('alpha', 2); + $repository->tag('alpha', 12); + $repository->tag('alpha', 20); + + verify( + fn() => $repository->tag('alpha', Arg::any()), + times: 2, + where: fn(string $name, int $weight) => $weight >= 10, + ); + } + + public function aSecondWhereIsRefusedWhereItIsWritten(): void + { + $repository = Understudy::for(BookRepository::class); + + Expect::exception(InvalidCallSpecification::class)->withMessage( + '`tag` already carries a `where()` guard (`where(…)`). ' + . 'One call, one guard: put both conditions in the one closure.', + ); + + when(fn() => $repository->tag('alpha')) + ->where(fn(string $name) => true) + ->where(fn(string $name) => false); + } + + public function aSecondWhereNamesTheFirstGuardsOwnDescription(): void + { + $repository = Understudy::for(BookRepository::class); + + Expect::exception(InvalidCallSpecification::class)->withMessage( + '`tag` already carries a `where()` guard (`where(weight >= 10)`). ' + . 'One call, one guard: put both conditions in the one closure.', + ); + + when(fn() => $repository->tag('alpha')) + ->where(fn(string $name, int $weight = 10) => $weight >= 10, description: 'where(weight >= 10)') + ->where(fn(string $name) => false); + } + + public function theGuardRendersInAFailureMessage(): void + { + $repository = Understudy::for(BookRepository::class); + + expect(fn() => $repository->tag('alpha', Arg::any())) + ->where(fn(string $name, int $weight) => $weight >= 10) + ->times(1); + + Expect::exception(VerificationFailed::class) + ->withMessageContaining("tag('alpha', any()) where(…)"); + + Understudy::verifyAll(); + } + + public function aDescribedGuardRendersItsOwnName(): void + { + $repository = Understudy::for(BookRepository::class); + + expect(fn() => $repository->tag('alpha', Arg::any())) + ->where( + fn(string $name, int $weight) => $weight >= 10, + description: 'where(weight >= 10)', + ) + ->times(1); + + Expect::exception(VerificationFailed::class) + ->withMessageContaining("tag('alpha', any()) where(weight >= 10)"); + + Understudy::verifyAll(); + } + + public function aRefusedGuardIsMarkedInAStrictRefusal(): void + { + $repository = Understudy::for(BookRepository::class); + + Understudy::strict($repository); + + when(fn() => $repository->tag('alpha', Arg::any())) + ->where(fn(string $name, int $weight) => $weight >= 10) + ->returns('heavy'); + + // The positions accepted the call; the joint constraint refused it. + // The message has to say so about the guard, not about a position. + Expect::exception(StrictModeViolation::class) + ->withMessageContaining("tag('alpha', any()) *where(…)*"); + + $repository->tag('alpha', 2); + } + + public function theByReferenceSlotWalkHonoursTheGuard(): void + { + $registry = Understudy::for(Registry::class); + + when(fn() => $registry->row(Arg::any())) + ->where(fn(int $id) => $id > 0) + ->returns(['ok' => true]); + + // The slot decision walks the expectations the way dispatch will, so + // a call the guard refuses must look unconfigured to it: the slot + // keeps the mode's own seed rather than the action's value. + $refused = &$registry->row(-1); + $refused['written'] = true; + + Assert::same($refused, ['written' => true]); + } + + public function aGuardRefusingAZeroArgumentCallIsAMismatch(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->count()) + ->where(fn() => false) + ->returns(7); + + // The call carried no arguments and the guard refused it anyway: the + // stub does not answer, and the mode's int default does. + Assert::same($repository->count(), 0); + } + + public function aGuardCanReadTheVariadicTail(): void + { + $sink = Understudy::for(VariadicSink::class); + + when(fn() => $sink->write(Arg::any(), Arg::rest())) + ->where(fn(string $channel, int ...$values) => $values === [1, 2, 3]) + ->returns(true); + + Assert::true($sink->write('log', 1, 2, 3)); + + // Same channel, same shape, different tail: the guard refused, and + // loose mode's bool default answers instead. + Assert::false($sink->write('log', 3, 2, 1)); + } +}