diff --git a/AGENTS.md b/AGENTS.md index e102e97..0fcd982 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -299,8 +299,12 @@ seconds instead of the full run's minute — and the full run stays the gate. the signature path. Every other magic method — including `__get`, `__set`, `__call`, `__invoke` and `__toString` — accepts the widened `OriginalType|ArgumentMatcher` parameter union on 8.3, 8.4 and 8.5. -- **Never call `getDefaultValue()` to find out what a default is.** On - `= new Foo()` it runs the constructor. The default's source expression comes +- **Never call `getDefaultValue()` to find out what a default is at generation + time.** On `= new Foo()` it runs the constructor. Dispatch is the exception, + and the only one: `MethodSignature::defaultAt()` calls it for an argument the + caller omitted, which is the moment PHP itself would have evaluated the + initializer — and per call, so `= new Foo()` still builds one object per + call rather than sharing one. The default's source expression comes from `ReflectionParameter::__toString()`, which renders it fully qualified without reading the declaring file — and is the only way to see a `new` default without evaluating it. Two things it does not qualify: @@ -383,9 +387,22 @@ seconds instead of the full run's minute — and the full run stays the gate. divergent return types (covariant) and by-reference mismatches. - **Generated methods collect arguments by name, never `func_get_args()`**, which omits parameters left at their default — `tag('alpha')` and - `tag('alpha', 1)` must record as the same call. -- **`= null` on a non-nullable parameter is a deprecated implicit nullable.** - When a parameter becomes optional through unification, `null` joins the type. + `tag('alpha', 1)` must record as the same call. `func_num_args()` cannot + stand in for the sentinel either: with named arguments PHP reports the + *filled* count, so a specification that skipped a middle parameter would be + invisible. +- **Every generated parameter defaults to the arity sentinel, optional ones + included.** That is what lets a specification leave a parameter unspelled and + *mean* it: a materialized default in its place is indistinguishable from + spelling the default value, which made arity an implicit part of every + specification (#123). Dispatch puts the contract's default back for a real + call, so `tag('alpha')` and `tag('alpha', 1)` still log as one call. Two + consequences to hold on to: a double's generated signature no longer + advertises the contract's defaults, and `null` joins the type only where **no + target declares a default** — a position optional because another target does + not declare it at all. Widening on the rendered default is what made + `mixed $v = null` render as `mixed|null`, an uncatchable fatal out of + `eval()`. - **Mutation numbers lie unless every class has its own `#[Covers]` test.** Infection's mutant-to-test mapping is `#[Covers]`-driven: with one test class covering one class, the run reported 56 mutants at 93% MSI while the honest diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ebb2e..fcb1b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,86 @@ # Changelog -## Unreleased +## 0.10.0 — 2026-09-06 + +The wave the 1.0 candidate turned out to still need: ten defects found by +migrating the monorepo onto the library and by probing the engine against its +own documentation. Two of them change what a specification means, which is why +this is a minor and not the tag after 0.9.0. + +### A specification is read the way the contract reads a call + +- **An optional parameter a specification did not spell no longer arrives as + the contract's default value.** It used to, and arity therefore became an + implicit part of every specification: + `claimReady(Arg::any(), Arg::any(), Arg::any())` did not match + `claimReady($now, 3, [], 100)` on a method whose fourth parameter defaults to + `1000`, and the report said `never called` beside a call whose only + difference was a position the author never wrote. Every generated parameter + now defaults to the arity sentinel, optional ones included, and what an + omission means follows the contract: one it declares **optional** may be left + out by any caller, so a specification that leaves it out says nothing about + it and matches whatever was passed there; one it declares **required** is + present in every real call, so stopping before it still has to be said with + `Arg::rest()`. A failure message renders an unspelled position as `…`, which + is what tells it apart from an `any()` the test did write. Found migrating + `yii3-outbox` and `yii3-centrifugo` (#123). +- **`Arg::rest()` works where the remaining parameters are optional.** The + matcher list called it "declared parameters left unspelled" and the engine + refused it — `translate(Arg::rest())` on a signature with four optional + parameters raised "`rest()` … may only be the last argument" about argument + #1 — because the optional ones had already become literals by the time the + check looked. Same root cause, and the docs, the message and the matcher now + agree (#125). +- **A real call is unchanged**: dispatch materializes the declared default for + an argument the caller omitted, so `tag('alpha')` and `tag('alpha', 1)` are + still one call in the log. What changed with it is that a double's + *generated signature* no longer advertises the contract's defaults — it + carries the sentinel — so Reflection over a double reports them differently + than Reflection over the contract. +- **`mixed $v = null` was a fatal error.** The nullability widening applied to + `mixed` too, and `mixed|null` is a type PHP refuses at compile time: an + uncatchable fatal out of `eval()`, for a signature that is neither exotic nor + rare. The widening now belongs to the branch where the union is real, and is + driven by what the contract declares rather than by what the double renders. + +### Refusals for what used to be silent + +- **A captor inside `Arg::allOf()`, `anyOf()`, `not()` or `containing()` + matched and recorded nothing.** The specification behaved correctly in every + observable way except the one it was written for, and the only way to find + out was an assertion on an empty captor further down the test. It is refused + where it is written (#124). +- **A matcher inside a plain array argument matched nothing** — + `find(['id' => Arg::any()])` compares by identity — and said nothing about + it. Refused, and `Arg::containing()` now reads matchers in its own entries, + nested, so there is something to be refused *towards*. +- **Understudy's own refusals are no longer rewrapped** in "the specification + closure threw before it reached an understudy", which buried the sentence + that said what to change. +- **A by-reference slot was chosen from arguments dispatch had not yet + completed.** `referenceSlot()` asks which expectation will answer *before* + dispatch, and asked with the omitted arguments still sentinels: a + specification spelling the contract's default answered "nothing configured", + so the slot kept what the test had written through the reference instead of + being replaced by the configured value. The call answered correctly and the + next read did not. +- **A protocol step due on another double says so.** Two doubles under one + `expectSequence()` render every step by its call alone, so `count()` arriving + on the wrong one read as the step that was due — identical text, and no hint + that the difference was the receiver. + +### Additions + +- **`Understudy::strict()` and `Understudy::label()` answer with the double + they configured**, so the mode can be chosen where the double is handed over: + `ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class))` + used to store `null` and fail three steps away from the cause (#126). +- **`WhenBuilder::throwsWith()`** builds the exception from the call it + answers, one per call — the shape `throws()` cannot express, and which + everyone re-derived as a throwing `answers()` closure (#127). +- **`Invocation::arg()`** reads one argument by position or by the contract's + own parameter name, and refuses a name the method does not declare rather + than answering `null` (#127). - **The API reference documents the satellites at their current versions.** `docs/.api-workspace/composer.json` pinned `understudy-psalm ^0.2`, diff --git a/README.md b/README.md index 38f3059..3333d25 100644 --- a/README.md +++ b/README.md @@ -292,10 +292,23 @@ fallback should handle later calls. | `Arg::remaining()` | the whole variadic tail, any length — last argument only | | `Arg::rest()` | declared parameters left unspelled — last argument only | +An **optional** parameter needs no matcher at all. The contract says a caller +may leave it out, so a specification that leaves it out says nothing about it +and matches whatever the call passed there: + +```php +// claimReady(DateTimeImmutable $t, int $max, array $kinds = [], int $limit = 1000) +verify(fn () => $storage->claimReady(Arg::any(), Arg::any(), Arg::any()), times: 1); +``` + +matches `claimReady($now, 3, [], 100)`. A failure message renders the +positions the specification never mentioned as `…`, so the report tells the +two apart: `claimReady(any(), any(), any(), …)`. + `Arg::rest()` and `Arg::remaining()` differ in what they stand for: `remaining()` matches the variadic tail a method declares, while `rest()` says "the arguments before me matter, the rest of the arity does not" — it is the -one matcher that lets a specification stop before the method's required +one matcher that lets a specification stop before the method's **required** parameters run out: ```php @@ -303,8 +316,9 @@ when(fn () => $storage->recordOutcome('svc', Arg::rest())) ->throws(new RuntimeException('storage unavailable')); ``` -A specification that stops early *without* ending in `Arg::rest()` is refused -with the reason, rather than becoming a stub that silently never matches. A +A specification that stops before a required parameter *without* ending in +`Arg::rest()` is refused with the reason, rather than becoming a stub that +silently never matches. A later, narrower specification for the same call still wins over the broad prefix stub. A static analyser reads the shortened call against the contract's arity; the [understudy-psalm](https://github.com/rasuvaeff/understudy-psalm) @@ -328,6 +342,13 @@ The pattern is yours and is used as written, PCRE semantics included: `$` matches before a trailing newline, so `Arg::string('/^ord-\d+$/')` accepts `"ord-1\n"`. Anchor with `\z` (or add the `D` modifier) where that matters. +A matcher inside a plain array argument is refused for the same reason: an +array is compared by identity, so `find(['id' => Arg::any()])` would match +nothing and say nothing about it. `Arg::containing()` is the matcher that +describes part of an array, and it reads matchers in its own entries — +`Arg::containing(['id' => Arg::int(min: 1)])`, nested as deep as the payload +goes. + `Arg::which()` calls only a public, non-static method that needs no arguments. 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 @@ -356,7 +377,11 @@ call the other arguments rejected captures nothing. It works in `when()`, `expect()` and `verify()` alike; a `verify()` captures from the calls it just claimed, the Mockito reading. A `capture()` inside an `expectSequence()` step matches but does not record — capture at declaration or at verification, not -in a protocol. `last()` on a captor that captured nothing +in a protocol. A captor inside `Arg::allOf()`, `anyOf()`, `not()` or +`containing()` is refused where it is written: a combinator asks its operands +whether they match, and a captor there would accept the call and record +nothing — correct in every observable way except the one it was written for. +`last()` on a captor that captured nothing raises `NothingCaptured`; `all()` answers an empty list. Captured values live exactly as long as the call log: `reset()` and a closing `Understudy::scope()` drop them, and the captor object is then simply empty again. @@ -411,6 +436,21 @@ when(fn () => $breaker->call($operation)) One link per call, and the last link keeps answering once the chain runs out. +`throwsWith()` is `throws()` for an exception that has to carry what the call +was made with — `throws()` takes an instance, which cannot know: + +```php +when(fn () => $publisher->publish(Arg::any())) + ->throwsWith(fn (Invocation $call) => new PublishException( + message: 'Publish failed', + outboxMessage: $call->arg('message'), + )); +``` + +One exception per call, where `throws()` is one instance for all of them. A +throwing `answers()` closure does the same thing and keeps working; this reads +as what it is at the call site. + ### Verifying ```php @@ -568,13 +608,18 @@ use Rasuvaeff\Understudy\Arg; $calls = Understudy::calls(fn () => $repository->find(Arg::any())); $calls[0]->args; // [123] +$calls[0]->arg('id'); // 123 — by the contract's own parameter name $calls[0]->didReturn(); // true $calls[0]->returned(); // the value it answered with $calls[1]->thrown(); // the throwable, if it threw ``` `null` is a valid return value, which is why the outcome is asked about -(`didReturn()`) rather than inferred from the value. +(`didReturn()`) rather than inferred from the value. `arg()` takes a position +or the contract's parameter name and refuses a name the method does not +declare, rather than answering `null` — which is a value an argument can +legitimately have. An argument the caller omitted reads as the contract's +default, exactly as it does in `args`. ```php $last = Understudy::lastCall(fn () => $repository->find(Arg::any())); @@ -607,8 +652,18 @@ One-way, like every other form of forgetting here. |---|---| | Loose (default) | a type-safe default: `null`, `0`, `''`, `[]`, an empty generator … | | Strict (`Understudy::strict($double)`) | an immediate failure naming the method, the call, and what did not accept it | + | Forwarding (`Understudy::forwarding($double, $real)`) | whatever the real instance answers, recorded like any other call | +`Understudy::strict()` and `Understudy::label()` answer with the double they +configured, so the mode can be chosen where the double is handed over: + +```php +$definitions = [ + ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class)), +]; +``` + A loose double never invents a value by running someone else's constructor, and never hands back an unconstructed instance of a real class. What it can hand back is another understudy: a return type that can itself be doubled becomes diff --git a/README.ru.md b/README.ru.md index 5dd6aa5..0954854 100644 --- a/README.ru.md +++ b/README.ru.md @@ -290,10 +290,23 @@ when(fn () => $repository->mode())->returns('fast', 'slow'); | `Arg::remaining()` | весь variadic-хвост любой длины — только последним | | `Arg::rest()` | объявленные параметры, оставленные неназванными — только последним | +**Необязательному** параметру матчер не нужен вовсе. Контракт разрешает +вызывающему его опустить — значит, спецификация, которая его не назвала, +ничего о нём и не утверждает и совпадает с тем, что было передано: + +```php +// claimReady(DateTimeImmutable $t, int $max, array $kinds = [], int $limit = 1000) +verify(fn () => $storage->claimReady(Arg::any(), Arg::any(), Arg::any()), times: 1); +``` + +совпадает с `claimReady($now, 3, [], 100)`. В сообщении об ошибке позиции, +которые спецификация не называла, печатаются как `…` — чтобы одно отличалось +от другого: `claimReady(any(), any(), any(), …)`. + `Arg::rest()` и `Arg::remaining()` отвечают за разное: `remaining()` матчит variadic-хвост, который метод объявляет, а `rest()` говорит «аргументы до меня важны, остальная арность — нет» — это единственный матчер, позволяющий -спецификации остановиться до того, как закончатся обязательные параметры +спецификации остановиться до того, как закончатся **обязательные** параметры метода: ```php @@ -301,8 +314,8 @@ when(fn () => $storage->recordOutcome('svc', Arg::rest())) ->throws(new RuntimeException('storage unavailable')); ``` -Спецификация, остановившаяся раньше *без* `Arg::rest()` в конце, отвергается с -объяснением — вместо того чтобы стать стабом, который молча никогда не +Спецификация, остановившаяся до обязательного параметра *без* `Arg::rest()` в +конце, отвергается с объяснением — вместо того чтобы стать стабом, который молча никогда не совпадёт. Более поздняя узкая спецификация того же вызова по-прежнему побеждает широкий префиксный стаб. Статанализатор читает укороченный вызов против арности контракта; плагин @@ -328,6 +341,12 @@ teardown: `Arg::int(min: 5, max: 1)` и его собратья `Arg::string('/^ord-\d+$/')` принимает `"ord-1\n"`. Где это важно — якорить `\z` (или добавить модификатор `D`). +Матчер внутри обычного массива-аргумента отвергается по той же причине: массив +сравнивается по идентичности, поэтому `find(['id' => Arg::any()])` не совпал бы +ни с чем и нигде бы об этом не сказал. Часть массива описывает +`Arg::containing()` — и он читает матчеры в своих элементах: +`Arg::containing(['id' => Arg::int(min: 1)])`, на любую глубину вложенности. + `Arg::which()` вызывает только публичный нестатический метод без обязательных аргументов. Геттер, бросивший исключение, считается несовпадением, а не ошибкой: сопоставление идёт во время работы тестируемого кода, и матчер не @@ -356,7 +375,11 @@ $options->all(); // list, в порядке в `when()`, `expect()` и `verify()`; `verify()` захватывает из вызовов, которые только что подтвердил, — прочтение Mockito. `capture()` в шаге `expectSequence()` совпадает, но не записывает — захватывайте при объявлении -или при верификации, не в протоколе. `last()` у пустого каптора бросает +или при верификации, не в протоколе. Каптор внутри `Arg::allOf()`, `anyOf()`, +`not()` или `containing()` отвергается там, где написан: комбинатор +спрашивает операнды, совпадают ли они, и каптор там принял бы вызов, не +записав ничего — корректно во всём наблюдаемом, кроме того единственного, +ради чего он и написан. `last()` у пустого каптора бросает `NothingCaptured`; `all()` отвечает пустым списком. Захваченные значения живут ровно столько же, сколько лог вызовов: `reset()` и закрывающийся `Understudy::scope()` сбрасывают их, каптор после этого просто снова пуст. @@ -416,6 +439,21 @@ when(fn () => $breaker->call($operation)) Одно звено на вызов; когда цепочка кончается, последнее звено продолжает отвечать. +`throwsWith()` — это `throws()` для исключения, которое обязано нести то, с чем +был сделан вызов; `throws()` принимает готовый экземпляр и знать этого не может: + +```php +when(fn () => $publisher->publish(Arg::any())) + ->throwsWith(fn (Invocation $call) => new PublishException( + message: 'Publish failed', + outboxMessage: $call->arg('message'), + )); +``` + +Одно исключение на вызов, тогда как `throws()` — один экземпляр на все. +Бросающее замыкание в `answers()` делает то же самое и продолжает работать; +здесь намерение читается прямо в месте вызова. + ### Проверки ```php @@ -571,13 +609,18 @@ use Rasuvaeff\Understudy\Arg; $calls = Understudy::calls(fn () => $repository->find(Arg::any())); $calls[0]->args; // [123] +$calls[0]->arg('id'); // 123 — по имени параметра из контракта $calls[0]->didReturn(); // true $calls[0]->returned(); // чем ответил $calls[1]->thrown(); // исключение, если бросил ``` `null` — полноценное возвращаемое значение, поэтому об исходе спрашивают -(`didReturn()`), а не выводят его из самого значения. +(`didReturn()`), а не выводят его из самого значения. `arg()` принимает +позицию или имя параметра из контракта и отвергает имя, которого у метода нет, +вместо ответа `null` — потому что `null` аргумент может нести и по-настоящему. +Опущенный вызывающим аргумент читается как значение по умолчанию из контракта, +ровно как и в `args`. ```php $last = Understudy::lastCall(fn () => $repository->find(Arg::any())); @@ -612,6 +655,15 @@ reset его больше не видят. Любой вызов на объек | Strict (`Understudy::strict($double)`) | немедленной ошибкой с именем метода, самим вызовом и тем, что его не приняло | | Forwarding (`Understudy::forwarding($double, $real)`) | тем, что ответит реальный инстанс — с записью вызова, как любого другого | +`Understudy::strict()` и `Understudy::label()` отвечают тем же дублем, который +настроили, — чтобы режим можно было выбрать там, где дубль передают дальше: + +```php +$definitions = [ + ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class)), +]; +``` + Loose-дубль никогда не выдумывает значение, запуская чужой конструктор, и никогда не отдаёт экземпляр настоящего класса с пропущенным конструктором. Отдать он может другой дубль: возвращаемый тип, который сам можно дублировать, diff --git a/docs/.vale/styles/config/vocabularies/Understudy/accept.txt b/docs/.vale/styles/config/vocabularies/Understudy/accept.txt index 7e8aa89..ac02233 100644 --- a/docs/.vale/styles/config/vocabularies/Understudy/accept.txt +++ b/docs/.vale/styles/config/vocabularies/Understudy/accept.txt @@ -66,3 +66,4 @@ PHAR bypassFinals phpspec [Uu]serland +[Cc]ombinator diff --git a/docs/scripts/api-snapshot.json b/docs/scripts/api-snapshot.json index ba6f0b7..43cb760 100644 --- a/docs/scripts/api-snapshot.json +++ b/docs/scripts/api-snapshot.json @@ -2,7 +2,7 @@ "classes": [ { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Arg", "kind": "class", @@ -33,7 +33,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 56 + "startLine": 57 }, { "name": "int", @@ -63,7 +63,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 65 + "startLine": 66 }, { "name": "float", @@ -93,7 +93,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 75 + "startLine": 76 }, { "name": "string", @@ -116,7 +116,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 87 + "startLine": 88 }, { "name": "bool", @@ -131,7 +131,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 99 + "startLine": 100 }, { "name": "same", @@ -154,7 +154,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 107 + "startLine": 108 }, { "name": "not", @@ -177,7 +177,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 115 + "startLine": 116 }, { "name": "allOf", @@ -200,7 +200,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 126 + "startLine": 129 }, { "name": "anyOf", @@ -223,7 +223,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 137 + "startLine": 140 }, { "name": "instanceOf", @@ -246,7 +246,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 147 + "startLine": 150 }, { "name": "captor", @@ -269,7 +269,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 179 + "startLine": 182 }, { "name": "satisfies", @@ -299,7 +299,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 198 + "startLine": 201 }, { "name": "containing", @@ -322,7 +322,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 209 + "startLine": 212 }, { "name": "count", @@ -352,7 +352,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 220 + "startLine": 230 }, { "name": "which", @@ -382,7 +382,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 232 + "startLine": 242 }, { "name": "none", @@ -397,7 +397,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 336 + "startLine": 370 }, { "name": "remaining", @@ -412,7 +412,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 345 + "startLine": 379 }, { "name": "rest", @@ -427,17 +427,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 366 + "startLine": 400 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L49", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FileWrapper", "kind": "class", @@ -1099,7 +1099,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FinalStripper", "kind": "class", @@ -1159,7 +1159,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Captor", "kind": "class", @@ -1287,7 +1287,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Cardinality", "kind": "class", @@ -1480,7 +1480,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\Blueprint", "kind": "class", @@ -1617,7 +1617,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\DoubleFactory", "kind": "class", @@ -1746,7 +1746,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\MethodSignature", "kind": "class", @@ -1864,10 +1864,75 @@ "promoted": true, "promotedVisibility": "public", "readonly": true + }, + { + "name": "parameterNames", + "type": "array", + "description": "the contract's own name for each fixed\nparameter, so a call can be read by name", + "default": "[]", + "promoted": true, + "promotedVisibility": "public", + "readonly": true + }, + { + "name": "optionalParameters", + "type": "array", + "description": "the positions the contract\nlets a caller omit, each with the parameter that declared\nthe default — null where the position is optional only\nbecause another target does not declare it at all. A\nposition missing from this map is required", + "default": "[]", + "promoted": true, + "promotedVisibility": "public", + "readonly": true } ], "publicProperties": [], - "publicMethods": [], + "publicMethods": [ + { + "name": "defaultAt", + "static": false, + "params": [ + { + "name": "position", + "type": "int", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "mixed", + "summary": "The value the contract gives a parameter the caller omitted.", + "description": "Evaluated per call, which is what PHP does for a default that builds an\nobject. `null` is also the answer for a position that is optional only\nbecause a second target does not declare it — there is no contract\ndefault to reproduce there, and null is what the parameter used to\ncarry when the double rendered defaults itself.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 61 + }, + { + "name": "isOptional", + "static": false, + "params": [ + { + "name": "position", + "type": "int", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "bool", + "summary": "", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 68 + } + ], "constants": [], "enumCases": [], "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Codegen/MethodSignature.php#L13", @@ -1875,7 +1940,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertyDefaults", "kind": "class", @@ -1928,7 +1993,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertySignature", "kind": "class", @@ -2018,7 +2083,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TargetUnifier", "kind": "class", @@ -2071,7 +2136,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TypeRenderer", "kind": "class", @@ -2191,7 +2256,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\DefaultFactories", "kind": "class", @@ -2296,7 +2361,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\TypeDefaultResolver", "kind": "class", @@ -2435,7 +2500,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\AmbiguousDefaultFactory", "kind": "class", @@ -2495,7 +2560,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\BypassUnavailable", "kind": "class", @@ -2601,7 +2666,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\CannotWire", "kind": "class", @@ -2846,7 +2911,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ConflictingExpectation", "kind": "class", @@ -2966,7 +3031,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ContextOwnershipViolation", "kind": "class", @@ -3011,7 +3076,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForgottenDouble", "kind": "class", @@ -3148,7 +3213,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", "kind": "class", @@ -3238,7 +3303,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "kind": "class", @@ -3426,6 +3491,66 @@ "attributes": [], "startLine": 122 }, + { + "name": "matcherInsideArray", + "static": true, + "params": [ + { + "name": "method", + "type": "non-empty-string", + "description": "the method the specification named", + "default": null, + "variadic": false + }, + { + "name": "position", + "type": "int", + "description": "zero-based position of the array argument", + "default": null, + "variadic": false + }, + { + "name": "matcher", + "type": "non-empty-string", + "description": "how the buried matcher describes itself", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", + "summary": "Builds the error for a matcher nested inside an array argument.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 140 + }, + { + "name": "captorInCombinator", + "static": true, + "params": [ + { + "name": "matcher", + "type": "non-empty-string", + "description": "the combinator the captor was passed to, without `Arg::`", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", + "summary": "Builds the error for a captor inside a combinator.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 158 + }, { "name": "emptySequence", "static": true, @@ -3439,7 +3564,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 137 + "startLine": 173 }, { "name": "protocolAlreadyArmed", @@ -3469,7 +3594,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 151 + "startLine": 187 }, { "name": "incompleteSpecification", @@ -3506,7 +3631,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 169 + "startLine": 205 }, { "name": "omittedBeforeSpecified", @@ -3543,7 +3668,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 187 + "startLine": 224 }, { "name": "omittedTailNeedsRest", @@ -3573,7 +3698,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 204 + "startLine": 242 }, { "name": "closureFailed", @@ -3596,7 +3721,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 219 + "startLine": 257 }, { "name": "staticMethodCalled", @@ -3619,7 +3744,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 233 + "startLine": 271 } ], "constants": [], @@ -3629,7 +3754,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidDefaultValue", "kind": "class", @@ -3689,7 +3814,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", "kind": "class", @@ -3711,6 +3836,43 @@ "constructorParams": [], "publicProperties": [], "publicMethods": [ + { + "name": "unknownArgument", + "static": true, + "params": [ + { + "name": "method", + "type": "non-empty-string", + "description": "the method the call was made on", + "default": null, + "variadic": false + }, + { + "name": "parameter", + "type": "int|string", + "description": "the position or name that was asked for", + "default": null, + "variadic": false + }, + { + "name": "known", + "type": "array", + "description": "the contract's own parameter names, in order", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "summary": "`Invocation::arg()` asked for a parameter the method does not declare.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 33 + }, { "name": "maximumBelowMinimum", "static": true, @@ -3739,7 +3901,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 33 + "startLine": 50 }, { "name": "negativeCount", @@ -3762,7 +3924,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 47 + "startLine": 64 }, { "name": "noReturnValues", @@ -3777,7 +3939,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 56 + "startLine": 73 }, { "name": "unknownType", @@ -3800,7 +3962,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 68 + "startLine": 85 }, { "name": "invertedBounds", @@ -3837,7 +3999,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 84 + "startLine": 101 }, { "name": "invalidPattern", @@ -3867,7 +4029,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 102 + "startLine": 119 } ], "constants": [], @@ -3877,7 +4039,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\MatcherLeaked", "kind": "class", @@ -3944,7 +4106,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NeverMethodCalled", "kind": "class", @@ -4064,7 +4226,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NoDefaultValue", "kind": "class", @@ -4131,7 +4293,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NothingCaptured", "kind": "class", @@ -4184,7 +4346,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", "kind": "class", @@ -4267,7 +4429,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", "kind": "class", @@ -4334,7 +4496,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OutcomeUnavailable", "kind": "class", @@ -4417,7 +4579,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\StrictModeViolation", "kind": "class", @@ -4484,7 +4646,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnderstudyError", "kind": "interface", @@ -4533,7 +4695,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnsupportedTarget", "kind": "class", @@ -4653,7 +4815,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\VerificationFailed", "kind": "class", @@ -4740,7 +4902,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\ExpectBuilder", "kind": "class", @@ -4788,7 +4950,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Action", "kind": "interface", @@ -4840,12 +5002,13 @@ "implementedBy": [ "Rasuvaeff\\Understudy\\Expectation\\ComputeAnswer", "Rasuvaeff\\Understudy\\Expectation\\ReturnValue", + "Rasuvaeff\\Understudy\\Expectation\\ThrowComputed", "Rasuvaeff\\Understudy\\Expectation\\ThrowError" ] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ArgumentFormatter", "kind": "class", @@ -4928,7 +5091,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ComputeAnswer", "kind": "class", @@ -4995,7 +5158,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Expectation", "kind": "class", @@ -5453,7 +5616,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnValue", "kind": "class", @@ -5520,7 +5683,74 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", + "rootReference": null, + "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowComputed", + "kind": "class", + "isApi": false, + "isAbstract": false, + "isThrowable": false, + "summary": "Throws an exception built from the call that triggered it.", + "description": "Separate from \\Rasuvaeff\\Understudy\\Expectation\\ThrowError, which throws one instance however often the\nlink answers: an exception carrying the argument of the call it answers has\nto be built per call.", + "deprecated": null, + "see": [], + "extensionTags": { + "internal": [ + "" + ] + }, + "extends": null, + "implements": [ + "Rasuvaeff\\Understudy\\Expectation\\Action" + ], + "attributes": [], + "constructorParams": [ + { + "name": "build", + "type": "callable", + "description": "", + "default": null, + "promoted": true, + "promotedVisibility": "private", + "readonly": true + } + ], + "publicProperties": [], + "publicMethods": [ + { + "name": "perform", + "static": false, + "params": [ + { + "name": "invocation", + "type": "Rasuvaeff\\Understudy\\Invocation", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "mixed", + "summary": "", + "description": "", + "throws": [], + "throwsInBody": true, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [ + "Override" + ], + "startLine": 26 + } + ], + "constants": [], + "enumCases": [], + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Expectation/ThrowComputed.php#L18", + "implementedBy": [] + }, + { + "root": "core", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowError", "kind": "class", @@ -5587,7 +5817,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureKind", "kind": "enum", @@ -5676,7 +5906,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureReport", "kind": "class", @@ -5861,7 +6091,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Invocation", "kind": "class", @@ -5930,10 +6160,47 @@ "promoted": true, "promotedVisibility": "public", "readonly": true + }, + { + "name": "parameterNames", + "type": "array", + "description": "the contract's own name for each fixed\nparameter, so a call can be read by name", + "default": "[]", + "promoted": true, + "promotedVisibility": "private", + "readonly": true } ], "publicProperties": [], "publicMethods": [ + { + "name": "arg", + "static": false, + "params": [ + { + "name": "parameter", + "type": "int|string", + "description": "zero-based position, or the contract's own parameter name", + "default": null, + "variadic": false + } + ], + "returnType": "mixed", + "summary": "One argument, by position or by the contract's own parameter name.", + "description": "`$call->args[0]` is opaque in a longer specification, and a library\nwhose specifications are real calls should let a call be read the way it\nwas written. A name that is not a fixed parameter of the method — a\nvalue the variadic tail absorbed, or a typo — is refused rather than\nanswered with null, which is a value an argument can legitimately have.", + "throws": [ + { + "type": "\\Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", + "description": "when the method declares no such parameter" + } + ], + "throwsInBody": true, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 77 + }, { "name": "argsAfter", "static": false, @@ -5947,7 +6214,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 70 + "startLine": 102 }, { "name": "recordFinalArguments", @@ -5970,7 +6237,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 82 + "startLine": 114 }, { "name": "belongsTo", @@ -5993,7 +6260,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 92 + "startLine": 124 }, { "name": "callOriginal", @@ -6013,7 +6280,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 108 + "startLine": 140 }, { "name": "recordOutcome", @@ -6036,7 +6303,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 131 + "startLine": 163 }, { "name": "recordReturned", @@ -6059,7 +6326,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 145 + "startLine": 177 }, { "name": "recordDiscardedReturn", @@ -6074,7 +6341,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 165 + "startLine": 197 }, { "name": "isReturnDiscarded", @@ -6089,7 +6356,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 180 + "startLine": 212 }, { "name": "recordThrown", @@ -6112,7 +6379,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 190 + "startLine": 222 }, { "name": "markAccounted", @@ -6127,7 +6394,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 208 + "startLine": 240 }, { "name": "isAccounted", @@ -6142,7 +6409,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 218 + "startLine": 250 }, { "name": "didReturn", @@ -6157,7 +6424,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 227 + "startLine": 259 }, { "name": "didThrow", @@ -6172,7 +6439,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 235 + "startLine": 267 }, { "name": "returned", @@ -6192,7 +6459,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 248 + "startLine": 280 }, { "name": "thrown", @@ -6207,17 +6474,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 270 + "startLine": 302 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L20", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AllOf", "kind": "class", @@ -6301,7 +6568,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyArgument", "kind": "class", @@ -6375,7 +6642,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyOf", "kind": "class", @@ -6459,7 +6726,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyRest", "kind": "class", @@ -6559,7 +6826,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyTail", "kind": "class", @@ -6659,7 +6926,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", "kind": "interface", @@ -6742,12 +7009,14 @@ "Rasuvaeff\\Understudy\\Matcher\\QueryEquals", "Rasuvaeff\\Understudy\\Matcher\\Satisfying", "Rasuvaeff\\Understudy\\Matcher\\StringMatching", - "Rasuvaeff\\Understudy\\Matcher\\TailMatcher" + "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", + "Rasuvaeff\\Understudy\\Matcher\\Unspelled", + "Rasuvaeff\\Understudy\\Matcher\\UnspelledTail" ] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArrayContaining", "kind": "class", @@ -6755,7 +7024,7 @@ "isAbstract": false, "isThrowable": false, "summary": "Matches an array that contains the given entries, ignoring anything else it\ncarries — the point being to pin the part of a payload a test cares about\nwithout restating the rest.", - "description": "A list is matched by value, a map by key and value.", + "description": "A list is matched by value, a map by key and value. An entry may itself be a\nmatcher: `containing(['id' => Arg::int(min: 1)])` is the way to say\nsomething about part of a payload without knowing the value, and comparing\nit by identity instead would silently never match.", "deprecated": null, "see": [], "extensionTags": { @@ -6804,7 +7073,7 @@ "attributes": [ "Override" ], - "startLine": 26 + "startLine": 29 }, { "name": "describe", @@ -6821,17 +7090,17 @@ "attributes": [ "Override" ], - "startLine": 54 + "startLine": 57 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Matcher/ArrayContaining.php#L18", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Matcher/ArrayContaining.php#L21", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\BooleanValue", "kind": "class", @@ -6905,7 +7174,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Bounds", "kind": "class", @@ -6965,7 +7234,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Capturing", "kind": "class", @@ -7058,7 +7327,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\CountBetween", "kind": "class", @@ -7151,7 +7420,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\EmptyTail", "kind": "class", @@ -7251,7 +7520,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\FloatInRange", "kind": "class", @@ -7344,7 +7613,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IdenticalTo", "kind": "class", @@ -7428,7 +7697,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\InstanceOfType", "kind": "class", @@ -7512,7 +7781,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IntInRange", "kind": "class", @@ -7605,7 +7874,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Negated", "kind": "class", @@ -7689,7 +7958,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Operand", "kind": "class", @@ -7772,7 +8041,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\QueryEquals", "kind": "class", @@ -7865,7 +8134,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Satisfying", "kind": "class", @@ -7958,7 +8227,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\StringMatching", "kind": "class", @@ -8042,7 +8311,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", "kind": "interface", @@ -8096,12 +8365,187 @@ "implementedBy": [ "Rasuvaeff\\Understudy\\Matcher\\AnyRest", "Rasuvaeff\\Understudy\\Matcher\\AnyTail", - "Rasuvaeff\\Understudy\\Matcher\\EmptyTail" + "Rasuvaeff\\Understudy\\Matcher\\EmptyTail", + "Rasuvaeff\\Understudy\\Matcher\\UnspelledTail" ] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", + "rootReference": null, + "class": "Rasuvaeff\\Understudy\\Matcher\\Unspelled", + "kind": "class", + "isApi": false, + "isAbstract": false, + "isThrowable": false, + "summary": "The stand-in for one optional parameter a specification did not spell.", + "description": "A contract that declares a parameter optional says a caller may leave it\nout; a specification that leaves it out says nothing about it, and this is\nthat \"nothing\". It accepts every value, and renders as `…` rather than as\n`any()` so a failure message distinguishes the position the test wrote a\nmatcher for from the position it never mentioned.", + "deprecated": null, + "see": [], + "extensionTags": { + "internal": [ + "" + ] + }, + "extends": null, + "implements": [ + "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher" + ], + "attributes": [], + "constructorParams": [], + "publicProperties": [], + "publicMethods": [ + { + "name": "matches", + "static": false, + "params": [ + { + "name": "argument", + "type": "mixed", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "bool", + "summary": "", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [ + "Override" + ], + "startLine": 21 + }, + { + "name": "describe", + "static": false, + "params": [], + "returnType": "string", + "summary": "Rendered into failure messages in place of the argument, e.g. `any()`.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", + "see": [], + "deprecated": null, + "attributes": [ + "Override" + ], + "startLine": 27 + } + ], + "constants": [], + "enumCases": [], + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Matcher/Unspelled.php#L18", + "implementedBy": [] + }, + { + "root": "core", + "rootVersion": "v0.9.0-10-g74f1dd3", + "rootReference": null, + "class": "Rasuvaeff\\Understudy\\Matcher\\UnspelledTail", + "kind": "class", + "isApi": false, + "isAbstract": false, + "isThrowable": false, + "summary": "What closes a specification that stopped before the contract's optional\nparameters ran out.", + "description": "Where \\Rasuvaeff\\Understudy\\Matcher\\AnyRest is written by hand — `Arg::rest()`, the way a\nspecification says it means to stop before a *required* parameter — this one\nis supplied for the parameters the contract itself allows a caller to omit.\nIt renders as `…`, so the report shows the difference between what the test\nspecified and what it left to the contract.", + "deprecated": null, + "see": [], + "extensionTags": { + "internal": [ + "" + ] + }, + "extends": null, + "implements": [ + "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", + "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher" + ], + "attributes": [], + "constructorParams": [], + "publicProperties": [], + "publicMethods": [ + { + "name": "matchesTail", + "static": false, + "params": [ + { + "name": "tail", + "type": "list", + "description": "every argument from this position onwards", + "default": null, + "variadic": false + } + ], + "returnType": "bool", + "summary": "", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", + "see": [], + "deprecated": null, + "attributes": [ + "Override" + ], + "startLine": 22 + }, + { + "name": "matches", + "static": false, + "params": [ + { + "name": "argument", + "type": "mixed", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "bool", + "summary": "", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [ + "Override" + ], + "startLine": 28 + }, + { + "name": "describe", + "static": false, + "params": [], + "returnType": "string", + "summary": "Rendered into failure messages in place of the argument, e.g. `any()`.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", + "see": [], + "deprecated": null, + "attributes": [ + "Override" + ], + "startLine": 34 + } + ], + "constants": [], + "enumCases": [], + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Matcher/UnspelledTail.php#L19", + "implementedBy": [] + }, + { + "root": "core", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Outcome", "kind": "class", @@ -10411,8 +10855,8 @@ }, { "root": "phpunit", - "rootVersion": "v0.3.0", - "rootReference": "1c5d658896cd8086b4f80087782f3c0a80e31501", + "rootVersion": "v0.4.0", + "rootReference": "181b68ef1c0c16143269d6f9e1f0490ab38a7d89", "class": "Rasuvaeff\\Understudy\\PhpUnit\\UnderstudyPHPUnitIntegration", "kind": "trait", "isApi": true, @@ -10435,7 +10879,7 @@ "publicMethods": [], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy-phpunit/blob/1c5d658896cd8086b4f80087782f3c0a80e31501/src/PhpUnit/UnderstudyPHPUnitIntegration.php#L83", + "sourceUrl": "https://github.com/rasuvaeff/understudy-phpunit/blob/181b68ef1c0c16143269d6f9e1f0490ab38a7d89/src/PhpUnit/UnderstudyPHPUnitIntegration.php#L83", "implementedBy": [] }, { @@ -11607,7 +12051,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Absent", "kind": "enum", @@ -11650,7 +12094,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ArmedSequence", "kind": "class", @@ -11780,6 +12224,44 @@ "attributes": [], "startLine": 80 }, + { + "name": "pendingOwner", + "static": false, + "params": [], + "returnType": "?object", + "summary": "The double the step due belongs to, or null once the protocol has run\nout.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 92 + }, + { + "name": "stepsOwnedElsewhere", + "static": false, + "params": [ + { + "name": "double", + "type": "object", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "array", + "summary": "Which steps belong to a double other than this one.", + "description": "A protocol across two doubles renders every step by its call alone, so\n`num()` arriving on the wrong double reads as the step that was due —\nidentical text, and no hint that the difference is the receiver. This is\nwhat lets the report say which lines are somebody else's.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 107 + }, { "name": "offer", "static": false, @@ -11808,7 +12290,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 93 + "startLine": 120 } ], "constants": [], @@ -11818,7 +12300,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\DoubleState", "kind": "class", @@ -12299,7 +12781,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\InvocationSignal", "kind": "class", @@ -12353,29 +12835,29 @@ "publicProperties": [], "publicMethods": [ { - "name": "withoutAbsentArguments", + "name": "asSpecification", "static": false, "params": [], "returnType": "Rasuvaeff\\Understudy\\Runtime\\InvocationSignal", - "summary": "The signal with the arity sentinels stripped, once the shape that made\nthe omission legitimate has been checked.", - "description": "A required parameter of a generated method defaults to the sentinel so a\nspecification may physically pass fewer arguments than the method\ndeclares. That is only meaningful when the specification *said* the rest\ndoes not matter — its last spelled argument is `Arg::rest()`. Every\nother shape is refused here, by name, rather than becoming a\nspecification that silently never matches: without a tail matcher the\nstripped prefix would demand an arity no materialized call ever has, and\n`Arg::remaining()`/`Arg::none()` make claims about a variadic tail, not\nabout parameters left unspelled.", + "summary": "The signal read as a specification: every sentinel resolved, or the\nomission refused.", + "description": "A generated parameter defaults to the sentinel, so a specification may\nphysically pass fewer arguments than the method declares. What an\nomission means depends on the contract:\n\n- a parameter the contract declares **optional** may be left out by any\n caller, so a specification that leaves it out says nothing about it.\n It becomes \\Rasuvaeff\\Understudy\\Matcher\\Unspelled in the middle of the argument list, and\n \\Rasuvaeff\\Understudy\\Matcher\\UnspelledTail where the list stops early — the specification\n then matches whatever the code under test passed there, which is what\n spelling nothing has to mean if arity is not to become a silent part\n of every specification.\n- a parameter the contract declares **required** is present in every\n real call, so stopping before one is only meaningful when the\n specification said the rest does not matter — its last spelled\n argument is `Arg::rest()`. Every other shape is refused here, by name,\n rather than becoming a specification that silently never matches.", "throws": [], "throwsInBody": true, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 49 + "startLine": 63 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/InvocationSignal.php#L21", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/InvocationSignal.php#L25", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Mode", "kind": "enum", @@ -12438,7 +12920,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ReferenceSlot", "kind": "class", @@ -12474,7 +12956,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Runtime", "kind": "class", @@ -12509,7 +12991,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 87 + "startLine": 88 }, { "name": "currentIfAny", @@ -12524,7 +13006,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 101 + "startLine": 102 }, { "name": "pushScope", @@ -12539,7 +13021,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 112 + "startLine": 113 }, { "name": "popScope", @@ -12554,7 +13036,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 120 + "startLine": 121 }, { "name": "adopt", @@ -12584,7 +13066,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 186 + "startLine": 187 }, { "name": "liveContexts", @@ -12599,7 +13081,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 220 + "startLine": 221 }, { "name": "adoptClone", @@ -12622,7 +13104,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 251 + "startLine": 252 }, { "name": "adoptInto", @@ -12652,7 +13134,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 279 + "startLine": 280 }, { "name": "adoptContractsInto", @@ -12682,7 +13164,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 291 + "startLine": 292 }, { "name": "ownerOf", @@ -12705,7 +13187,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 307 + "startLine": 308 }, { "name": "isForgotten", @@ -12728,7 +13210,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 321 + "startLine": 322 }, { "name": "isRetiredOnPurpose", @@ -12751,7 +13233,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 329 + "startLine": 330 }, { "name": "forget", @@ -12774,7 +13256,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 341 + "startLine": 342 }, { "name": "stateOf", @@ -12797,7 +13279,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 404 + "startLine": 405 }, { "name": "isOwnedByCurrentContext", @@ -12820,7 +13302,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 409 + "startLine": 410 }, { "name": "dispatch", @@ -12857,7 +13339,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 422 + "startLine": 423 }, { "name": "propertyRead", @@ -12887,7 +13369,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 735 + "startLine": 755 }, { "name": "propertyWrite", @@ -12924,7 +13406,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 771 + "startLine": 791 }, { "name": "referenceSlot", @@ -12961,7 +13443,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 829 + "startLine": 849 }, { "name": "callOriginal", @@ -12998,7 +13480,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 857 + "startLine": 877 }, { "name": "reset", @@ -13013,17 +13495,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1017 + "startLine": 1083 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/Runtime.php#L28", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Runtime/Runtime.php#L29", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\RuntimeContext", "kind": "class", @@ -13370,7 +13852,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\SequenceVerdict", "kind": "enum", @@ -13579,7 +14061,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Understudy", "kind": "class", @@ -13625,7 +14107,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 61 + "startLine": 62 }, { "name": "when", @@ -13648,7 +14130,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 126 + "startLine": 127 }, { "name": "expect", @@ -13671,7 +14153,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 146 + "startLine": 147 }, { "name": "verifyAll", @@ -13694,7 +14176,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 167 + "startLine": 168 }, { "name": "verify", @@ -13745,7 +14227,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 418 + "startLine": 419 }, { "name": "calls", @@ -13768,7 +14250,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 502 + "startLine": 503 }, { "name": "lastCall", @@ -13791,7 +14273,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 524 + "startLine": 525 }, { "name": "strict", @@ -13799,22 +14281,22 @@ "params": [ { "name": "double", - "type": "object", + "type": "\\Rasuvaeff\\Understudy\\T", "description": "", "default": null, "variadic": false } ], - "returnType": "void", + "returnType": "object", "summary": "Makes an understudy fail on any call no expectation matched.", - "description": "", + "description": "Answers with the double it configured, so the mode can be chosen where\nthe double is handed over — `ClientInterface::class =>\nUnderstudy::strict(Understudy::for(ClientInterface::class))` in a\ncontainer definition, rather than as a statement that has to find a\nvariable to name.", "throws": [], "throwsInBody": false, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 542 + "startLine": 555 }, { "name": "lean", @@ -13837,7 +14319,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 565 + "startLine": 580 }, { "name": "forwarding", @@ -13867,7 +14349,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 583 + "startLine": 598 }, { "name": "delegate", @@ -13897,7 +14379,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 634 + "startLine": 649 }, { "name": "wire", @@ -13927,7 +14409,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 666 + "startLine": 681 }, { "name": "bypassFinals", @@ -13955,7 +14437,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 703 + "startLine": 718 }, { "name": "defaults", @@ -13985,7 +14467,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 788 + "startLine": 803 }, { "name": "label", @@ -13993,7 +14475,7 @@ "params": [ { "name": "double", - "type": "object", + "type": "\\Rasuvaeff\\Understudy\\T", "description": "", "default": null, "variadic": false @@ -14006,16 +14488,16 @@ "variadic": false } ], - "returnType": "void", + "returnType": "object", "summary": "Names one understudy in failure messages, which is what makes two\ndoubles of the same contract tellable apart.", - "description": "", + "description": "Answers with the double it named, for the same reason\n\\Rasuvaeff\\Understudy\\Understudy::strict() does.", "throws": [], "throwsInBody": false, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 799 + "startLine": 822 }, { "name": "unused", @@ -14038,7 +14520,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 807 + "startLine": 832 }, { "name": "forget", @@ -14061,7 +14543,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 847 + "startLine": 872 }, { "name": "nothingElse", @@ -14091,7 +14573,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 867 + "startLine": 892 }, { "name": "allVerified", @@ -14114,7 +14596,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 910 + "startLine": 935 }, { "name": "expectSequence", @@ -14137,7 +14619,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 993 + "startLine": 1018 }, { "name": "verifySequence", @@ -14160,7 +14642,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1033 + "startLine": 1058 }, { "name": "transcript", @@ -14183,7 +14665,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1059 + "startLine": 1084 }, { "name": "scope", @@ -14213,7 +14695,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1195 + "startLine": 1220 }, { "name": "checkpoint", @@ -14236,7 +14718,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1228 + "startLine": 1253 }, { "name": "reset", @@ -14251,7 +14733,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1255 + "startLine": 1280 }, { "name": "idle", @@ -14266,17 +14748,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1267 + "startLine": 1292 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L39", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L40", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\VerificationFailure", "kind": "class", @@ -14383,7 +14865,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\WhenBuilder", "kind": "class", @@ -14436,7 +14918,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 56 + "startLine": 57 }, { "name": "throws", @@ -14459,7 +14941,30 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 82 + "startLine": 83 + }, + { + "name": "throwsWith", + "static": false, + "params": [ + { + "name": "build", + "type": "callable", + "description": "builds the exception from the call it answers", + "default": null, + "variadic": false + } + ], + "returnType": "static", + "summary": "Throws an exception built from the call itself, one per call.", + "description": "The shape `throws()` cannot express: an exception that carries what the\ncall was made with — `new PublishException($message, outboxMessage:\n$call->args[0])`. A throwing `answers()` closure does the same thing and\nstays supported; this reads as what it is at the call site.\n\n```php\nwhen(fn () => $publisher->publish(Arg::any()))\n ->throwsWith(fn (Invocation $call) => new PublishException(\n message: 'Publish failed',\n outboxMessage: $call->arg('message'),\n ));\n```", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 108 }, { "name": "answers", @@ -14482,7 +14987,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 94 + "startLine": 120 }, { "name": "then", @@ -14497,7 +15002,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 113 + "startLine": 139 }, { "name": "times", @@ -14527,17 +15032,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 130 + "startLine": 156 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L36", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L37", "implementedBy": [] }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Wiring\\Wire", "kind": "class", @@ -14602,7 +15107,7 @@ "functions": [ { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expect", @@ -14626,7 +15131,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expectSequence", @@ -14650,7 +15155,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\verify", @@ -14702,7 +15207,7 @@ }, { "root": "core", - "rootVersion": "v0.9.0-5-geda3337", + "rootVersion": "v0.9.0-10-g74f1dd3", "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 f9f8a7a..ed0af52 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#L49) — **Version:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v0.9.0-10-g74f1dd3 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 fbc4290..fed067e 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v0.9.0-10-g74f1dd3 **Type parameters:** diff --git a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md index 4840453..dedc18a 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/BypassUnavailable.md b/docs/src/api/classes/Exception/BypassUnavailable.md index b261e21..25f46f8 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/CannotWire.md b/docs/src/api/classes/Exception/CannotWire.md index 331d679..fc10c55 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/ConflictingExpectation.md b/docs/src/api/classes/Exception/ConflictingExpectation.md index 1acdd91..04d0678 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ContextOwnershipViolation.md b/docs/src/api/classes/Exception/ContextOwnershipViolation.md index 2c10f3d..5cb0530 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForgottenDouble.md b/docs/src/api/classes/Exception/ForgottenDouble.md index a5c9616..e3fa63d 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md index 82762cf..f09a19e 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/InvalidCallSpecification.md b/docs/src/api/classes/Exception/InvalidCallSpecification.md index c6b154b..159c6db 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` @@ -104,6 +104,32 @@ static tailMatcherInCombinator( Builds the error for putting a tail matcher inside a combinator. +### matcherInsideArray() + +```php +static matcherInsideArray( + non-empty-string $method, + int $position, + non-empty-string $matcher, +): Exception\InvalidCallSpecification +``` + +Builds the error for a matcher nested inside an array argument. + +- `$method` — the method the specification named +- `$position` — zero-based position of the array argument +- `$matcher` — how the buried matcher describes itself + +### captorInCombinator() + +```php +static captorInCombinator(non-empty-string $matcher): Exception\InvalidCallSpecification +``` + +Builds the error for a captor inside a combinator. + +- `$matcher` — the combinator the captor was passed to, without `Arg::` + ### emptySequence() ```php diff --git a/docs/src/api/classes/Exception/InvalidDefaultValue.md b/docs/src/api/classes/Exception/InvalidDefaultValue.md index 5c0e696..ed1d9e2 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md index 741d18f..266718d 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `InvalidArgumentException` @@ -31,6 +31,22 @@ interface is implemented by every exception this library throws. ## Methods +### unknownArgument() + +```php +static unknownArgument( + non-empty-string $method, + int|string $parameter, + array $known, +): Exception\InvalidSpecificationArgument +``` + +`Invocation::arg()` asked for a parameter the method does not declare. + +- `$method` — the method the call was made on +- `$parameter` — the position or name that was asked for +- `$known` — the contract's own parameter names, in order + ### maximumBelowMinimum() ```php diff --git a/docs/src/api/classes/Exception/MatcherLeaked.md b/docs/src/api/classes/Exception/MatcherLeaked.md index 41702c4..f8e86a9 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/NeverMethodCalled.md b/docs/src/api/classes/Exception/NeverMethodCalled.md index 1edf122..2e199fb 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NoDefaultValue.md b/docs/src/api/classes/Exception/NoDefaultValue.md index 9d9db57..a48a23e 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NothingCaptured.md b/docs/src/api/classes/Exception/NothingCaptured.md index 7025ca9..3fb4f94 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalCallUnavailable.md b/docs/src/api/classes/Exception/OriginalCallUnavailable.md index b4a1d68..b3f6438 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md index 6d15f63..3d676a9 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/OutcomeUnavailable.md b/docs/src/api/classes/Exception/OutcomeUnavailable.md index 5cedf90..401ae55 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/StrictModeViolation.md b/docs/src/api/classes/Exception/StrictModeViolation.md index 7e01fd1..c986175 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/UnderstudyError.md b/docs/src/api/classes/Exception/UnderstudyError.md index e548c04..fe95d3f 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:** v0.9.0-5-geda3337 +**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v0.9.0-10-g74f1dd3 **Implements:** `Throwable`, `Stringable` diff --git a/docs/src/api/classes/Exception/UnsupportedTarget.md b/docs/src/api/classes/Exception/UnsupportedTarget.md index 92627fd..7141d03 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/VerificationFailed.md b/docs/src/api/classes/Exception/VerificationFailed.md index 299a977..6afbce9 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/ExpectBuilder.md b/docs/src/api/classes/ExpectBuilder.md index aab8b05..57f456d 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v0.9.0-10-g74f1dd3 **Extends:** [`WhenBuilder`](/api/classes/WhenBuilder) diff --git a/docs/src/api/classes/FailureKind.md b/docs/src/api/classes/FailureKind.md index ea1bb5f..96a2ed5 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:** v0.9.0-5-geda3337 +**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v0.9.0-10-g74f1dd3 **Implements:** `UnitEnum` diff --git a/docs/src/api/classes/Invocation.md b/docs/src/api/classes/Invocation.md index dfc9376..da4546e 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#L20) — **Version:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v0.9.0-10-g74f1dd3 One recorded call on an understudy. @@ -27,6 +27,7 @@ __construct( ?object $double = NULL, list $liveArgs = [], list $sensitiveArguments = [], + array $parameterNames = [], ) ``` @@ -38,9 +39,30 @@ __construct( | `$double` | `?object` | `NULL` | | | `$liveArgs` | `list` | `[]` | the arguments as the caller still holds them, references included — what delegation needs, where $args is a reading of them | | `$sensitiveArguments` | `list` | `[]` | positions the contract marked `#[\SensitiveParameter]`; carried on the call so a failure message and a transcript can redact the value the way PHP redacts it in its own traces | +| `$parameterNames` | `array` | `[]` | the contract's own name for each fixed parameter, so a call can be read by name | ## Methods +### arg() + +```php +arg(int|string $parameter): mixed +``` + +One argument, by position or by the contract's own parameter name. + +- `$parameter` — zero-based position, or the contract's own parameter name + +**Throws:** + +- [`Exception\InvalidSpecificationArgument`](/api/classes/Exception/InvalidSpecificationArgument) — when the method declares no such parameter + +`$call->args[0]` is opaque in a longer specification, and a library +whose specifications are real calls should let a call be read the way it +was written. A name that is not a fixed parameter of the method — a +value the variadic tail absorbed, or a typo — is refused rather than +answered with null, which is a value an argument can legitimately have. + ### argsAfter() ```php diff --git a/docs/src/api/classes/PhpUnit/UnderstudyPHPUnitIntegration.md b/docs/src/api/classes/PhpUnit/UnderstudyPHPUnitIntegration.md index 1dccc7f..2f23681 100644 --- a/docs/src/api/classes/PhpUnit/UnderstudyPHPUnitIntegration.md +++ b/docs/src/api/classes/PhpUnit/UnderstudyPHPUnitIntegration.md @@ -9,7 +9,7 @@ description: "Ends every PHPUnit test with understudy's own bookkeeping done for `Rasuvaeff\Understudy\PhpUnit\UnderstudyPHPUnitIntegration` -**Trait** — **Package:** [rasuvaeff/understudy-phpunit](https://github.com/rasuvaeff/understudy-phpunit) — [Source](https://github.com/rasuvaeff/understudy-phpunit/blob/1c5d658896cd8086b4f80087782f3c0a80e31501/src/PhpUnit/UnderstudyPHPUnitIntegration.php#L83) — **Version:** v0.3.0 +**Trait** — **Package:** [rasuvaeff/understudy-phpunit](https://github.com/rasuvaeff/understudy-phpunit) — [Source](https://github.com/rasuvaeff/understudy-phpunit/blob/181b68ef1c0c16143269d6f9e1f0490ab38a7d89/src/PhpUnit/UnderstudyPHPUnitIntegration.php#L83) — **Version:** v0.4.0 Ends every PHPUnit test with understudy's own bookkeeping done for it. diff --git a/docs/src/api/classes/Understudy.md b/docs/src/api/classes/Understudy.md index 648ec6e..5de83c6 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#L39) — **Version:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L40) — **Version:** v0.9.0-10-g74f1dd3 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 @@ -107,11 +107,17 @@ the test even runs. ### strict() ```php -static strict(object $double): void +static strict(\T $double): object ``` Makes an understudy fail on any call no expectation matched. +Answers with the double it configured, so the mode can be chosen where +the double is handed over — `ClientInterface::class => +Understudy::strict(Understudy::for(ClientInterface::class))` in a +container definition, rather than as a statement that has to find a +variable to name. + ### lean() ```php @@ -258,12 +264,15 @@ each other's, and `reset()` drops them with the test. ### label() ```php -static label(object $double, non-empty-string $label): void +static label(\T $double, non-empty-string $label): object ``` Names one understudy in failure messages, which is what makes two doubles of the same contract tellable apart. +Answers with the double it named, for the same reason +[`Understudy`](/api/classes/Understudy)::strict() does. + ### unused() ```php diff --git a/docs/src/api/classes/VerificationFailure.md b/docs/src/api/classes/VerificationFailure.md index 01642ef..be799f7 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:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v0.9.0-10-g74f1dd3 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 94015a3..080838f 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#L36) — **Version:** v0.9.0-5-geda3337 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L37) — **Version:** v0.9.0-10-g74f1dd3 **Type parameters:** @@ -65,6 +65,29 @@ throws(Throwable $error): static Throws this exact instance on the call — the same object every time the link answers, which is what a test holding a reference to it expects. +### throwsWith() + +```php +throwsWith(callable $build): static +``` + +Throws an exception built from the call itself, one per call. + +- `$build` — builds the exception from the call it answers + +The shape `throws()` cannot express: an exception that carries what the +call was made with — `new PublishException($message, outboxMessage: +$call->args[0])`. A throwing `answers()` closure does the same thing and +stays supported; this reads as what it is at the call site. + +```php +when(fn () => $publisher->publish(Arg::any())) + ->throwsWith(fn (Invocation $call) => new PublishException( + message: 'Publish failed', + outboxMessage: $call->arg('message'), + )); +``` + ### answers() ```php diff --git a/docs/src/guide/examples.md b/docs/src/guide/examples.md index 31fc296..fb6fd49 100644 --- a/docs/src/guide/examples.md +++ b/docs/src/guide/examples.md @@ -15,7 +15,7 @@ changes. | Script | Shows | |---|---| -| `basic-usage.php` | stubbing with `when()`, argument matchers — `Arg::rest()` and a typed `Arg::captor()` included — verifying counts, reading the call log with outcomes, strict mode and labels | +| `basic-usage.php` | stubbing with `when()` and `throwsWith()`, argument matchers — `Arg::rest()`, an unspelled optional parameter and a typed `Arg::captor()` included — verifying counts, reading the call log by name with `arg()`, strict mode and labels | | `property-hooks.php` | doubling a contract that declares properties (PHP 8.4+): default reads, `{ get; set; }` round-trip, the get-only write refusal — self-skipping on 8.3 | | `modes.php` | the three modes a double can be in: loose defaults, `strict()`, and `forwarding()` to a real object — including the partial double (`delegate()` plus a stub on top) and `lean()` | | `wiring.php` | `Understudy::wire()`: doubles keyed by constructor parameter name, overriding one dependency, and the refusal that happens before the constructor runs | diff --git a/docs/src/guide/lifecycle/index.md b/docs/src/guide/lifecycle/index.md index ad68c93..54290e5 100644 --- a/docs/src/guide/lifecycle/index.md +++ b/docs/src/guide/lifecycle/index.md @@ -44,13 +44,18 @@ use Rasuvaeff\Understudy\Arg; $calls = Understudy::calls(fn () => $repository->find(Arg::any())); $calls[0]->args; // [123] +$calls[0]->arg('id'); // 123 — by the contract's own parameter name $calls[0]->didReturn(); // true $calls[0]->returned(); // the value it answered with $calls[1]->thrown(); // the throwable, if it threw ``` `null` is a valid return value, which is why the outcome is **asked about** -(`didReturn()`) rather than inferred from the value. +(`didReturn()`) rather than inferred from the value. `arg()` takes a position +or the contract's parameter name, and refuses a name the method does not +declare rather than answering `null` — which is a value an argument can +legitimately have. An argument the caller omitted reads as the contract's +default, exactly as it does in `args`. ```php $last = Understudy::lastCall(fn () => $repository->find(Arg::any())); diff --git a/docs/src/guide/modes.md b/docs/src/guide/modes.md index ae2cc46..d633aad 100644 --- a/docs/src/guide/modes.md +++ b/docs/src/guide/modes.md @@ -18,6 +18,15 @@ Strictness is per double, not per test. It is a different setting from [strict stubs](/guide/expectations/strict-stubs), which is about registrations nobody used. +`Understudy::strict()` and `Understudy::label()` answer with the double they +configured, so the mode can be chosen where the double is handed over: + +```php +$definitions = [ + ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class)), +]; +``` + ## What loose will and will not invent A loose double never invents a value by running another class's constructor, and diff --git a/docs/src/guide/stubbing/capturing.md b/docs/src/guide/stubbing/capturing.md index 3fdc9e5..caf0e55 100644 --- a/docs/src/guide/stubbing/capturing.md +++ b/docs/src/guide/stubbing/capturing.md @@ -44,6 +44,17 @@ matches but does not record. Capture at declaration or at verification, not in a protocol. ::: +::: warning Not inside a combinator +A captor inside `Arg::allOf()`, `anyOf()`, `not()` or `containing()` is +refused where it is written. It used to be accepted, and then matched without +ever recording: recording happens once the whole specification matched, for +the captors the specification holds in a position of their own. The +specification was correct in every observable way except the one it was +written for, and the only way to find out was an assertion on an empty captor +further down the test. For an ordered history across several arguments, read +[`Understudy::calls()`](/guide/lifecycle/index#reading-the-call-log) instead. +::: + ## Reading it | Call | On an empty captor | diff --git a/docs/src/guide/stubbing/index.md b/docs/src/guide/stubbing/index.md index 5550587..4ab0353 100644 --- a/docs/src/guide/stubbing/index.md +++ b/docs/src/guide/stubbing/index.md @@ -27,6 +27,7 @@ when(fn () => $repository->find(Arg::any()))->answers( | `returns($value, …)` | the value; with several, one per call, and the last one repeats | | `throws($exception)` | the exception, thrown at the call site | | `answers(fn (Invocation $call) => …)` | whatever the callback computes from the actual call | +| `throwsWith(fn (Invocation $call) => …)` | an exception built from the call, one per call | ```php // One value per call, then the last one repeats. @@ -36,6 +37,20 @@ when(fn () => $repository->mode())->returns('fast', 'slow'); For a different answer per call in a longer sequence, see [Chaining behaviour](/guide/stubbing/chaining). +`throws()` takes an instance, which cannot know what the call carried. +`throwsWith()` is for the exception that has to: + +```php +when(fn () => $publisher->publish(Arg::any())) + ->throwsWith(fn (Invocation $call) => new PublishException( + message: 'Publish failed', + outboxMessage: $call->arg('message'), + )); +``` + +`Invocation::arg()` reads one argument by position or by the contract's own +parameter name. + ## Which stub wins A later stub for the same call wins. Earlier ones stay reachable as fallbacks diff --git a/docs/src/guide/stubbing/matchers.md b/docs/src/guide/stubbing/matchers.md index 47b26dc..50e4856 100644 --- a/docs/src/guide/stubbing/matchers.md +++ b/docs/src/guide/stubbing/matchers.md @@ -26,7 +26,7 @@ when(fn () => $repository->find(Arg::any()))->returns($book); | `Arg::anyOf(...)` | anything at least one operand accepts, so `anyOf('draft', 'review')` reads as a set | | `Arg::instanceOf($class)` | an instance of the class or interface | | `Arg::satisfies($fn)` | whatever the predicate accepts | -| `Arg::containing($entries)` | an array holding these entries and possibly more | +| `Arg::containing($entries)` | an array holding these entries and possibly more; an entry may itself be a matcher | | `Arg::count(minimum:, maximum:)` | an array or `Countable` of that size | | `Arg::which($method, $value)` | an object whose getter answers this value | | `Arg::none()` | an empty variadic tail — last argument only | @@ -36,6 +36,23 @@ when(fn () => $repository->find(Arg::any()))->returns($book); There is also [`Arg::captor()`](/guide/stubbing/capturing), which matches and records at the same time. +## An optional parameter needs no matcher + +The contract says a caller may leave it out, so a specification that leaves it +out says nothing about it — and matches whatever the call passed there: + +```php +// claimReady(DateTimeImmutable $t, int $max, array $kinds = [], int $limit = 1000) +verify(fn () => $storage->claimReady(Arg::any(), Arg::any(), Arg::any()), times: 1); +``` + +matches `claimReady($now, 3, [], 100)`. A failure message renders a position +the specification never mentioned as `…`, so the report distinguishes it from +an `any()` the test did write: `claimReady(any(), any(), any(), …)`. + +A **required** parameter is a different claim — every real call carries one — +so stopping before it still has to be said with `rest()` below. + ## The type matchers are strict on purpose `Arg::int()` rejects `'5'`. `Arg::float()` rejects `1`. @@ -73,15 +90,16 @@ They look similar and stand for different things: | `Arg::rest()` | "the arguments written here matter, the rest of the arity does not" | `rest()` is the one matcher that lets a specification stop before the method's -required parameters run out: +**required** parameters run out — an optional one needs nothing: ```php when(fn () => $storage->recordOutcome('svc', Arg::rest())) ->throws(new RuntimeException('storage unavailable')); ``` -A specification that stops early **without** ending in `Arg::rest()` is refused -with the reason, rather than becoming a stub that silently never matches. A +A specification that stops before a required parameter **without** ending in +`Arg::rest()` is refused with the reason, rather than becoming a stub that +silently never matches. A later, narrower specification for the same call still wins over the broad prefix stub. @@ -92,6 +110,20 @@ the idiom. The [Psalm plugin](/adapters/psalm) and the [PHPStan extension](/adapters/phpstan) teach it. ::: +## A matcher inside an array argument + +An array argument is compared by identity, so a matcher buried in one would +match nothing and say nothing about it. It is refused where it is written, and +the message names what to use instead: + +```php +when(fn () => $repo->search(['status' => Arg::any()])); // refused +when(fn () => $repo->search(Arg::containing(['status' => Arg::any()]))); // this +``` + +`Arg::containing()` reads matchers in its own entries, nested as deep as the +payload goes. + ## `Arg::which()` and a getter that throws `Arg::which()` calls only a public, non-static method that needs no arguments. diff --git a/examples/README.md b/examples/README.md index 2c7907e..24a8e4e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +9,7 @@ demonstrate changes. | Script | Shows | Needs server? | |---|---|---| -| `basic-usage.php` | stubbing with `when()`, argument matchers — `Arg::rest()` and a typed `Arg::captor()` included — verifying counts, reading the call log with outcomes, strict mode and labels | no | +| `basic-usage.php` | stubbing with `when()` and `throwsWith()`, argument matchers — `Arg::rest()`, an unspelled optional parameter and a typed `Arg::captor()` included — verifying counts, reading the call log by name with `arg()`, strict mode and labels | no | | `property-hooks.php` | doubling a contract that declares properties (PHP 8.4+): default reads, `{ get; set; }` round-trip, the get-only write refusal — self-skipping on 8.3 | no | | `modes.php` | the three modes a double can be in: loose defaults, `strict()`, and `forwarding()` to a real object — including the partial double (`delegate()` + a stub on top) and `lean()`, the call log that does not retain returned values | no | | `wiring.php` | `Understudy::wire()`: doubles keyed by constructor parameter name, overriding one dependency, and the refusal that happens before the constructor runs | no | diff --git a/examples/basic-usage.php b/examples/basic-usage.php index b5a1d70..7912674 100644 --- a/examples/basic-usage.php +++ b/examples/basic-usage.php @@ -37,6 +37,8 @@ public function save(Book $book): void; public function count(): int; public function tag(string $name, int $weight, bool $pinned): string; + + public function archive(int $id, ?string $reason = null): void; } $repository = Understudy::for(BookRepository::class); @@ -103,6 +105,42 @@ public function tag(string $name, int $weight, bool $pinned): string; check($catalogue->tag('sale', 3, true) === 'tagged', 'Arg::rest() matches whatever follows the prefix'); check($catalogue->tag('fresh', 1, false) === '', 'a different prefix falls through to the loose default'); +// --- An optional parameter needs no matcher --------------------------------- + +// The contract lets a caller omit `$reason`, so a specification that omits it +// says nothing about it and matches whatever the subject passed there. +$catalogue->archive(7, 'duplicate'); + +verify(fn() => $catalogue->archive(7), times: 1); +check( + Understudy::lastCall(fn() => $catalogue->archive(Arg::any(), Arg::any()))?->arg('reason') === 'duplicate', + 'arg() reads one argument by the contract\'s own parameter name', +); + +$catalogue->archive(8); +check( + Understudy::lastCall(fn() => $catalogue->archive(Arg::any(), Arg::any()))?->args === [8, null], + 'an omitted argument is logged as the value the contract gives it', +); + +// --- throwsWith(): an exception built from the call it answers --------------- + +when(fn() => $catalogue->save(Arg::any()))->throwsWith( + static fn(Invocation $call): \Throwable => new \RuntimeException( + 'cannot save ' . $call->arg('book')->title, + ), +); + +try { + $catalogue->save(new Book('Neuromancer')); + check(false, 'throwsWith() throws'); +} catch (\RuntimeException $e) { + check($e->getMessage() === 'cannot save Neuromancer', 'throwsWith() builds the exception from the call'); +} + +Understudy::reset(); +$catalogue = Understudy::for(BookRepository::class); + // --- Arg::captor(): typed reading of what the subject passed ---------------- $saved = Arg::captor(Book::class); diff --git a/llms.txt b/llms.txt index 27f40be..4ac00b8 100644 --- a/llms.txt +++ b/llms.txt @@ -15,6 +15,11 @@ No runtime deps; `ext-mbstring` is not needed. - A call is specified by making it inside a closure, never by a method-name string: `when(fn () => $repo->find(123))`. +- A parameter the specification does not spell means "whatever was passed" + where the contract declares it optional, and is refused unless `Arg::rest()` + covers it where the contract declares it required. A real call still logs the + contract's default for an argument the caller omitted, so `tag('a')` and + `tag('a', 1)` are one call. - The closure must contain **exactly one** direct call on a double. Anything else raises `InvalidCallSpecification`. - A double exposes nothing beyond its contract. All operations are static @@ -163,6 +168,9 @@ use Rasuvaeff\Understudy\Invocation; when(fn () => $repo->find(1))->returns($book); when(fn () => $repo->find(1))->returns($first, $second); // one per call, last repeats when(fn () => $repo->find(1))->throws(new NotFound()); +when(fn () => $repo->save(Arg::any()))->throwsWith( // built per call, from the call + fn (Invocation $call) => new Rejected($call->arg('book')), +); when(fn () => $repo->find(Arg::any()))->answers( fn (Invocation $call) => new Book((string) $call->args[0]), ); @@ -193,7 +201,14 @@ after it. Arguments match by `===`, or by matcher. | `which($method, $value)` | an object whose getter answers this value | | `none()` | an empty variadic tail; last argument only | | `remaining()` | the whole variadic tail, any length; last argument only | -| `rest()` | declared parameters left unspelled; last argument only — the one matcher that lets a specification pass fewer arguments than the method requires: `when(fn () => $s->recordOutcome('svc', Arg::rest()))`. Stopping early without it is refused | +| `rest()` | declared parameters left unspelled; last argument only — the one matcher that lets a specification stop before a REQUIRED parameter: `when(fn () => $s->recordOutcome('svc', Arg::rest()))`. Stopping before one without it is refused | + +An optional parameter needs no matcher: the contract lets a caller omit it, so +a specification that omits it says nothing about it and matches whatever was +passed there. Failure messages render such a position as `…`, never as +`any()`. A matcher inside a plain array argument is refused (an array is +compared by identity) — `Arg::containing()` describes part of an array and +reads matchers in its entries, nested. Every `Arg::*` returns `mixed`, so passing one where the contract says `int` is not a type error in an IDE. `which()` calls only a public, non-static @@ -218,7 +233,9 @@ $options->all(); // list, in call order ``` `capture()` matches like `instanceOf($class)` (untyped: like `any()`); the -value is recorded only once the whole specification matched. Works in +value is recorded only once the whole specification matched. A captor inside +`allOf()`/`anyOf()`/`not()`/`containing()` is refused where it is written: it +would match and record nothing. Works in `when()`, `expect()` and `verify()` (a `verify()` captures from the calls it claimed, on success only); in an `expectSequence()` step it matches but does not record. Captured values are dropped with the context — @@ -378,7 +395,7 @@ $calls = Understudy::calls(fn () => $repo->find(Arg::any())); // list $repo->find(Arg::any())); // ?Invocation ``` -`Invocation`: `->method`, `->args`, `->sequence`, `->sensitiveArguments`, +`Invocation`: `->method`, `->args`, `->arg(int|string)`, `->sequence`, `->sensitiveArguments`, `->argsAfter()`, `->didReturn()`, `->didThrow()`, `->returned()`, `->thrown()`, `->callOriginal()`. The outcome is asked about, never inferred from a value: `null` is a valid return. @@ -404,8 +421,8 @@ One-way; a replacement is a different object. ### Modes and naming ```php -Understudy::strict($repo); // unmatched call fails immediately -Understudy::label($repo, 'primary cache'); // name it in failure messages +Understudy::strict($repo); // unmatched call fails; answers with $repo +Understudy::label($repo, 'primary cache'); // name it in failures; answers with $repo Understudy::reset(); // drop the current context ``` @@ -422,8 +439,8 @@ All implement `Rasuvaeff\Understudy\Exception\UnderstudyError`. | Exception | Raised when | |---|---| -| `InvalidCallSpecification` | the SHAPE of a specification is wrong: the closure made no call on a double, called a static method or threw first; a tail matcher is not the last argument; the specification stopped early without `Arg::rest()`; an empty `allOf()`/`anyOf()`; `verify()` arguments that contradict each other; a facade handed an object that is not a double; a protocol armed over a running one | -| `InvalidSpecificationArgument` | a VALUE no run could act on: `times(5, 2)`, a negative count, `returns()` with nothing to return, an inverted `Arg::int/float/count()` range, an `Arg::string()` pattern PCRE cannot compile, `Arg::instanceOf()` naming a type that is not loadable. Extends `\InvalidArgumentException` | +| `InvalidCallSpecification` | the SHAPE of a specification is wrong: the closure made no call on a double, called a static method or threw first; a tail matcher is not the last argument; the specification stopped before a required parameter without `Arg::rest()`; a matcher nested inside an array argument; a captor inside a combinator; an empty `allOf()`/`anyOf()`; `verify()` arguments that contradict each other; a facade handed an object that is not a double; a protocol armed over a running one | +| `InvalidSpecificationArgument` | a VALUE no run could act on: `times(5, 2)`, a negative count, `returns()` with nothing to return, an inverted `Arg::int/float/count()` range, an `Arg::string()` pattern PCRE cannot compile, `Arg::instanceOf()` naming a type that is not loadable, `Invocation::arg()` naming a parameter the method does not declare. Extends `\InvalidArgumentException` | | `UnsupportedTarget` | target missing, undoublable, or targets conflict | | `StrictModeViolation` | a strict double got an unconfigured call | | `NoDefaultValue` | loose mode has no safe value for the return type | diff --git a/resources/skills/rasuvaeff-understudy/SKILL.md b/resources/skills/rasuvaeff-understudy/SKILL.md index 3e438ff..cfbdaae 100644 --- a/resources/skills/rasuvaeff-understudy/SKILL.md +++ b/resources/skills/rasuvaeff-understudy/SKILL.md @@ -122,11 +122,100 @@ is not an IDE type error. A matcher that reaches a real call raises pattern, an unloadable `instanceOf()` type — is refused where it is written with `InvalidSpecificationArgument`. -`Arg::rest()` is the one matcher that lets a specification stop before the -method's required parameters run out — `when(fn () => $s->record('svc', -Arg::rest()))`; stopping early without it is refused. `Arg::captor(X::class)` -plus `$captor->capture()` in the specification, then `$captor->last()` / -`all()`, is the typed replacement for reading `args[N]` out of the call log. +**An optional parameter needs no matcher at all.** The contract lets a caller +omit it, so a specification that omits it says nothing about it and matches +whatever was passed there; a failure message renders that position as `…`. +`Arg::rest()` is for the other case — stopping before a **required** +parameter, `when(fn () => $s->record('svc', Arg::rest()))` — and stopping +before one without it is refused. + +A matcher buried in a plain array argument is refused too (an array is +compared by identity): `Arg::containing(['id' => Arg::int()])` is how part of +a payload is described, and it reads matchers in its own entries. + +`Arg::captor(X::class)` plus `$captor->capture()` in the specification, then +`$captor->last()` / `all()`, is the typed replacement for reading `args[N]` +out of the call log. A captor inside `allOf()`/`anyOf()`/`not()`/ +`containing()` is refused: it would match and record nothing. + +## Five doubles that cover a real package + +Migrating four packages onto this library replaced about thirty hand-rolled +doubles, and these five archetypes covered every one of them. Reach for the +one that fits before inventing a sixth. + +**1. A request-capturing client** (PSR-18 and every shape like it): + +```php +$requests = Arg::captor(RequestInterface::class); +when(fn () => $client->sendRequest($requests->capture())) + ->answers(fn (Invocation $call): ResponseInterface => $this->currentResponse); + +$requests->last()->getUri()->getPath(); +``` + +`NothingCaptured` failing a test that never sent a request is the feature, not +a nuisance. + +**2. A strict dependency nothing may call:** + +```php +$httpClient = Understudy::strict(Understudy::for(ClientInterface::class)); +``` + +Replaces the `throw new LogicException('not called in this test')` fake, and +fails **at** the call rather than never. + +**3. A counting spy — `calls()`, not captors.** For an ordered history, and +for anything that has to include calls that threw: + +```php +$publishedIds = array_map( + static fn (Invocation $call): string => $call->arg('message')->getId(), + Understudy::calls(fn () => $publisher->publish(Arg::any())), +); +``` + +A PSR-3 logger across two levels took four captors before `calls()` collapsed +it to one line. Captors are for typed positional reads; `calls()` is for +histories. + +**4. A fault-injecting decorator over a real implementation:** + +```php +$inner = new InMemoryStorage(); +$storage = Understudy::delegate(StorageInterface::class, $inner); + +$inner->save($seed); // seed WITHOUT recording +when(fn () => $storage->markPublished(Arg::any()))->throws($failure); +verify(fn () => $storage->save(Arg::any()), never: true); // seeds stay uncounted +``` + +The non-obvious part is the seeding: through the inner instance, or the seeds +pollute `verify()` counts. This replaced two stateful decorator classes of 73 +and 87 lines. + +**5. Per-object behaviour — broad first, specific after:** + +```php +when(fn () => $publisher->publish(Arg::any())); // permission FIRST +when(fn () => $publisher->publish(Arg::which('getId', 'msg-1'))) // specific AFTER + ->throwsWith(fn (Invocation $call) => new PublishException( + message: 'Publish failed', + outboxMessage: $call->arg('message'), + )); +``` + +`throwsWith()` is what makes this one readable: the exception carries the +argument of the very call it answers, which `throws()` cannot know. + +### What is NOT a double's job + +Value fixtures (a PSR-7 request built by hand), a movable clock, a stateful +in-memory model, a property-test harness, and anything the DI container binds +for the test are ordinary objects. A double stands in for a **collaborator +the test wants to observe or steer** — reaching for one elsewhere buys +indirection and pays for it in a test nobody can read. ## Choosing a target @@ -270,5 +359,7 @@ graph in the arguments multiplies by the run count. - [ ] `verify(..., never: true)`, not `verify(...)->times(0)`. - [ ] An adapter is installed, or `reset()` runs in teardown. - [ ] No `Arg::*` left in a real call — that is `MatcherLeaked`. +- [ ] No captor inside a combinator, and no matcher inside an array argument — + both are refused where they are written. - [ ] A static contract method is not being doubled: calling one raises `InvalidCallSpecification`. Inject an instance dependency instead. diff --git a/src/Arg.php b/src/Arg.php index 1c2e7cc..e42b6f1 100644 --- a/src/Arg.php +++ b/src/Arg.php @@ -13,6 +13,7 @@ use Rasuvaeff\Understudy\Matcher\AnyTail; use Rasuvaeff\Understudy\Matcher\ArrayContaining; use Rasuvaeff\Understudy\Matcher\BooleanValue; +use Rasuvaeff\Understudy\Matcher\Capturing; use Rasuvaeff\Understudy\Matcher\CountBetween; use Rasuvaeff\Understudy\Matcher\EmptyTail; use Rasuvaeff\Understudy\Matcher\FloatInRange; @@ -114,6 +115,8 @@ public static function same(mixed $value): mixed */ public static function not(mixed $value): mixed { + self::rejectCaptor('not', $value); + return new Negated($value); } @@ -208,6 +211,13 @@ public static function satisfies(callable $predicate, string $description = 'sat */ public static function containing(array $entries): mixed { + // Nested, because `containing(['user' => ['id' => $ids->capture()]])` + // reads like it would record and does not. + array_walk_recursive( + $entries, + static fn(mixed $entry): null => self::rejectCaptor('containing', $entry), + ); + return new ArrayContaining($entries); } @@ -325,11 +335,35 @@ private static function operands(string $matcher, array $operands): array if ($operand instanceof TailMatcher) { throw InvalidCallSpecification::tailMatcherInCombinator($matcher, $operand->describe()); } + + self::rejectCaptor($matcher, $operand); } return $operands; } + /** + * A captor inside a combinator is refused rather than accepted silently. + * + * It would match — a combinator asks its operands and a captor accepts — + * and it would never record: recording happens once the whole + * specification matched, and only for the captors the specification holds + * in a position of their own. The middle ground the engine used to offer + * (matching, not recording, not complaining) is the worst of the three, + * because the specification then behaves correctly in every observable way + * except the one it was written for. + * + * @param non-empty-string $matcher + */ + private static function rejectCaptor(string $matcher, mixed $operand): null + { + if ($operand instanceof Capturing) { + throw InvalidCallSpecification::captorInCombinator($matcher); + } + + return null; + } + /** * Requires the variadic tail to be empty. Only valid as the last argument. */ diff --git a/src/Codegen/MethodSignature.php b/src/Codegen/MethodSignature.php index a895787..c23ef74 100644 --- a/src/Codegen/MethodSignature.php +++ b/src/Codegen/MethodSignature.php @@ -25,6 +25,13 @@ * Resolved here, with the rest of the reflection, because a * failure message is rendered on the hot path of a failing * test and PHP redacts these in its own traces + * @param array $parameterNames the contract's own name for each fixed + * parameter, so a call can be read by name + * @param array $optionalParameters the positions the contract + * lets a caller omit, each with the parameter that declared + * the default — null where the position is optional only + * because another target does not declare it at all. A + * position missing from this map is required */ public function __construct( public string $name, @@ -38,5 +45,28 @@ public function __construct( public bool $static = false, public string $visibility = 'public', public array $sensitiveParameters = [], + public array $parameterNames = [], + public array $optionalParameters = [], ) {} + + /** + * The value the contract gives a parameter the caller omitted. + * + * Evaluated per call, which is what PHP does for a default that builds an + * object. `null` is also the answer for a position that is optional only + * because a second target does not declare it — there is no contract + * default to reproduce there, and null is what the parameter used to + * carry when the double rendered defaults itself. + */ + public function defaultAt(int $position): mixed + { + $parameter = $this->optionalParameters[$position] ?? null; + + return $parameter?->getDefaultValue(); + } + + public function isOptional(int $position): bool + { + return \array_key_exists($position, $this->optionalParameters); + } } diff --git a/src/Codegen/TargetUnifier.php b/src/Codegen/TargetUnifier.php index a36f4db..c13275b 100644 --- a/src/Codegen/TargetUnifier.php +++ b/src/Codegen/TargetUnifier.php @@ -287,10 +287,22 @@ private static function unifyMethod(string $name, array $declarations): MethodSi $parameters = []; $arguments = []; $byReferenceParameters = false; + $parameterNames = []; + $optionalParameters = []; for ($position = 0; $position < $arity; $position++) { $parameter = self::unifyParameter($name, $declarations, $position); $parameters[] = $parameter['rendered']; + $parameterNames[$position] = $parameter['name']; + + // Only the optional positions are listed, and each one carries the + // reflection of the parameter that declared the default rather than + // the value: `new Foo()` as a default builds one instance per call + // in PHP, and evaluating it once at generation time would hand + // every caller the same object. + if ($parameter['optional']) { + $optionalParameters[$position] = $parameter['default']; + } // Collected by name rather than with func_get_args(), which omits // parameters the caller left at their default: the call log must @@ -336,6 +348,8 @@ private static function unifyMethod(string $name, array $declarations): MethodSi // public declaration makes the whole override public. visibility: self::visibilityOf($declarations), sensitiveParameters: self::sensitiveParameters($declarations), + parameterNames: $parameterNames, + optionalParameters: $optionalParameters, ); } @@ -921,7 +935,8 @@ private static function unifyVariadicTail(string $name, array $declarations, int * @param non-empty-string $name * @param non-empty-list<\ReflectionMethod> $declarations * - * @return array{rendered: non-empty-string, name: non-empty-string, byReference: bool} + * @return array{rendered: non-empty-string, name: non-empty-string, byReference: bool, + * optional: bool, default: \ReflectionParameter|null} */ private static function unifyParameter(string $name, array $declarations, int $position): array { @@ -941,6 +956,7 @@ private static function unifyParameter(string $name, array $declarations, int $p // `renderDefault()` never returns it. $declaredDefault = ''; $defaultDeclaredBy = null; + $defaultParameter = null; foreach ($declarations as $declaration) { $parameter = $declaration->getParameters()[$position] ?? null; @@ -998,6 +1014,7 @@ private static function unifyParameter(string $name, array $declarations, int $p if ($defaultDeclaredBy === null) { $declaredDefault = $declared; $defaultDeclaredBy = $declaration; + $defaultParameter = $parameter; } elseif ($declaredDefault !== $declared) { throw UnsupportedTarget::signatureConflict( $name, @@ -1028,45 +1045,56 @@ private static function unifyParameter(string $name, array $declarations, int $p // and under the same exemptions: `mixed`, `object` and an // untyped parameter admit the sentinel already, and appending // it to `object` would be the redundant union PHP refuses. - if (!$optional) { - $types[self::ABSENT] = true; - } + // + // Every parameter carries it, optional ones included: the + // sentinel is how a specification says nothing about a + // parameter it did not spell, and a materialized default + // in its place would make the omission indistinguishable + // from spelling the default value. + $types[self::ABSENT] = true; } $type = implode('|', array_keys($types)); + + // A position optional only because another target does not + // declare it has no contract default to put back, so dispatch + // fills it with `null` and the type has to admit one. A parameter + // that declares its own default needs nothing here: an implicitly + // nullable one is reported nullable by Reflection already, and + // this branch is the only place the union is real — `mixed|null` + // is not a type PHP accepts at all. + if ($optional && $defaultParameter === null && !isset($types['null'])) { + $type .= '|null'; + } } - // Keeping the contract's own default is what makes an omitted argument - // observable: `tag('alpha')` must log the same arguments as - // `tag('alpha', 1)`, or the two would verify as different calls. + // Every parameter defaults to the sentinel, so a specification may + // physically pass fewer arguments than the method declares — and, for + // an optional parameter, so that leaving it unspelled stays visible as + // such instead of arriving as the contract's default value. // - // Empty only when no target declares one — the parameter is optional - // here because another target does not declare it at all, so there is - // no contract default to preserve. Conflicting defaults never reach - // this line; they reject the target above. - $default = $declaredDefault === '' ? 'null' : $declaredDefault; - - if ($optional && $default === 'null' && $type !== '' && !str_contains($type, 'null')) { - // `= null` on a non-nullable type is an implicitly nullable - // parameter, deprecated since 8.4 — widen the type instead. - $type .= '|null'; - } - - // A required parameter gets the sentinel as its default, so a - // specification ending with `Arg::rest()` can stop early without PHP - // refusing the call on arity. Dispatch turns a sentinel that survives - // a real call back into the `ArgumentCountError` PHP would have - // raised, so the double is no more permissive than the contract. + // Keeping an omitted argument observable is still the rule for a real + // call: `tag('alpha')` must log the same arguments as `tag('alpha', 1)`, + // or the two would verify as different calls. Dispatch materializes the + // declared default for a sentinel that survives into one, and raises + // the `ArgumentCountError` PHP would have raised where the contract + // declares no default at all. $rendered = trim(sprintf( '%s %s$%s = %s', $type, $byReference === true ? '&' : '', $parameterName, - $optional ? $default : self::ABSENT_DEFAULT, + self::ABSENT_DEFAULT, )); \assert($rendered !== ''); - return ['rendered' => $rendered, 'name' => $parameterName, 'byReference' => $byReference === true]; + return [ + 'rendered' => $rendered, + 'name' => $parameterName, + 'byReference' => $byReference === true, + 'optional' => $optional, + 'default' => $defaultParameter, + ]; } /** diff --git a/src/Exception/InvalidCallSpecification.php b/src/Exception/InvalidCallSpecification.php index dc5576d..3646f1c 100644 --- a/src/Exception/InvalidCallSpecification.php +++ b/src/Exception/InvalidCallSpecification.php @@ -130,6 +130,42 @@ public static function tailMatcherInCombinator(string $matcher, string $operand) )); } + /** + * Builds the error for a matcher nested inside an array argument. + * + * @param non-empty-string $method the method the specification named + * @param int $position zero-based position of the array argument + * @param non-empty-string $matcher how the buried matcher describes itself + */ + public static function matcherInsideArray(string $method, int $position, string $matcher): self + { + return new self(sprintf( + "`%s` sits inside the array given as argument #%d of `%s()`, where it is compared by " + . "identity and can never match.\n" + . 'Describe the array with Arg::containing([...]), which reads matchers in its entries, ' + . 'or the whole argument with Arg::satisfies().', + $matcher, + $position + 1, + $method, + )); + } + + /** + * Builds the error for a captor inside a combinator. + * + * @param non-empty-string $matcher the combinator the captor was passed to, without `Arg::` + */ + public static function captorInCombinator(string $matcher): self + { + return new self(sprintf( + "A captor records the argument it stands for, and `Arg::%s()` builds one matcher out of " + . "others, so a captor inside it would match without ever recording.\n" + . 'Put the captor in the argument position itself, or read the calls with ' + . 'Understudy::calls().', + $matcher, + )); + } + /** * `expectSequence()` with no steps: arming an empty protocol would put every * later call on trial with nothing to try it against. @@ -169,8 +205,9 @@ public static function protocolAlreadyArmed(int $position, int $length): self public static function incompleteSpecification(string $method, int $given, int $declared): self { return new self(sprintf( - "The specification for `%s()` passed %d of its %d arguments.\n" - . 'Spell every argument, or say the rest does not matter by ending with Arg::rest().', + "The specification for `%s()` passed %d of its %d arguments, and the ones it left out " + . "are not all optional.\n" + . 'Spell every required argument, or say the rest does not matter by ending with Arg::rest().', $method, $given, $declared, @@ -187,8 +224,9 @@ public static function incompleteSpecification(string $method, int $given, int $ public static function omittedBeforeSpecified(string $method, int $omitted, int $specified): self { return new self(sprintf( - "The specification for `%s()` omitted argument #%d but specified argument #%d after it.\n" - . 'A specification spells its arguments in order — use Arg::any() for one that does not matter.', + "The specification for `%s()` omitted argument #%d — which the contract declares required — " + . "but specified argument #%d after it.\n" + . 'A specification spells its required arguments in order — use Arg::any() for one that does not matter.', $method, $omitted + 1, $specified + 1, @@ -205,7 +243,7 @@ public static function omittedTailNeedsRest(string $method, string $matcher): se { return new self(sprintf( "`%s` describes a variadic tail, not parameters left unspelled, and the specification " - . "for `%s()` stopped before its required parameters ran out.\n" + . "for `%s()` stopped before its parameters ran out.\n" . 'End with Arg::rest() to say the remaining parameters do not matter.', $matcher, $method, diff --git a/src/Exception/InvalidSpecificationArgument.php b/src/Exception/InvalidSpecificationArgument.php index 4eed37f..7ace1b0 100644 --- a/src/Exception/InvalidSpecificationArgument.php +++ b/src/Exception/InvalidSpecificationArgument.php @@ -23,6 +23,23 @@ */ final class InvalidSpecificationArgument extends \InvalidArgumentException implements UnderstudyError { + /** + * `Invocation::arg()` asked for a parameter the method does not declare. + * + * @param non-empty-string $method the method the call was made on + * @param int|string $parameter the position or name that was asked for + * @param array $known the contract's own parameter names, in order + */ + public static function unknownArgument(string $method, int|string $parameter, array $known): self + { + return new self(sprintf( + '`%s()` has no argument %s. It takes: %s', + $method, + \is_int($parameter) ? '#' . ($parameter + 1) : sprintf('named `$%s`', $parameter), + $known === [] ? 'none' : '$' . implode(', $', $known), + )); + } + /** * A cardinality whose upper bound is below its lower one: no number of * calls satisfies both. diff --git a/src/Expectation/ThrowComputed.php b/src/Expectation/ThrowComputed.php new file mode 100644 index 0000000..36149ea --- /dev/null +++ b/src/Expectation/ThrowComputed.php @@ -0,0 +1,30 @@ +build)($invocation); + } +} diff --git a/src/Invocation.php b/src/Invocation.php index edbabba..cecc42f 100644 --- a/src/Invocation.php +++ b/src/Invocation.php @@ -4,6 +4,7 @@ namespace Rasuvaeff\Understudy; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Exception\OriginalCallUnavailable; use Rasuvaeff\Understudy\Exception\OutcomeUnavailable; use Rasuvaeff\Understudy\Runtime\Runtime; @@ -41,6 +42,8 @@ final class Invocation * @param list $liveArgs the arguments as the caller still holds them, * references included — what delegation needs, * where {@see $args} is a reading of them + * @param array $parameterNames the contract's own name for each fixed + * parameter, so a call can be read by name * @param list $sensitiveArguments positions the contract marked * `#[\SensitiveParameter]`; carried on the call so a * failure message and a transcript can redact the value @@ -55,8 +58,37 @@ public function __construct( private readonly ?object $double = null, private readonly array $liveArgs = [], public readonly array $sensitiveArguments = [], + private readonly array $parameterNames = [], ) {} + /** + * One argument, by position or by the contract's own parameter name. + * + * `$call->args[0]` is opaque in a longer specification, and a library + * whose specifications are real calls should let a call be read the way it + * was written. A name that is not a fixed parameter of the method — a + * value the variadic tail absorbed, or a typo — is refused rather than + * answered with null, which is a value an argument can legitimately have. + * + * @param int|string $parameter zero-based position, or the contract's own parameter name + * + * @throws InvalidSpecificationArgument when the method declares no such parameter + */ + public function arg(int|string $parameter): mixed + { + $position = \is_int($parameter) ? $parameter : array_search($parameter, $this->parameterNames, strict: true); + + if ($position === false || !\array_key_exists($position, $this->args)) { + throw InvalidSpecificationArgument::unknownArgument( + $this->method, + $parameter, + array_values($this->parameterNames), + ); + } + + return $this->args[$position]; + } + /** * What the arguments were once the call had been answered. * diff --git a/src/Matcher/ArrayContaining.php b/src/Matcher/ArrayContaining.php index 0eacc53..f47156a 100644 --- a/src/Matcher/ArrayContaining.php +++ b/src/Matcher/ArrayContaining.php @@ -11,7 +11,10 @@ * carries — the point being to pin the part of a payload a test cares about * without restating the rest. * - * A list is matched by value, a map by key and value. + * A list is matched by value, a map by key and value. An entry may itself be a + * matcher: `containing(['id' => Arg::int(min: 1)])` is the way to say + * something about part of a payload without knowing the value, and comparing + * it by identity instead would silently never match. * * @internal */ @@ -32,7 +35,7 @@ public function matches(mixed $argument): bool if (array_is_list($this->expected)) { /** @var mixed $value */ foreach ($this->expected as $value) { - if (!in_array($value, $argument, strict: true)) { + if (!$this->containsAMatch($argument, $value)) { return false; } } @@ -42,7 +45,7 @@ public function matches(mixed $argument): bool /** @var mixed $value */ foreach ($this->expected as $key => $value) { - if (!array_key_exists($key, $argument) || $argument[$key] !== $value) { + if (!array_key_exists($key, $argument) || !Operand::matches($value, $argument[$key])) { return false; } } @@ -55,4 +58,19 @@ public function describe(): string { return 'containing(' . ArgumentFormatter::format($this->expected) . ')'; } + + /** + * @param array $argument + */ + private function containsAMatch(array $argument, mixed $expected): bool + { + /** @var mixed $value */ + foreach ($argument as $value) { + if (Operand::matches($expected, $value)) { + return true; + } + } + + return false; + } } diff --git a/src/Matcher/Unspelled.php b/src/Matcher/Unspelled.php new file mode 100644 index 0000000..5cc4700 --- /dev/null +++ b/src/Matcher/Unspelled.php @@ -0,0 +1,31 @@ +steps[$this->cursor][0] ?? null; + } + + /** + * Which steps belong to a double other than this one. + * + * A protocol across two doubles renders every step by its call alone, so + * `num()` arriving on the wrong double reads as the step that was due — + * identical text, and no hint that the difference is the receiver. This is + * what lets the report say which lines are somebody else's. + * + * @return list + */ + public function stepsOwnedElsewhere(object $double): array + { + return array_map( + static fn(array $step): bool => $step[0] !== $double, + $this->steps, + ); + } + /** * Offers one call to the protocol, advancing it when the call is the step * due. Called before anything answers the call: a call refused here must diff --git a/src/Runtime/InvocationSignal.php b/src/Runtime/InvocationSignal.php index 99b4430..ec03826 100644 --- a/src/Runtime/InvocationSignal.php +++ b/src/Runtime/InvocationSignal.php @@ -4,9 +4,13 @@ namespace Rasuvaeff\Understudy\Runtime; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Matcher\AnyRest; +use Rasuvaeff\Understudy\Matcher\ArgumentMatcher; use Rasuvaeff\Understudy\Matcher\TailMatcher; +use Rasuvaeff\Understudy\Matcher\Unspelled; +use Rasuvaeff\Understudy\Matcher\UnspelledTail; /** * Thrown by a generated method during a recording phase so that the call @@ -20,6 +24,9 @@ */ final class InvocationSignal extends \Exception { + /** How deep a specification argument is searched for a stray matcher. */ + private const int NESTING_DEPTH = 8; + /** * @param non-empty-string $method * @param list $args @@ -33,45 +40,155 @@ public function __construct( } /** - * The signal with the arity sentinels stripped, once the shape that made - * the omission legitimate has been checked. + * The signal read as a specification: every sentinel resolved, or the + * omission refused. + * + * A generated parameter defaults to the sentinel, so a specification may + * physically pass fewer arguments than the method declares. What an + * omission means depends on the contract: * - * A required parameter of a generated method defaults to the sentinel so a - * specification may physically pass fewer arguments than the method - * declares. That is only meaningful when the specification *said* the rest - * does not matter — its last spelled argument is `Arg::rest()`. Every - * other shape is refused here, by name, rather than becoming a - * specification that silently never matches: without a tail matcher the - * stripped prefix would demand an arity no materialized call ever has, and - * `Arg::remaining()`/`Arg::none()` make claims about a variadic tail, not - * about parameters left unspelled. + * - a parameter the contract declares **optional** may be left out by any + * caller, so a specification that leaves it out says nothing about it. + * It becomes {@see Unspelled} in the middle of the argument list, and + * {@see UnspelledTail} where the list stops early — the specification + * then matches whatever the code under test passed there, which is what + * spelling nothing has to mean if arity is not to become a silent part + * of every specification. + * - a parameter the contract declares **required** is present in every + * real call, so stopping before one is only meaningful when the + * specification said the rest does not matter — its last spelled + * argument is `Arg::rest()`. Every other shape is refused here, by name, + * rather than becoming a specification that silently never matches. */ - public function withoutAbsentArguments(): self + public function asSpecification(): self { - $first = array_search(Absent::Argument, $this->args, strict: true); + foreach ($this->args as $position => $argument) { + if (\is_array($argument)) { + self::rejectNestedMatchers($this->method, $position, $argument, 0); + } + } - if ($first === false) { + if (!\in_array(Absent::Argument, $this->args, strict: true)) { return $this; } + // The one nullable dereference: whether a position is optional is asked + // three times below, and asking a signature that may be missing three + // times reads as three different doubts about the same thing. + $optional = DoubleFactory::blueprintOfGenerated($this->double::class) + ?->method($this->method) + ?->optionalParameters ?? []; + $args = $this->args; + $count = count($args); + + // Where the trailing run of sentinels begins — the arguments the + // specification never reached, as opposed to a named argument that + // jumped over a parameter in the middle. + $tailFrom = $count; + + while ($tailFrom > 0 && $args[$tailFrom - 1] instanceof Absent) { + --$tailFrom; + } + /** @var mixed $argument */ - foreach (array_slice($this->args, $first + 1, preserve_keys: true) as $position => $argument) { + foreach (array_slice($args, 0, $tailFrom, preserve_keys: true) as $position => $argument) { if (!$argument instanceof Absent) { - // A named argument skipped over an earlier parameter. - throw InvalidCallSpecification::omittedBeforeSpecified($this->method, $first, $position); + continue; } + + if (!\array_key_exists($position, $optional)) { + // A named argument skipped over a parameter no caller can + // skip: the specification describes a call that cannot happen. + throw InvalidCallSpecification::omittedBeforeSpecified( + $this->method, + $position, + $this->nextSpelled($args, $position), + ); + } + + $args[$position] = new Unspelled(); } - $last = $first === 0 ? null : $this->args[$first - 1]; + if ($tailFrom === $count) { + /** @var list $args */ + return new self($this->double, $this->method, $args); + } + + /** @var mixed $last */ + $last = $tailFrom === 0 ? null : $args[$tailFrom - 1]; + $args = array_slice($args, 0, $tailFrom); + + if ($last instanceof AnyRest) { + return new self($this->double, $this->method, $args); + } - if ($last instanceof TailMatcher && !$last instanceof AnyRest) { + if ($last instanceof TailMatcher) { throw InvalidCallSpecification::omittedTailNeedsRest($this->method, $last->describe()); } - if (!$last instanceof AnyRest) { - throw InvalidCallSpecification::incompleteSpecification($this->method, $first, count($this->args)); + for ($position = $tailFrom; $position < $count; ++$position) { + if (!\array_key_exists($position, $optional)) { + throw InvalidCallSpecification::incompleteSpecification($this->method, $tailFrom, $count); + } + } + + $args[] = new UnspelledTail(); + + return new self($this->double, $this->method, $args); + } + + /** + * Refuses a matcher buried inside an array argument. + * + * A literal array is compared by identity, so `find(['id' => Arg::any()])` + * matches nothing at all — and says nothing about it, which is the failure + * mode this library refuses everywhere else. `Arg::containing()` is the + * matcher that describes part of an array, and it reads nested matchers. + * + * Depth is capped for the same reason a snapshot's is: `$a[] = &$a` is + * legal PHP, and a walk that followed it would not return. + * + * @param non-empty-string $method + * @param array $argument + */ + private static function rejectNestedMatchers(string $method, int $position, array $argument, int $depth): void + { + if ($depth >= self::NESTING_DEPTH) { + return; + } + + /** @var mixed $value */ + foreach ($argument as $value) { + if ($value instanceof ArgumentMatcher) { + throw InvalidCallSpecification::matcherInsideArray($method, $position, $value->describe()); + } + + if (\is_array($value)) { + self::rejectNestedMatchers($method, $position, $value, $depth + 1); + } + } + } + + /** + * The first position after `$from` the specification actually spelled. + * + * @param array $args + * + * @return int<0, max> + */ + private function nextSpelled(array $args, int $from): int + { + /** @var mixed $argument */ + foreach ($args as $position => $argument) { + if ($position > $from && !$argument instanceof Absent) { + \assert($position >= 0); + + return $position; + } } - return new self($this->double, $this->method, array_slice($this->args, 0, $first)); + \assert($from >= 0); + + return $from; } } diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 944b3b2..2e593ae 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -5,6 +5,7 @@ namespace Rasuvaeff\Understudy\Runtime; use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; use Rasuvaeff\Understudy\Exception\ForgottenDouble; use Rasuvaeff\Understudy\Exception\MatcherLeaked; @@ -464,6 +465,13 @@ public static function dispatch(object $double, string $method, array $args): mi $signature = $state->blueprint->method($method); $tracksReferences = $signature?->hasReferenceParameters ?? false; + // Whatever the caller left out arrives as the sentinel. A real call + // gets the contract's own default put back, so that `tag('alpha')` + // and `tag('alpha', 1)` are still the same call in the log; a + // position the contract declares required has nothing to put back + // and raises the error PHP itself would have raised. + self::materializeOmittedArguments($signature, $method, $args); + $invocation = new Invocation( method: $method, args: $tracksReferences ? self::detached($args) : $args, @@ -471,6 +479,7 @@ public static function dispatch(object $double, string $method, array $args): mi double: $double, liveArgs: $args, sensitiveArguments: $signature?->sensitiveParameters ?? [], + parameterNames: $signature?->parameterNames ?? [], ); $state->record($invocation); @@ -539,7 +548,7 @@ private static function answer( $verdict = $sequence->offer($double, $invocation); if ($verdict === SequenceVerdict::OutOfTurn) { - throw VerificationFailed::of([self::outOfTurn($state, $sequence, $invocation)]); + throw VerificationFailed::of([self::outOfTurn($state, $sequence, $invocation, $double)]); } if ($verdict === SequenceVerdict::Advanced) { @@ -625,7 +634,7 @@ private static function answer( if (!$matched && $verdict === SequenceVerdict::NotAStep) { \assert($sequence !== null); - throw VerificationFailed::of([self::unconfiguredUnderProtocol($state, $sequence, $invocation)]); + throw VerificationFailed::of([self::unconfiguredUnderProtocol($state, $sequence, $invocation, $double)]); } // A matched expectation means the call was expected, so strictness has @@ -662,17 +671,19 @@ private static function answer( * * @return non-empty-string */ - private static function describeProtocol(ArmedSequence $sequence, Invocation $invocation): string + private static function describeProtocol(ArmedSequence $sequence, Invocation $invocation, object $double): string { - return ArgumentFormatter::scope(static function () use ($sequence, $invocation): string { + return ArgumentFormatter::scope(static function () use ($sequence, $invocation, $double): string { $call = FailureReport::renderCall($invocation); + $elsewhere = $sequence->stepsOwnedElsewhere($double); $lines = []; foreach ($sequence->describe() as $index => $step) { $lines[] = sprintf( - ' %d. %s%s', + ' %d. %s%s%s', $index + 1, $step, + ($elsewhere[$index] ?? false) ? ' (on another understudy)' : '', $index + 1 === $sequence->position() ? ' <- due here' : '', ); } @@ -681,17 +692,26 @@ private static function describeProtocol(ArmedSequence $sequence, Invocation $in }); } - private static function outOfTurn(DoubleState $state, ArmedSequence $sequence, Invocation $invocation): VerificationFailure + private static function outOfTurn(DoubleState $state, ArmedSequence $sequence, Invocation $invocation, object $double): VerificationFailure { + // The step due can read exactly like the call that arrived, because a + // protocol spanning two doubles renders every step by its call alone. + // Saying whose step it was is the difference between a report and a + // riddle. + $elsewhere = $sequence->pendingOwner() !== null && $sequence->pendingOwner() !== $double + ? ' on another understudy' + : ''; + return ArgumentFormatter::scope(static fn(): VerificationFailure => new VerificationFailure( kind: FailureKind::OutOfSequence, summary: sprintf( - "Understudy `%s` received a protocol call out of turn: step %d of %d was expected to be `%s`.\n\n%s", + "Understudy `%s` received a protocol call out of turn: step %d of %d was expected to be `%s`%s.\n\n%s", $state->label(), $sequence->position(), $sequence->length(), $sequence->pending()?->describe() ?? 'nothing — the protocol has run out', - self::describeProtocol($sequence, $invocation), + $elsewhere, + self::describeProtocol($sequence, $invocation, $double), ), double: $state->label(), expectation: $sequence->pending()?->describe(), @@ -700,7 +720,7 @@ private static function outOfTurn(DoubleState $state, ArmedSequence $sequence, I )); } - private static function unconfiguredUnderProtocol(DoubleState $state, ArmedSequence $sequence, Invocation $invocation): VerificationFailure + private static function unconfiguredUnderProtocol(DoubleState $state, ArmedSequence $sequence, Invocation $invocation, object $double): VerificationFailure { return ArgumentFormatter::scope(static fn(): VerificationFailure => new VerificationFailure( kind: FailureKind::OutOfSequence, @@ -708,7 +728,7 @@ private static function unconfiguredUnderProtocol(DoubleState $state, ArmedSeque "Understudy `%s` is under an armed protocol and received a call that is neither a step nor configured.\n\n%s\n\n" . 'Say it may happen — when(fn () => $double->%s(...))->returns(...) — or make it a step.', $state->label(), - self::describeProtocol($sequence, $invocation), + self::describeProtocol($sequence, $invocation, $double), $invocation->method, ), double: $state->label(), @@ -829,6 +849,18 @@ private static function propertyState(object $double, string $property): array public static function referenceSlot(object $double, string $method, array $args): ReferenceSlot { $state = (self::ownerOf($double) ?? self::current())->stateOf($double); + + // Before `hasActionFor()`, not after: it walks the expectations the + // way dispatch will, and dispatch will see the arguments with the + // omitted ones put back. Asking it about a sentinel would answer + // "nothing configured" for a specification that spells the default, + // and the slot would keep the mode's own value instead of the + // configured one. A recording phase is the exception — the sentinel + // is what it came for. + if ($state !== null && !self::current()->isRecording()) { + self::materializeOmittedArguments($state->blueprint->method($method), $method, $args); + } + $configured = $state?->hasActionFor($method, $args) ?? false; /** @var mixed $value */ @@ -982,13 +1014,6 @@ private static function detachValue(mixed $value, int $depth): mixed * arrives during a real call, the specification closure leaked it — say so * instead of letting the code under test receive a matcher object. * - * The arity sentinel is the same kind of artifact: it exists so a - * *specification* may stop before the required parameters run out, and a - * real call it survives into is a call that omitted a required argument. - * That is answered with the `ArgumentCountError` PHP itself would have - * raised had the generated parameter kept its required arity — a double - * must not be more permissive about arity than the real implementation. - * * @param non-empty-string $method * @param list $args */ @@ -999,15 +1024,68 @@ private static function rejectLeakedMatchers(string $method, array $args): void if ($argument instanceof ArgumentMatcher) { throw MatcherLeaked::intoRealCall($method, $position, $argument->describe()); } + } + } + + /** + * Puts the contract's declared default back into every parameter the + * caller omitted. + * + * Every generated parameter defaults to the sentinel, optional ones + * included, so that a *specification* can leave a parameter unspelled + * without the default value standing in for it. A real call wants the + * opposite: the log has to show what the method received, so an omitted + * argument reads as the value the real implementation would have seen. + * + * The sentinel surviving on a position the contract declares required is + * a call that omitted a required argument, and it is answered with the + * `ArgumentCountError` PHP itself would have raised had the generated + * parameter kept its arity — a double must not be more permissive about + * arity than the real implementation. + * + * @param non-empty-string $method + * @param list $args + * + * @param-out list $args + */ + private static function materializeOmittedArguments(?MethodSignature $signature, string $method, array &$args): void + { + $omitted = []; + + /** @var mixed $argument */ + foreach ($args as $position => $argument) { + if (!$argument instanceof Absent) { + continue; + } - if ($argument instanceof Absent) { + if ($signature === null || !$signature->isOptional($position)) { throw new \ArgumentCountError(sprintf( 'Too few arguments to function %s(), argument #%d not passed', $method, $position + 1, )); } + + $omitted[] = $position; + } + + if ($omitted === []) { + return; } + + \assert($signature instanceof MethodSignature); + + // `array_replace` rather than a rebuilt list: it keeps the reference + // elements a by-reference parameter is collected with — measured, not + // assumed — and a forwarded call must still be able to write back to + // the caller's variable. + $args = array_replace($args, array_combine( + $omitted, + array_map( + $signature->defaultAt(...), + $omitted, + ), + )); } /** diff --git a/src/Understudy.php b/src/Understudy.php index 33fe2b0..80569cc 100644 --- a/src/Understudy.php +++ b/src/Understudy.php @@ -13,6 +13,7 @@ use Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\OriginalCallUnavailable; +use Rasuvaeff\Understudy\Exception\UnderstudyError; use Rasuvaeff\Understudy\Exception\UnsupportedTarget; use Rasuvaeff\Understudy\Exception\VerificationFailed; use Rasuvaeff\Understudy\Expectation\ArgumentFormatter; @@ -538,10 +539,24 @@ public static function lastCall(callable $call): ?Invocation /** * Makes an understudy fail on any call no expectation matched. + * + * Answers with the double it configured, so the mode can be chosen where + * the double is handed over — `ClientInterface::class => + * Understudy::strict(Understudy::for(ClientInterface::class))` in a + * container definition, rather than as a statement that has to find a + * variable to name. + * + * @template T of object + * + * @param T $double + * + * @return T */ - public static function strict(object $double): void + public static function strict(object $double): object { self::stateOf($double, 'strict')->setMode(Mode::Strict); + + return $double; } /** @@ -794,11 +809,21 @@ public static function defaults(string $contract, callable $factory): void * Names one understudy in failure messages, which is what makes two * doubles of the same contract tellable apart. * + * Answers with the double it named, for the same reason + * {@see self::strict()} does. + * + * @template T of object + * + * @param T $double * @param non-empty-string $label + * + * @return T */ - public static function label(object $double, string $label): void + public static function label(object $double, string $label): object { self::stateOf($double, 'label')->setLabel($label); + + return $double; } /** @@ -1292,7 +1317,14 @@ private static function record(callable $call): InvocationSignal // parameters answered with their sentinel defaults, and those are // stripped — or the omission is refused — before anything reads // the arguments as a specification. - return $signal->withoutAbsentArguments(); + return $signal->asSpecification(); + } catch (UnderstudyError $failure) { + // Our own refusals are already about the specification — a matcher + // built with an impossible range, a captor inside a combinator, a + // double the test retired. Wrapping one in "the closure threw + // before it reached an understudy" would bury the sentence that + // says what to change. + throw $failure; } catch (\Throwable $failure) { throw InvalidCallSpecification::closureFailed($failure); } finally { diff --git a/src/WhenBuilder.php b/src/WhenBuilder.php index 1711511..c66ece2 100644 --- a/src/WhenBuilder.php +++ b/src/WhenBuilder.php @@ -8,6 +8,7 @@ use Rasuvaeff\Understudy\Expectation\ComputeAnswer; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Expectation\ReturnValue; +use Rasuvaeff\Understudy\Expectation\ThrowComputed; use Rasuvaeff\Understudy\Expectation\ThrowError; /** @@ -86,6 +87,31 @@ public function throws(\Throwable $error): static return $this; } + /** + * Throws an exception built from the call itself, one per call. + * + * The shape `throws()` cannot express: an exception that carries what the + * call was made with — `new PublishException($message, outboxMessage: + * $call->args[0])`. A throwing `answers()` closure does the same thing and + * stays supported; this reads as what it is at the call site. + * + * ```php + * when(fn () => $publisher->publish(Arg::any())) + * ->throwsWith(fn (Invocation $call) => new PublishException( + * message: 'Publish failed', + * outboxMessage: $call->arg('message'), + * )); + * ``` + * + * @param callable(Invocation): \Throwable $build builds the exception from the call it answers + */ + public function throwsWith(callable $build): static + { + $this->expectation->setAction(new ThrowComputed($build), $this->slot); + + return $this; + } + /** * Computes the return value for each matching call. * diff --git a/tests/ArgTest.php b/tests/ArgTest.php index 9d50c72..0eb14fb 100644 --- a/tests/ArgTest.php +++ b/tests/ArgTest.php @@ -209,6 +209,26 @@ public static function matchProvider(): iterable yield 'containing matches list membership' => [Arg::containing([2]), [1, 2, 3], true]; yield 'containing rejects an absent element' => [Arg::containing([9]), [1, 2, 3], false]; yield 'containing rejects a non-array' => [Arg::containing([]), 'nope', false]; + // An entry may be a matcher: describing part of a payload without + // knowing the value is the whole point, and comparing the entry by + // identity would make the specification silently unmatchable. + yield 'containing reads a matcher in a map entry' => [ + Arg::containing(['a' => Arg::int(min: 1)]), + ['a' => 5, 'b' => 2], + true, + ]; + yield 'containing rejects a map entry the matcher refuses' => [ + Arg::containing(['a' => Arg::int(min: 10)]), + ['a' => 5], + false, + ]; + yield 'containing reads a matcher in a list entry' => [Arg::containing([Arg::string()]), [1, 'a'], true]; + yield 'containing rejects a list without a match' => [Arg::containing([Arg::string()]), [1, 2], false]; + yield 'containing reads a nested matcher' => [ + Arg::containing(['user' => Arg::containing(['id' => Arg::int()])]), + ['user' => ['id' => 7, 'name' => 'a']], + true, + ]; yield 'count honours minimum' => [Arg::count(minimum: 2), [1], false]; yield 'count honours maximum' => [Arg::count(maximum: 2), [1, 2, 3], false]; @@ -270,6 +290,52 @@ public function aTailMatcherCannotBeAnOperand(): void Arg::allOf(Arg::string(), Arg::remaining()); } + /** + * A captor inside a combinator matched and recorded nothing: the + * specification then behaved correctly in every observable way except the + * one it was written for, and the only way to find out was an assertion on + * an empty captor further down the test. + */ + public function aCaptorCannotBeAnOperand(): void + { + Expect::exception(InvalidCallSpecification::class)->withMessage( + "A captor records the argument it stands for, and `Arg::allOf()` builds one matcher out " + . "of others, so a captor inside it would match without ever recording.\n" + . 'Put the captor in the argument position itself, or read the calls with ' + . 'Understudy::calls().', + ); + + Arg::allOf(Arg::instanceOf(Book::class), Arg::captor()->capture()); + } + + public function aCaptorCannotBeADisjunctionOperandEither(): void + { + Expect::exception(InvalidCallSpecification::class) + ->withMessageContaining('`Arg::anyOf()` builds one matcher out of others'); + + Arg::anyOf(Arg::captor()->capture(), 5); + } + + public function aCaptorCannotBeNegated(): void + { + Expect::exception(InvalidCallSpecification::class) + ->withMessageContaining('`Arg::not()` builds one matcher out of others'); + + Arg::not(Arg::captor()->capture()); + } + + /** + * Nested, because `containing(['user' => ['id' => $ids->capture()]])` + * reads exactly like something that would record. + */ + public function aCaptorCannotSitInsideContaining(): void + { + Expect::exception(InvalidCallSpecification::class) + ->withMessageContaining('`Arg::containing()` builds one matcher out of others'); + + Arg::containing(['user' => ['id' => Arg::captor()->capture()]]); + } + public function anEmptyTailMatcherCannotBeAnOperandEither(): void { Expect::exception(InvalidCallSpecification::class)->withMessage( diff --git a/tests/ByReferenceTest.php b/tests/ByReferenceTest.php index 4e0ef36..b00f6d0 100644 --- a/tests/ByReferenceTest.php +++ b/tests/ByReferenceTest.php @@ -152,6 +152,26 @@ public function aConfiguredAnswerReplacesTheSlot(): void Assert::same($registry->values(), ['configured' => true]); } + /** + * The slot is chosen before dispatch, by walking the expectations the way + * dispatch will — so it has to see the same arguments dispatch will see. + * With the omitted ones still sentinels, a specification that spells the + * contract's default answered "nothing configured", the slot kept what the + * test had written through the reference, and the configured value was + * quietly not the one that came back. + */ + public function aConfiguredAnswerReplacesTheSlotOfACallThatOmittedAnArgument(): void + { + $registry = Understudy::for(Registry::class); + + when(static fn(): array => $registry->bucket('a', 3))->returns(['configured' => true]); + + $bucket = &$registry->bucket('a'); + $bucket['written'] = true; + + Assert::same($registry->bucket('a'), ['configured' => true]); + } + public function aByReferenceCallIsRecordedLikeAnyOther(): void { $registry = Understudy::for(Registry::class); diff --git a/tests/Codegen/TargetUnifierEdgeTest.php b/tests/Codegen/TargetUnifierEdgeTest.php index 239782c..ef731ab 100644 --- a/tests/Codegen/TargetUnifierEdgeTest.php +++ b/tests/Codegen/TargetUnifierEdgeTest.php @@ -4,8 +4,10 @@ namespace Rasuvaeff\Understudy\Tests\Codegen; +use Rasuvaeff\Understudy\Arg; use Rasuvaeff\Understudy\Codegen\TargetUnifier; use Rasuvaeff\Understudy\Exception\UnsupportedTarget; +use Rasuvaeff\Understudy\Tests\Fixture\Unify\MixedNullDefault; use Rasuvaeff\Understudy\Tests\Fixture\Unify\NarrowThenWideReturn; use Rasuvaeff\Understudy\Tests\Fixture\Unify\NullableObjectParam; use Rasuvaeff\Understudy\Tests\Fixture\Unify\ParentParameterChild; @@ -126,6 +128,31 @@ public function aNullableObjectParameterUnifiesWithAWiderUnion(): void Assert::same(count(Understudy::calls(static fn() => $double->accept(null))), 1); } + /** + * `mixed $v = null` used to render as `mixed|null`, which PHP refuses at + * compile time — a fatal out of `eval()`, uncatchable, killing the whole + * run for a signature that is neither exotic nor rare. The widening + * belongs to the branch where the union is real, and the default belongs + * to dispatch. + */ + public function aMixedParameterWithANullDefaultIsDoublable(): void + { + $double = Understudy::for(MixedNullDefault::class); + + $double->accept(); + $double->accept('given'); + + Assert::same( + array_map( + static fn(\Rasuvaeff\Understudy\Invocation $call): mixed => $call->args[0], + Understudy::calls(static function () use ($double): void { + $double->accept(Arg::any()); + }), + ), + [null, 'given'], + ); + } + public function aDefaultComputedFromSelfIsReproducedByValue(): void { // `self::STEP * 2` is an expression, not a constant name, so @@ -136,10 +163,12 @@ public function aDefaultComputedFromSelfIsReproducedByValue(): void Assert::same($double->step(), 0); - $parameter = (new \ReflectionMethod($double, 'step'))->getParameters()[0]; + // The generated parameter carries the sentinel — what the contract + // computed is put back by dispatch, and the call log is where that is + // visible. + $double->step(); - Assert::true($parameter->isDefaultValueAvailable()); - Assert::same($parameter->getDefaultValue(), 6); + Assert::same(Understudy::lastCall(static fn(): int => $double->step(Arg::any()))?->args, [6]); } public function aReturnThatAlreadySatisfiesAnotherDoesNotWidenTheIntersection(): void diff --git a/tests/Codegen/TargetUnifierTest.php b/tests/Codegen/TargetUnifierTest.php index 075e50d..f19d793 100644 --- a/tests/Codegen/TargetUnifierTest.php +++ b/tests/Codegen/TargetUnifierTest.php @@ -136,7 +136,7 @@ final class TargetUnifierTest private const string ABSENT = '\\' . Absent::class; - /** The default every required parameter of an override now carries. */ + /** The default every parameter of an override now carries. */ private const string OMITTED = ' = ' . self::ABSENT . '::Argument'; // --- Rendered signatures ------------------------------------------------- @@ -161,8 +161,11 @@ public static function parameterProvider(): iterable // The matcher goes after the contract's own branches, and `null` is // part of the type rather than an implicit nullable default. yield 'nullable expands then widens' => ['nullable', "string|null|{$m}|{$absent} \$a{$omitted}"]; - yield 'a declared default is preserved' => ['withDefault', "int|{$m} \$a = 7"]; - yield 'a null default renders lowercase' => ['withNullDefault', "int|null|{$m} \$a = null"]; + // An optional parameter carries the sentinel like every other one: + // the contract's default is put back by dispatch, not by the + // signature, so that a specification can leave it unspelled. + yield 'a declared default gives way to the sentinel' => ['withDefault', "int|{$m}|{$absent} \$a{$omitted}"]; + yield 'a null default keeps null in the type' => ['withNullDefault', "int|null|{$m}|{$absent} \$a{$omitted}"]; yield 'variadic carries no default' => ['variadic', "string|{$m} ...\$rest"]; yield 'variadic follows a fixed parameter' => [ 'scalarThenVariadic', @@ -339,7 +342,7 @@ public function aParameterMissingFromOneTargetBecomesOptional(): void Assert::same( $signature->parameters, 'int|' . self::MATCHER . '|' . self::ABSENT . ' $a' . self::OMITTED - . ', int|' . self::MATCHER . '|null $b = null', + . ', int|' . self::MATCHER . '|' . self::ABSENT . '|null $b' . self::OMITTED, ); } @@ -483,13 +486,28 @@ public function conflictingParameterDefaultsAreRejected(): void * parameter required leaves exactly one default to preserve, and the same * default written twice is one value however many targets declare it. */ - public function aDefaultDeclaredOnlyOnceIsKept(): void + /** + * A position optional only because another target does not declare it has + * no contract default to put back — dispatch fills it with `null`, and the + * signature has to say so rather than dereferencing a parameter that is + * not there. + */ + public function aPositionNoTargetDefaultsHasNoDefaultToMaterialize(): void { - Assert::string($this->tagParameters(TaggerFive::class, TaggerRequired::class)) - ->contains('$weight = 5'); + $signature = $this->unify(ArityOne::class, ArityTwo::class)['emit']; - Assert::string($this->tagParameters(TaggerFive::class, TaggerFive::class)) - ->contains('$weight = 5'); + Assert::false($signature->isOptional(0)); + Assert::true($signature->isOptional(1)); + Assert::null($signature->defaultAt(1)); + } + + public function aDefaultDeclaredOnlyOnceIsKept(): void + { + // Kept as the contract's, not as the signature's: the rendered + // parameter carries the sentinel now, and the value it stands for is + // what dispatch puts back into an omitted argument. + Assert::same($this->tagSignature(TaggerFive::class, TaggerRequired::class)->defaultAt(1), 5); + Assert::same($this->tagSignature(TaggerFive::class, TaggerFive::class)->defaultAt(1), 5); } /** @@ -1042,45 +1060,36 @@ public function oneAtomCanSupersedeSeveralAlreadyCollected(): void ); } - // --- Constant defaults render as their declared form --------------------- + // --- Constant defaults are materialized from the declaring class --------- /** - * The value alone cannot tell `= \\Cfg::LIMIT` from `= 5`, so the rendered - * SOURCE is asserted: a constant default keeps its name, resolved through - * the class that declares it — `SELF`/`PARENT` (case included) never - * follow the double into a class that never had the constant. + * The value alone cannot tell `= \\Cfg::LIMIT` from `= 5`, and the double + * must answer with the contract's own — `SELF`/`PARENT` (case included) + * never resolving against the generated class, which never had the + * constant. The generated signature carries the sentinel, so what is + * asserted is the value dispatch puts back into an omitted argument. */ #[DataProvider('constantDefaultProvider')] - public function rendersAConstantDefaultByItsDeclaredName(string $method, string $expected): void + public function materializesAConstantDefaultFromTheDeclaringClass(string $method, mixed $expected): void { $signature = TargetUnifier::unify([new \ReflectionClass(ConstantDefaults::class)])[$method]; - Assert::same($signature->parameters, $expected); + Assert::true($signature->isOptional(0)); + Assert::same($signature->defaultAt(0), $expected); } /** - * @return iterable + * @return iterable */ public static function constantDefaultProvider(): iterable { - $m = self::MATCHER; - - yield "another class's constant" => [ - 'viaClass', - "int|{$m} \$a = \\" . KnownConstants::class . '::LIMIT', - ]; - yield 'SELF resolves to the declaring class, whatever the case' => [ - 'viaSelfUpper', - "int|{$m} \$a = \\" . ConstantDefaults::class . '::MINE', - ]; + yield "another class's constant" => ['viaClass', KnownConstants::LIMIT]; + yield 'SELF resolves to the declaring class, whatever the case' => ['viaSelfUpper', ConstantDefaults::MINE]; yield 'PARENT resolves to the parent class, not the declaring one' => [ 'viaParentUpper', - "int|{$m} \$a = \\" . ConstantDefaultBase::class . '::FROM_PARENT', - ]; - yield 'an interface constant' => [ - 'viaInterfaceConstant', - "string|{$m} \$a = \\" . ConstantsInterface::class . '::MODE', + ConstantDefaultBase::FROM_PARENT, ]; + yield 'an interface constant' => ['viaInterfaceConstant', ConstantsInterface::MODE]; } // --- Reference detection ------------------------------------------------- @@ -1151,13 +1160,13 @@ private function unify(string ...$contracts): array )); } - private function tagParameters(string ...$contracts): string + private function tagSignature(string ...$contracts): MethodSignature { $signature = $this->unify(...$contracts)['tag'] ?? null; Assert::instanceOf($signature, MethodSignature::class); - return $signature instanceof MethodSignature ? $signature->parameters : ''; + return $signature; } private function showcase(string $method): MethodSignature diff --git a/tests/ExpectationsTest.php b/tests/ExpectationsTest.php index 9b95dfd..44c1f8c 100644 --- a/tests/ExpectationsTest.php +++ b/tests/ExpectationsTest.php @@ -5,11 +5,13 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Exception\NeverMethodCalled; use Rasuvaeff\Understudy\Exception\VerificationFailed; use Rasuvaeff\Understudy\Expectation\ComputeAnswer; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Expectation\ReturnValue; +use Rasuvaeff\Understudy\Expectation\ThrowComputed; use Rasuvaeff\Understudy\Expectation\ThrowError; use Rasuvaeff\Understudy\ExpectBuilder; use Rasuvaeff\Understudy\FailureReport; @@ -38,10 +40,12 @@ #[Covers(Expectation::class)] #[Covers(ReturnValue::class)] #[Covers(ThrowError::class)] +#[Covers(ThrowComputed::class)] #[Covers(ComputeAnswer::class)] #[Covers(Invocation::class)] #[Covers(FailureReport::class)] #[Covers(NeverMethodCalled::class)] +#[Covers(InvalidSpecificationArgument::class)] #[Covers(VerificationFailed::class)] final class ExpectationsTest { @@ -369,4 +373,125 @@ public function chainedReturnsAnswerInOrder(): void [1, 2, 3, 4, 5], ); } + + // --- Throwing something built from the call itself ---------------------- + + /** + * `throws()` takes an instance, which cannot know what the call carried. + * An exception built FROM the argument of the call it answers is the + * shape a hand-written fake had, and re-deriving the throwing-`answers()` + * idiom was left to every reader. + */ + public function throwsWithBuildsTheExceptionFromTheCall(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->tag(Arg::any(), Arg::rest()))->throwsWith( + static fn(Invocation $call) => new \DomainException('cannot tag ' . $call->arg('name')), + ); + + Expect::exception(\DomainException::class)->withMessage('cannot tag alpha'); + + $repository->tag('alpha'); + } + + /** + * One exception per call, where `throws()` is one instance for all of + * them: an exception carrying the call's own arguments cannot be shared. + */ + public function throwsWithBuildsANewExceptionEachTime(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->tag(Arg::any(), Arg::rest()))->throwsWith( + static fn(Invocation $call): \Throwable => new \DomainException((string) $call->arg(0)), + ); + + $thrown = []; + + foreach (['alpha', 'beta'] as $name) { + try { + $repository->tag($name); + } catch (\DomainException $error) { + $thrown[] = $error; + } + } + + Assert::same(array_map(static fn(\Throwable $e): string => $e->getMessage(), $thrown), ['alpha', 'beta']); + Assert::false($thrown[0] === ($thrown[1] ?? $thrown[0])); + } + + public function throwsWithTakesItsPlaceInAChain(): void + { + $repository = Understudy::for(BookRepository::class); + + when(fn() => $repository->count()) + ->returns(1) + ->then()->throwsWith(static fn(Invocation $call) => new \DomainException('call ' . $call->sequence)); + + Assert::same($repository->count(), 1); + + Expect::exception(\DomainException::class)->withMessageContaining('call '); + + $repository->count(); + } + + // --- Reading a call by parameter name ----------------------------------- + + public function anInvocationReadsItsArgumentsByNameAndPosition(): void + { + $repository = Understudy::for(BookRepository::class); + $repository->tag('alpha', 3); + + $call = Understudy::lastCall(fn() => $repository->tag(Arg::any(), Arg::any())); + + Assert::same($call?->arg('name'), 'alpha'); + Assert::same($call?->arg('weight'), 3); + Assert::same($call?->arg(1), 3); + } + + /** + * An omitted optional argument reads as the contract's default, like it + * does in `args` — the call log is one reading, not two. + */ + public function anOmittedArgumentReadsAsTheContractsDefault(): void + { + $repository = Understudy::for(BookRepository::class); + $repository->tag('alpha'); + + Assert::same( + Understudy::lastCall(fn() => $repository->tag(Arg::any(), Arg::any()))?->arg('weight'), + 1, + ); + } + + /** + * Answering `null` for a name the method does not declare would be + * indistinguishable from an argument that really is null. + */ + public function anUnknownArgumentNameIsRefused(): void + { + $repository = Understudy::for(BookRepository::class); + $repository->tag('alpha'); + + $call = Understudy::lastCall(fn() => $repository->tag(Arg::any(), Arg::any())); + + Expect::exception(InvalidSpecificationArgument::class) + ->withMessage('`tag()` has no argument named `$nope`. It takes: $name, $weight'); + + $call?->arg('nope'); + } + + public function anArgumentPositionThatWasNeverPassedIsRefused(): void + { + $repository = Understudy::for(BookRepository::class); + $repository->count(); + + $call = Understudy::lastCall(fn() => $repository->count()); + + Expect::exception(InvalidSpecificationArgument::class) + ->withMessage('`count()` has no argument #1. It takes: none'); + + $call?->arg(0); + } } diff --git a/tests/Fixture/Ref/RealRegistry.php b/tests/Fixture/Ref/RealRegistry.php index 929ccce..c90c419 100644 --- a/tests/Fixture/Ref/RealRegistry.php +++ b/tests/Fixture/Ref/RealRegistry.php @@ -12,6 +12,9 @@ class RealRegistry implements Registry /** @var list */ private array $labels = []; + /** @var array> */ + private array $buckets = []; + /** @var array> */ private array $rows = []; @@ -52,6 +55,16 @@ public function absorb(array &$rows): void } #[\Override] + /** + * @return array + */ + public function &bucket(string $key, int $size = 3): array + { + $this->buckets[$key] ??= ['size' => $size]; + + return $this->buckets[$key]; + } + public function count(): int { return count($this->stored); diff --git a/tests/Fixture/Ref/Registry.php b/tests/Fixture/Ref/Registry.php index 54f622b..06ea3db 100644 --- a/tests/Fixture/Ref/Registry.php +++ b/tests/Fixture/Ref/Registry.php @@ -15,6 +15,9 @@ public function &names(): array; /** @return array */ public function &row(int $id): array; + /** An optional parameter on a by-reference method: the slot's owner is chosen before dispatch. */ + public function &bucket(string $key, int $size = 3): array; + public function fill(string &$slot, string $value): void; /** diff --git a/tests/Fixture/Rest/WideStorage.php b/tests/Fixture/Rest/WideStorage.php index 5ece4ff..5aa8e84 100644 --- a/tests/Fixture/Rest/WideStorage.php +++ b/tests/Fixture/Rest/WideStorage.php @@ -22,5 +22,8 @@ public function recordOutcome( public function tag(string $name, int $weight = 1): void; + /** The default differs from the position it sits at, so one cannot stand in for the other. */ + public function note(string $text, int $level = 7): void; + public function emit(string $channel, string ...$payloads): int; } diff --git a/tests/Fixture/Unify/MixedNullDefault.php b/tests/Fixture/Unify/MixedNullDefault.php new file mode 100644 index 0000000..37f0a5c --- /dev/null +++ b/tests/Fixture/Unify/MixedNullDefault.php @@ -0,0 +1,14 @@ + $first->count(), + fn() => $second->count(), + ); + + Expect::exception(VerificationFailed::class) + ->withMessageContaining('was expected to be `count()` on another understudy') + ->withMessageContaining('1. count() (on another understudy) <- due here'); + + $second->count(); + } + public function anArmedProtocolFailsOnTheCallThatBrokeTheOrder(): void { // The whole point: the subject's own frame is on top of the stack, diff --git a/tests/RestArgumentsTest.php b/tests/RestArgumentsTest.php index 9da4103..a08bb63 100644 --- a/tests/RestArgumentsTest.php +++ b/tests/RestArgumentsTest.php @@ -9,6 +9,8 @@ use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\VerificationFailed; use Rasuvaeff\Understudy\Matcher\AnyRest; +use Rasuvaeff\Understudy\Matcher\Unspelled; +use Rasuvaeff\Understudy\Matcher\UnspelledTail; use Rasuvaeff\Understudy\Runtime\Absent; use Rasuvaeff\Understudy\Runtime\InvocationSignal; use Rasuvaeff\Understudy\Runtime\Runtime; @@ -35,6 +37,8 @@ #[Covers(Understudy::class)] #[Covers(\Rasuvaeff\Understudy\Expectation\Expectation::class)] #[Covers(AnyRest::class)] +#[Covers(Unspelled::class)] +#[Covers(UnspelledTail::class)] #[Covers(Absent::class)] #[Covers(InvocationSignal::class)] #[Covers(InvalidCallSpecification::class)] @@ -177,8 +181,9 @@ public function anIdenticalPrefixSpecificationIsRefusedAcrossVerbs(): void public function anIncompleteSpecificationWithoutRestIsRefused(): void { Expect::exception(InvalidCallSpecification::class)->withMessage( - "The specification for `recordOutcome()` passed 1 of its 7 arguments.\n" - . 'Spell every argument, or say the rest does not matter by ending with Arg::rest().', + "The specification for `recordOutcome()` passed 1 of its 7 arguments, and the ones it " + . "left out are not all optional.\n" + . 'Spell every required argument, or say the rest does not matter by ending with Arg::rest().', ); when(fn(): ?string => $this->storage->recordOutcome('svc')); @@ -195,8 +200,10 @@ public function anEmptySpecificationForARequiredArityIsRefused(): void public function aNamedArgumentSkippingAParameterIsRefused(): void { Expect::exception(InvalidCallSpecification::class)->withMessage( - "The specification for `recordOutcome()` omitted argument #2 but specified argument #7 after it.\n" - . 'A specification spells its arguments in order — use Arg::any() for one that does not matter.', + "The specification for `recordOutcome()` omitted argument #2 — which the contract declares " + . "required — but specified argument #7 after it.\n" + . 'A specification spells its required arguments in order — use Arg::any() for one that ' + . 'does not matter.', ); when(fn(): ?string => $this->storage->recordOutcome(key: 'svc', attemptId: 'attempt-1')); @@ -210,8 +217,10 @@ public function aNamedArgumentSkippingAParameterIsRefused(): void public function aHoleRightBeforeTheNextSpecifiedArgumentIsRefused(): void { Expect::exception(InvalidCallSpecification::class)->withMessage( - "The specification for `recordOutcome()` omitted argument #2 but specified argument #3 after it.\n" - . 'A specification spells its arguments in order — use Arg::any() for one that does not matter.', + "The specification for `recordOutcome()` omitted argument #2 — which the contract declares " + . "required — but specified argument #3 after it.\n" + . 'A specification spells its required arguments in order — use Arg::any() for one that ' + . 'does not matter.', ); when(fn(): ?string => $this->storage->recordOutcome(key: 'svc', config: [])); @@ -221,7 +230,7 @@ public function remainingDoesNotStandForOmittedParameters(): void { Expect::exception(InvalidCallSpecification::class)->withMessage( "`remaining()` describes a variadic tail, not parameters left unspelled, and the " - . "specification for `recordOutcome()` stopped before its required parameters ran out.\n" + . "specification for `recordOutcome()` stopped before its parameters ran out.\n" . 'End with Arg::rest() to say the remaining parameters do not matter.', ); @@ -275,4 +284,190 @@ public function aRealCallWithFullArityIsUntouched(): void verify(fn(): ?string => $this->storage->recordOutcome(Arg::rest()), times: 1); } + + // --- Optional parameters a specification did not spell ------------------- + + /** + * The contract says a caller may omit an optional parameter; a + * specification that omits it therefore says nothing about it, and matches + * whatever the code under test passed there. + * + * Materializing the declared default instead — which is what the double + * used to do — made arity an implicit part of every specification, and the + * report then said `never called` beside a call that differed only in a + * position the author never wrote. + */ + #[ExpectNoAssertions] + public function anUnspelledOptionalParameterMatchesWhateverWasPassed(): void + { + $this->storage->tag('alpha', 5); + + verify(fn() => $this->storage->tag('alpha'), times: 1); + } + + #[ExpectNoAssertions] + public function anUnspelledOptionalParameterAlsoMatchesTheDefaultedCall(): void + { + $this->storage->tag('alpha'); + $this->storage->tag('alpha', 5); + $this->storage->tag('beta', 5); + + verify(fn() => $this->storage->tag('alpha'), times: 2); + } + + /** + * The real call is the other half: an omitted argument is logged as the + * value the contract gives it, so `tag('alpha')` and `tag('alpha', 1)` + * stay the same call in the log. + */ + public function arealCallMaterializesTheContractsDefault(): void + { + $this->storage->tag('alpha'); + + Assert::same( + Understudy::lastCall(fn() => $this->storage->tag(Arg::any(), Arg::any()))?->args, + ['alpha', 1], + ); + } + + /** + * The value put back is the contract's own default, not something derived + * from where the parameter sits: `note()` defaults its second parameter to + * `7` precisely so that a position cannot stand in for a value. + */ + public function theMaterializedDefaultIsTheContractsValue(): void + { + $this->storage->note('hello'); + + Assert::same( + Understudy::lastCall(fn() => $this->storage->note(Arg::any(), Arg::any()))?->args, + ['hello', 7], + ); + } + + /** + * A named argument may skip an optional parameter, so a specification + * written with named arguments may too. + */ + #[ExpectNoAssertions] + public function aNamedArgumentSkippingAnOptionalParameterSaysNothingAboutIt(): void + { + $this->storage->emit('ch', 'a'); + $this->storage->tag(name: 'alpha', weight: 9); + + verify(fn() => $this->storage->tag(name: 'alpha'), times: 1); + } + + /** + * `Arg::rest()` on a signature whose remaining parameters are all optional + * is accepted: the docs call it "declared parameters left unspelled", and + * the engine used to refuse it because the optional ones had already + * become literals by the time it looked. + */ + #[ExpectNoAssertions] + public function restIsAcceptedWhereOnlyOptionalParametersFollow(): void + { + when(fn() => $this->storage->tag(Arg::any(), Arg::rest())); + when(fn() => $this->storage->tag(Arg::rest())); + } + + /** + * The report distinguishes what the test specified from what it left to + * the contract: `…` is not `any()`, which the test would have had to write. + */ + public function anUnspelledParameterRendersAsAnEllipsis(): void + { + $this->storage->tag('alpha', 5); + + Expect::exception(VerificationFailed::class)->withMessageContaining("tag('beta', …)"); + + verify(fn() => $this->storage->tag('beta')); + } + + // --- A matcher that cannot act where it was put ------------------------- + + /** + * A literal array is compared by identity, so a matcher inside one matches + * nothing and says nothing about it. `Arg::containing()` is the matcher + * that describes part of an array, and the refusal names it. + */ + public function aMatcherInsideAnArrayArgumentIsRefused(): void + { + Expect::exception(InvalidCallSpecification::class)->withMessage( + "`any()` sits inside the array given as argument #3 of `recordOutcome()`, where it is " + . "compared by identity and can never match.\n" + . 'Describe the array with Arg::containing([...]), which reads matchers in its entries, ' + . 'or the whole argument with Arg::satisfies().', + ); + + when(fn(): ?string => $this->storage->recordOutcome('svc', 1, ['id' => Arg::any()], Arg::rest())); + } + + public function aMatcherNestedDeeperInsideAnArrayArgumentIsRefusedToo(): void + { + Expect::exception(InvalidCallSpecification::class) + ->withMessageContaining('sits inside the array given as argument #3'); + + when(fn(): ?string => $this->storage->recordOutcome( + 'svc', + 1, + ['user' => ['id' => Arg::int()]], + Arg::rest(), + )); + } + + /** + * The walk is depth-capped, for the same reason a snapshot's is: `$a[] = + * &$a` is legal PHP, and a search that followed it would not return. Eight + * levels are searched; the ninth is where bounded work stops, and a + * matcher that deep is a shape nobody writes by hand. + */ + public function theSearchForABuriedMatcherIsDepthCapped(): void + { + Expect::exception(InvalidCallSpecification::class) + ->withMessageContaining('sits inside the array given as argument #3'); + + when(fn(): ?string => $this->storage->recordOutcome('svc', 1, $this->nest(8), Arg::rest())); + } + + #[ExpectNoAssertions] + public function aMatcherPastTheDepthCapIsNotSearchedFor(): void + { + when(fn(): ?string => $this->storage->recordOutcome('svc', 1, $this->nest(9), Arg::rest())); + } + + /** + * A matcher wrapped in `$depth` levels of array. + * + * @param int<1, max> $depth + * + * @return array + */ + private function nest(int $depth): array + { + /** @var mixed $value */ + $value = Arg::any(); + + for ($level = 0; $level < $depth; ++$level) { + $value = [$value]; + } + + \assert(\is_array($value)); + + return $value; + } + + /** + * A specification and a stub naming the same call still collide when the + * omission is what they have in common — the unspelled tail is part of the + * specification, not a wildcard that makes two of them different. + */ + public function twoVerbsOmittingTheSameOptionalParameterStillCollide(): void + { + when(fn() => $this->storage->tag('alpha')); + + Expect::exception(ConflictingExpectation::class); + + Understudy::expect(fn() => $this->storage->tag('alpha')); + } } diff --git a/tests/UnderstudyTest.php b/tests/UnderstudyTest.php index 2c1a307..cc7ad3b 100644 --- a/tests/UnderstudyTest.php +++ b/tests/UnderstudyTest.php @@ -12,6 +12,7 @@ use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\ForgottenDouble; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Exception\MatcherLeaked; use Rasuvaeff\Understudy\Exception\NeverMethodCalled; use Rasuvaeff\Understudy\Exception\StrictModeViolation; @@ -319,6 +320,20 @@ public function strictDoubleRejectsAnUnexpectedCall(): void $repository->count(); } + /** + * The mode reads as "a strict double of this", so it has to be usable as + * an expression: a container definition built from + * `Understudy::strict(Understudy::for(X::class))` used to store `null` and + * fail three steps away from the cause. + */ + public function strictAndLabelAnswerWithTheDoubleTheyConfigured(): void + { + $repository = Understudy::for(BookRepository::class); + + Assert::true(Understudy::strict($repository) === $repository); + Assert::true(Understudy::label($repository, 'primary') === $repository); + } + public function strictRefusalShowsTheCallAndWhatDidNotAcceptIt(): void { // Naming only the method sent the reader back to a test that did @@ -577,6 +592,28 @@ public function specificationClosureWithoutACallIsRejected(): void Understudy::when(static fn(): bool => true); } + /** + * The library's own refusals are already about the specification — a + * matcher built with an impossible range, a captor inside a combinator, a + * double the test retired. Wrapping one in "the closure threw before it + * reached an understudy" buried the sentence that says what to change. + */ + public function anUnderstudyErrorRaisedInsideTheClosureIsNotRewrapped(): void + { + $repository = Understudy::for(BookRepository::class); + + try { + Understudy::when(static fn(): ?Book => $repository->find(Arg::int(min: 5, max: 1))); + } catch (InvalidSpecificationArgument $refusal) { + Assert::string($refusal->getMessage())->contains('describes an empty range'); + Assert::false(str_contains($refusal->getMessage(), 'threw before it reached an understudy')); + + return; + } + + Assert::fail('the impossible range was not refused'); + } + public function specificationClosureFailureKeepsTheOriginalCause(): void { Expect::exception(InvalidCallSpecification::class) diff --git a/tests/fixtures/messages/verify-all-lists-same-method-calls-with-marked-argument.txt b/tests/fixtures/messages/verify-all-lists-same-method-calls-with-marked-argument.txt index 89a75f6..ffd55f9 100644 --- a/tests/fixtures/messages/verify-all-lists-same-method-calls-with-marked-argument.txt +++ b/tests/fixtures/messages/verify-all-lists-same-method-calls-with-marked-argument.txt @@ -1,4 +1,4 @@ -Understudy `BookRepository` expected `tag('gamma', 1)` to be called exactly 1 time, but it was never called. +Understudy `BookRepository` expected `tag('gamma', …)` to be called exactly 1 time, but it was never called. The following calls to `tag` were made during this test: tag(*'alpha'*, 1) diff --git a/tests/fixtures/messages/verify-lists-same-method-calls-with-marked-argument.txt b/tests/fixtures/messages/verify-lists-same-method-calls-with-marked-argument.txt index e276731..291a63d 100644 --- a/tests/fixtures/messages/verify-lists-same-method-calls-with-marked-argument.txt +++ b/tests/fixtures/messages/verify-lists-same-method-calls-with-marked-argument.txt @@ -1,4 +1,4 @@ -Understudy `BookRepository` expected `tag('gamma', 1)` to be called at least 1 time, but it was never called. +Understudy `BookRepository` expected `tag('gamma', …)` to be called at least 1 time, but it was never called. The following calls to `tag` were made during this test: tag(*'alpha'*, 1)