diff --git a/AGENTS.md b/AGENTS.md index aa8aa49..91e97c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,6 +273,81 @@ seconds instead of the full run's minute — and the full run stays the gate. other class failed with "there is no such class"; the same mistake reached review once already with `Fixture\Cls\Stamp`. +- **Inline forwarding enters a real body by reflection, not by `parent::`.** + `Runtime::runInline()` calls `ReflectionMethod::invokeArgs()` on a + reflection of the *doubled* class, which enters that class's body even + though the double overrides the method, while `$this` stays the double so + the body's self-calls dispatch virtually and come back through the + override. The alternative — a generated `__understudyReal_*()` sibling + calling `parent::`, which is what jasonmccreary/double does — is closed to + us by golden rule 3, and a per-call `fn () => parent::m(...)` closure + handed to the dispatcher was built, measured and reverted: it cost **+10% + per call on every class double**, forwarding or not, because the argument + is evaluated before dispatch can decide it is not needed (`perf/` doubles + only interfaces, so `make perf` cannot see this — the measurement was a + loop over a loose class double). The language claim is pinned by + `spikes/08-inline-forwarding` on 8.3/8.4/8.5. The reflections are cached in + a static keyed by `Class::method`, and `reset()` does not clear it: it is + metadata about a type, not state of a double. +- **Inline forwarding needs the instance to be exactly the doubled class, and + the state copy decides when it is on.** `get_parent_class($double) === + $real::class` is the whole test: an interface double has no parent, and a + donor that is a *subclass* carries overrides the doubled class's bodies do + not contain — running those instead would silently change which code + executes, so it keeps the proxy form. `DoubleState::$inlineParent` is set + only after `Runtime::copyState()` succeeded, because a body that runs + inline reads `$this->` properties the skipped constructor never filled. + Re-pointing an inline double at another instance is refused rather than + copied twice: a `readonly` property cannot be written again, and a second + copy would discard what the real code has written since. +- **A state copy reflects each property against its DECLARING class.** Before + PHP 8.4 a `readonly` property can only be initialized from the scope of the + class that declares it, and a `ReflectionProperty` reached through the + subclass writes from the subclass's scope. The loop therefore walks the + donor's hierarchy and skips what the current class did not declare. A + hooked property is copied through `setRawValue()`/`getRawValue()` (reading + through the hooks would execute the donor's code during setup, writing + through them would re-run a `set` the real object already ran), a virtual + one has no state to copy, and a property the donor left uninitialized is + `unset()` on the double — which is enough from outside, because the only + thing that writes a property on a constructor-less double is + `PropertyDefaults`, and it writes public writable ones and nothing else. +- **A clone made while a real body runs inherits that double's forwarding; + an object no context knows is refused.** Both shapes satisfy the override's + `: static` — they are of the generated class — so the language stops + refusing what the proxy path refused as a foreign instance, and the two + need opposite answers. `clone $this` is adopted in `Runtime::adoptClone()` + from `Runtime::$inlineFrames`, the stack of doubles whose bodies are + running: PHP already copied the state those bodies read, and without the + frame a `: self` wither — or a body merely computing over an internal copy, + which proxied worked — would hold a double answering defaults in the middle + of real code. Expectations are still not copied, and a `clone $double` the + test makes is untouched — whether no body is running at all, or the test's + own `answers()` closure was reached from one, which `Runtime::$inlineFloor` + hides the frames underneath for exactly the span of. The frame alone is a + guess, because one generated class serves every double of a type: a body + handed another double of its own class — `merge(Basket $other)` — and cloning + THAT would adopt it on the running double's behalf, with bodies to run and + no state copy to run them over, which is a bare `must not be accessed before + initialization` where the property is typed and a quiet wrong answer where + it has a declared default. `Runtime::isCopyOf()` therefore requires the copy + to hold the frame's own state, and the comparison is + `get_mangled_object_vars()` on both — the object's own property table, which + is the only reading that reaches a parent's private properties, answers a + hooked property with its backing store rather than running the doubled + class's `get` in the middle of `__clone()`, has no entry for a virtual one, + and omits a property nobody initialized, so "never set" differs from every + value. The copy is property-identical to its source at this moment, because + a generated `__clone()` does nothing but call `adoptClone()`. Two doubles in + the same state stay indistinguishable — the copy is adopted and runs real + bodies where a plain double answered defaults — and that is the residual of + PHP's blind spot, not a thing the comparison can close: it runs them over + the state the copy already holds, so no reading is of a value the object + never had, it is the other double's. `new static` cannot be adopted — its constructor built a new object + with its own state — so it is refused by + `OriginalReturnTypeViolation::foreignSelf()`, and the check is registration + (`ownerOf() === null`), not return type: an unknown instance is broken for + every caller, not only for one whose signature says `static`. - **Forwarding is not gated on `$matched`, and strictness is.** Strict mode is a complaint that a matched expectation answers; forwarding is the mode's own answer, and an expectation that only counts the call — diff --git a/CHANGELOG.md b/CHANGELOG.md index 9390c15..d1bdd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## 1.1.0 — Unreleased +- **Changed.** A forwarding double of a **class** now runs the real bodies on + itself instead of proxying them to the instance: the state that instance held + is copied onto the double when `forwarding()` is called, and an unmatched + call executes the parent implementation with `$this` still the double. A + self-call inside real code therefore re-enters the double — it meets stubs, + an armed protocol and the call log — which is what `makePartial()` ports + needed and what the standing limitation ("understudy proxies an object; it + does not instrument one") described. An interface double has no bodies to + reach and proxies exactly as before. Five consequences in a suite that + already forwards class doubles: a self-call now counts toward + `expect(...)->times(n)` and can fill a step of an armed protocol; + `callOriginal()` on such a double runs on the double rather than on the + instance, over the state the double holds; `static::class` inside a real + body names the generated class; a real body that hands out a `new static` + is refused with `OriginalReturnTypeViolation` (an object no context knows, + where a `clone $this` is adopted while the body runs and comes back + working); and the instance stops receiving the calls, so a test asserting + on it afterwards sees the state as of the moment `forwarding()` ran. That + copy is shallow, which is the same reach the instance had before: an + object-valued property is the same object on both, so writes *through* it + still land on the donor's collaborators, and a closure the constructor + bound to the donor still runs with `$this` on the donor. Inline needs the instance to be exactly + the doubled class — a subclass instance keeps the proxy form, so its own + overrides answer — and an inline double cannot be re-pointed at a second + instance, because a `readonly` property cannot be copied onto it twice + (`ForwardingTargetMismatch`). Adopting a clone asks for a copy of the double + whose body is running, holding its state: one generated class serves every + double of a type, so a body that clones another double it was handed — and a + `clone $double` the test writes in its own `answers()` closure, which a real + body can reach — gets a fresh double of that class, the documented rule for + `clone $double`, rather than real bodies over a state nothing copied. Two + doubles of one class in the same state are indistinguishable, because + `__clone()` is handed no reference to its source: a copy of one of those is + adopted and answers for real, over the state it already holds. No name, + signature or readonly field of the public API changed. (#150) - **Changed.** Dispatch between overlapping specifications now runs in two tiers: one that pins at least one argument to something narrower than "anything" — a literal, any matcher except `Arg::any()`, or a tail pinned diff --git a/MIGRATION.md b/MIGRATION.md index 079c9ae..529868c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -44,7 +44,7 @@ here, and the sections after it cover what a mechanical translation gets wrong. | `$mock->shouldNotHaveReceived('save')` | `Understudy::unused($mock)` | or `verify(…, never: true)` for one call | | `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(…))` | after the fact | | `->ordered()` | `expect(…)->ordered()` | or `verifySequence()` / `expectSequence()` for a whole protocol | -| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded | +| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded. Pass the **class** to get what `makePartial()` does with a self-call: the bodies run on the double, so `$this->other()` meets your stub. An interface there proxies instead | | `Mockery::close()` | the adapter's `reset()`, or your own teardown | | ## The traps diff --git a/README.md b/README.md index ff5f807..f759f0c 100644 --- a/README.md +++ b/README.md @@ -891,11 +891,33 @@ when(fn () => $spy->get('key')) ->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal())); ``` -Five things are worth knowing before relying on it: - -- **Only the call at the boundary is recorded.** If the real method calls - another method on itself, that happens inside the real object. Understudy - proxies an object; it does not instrument one. +Eight things are worth knowing before relying on it: + +- **A class double runs the real bodies on itself.** The state the real + instance held is copied onto the double when `forwarding()` is called, and an + unmatched call executes the parent implementation with `$this` still the + double — so a self-call inside the real code re-enters the double: it meets + stubs, an armed protocol and the call log, and every call is recorded, the + boundary one and the self-calls alike. Two consequences to hold on to: + `static::class` inside a real body names the generated class rather than + yours, and a `final` or `private` method is not overridden, so a self-call to + one of those runs the real body directly — it works, but nothing intercepts + or records it. The instance itself stops receiving the calls: after + `forwarding()` it holds the state it had at that moment, and assertions + belong on the double. The copy is shallow, which is the reach the instance + already had — an object-valued property is the same object on both sides, so + writes *through* one still land on the donor's collaborators. +- **An interface double proxies instead.** It has no bodies to reach: the call + runs on the real instance, only the call at the boundary is recorded, and + self-calls happen inside the real object where nothing can see them. +- **Running inline needs the instance to be exactly the doubled class.** A + donor that is a *subclass* carries overrides the doubled class's own bodies + do not contain, and running those bodies in their place would change which + code executes — such a double keeps the proxy form, where the overrides answer. + An inline double also cannot be re-pointed at a second instance: its state + was copied from the first, a `readonly` property cannot be written twice, and + a second copy would discard everything the real code has written since. Build + a second double for the other instance. - **A `: never` method reaches the real implementation.** Its throw lives there, and a forwarding double has something that can answer for itself. - **An understudy is not a valid target.** Forwarding to one — itself included @@ -904,11 +926,27 @@ Five things are worth knowing before relying on it: - **A by-reference argument is the caller's variable.** A forwarded method writes to it, and the call log keeps both readings — what was passed and what it became — so a verification still sees the value the caller handed over. -- **A fluent method comes back as the double.** When the real instance returns - itself, the double is returned instead, so a chain stays doubled. A `static` - method that returns a *different* instance of the real class is refused — - that object is not a double, and returning it would break the override's own - `: static`. +- **A fluent method comes back as the double.** When the real code returns + `$this`, that already *is* the double — the parent body ran on it — so a + chain stays doubled by itself. A `clone $this` works too: the copy is + adopted while the body runs and inherits this double's forwarding, so a + wither comes back as a working double — carrying none of this one's stubs, + the same rule a `clone $double` in the test follows. Adoption asks for a + copy of *this* double, holding its state: a body that clones another double + it was handed gets a fresh double of the right class instead, and so does a + `clone $double` written in your own `answers()` closure, even when a real + body is what reached it. Two doubles of one class in the same state cannot + be told apart — `__clone()` is given no reference to what it copied — so a + copy of one of those is adopted and answers for real; it does so over the + state it already holds, which is the other double's. What is refused, with + `OriginalReturnTypeViolation`, is a real body handing out a `new static`: + that object is of the generated class, so the return type accepts it, but no + context knows it and every call on it would fail as an unregistered + double — under the proxy the same shape was refused as a foreign instance. +- **`callOriginal()` follows the double's own form.** On an inline double it + runs the parent body on the double, over the state the double holds — the + donor's copy stopped being the current one at the first inline call. On every + other double it runs on the real instance, where self-calls stay inside it. ### Failure messages diff --git a/README.ru.md b/README.ru.md index e8bcc72..bee3323 100644 --- a/README.ru.md +++ b/README.ru.md @@ -887,11 +887,34 @@ when(fn () => $spy->get('key')) ->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal())); ``` -Пять вещей, которые стоит знать заранее: - -- **Записывается только вызов на границе.** Если реальный метод зовёт другой - метод на себе, это происходит внутри реального объекта. Understudy проксирует - объект, а не инструментирует его. +Восемь вещей, которые стоит знать заранее: + +- **Дубль класса выполняет реальные тела на самом себе.** Состояние, которое + держал реальный инстанс, копируется на дубль в момент `forwarding()`, и + несовпавший вызов исполняет реализацию родителя, оставляя `$this` дублем — + поэтому self-call внутри реального кода снова входит в дубль: встречает + стабы, вооружённый протокол и журнал вызовов, и записывается каждый вызов — + и граничный, и вложенные. Два следствия: `static::class` внутри реального + тела называет сгенерированный класс, а не ваш, а методы `final` и `private` + не переопределяются — self-call в такой метод выполняет реальное тело + напрямую: работает, но ничего его не перехватывает и не записывает. Сам + инстанс больше не получает вызовов: после `forwarding()` он держит то + состояние, которое было на тот момент, и проверять нужно дубль. Копия + поверхностная — ровно та же досягаемость, что была у инстанса: свойство с + объектом — один и тот же объект с обеих сторон, так что записи *через* него + по-прежнему попадают в коллабораторов донора. +- **Дубль интерфейса вместо этого проксирует.** Ему не до каких тел + дотягиваться: вызов исполняется на реальном инстансе, записывается только + вызов на границе, а self-call'ы происходят внутри реального объекта, где их + никто не видит. +- **Для инлайна инстанс должен быть ровно тем классом, который дублируется.** + Инстанс-*наследник* несёт переопределения, которых в телах самого + дублируемого класса нет, и исполнение этих тел вместо них сменило бы + исполняемый код — такой дубль остаётся прокси, где отвечают его + переопределения. Инлайновый дубль ещё и нельзя перенаправить на второй + инстанс: его состояние скопировано с первого, `readonly`-свойство нельзя + записать дважды, а вторая копия выбросила бы всё, что реальный код записал с + тех пор. Для другого инстанса — другой дубль. - **Метод с `: never` доходит до реальной реализации.** Бросок живёт именно там, а у forwarding-дубля есть кому ответить за себя. - **Дубль не может быть целью.** Делегирование дублю — в том числе самому себе — @@ -900,10 +923,29 @@ when(fn () => $spy->get('key')) - **Аргумент по ссылке — это переменная вызывающего.** Форвардящийся метод в неё пишет, и журнал хранит оба чтения — что передали и чем оно стало, — так что проверка по-прежнему видит значение, которое отдал вызывающий. -- **Fluent-метод возвращается дублем.** Когда реальный инстанс вернул сам себя, - наружу уходит дубль — цепочка остаётся дублированной. Метод с `static`, - вернувший *другой* экземпляр реального класса, отвергается: этот объект не - дубль, и вернуть его значило бы нарушить собственный `: static` переопределения. +- **Fluent-метод возвращается дублем.** Когда реальный код возвращает `$this`, + это и *есть* дубль — тело родителя исполнилось на нём, — так что цепочка + остаётся дублированной сама собой. `clone $this` тоже работает: копия + принимается в момент исполнения тела и наследует форвардинг этого дубля — + wither возвращается рабочим дублем, но без стабов этого дубля, по тому же + правилу, что и `clone $double` в тесте. Принимается только копия *этого* + дубля, держащая его состояние: тело, клонирующее другой переданный ему + дубль, получит самостоятельный дубль нужного класса — как и `clone $double`, + написанный в вашем собственном `answers()`-замыкании, даже если до него + добралось реальное тело. Два дубля одного класса в одинаковом состоянии + различить нельзя — `__clone()` не получает ссылки на то, что копировал, — + поэтому копия такого дубля принимается и отвечает по-настоящему: над тем + состоянием, которое она и держит, то есть над состоянием другого дубля. Отвергается — + `OriginalReturnTypeViolation` — реальное тело, отдающее наружу `new static`: + этот объект сгенерированного класса, тип возврата его принимает, но ни один + контекст его не знает, и любой вызов на нём упал бы как на + незарегистрированном дубле; под прокси ровно эта форма отвергалась как чужой + экземпляр. +- **`callOriginal()` следует форме самого дубля.** На инлайновом дубле он + исполняет тело родителя на дубле, над состоянием, которое дубль держит, — + копия донора перестала быть текущей на первом же инлайновом вызове. На + любом другом дубле вызов идёт на реальный инстанс, где self-call'ы остаются + внутри него. ### Сообщения об ошибках diff --git a/docs/scripts/api-snapshot.json b/docs/scripts/api-snapshot.json index 7f0dc0a..58e3e4b 100644 --- a/docs/scripts/api-snapshot.json +++ b/docs/scripts/api-snapshot.json @@ -2,7 +2,7 @@ "classes": [ { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Arg", "kind": "class", @@ -437,7 +437,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FileWrapper", "kind": "class", @@ -1099,7 +1099,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Bypass\\FinalStripper", "kind": "class", @@ -1159,7 +1159,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Captor", "kind": "class", @@ -1287,7 +1287,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Cardinality", "kind": "class", @@ -1480,7 +1480,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\Blueprint", "kind": "class", @@ -1617,7 +1617,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\DoubleFactory", "kind": "class", @@ -1746,7 +1746,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\MethodSignature", "kind": "class", @@ -1940,7 +1940,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertyDefaults", "kind": "class", @@ -1993,7 +1993,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\PropertySignature", "kind": "class", @@ -2083,7 +2083,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TargetUnifier", "kind": "class", @@ -2136,7 +2136,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Codegen\\TypeRenderer", "kind": "class", @@ -2256,7 +2256,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\DefaultFactories", "kind": "class", @@ -2361,7 +2361,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Defaults\\TypeDefaultResolver", "kind": "class", @@ -2500,7 +2500,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\AmbiguousDefaultFactory", "kind": "class", @@ -2560,7 +2560,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\BypassUnavailable", "kind": "class", @@ -2666,7 +2666,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\CannotWire", "kind": "class", @@ -2911,7 +2911,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ConflictingExpectation", "kind": "class", @@ -3031,7 +3031,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ContextOwnershipViolation", "kind": "class", @@ -3076,7 +3076,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForgottenDouble", "kind": "class", @@ -3236,7 +3236,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", "kind": "class", @@ -3317,6 +3317,43 @@ "deprecated": null, "attributes": [], "startLine": 36 + }, + { + "name": "alreadyInline", + "static": true, + "params": [ + { + "name": "label", + "type": "non-empty-string", + "description": "the double's own name in reports", + "default": null, + "variadic": false + }, + { + "name": "current", + "type": "class-string", + "description": "the class whose bodies the double already runs", + "default": null, + "variadic": false + }, + { + "name": "given", + "type": "class-string", + "description": "the class of the instance offered instead", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", + "summary": "Builds the error for re-pointing a double that already forwards inline.", + "description": "The state of the first donor was copied onto the double, and a copy\ncannot be taken twice: a `readonly` property is initialized, and\nwhatever the inline calls have written since would be overwritten by a\nsecond donor's reading. Re-pointing is refused rather than silently\nleaving the double running the first donor's state against the second\none's instance.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 62 } ], "constants": [], @@ -3326,7 +3363,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidCallSpecification", "kind": "class", @@ -3911,7 +3948,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidDefaultValue", "kind": "class", @@ -3971,7 +4008,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\InvalidSpecificationArgument", "kind": "class", @@ -4240,7 +4277,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\MatcherLeaked", "kind": "class", @@ -4307,7 +4344,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NeverMethodCalled", "kind": "class", @@ -4427,7 +4464,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NoDefaultValue", "kind": "class", @@ -4494,7 +4531,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\NothingCaptured", "kind": "class", @@ -4547,7 +4584,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", "kind": "class", @@ -4630,7 +4667,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", "kind": "class", @@ -4688,6 +4725,36 @@ "deprecated": null, "attributes": [], "startLine": 20 + }, + { + "name": "foreignSelf", + "static": true, + "params": [ + { + "name": "label", + "type": "non-empty-string", + "description": "the double's own name in reports", + "default": null, + "variadic": false + }, + { + "name": "method", + "type": "non-empty-string", + "description": "the method whose real body returned it", + "default": null, + "variadic": false + } + ], + "returnType": "Rasuvaeff\\Understudy\\Exception\\OriginalReturnTypeViolation", + "summary": "Builds the error for an inline call whose real body built a new instance\nof the double's own class — a `new static`.", + "description": "It is of the generated class, so it satisfies the override's `: static`\nand the language lets it through; no context knows it, so every call on\nit would fail as a double that was never registered. A `clone $this`\ndoes not reach here: the copy inherits the original's forwarding and\nanswers for real.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 48 } ], "constants": [], @@ -4697,7 +4764,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\OutcomeUnavailable", "kind": "class", @@ -4780,7 +4847,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\StrictModeViolation", "kind": "class", @@ -4847,7 +4914,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnderstudyError", "kind": "interface", @@ -4896,7 +4963,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\UnsupportedTarget", "kind": "class", @@ -5016,7 +5083,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Exception\\VerificationFailed", "kind": "class", @@ -5103,7 +5170,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\ExpectBuilder", "kind": "class", @@ -5151,7 +5218,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Action", "kind": "interface", @@ -5209,7 +5276,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ArgumentFormatter", "kind": "class", @@ -5292,7 +5359,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ComputeAnswer", "kind": "class", @@ -5359,7 +5426,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\Expectation", "kind": "class", @@ -5400,6 +5467,21 @@ ], "publicProperties": [], "publicMethods": [ + { + "name": "isGeneric", + "static": false, + "params": [], + "returnType": "bool", + "summary": "Whether no position of this expectation narrows the call — the tier it\ndispatches in. A generic expectation is tried only after every\nspecific one for the same method refused the call, whichever order\nthe two were declared in.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 143 + }, { "name": "hasCaptors", "static": false, @@ -5413,7 +5495,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 110 + "startLine": 148 }, { "name": "setGuard", @@ -5443,7 +5525,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 131 + "startLine": 169 }, { "name": "captureFrom", @@ -5466,7 +5548,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 174 + "startLine": 212 }, { "name": "setAction", @@ -5496,7 +5578,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 195 + "startLine": 233 }, { "name": "hasAction", @@ -5511,7 +5593,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 210 + "startLine": 248 }, { "name": "cardinality", @@ -5526,7 +5608,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 219 + "startLine": 257 }, { "name": "setCardinality", @@ -5549,7 +5631,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 224 + "startLine": 262 }, { "name": "declareClaim", @@ -5564,7 +5646,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 234 + "startLine": 272 }, { "name": "isClaim", @@ -5579,7 +5661,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 239 + "startLine": 277 }, { "name": "setDeclarationOrder", @@ -5602,7 +5684,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 250 + "startLine": 288 }, { "name": "declarationOrder", @@ -5617,7 +5699,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 258 + "startLine": 296 }, { "name": "matchedSequences", @@ -5632,7 +5714,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 266 + "startLine": 304 }, { "name": "requireOrder", @@ -5647,7 +5729,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 275 + "startLine": 313 }, { "name": "isOrdered", @@ -5662,7 +5744,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 280 + "startLine": 318 }, { "name": "matchCount", @@ -5677,7 +5759,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 288 + "startLine": 326 }, { "name": "matches", @@ -5707,7 +5789,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 297 + "startLine": 335 }, { "name": "matchesArguments", @@ -5730,7 +5812,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 312 + "startLine": 350 }, { "name": "specEquals", @@ -5753,7 +5835,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 377 + "startLine": 415 }, { "name": "recordMatch", @@ -5776,7 +5858,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 412 + "startLine": 450 }, { "name": "performAction", @@ -5799,7 +5881,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 426 + "startLine": 464 }, { "name": "describeAgainst", @@ -5822,7 +5904,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 450 + "startLine": 488 }, { "name": "describe", @@ -5837,17 +5919,17 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 547 + "startLine": 585 } ], "constants": [], "enumCases": [], - "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Expectation/Expectation.php#L21", + "sourceUrl": "https://github.com/rasuvaeff/understudy/blob/master/src/Expectation/Expectation.php#L24", "implementedBy": [] }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnContract", "kind": "class", @@ -5937,7 +6019,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ReturnValue", "kind": "class", @@ -6004,7 +6086,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowComputed", "kind": "class", @@ -6071,7 +6153,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Expectation\\ThrowError", "kind": "class", @@ -6138,7 +6220,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureKind", "kind": "enum", @@ -6227,7 +6309,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\FailureReport", "kind": "class", @@ -6412,7 +6494,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Invocation", "kind": "class", @@ -6588,8 +6670,8 @@ "static": false, "params": [], "returnType": "mixed", - "summary": "Delegates this call to the double's real instance and returns what it\nanswered — the `answers()` escape hatch for \"behave normally, except\nhere\".", - "description": "Without a forwarding target it raises `OriginalCallUnavailable` rather\nthan reaching for the parent implementation: the double's constructor\nnever ran, so the parent body would work over state that does not exist.", + "summary": "Runs the real implementation of this call and returns what it answered —\nthe `answers()` escape hatch for \"behave normally, except here\".", + "description": "Where that implementation runs follows the double's own form. A class\ndouble that forwards runs the real body on itself, over the state\n`forwarding()` copied there; every other double delegates to the real\ninstance it remembers, where the body's own self-calls stay inside that\nobject.\n\nWithout a forwarding target it raises `OriginalCallUnavailable` rather\nthan reaching for the real body anyway: nothing copied the state, so the\nbody would work over properties the skipped constructor never filled.", "throws": [ { "type": "\\Rasuvaeff\\Understudy\\Exception\\OriginalCallUnavailable", @@ -6601,7 +6683,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 140 + "startLine": 145 }, { "name": "recordOutcome", @@ -6624,7 +6706,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 163 + "startLine": 168 }, { "name": "recordReturned", @@ -6647,7 +6729,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 177 + "startLine": 182 }, { "name": "recordDiscardedReturn", @@ -6662,7 +6744,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 197 + "startLine": 202 }, { "name": "isReturnDiscarded", @@ -6677,7 +6759,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 212 + "startLine": 217 }, { "name": "recordThrown", @@ -6700,7 +6782,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 222 + "startLine": 227 }, { "name": "markAccounted", @@ -6715,7 +6797,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 240 + "startLine": 245 }, { "name": "isAccounted", @@ -6730,7 +6812,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 250 + "startLine": 255 }, { "name": "didReturn", @@ -6745,7 +6827,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 259 + "startLine": 264 }, { "name": "didThrow", @@ -6760,7 +6842,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 267 + "startLine": 272 }, { "name": "returned", @@ -6780,7 +6862,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 280 + "startLine": 285 }, { "name": "thrown", @@ -6795,7 +6877,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 302 + "startLine": 307 } ], "constants": [], @@ -6805,7 +6887,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AllOf", "kind": "class", @@ -6889,7 +6971,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyArgument", "kind": "class", @@ -6963,7 +7045,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyOf", "kind": "class", @@ -7047,7 +7129,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyRest", "kind": "class", @@ -7147,7 +7229,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\AnyTail", "kind": "class", @@ -7247,7 +7329,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArgumentMatcher", "kind": "interface", @@ -7337,7 +7419,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\ArrayContaining", "kind": "class", @@ -7421,7 +7503,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\BooleanValue", "kind": "class", @@ -7495,7 +7577,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Bounds", "kind": "class", @@ -7555,7 +7637,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Capturing", "kind": "class", @@ -7648,7 +7730,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\CountBetween", "kind": "class", @@ -7741,7 +7823,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\EmptyTail", "kind": "class", @@ -7841,7 +7923,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\FloatInRange", "kind": "class", @@ -7934,7 +8016,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IdenticalTo", "kind": "class", @@ -8018,7 +8100,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\InstanceOfType", "kind": "class", @@ -8102,7 +8184,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\IntInRange", "kind": "class", @@ -8195,7 +8277,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Negated", "kind": "class", @@ -8279,7 +8361,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Operand", "kind": "class", @@ -8362,7 +8444,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\QueryEquals", "kind": "class", @@ -8455,7 +8537,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Satisfying", "kind": "class", @@ -8548,7 +8630,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\StringMatching", "kind": "class", @@ -8632,7 +8714,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\TailMatcher", "kind": "interface", @@ -8692,7 +8774,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\Unspelled", "kind": "class", @@ -8766,7 +8848,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Matcher\\UnspelledTail", "kind": "class", @@ -8866,7 +8948,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Outcome", "kind": "class", @@ -12439,7 +12521,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Absent", "kind": "enum", @@ -12482,7 +12564,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ArmedSequence", "kind": "class", @@ -12688,7 +12770,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\DoubleState", "kind": "class", @@ -12751,7 +12833,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 77 + "startLine": 80 }, { "name": "makeLean", @@ -12766,7 +12848,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 88 + "startLine": 91 }, { "name": "isLean", @@ -12781,7 +12863,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 93 + "startLine": 96 }, { "name": "setMode", @@ -12804,7 +12886,60 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 98 + "startLine": 101 + }, + { + "name": "forwardsInline", + "static": false, + "params": [], + "returnType": "bool", + "summary": "Whether forwarding runs the real bodies on the double itself.", + "description": "True only for a class double whose parent is exactly the donor's class,\nand only once its state has been copied over: the inline form reads\n`$this->` properties, so turning it on without the copy would run real\ncode against a double the constructor never touched. Every other\nforwarding double — an interface double, a donor that is a subclass of\nthe doubled class — proxies instead.\n\nIt survives a later `setMode()`: the state copy cannot be undone, and\n`callOriginal()` still has to know which form to use.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 119 + }, + { + "name": "inlineParent", + "static": false, + "params": [], + "returnType": "?string", + "summary": "The class whose bodies an inline double runs: the one the generated\nclass extends, which is also the donor's own class.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 130 + }, + { + "name": "forwardInline", + "static": false, + "params": [ + { + "name": "parent", + "type": "class-string", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "void", + "summary": "One-way, and called only after the state copy succeeded.", + "description": "", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 140 }, { "name": "label", @@ -12819,7 +12954,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 106 + "startLine": 148 }, { "name": "setDefaultLabel", @@ -12842,7 +12977,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 118 + "startLine": 160 }, { "name": "forwardingTarget", @@ -12857,7 +12992,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 129 + "startLine": 171 }, { "name": "setForwardingTarget", @@ -12880,7 +13015,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 134 + "startLine": 176 }, { "name": "setLabel", @@ -12903,7 +13038,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 142 + "startLine": 184 }, { "name": "writeProperty", @@ -12933,7 +13068,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 150 + "startLine": 192 }, { "name": "hasPropertyValue", @@ -12956,7 +13091,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 158 + "startLine": 200 }, { "name": "propertyValue", @@ -12979,7 +13114,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 166 + "startLine": 208 }, { "name": "referenceSlot", @@ -13016,7 +13151,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 180 + "startLine": 222 }, { "name": "hasActionFor", @@ -13046,7 +13181,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 202 + "startLine": 244 }, { "name": "addExpectation", @@ -13069,7 +13204,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 221 + "startLine": 266 }, { "name": "expectations", @@ -13084,7 +13219,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 293 + "startLine": 338 }, { "name": "expectationsFor", @@ -13107,14 +13242,14 @@ ], "returnType": "array", "summary": "Most recently registered expectations for one method, in matching order.", - "description": "The overwhelmingly common shape — a method with a single stub — is\nanswered by a lookup and nothing else. Indexing only earns its keep once\nthere is something to choose between, and computing a key for a method\nthat has one expectation would tax every call in every suite to speed up\nthe rare one.", + "description": "The overwhelmingly common shape — a method with a single stub — is\nanswered by a lookup and nothing else. Indexing only earns its keep once\nthere is something to choose between, and computing a key for a method\nthat has one expectation would tax every call in every suite to speed up\nthe rare one.\n\nTwo tiers, whichever order they were declared in: an expectation that\npins at least one argument to something narrower than \"anything\" is\ntried before one that says nothing about any argument, so a catch-all\nregistered after a specific stub cannot steal the calls the specific\none describes — the specific one is never unreachable, and the trap\nneeds no diagnosis because it cannot be written. Within a tier the\ndocumented layering stands: most recently registered first, earlier\nones as fallbacks. Two specifics are never compared against each other\nfor narrowness; registration order decides between them.", "throws": [], "throwsInBody": false, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 311 + "startLine": 366 }, { "name": "declaredExpectations", @@ -13129,7 +13264,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 359 + "startLine": 413 }, { "name": "settle", @@ -13144,7 +13279,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 369 + "startLine": 423 }, { "name": "record", @@ -13167,7 +13302,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 398 + "startLine": 452 }, { "name": "callLog", @@ -13182,7 +13317,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 406 + "startLine": 460 } ], "constants": [], @@ -13192,7 +13327,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\InvocationSignal", "kind": "class", @@ -13268,7 +13403,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Mode", "kind": "enum", @@ -13331,7 +13466,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\ReferenceSlot", "kind": "class", @@ -13367,7 +13502,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\Runtime", "kind": "class", @@ -13402,7 +13537,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 98 + "startLine": 121 }, { "name": "currentIfAny", @@ -13417,7 +13552,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 112 + "startLine": 135 }, { "name": "pushScope", @@ -13432,7 +13567,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 123 + "startLine": 146 }, { "name": "popScope", @@ -13447,7 +13582,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 131 + "startLine": 154 }, { "name": "adopt", @@ -13477,7 +13612,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 197 + "startLine": 220 }, { "name": "liveContexts", @@ -13492,7 +13627,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 231 + "startLine": 254 }, { "name": "adoptClone", @@ -13515,7 +13650,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 262 + "startLine": 285 }, { "name": "adoptInto", @@ -13545,7 +13680,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 290 + "startLine": 335 }, { "name": "adoptContractsInto", @@ -13575,7 +13710,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 302 + "startLine": 347 }, { "name": "ownerOf", @@ -13598,7 +13733,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 318 + "startLine": 363 }, { "name": "isForgotten", @@ -13621,7 +13756,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 332 + "startLine": 377 }, { "name": "isDroppedByScope", @@ -13644,7 +13779,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 341 + "startLine": 386 }, { "name": "forgottenDouble", @@ -13674,7 +13809,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 352 + "startLine": 397 }, { "name": "isRetiredOnPurpose", @@ -13697,7 +13832,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 368 + "startLine": 413 }, { "name": "forget", @@ -13720,7 +13855,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 380 + "startLine": 425 }, { "name": "stateOf", @@ -13743,7 +13878,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 457 + "startLine": 502 }, { "name": "isOwnedByCurrentContext", @@ -13766,7 +13901,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 462 + "startLine": 507 }, { "name": "dispatch", @@ -13803,7 +13938,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 475 + "startLine": 520 }, { "name": "propertyRead", @@ -13833,7 +13968,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 827 + "startLine": 876 }, { "name": "propertyWrite", @@ -13870,7 +14005,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 863 + "startLine": 912 }, { "name": "referenceSlot", @@ -13907,7 +14042,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 921 + "startLine": 970 }, { "name": "callOriginal", @@ -13936,15 +14071,45 @@ } ], "returnType": "mixed", - "summary": "Delegates one recorded call to the real instance, from inside an answer.", - "description": "Explicit rather than mode-driven: `answers(fn (Invocation $i) =>\n$i->callOriginal())` says \"this one call goes through\", and works whether\nor not the double is forwarding by default.", + "summary": "Runs the real implementation of one recorded call, from inside an answer.", + "description": "Explicit rather than mode-driven: `answers(fn (Invocation $i) =>\n$i->callOriginal())` says \"this one call goes through\", and works whether\nor not the double is forwarding by default.\n\nWhichever form the double forwards in: an inline double runs the real\nbody on itself, because the state that body reads lives on the double\nfrom the moment `forwarding()` copied it — the donor's own copy is\nwhatever the first inline call left behind. Everything else proxies to\nthe remembered instance.", "throws": [], "throwsInBody": true, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 971 + "startLine": 1026 + }, + { + "name": "copyState", + "static": true, + "params": [ + { + "name": "double", + "type": "object", + "description": "", + "default": null, + "variadic": false + }, + { + "name": "donor", + "type": "object", + "description": "", + "default": null, + "variadic": false + } + ], + "returnType": "void", + "summary": "Copies the donor instance's initialized state onto the double, so a\nparent body run inline works over the values the real object held.", + "description": "The double's constructor never ran — beyond the empty scalars\n`PropertyDefaults` wrote, nothing on it is initialized — while the\nparent bodies inline forwarding runs read `$this->` properties. Every\nproperty the donor has initialized is copied; one the donor left\nuninitialized is left (or made) uninitialized on the double, so the\nreal body sees exactly what it would have seen on the donor — including\nPHP's own \"must not be accessed before initialization\" for a property\nnobody set.\n\nEach property is handled through a reflection against its **declaring**\nclass: before PHP 8.4 a readonly property can only be initialized from\nthe scope of the class that declares it, and a reflection reached\nthrough the subclass writes from the subclass's scope.", + "throws": [], + "throwsInBody": false, + "inheritedFrom": null, + "see": [], + "deprecated": null, + "attributes": [], + "startLine": 1202 }, { "name": "reset", @@ -13959,7 +14124,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1177 + "startLine": 1372 } ], "constants": [], @@ -13969,7 +14134,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\RuntimeContext", "kind": "class", @@ -14354,7 +14519,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Runtime\\SequenceVerdict", "kind": "enum", @@ -14563,7 +14728,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Understudy", "kind": "class", @@ -14850,15 +15015,20 @@ } ], "returnType": "void", - "summary": "Delegates unmatched calls to a real instance, recording each one.", - "description": "With `$real`, the double starts standing in front of that object; without\nit, the mode is turned on for a double built from an instance by\n\\Rasuvaeff\\Understudy\\for(). Splitting the two is deliberate: `for($real)` gives you a\ndouble that remembers where it came from, and until you say so it still\nanswers with defaults rather than running real code.\n\nOnly the call at the boundary is recorded. A real method that calls\nanother method on itself does so inside the real object; understudy\nproxies an object, it does not instrument one.", - "throws": [], + "summary": "Delegates unmatched calls to the real implementation, recording each\none.", + "description": "With `$real`, the double starts standing in front of that object; without\nit, the mode is turned on for a double built from an instance by\n\\Rasuvaeff\\Understudy\\for(). Splitting the two is deliberate: `for($real)` gives you a\ndouble that remembers where it came from, and until you say so it still\nanswers with defaults rather than running real code.\n\nA double of a **class** runs the real bodies inline, on itself: the\nstate the real instance held is copied onto the double, and an unmatched\ncall executes the parent implementation with `$this` still the double —\nso a self-call inside the real code re-enters the double, meets stubs,\nan armed protocol and the call log, and is recorded like the boundary\ncall. `Invocation::callOriginal()` runs inline there too, over the state\nthe double now holds. A double of an **interface** has no bodies to\nreach and proxies to the real instance — only the boundary call is\nrecorded, and self-calls happen inside the real object where nothing can\nsee them.\n\nInline needs `$real` to be *exactly* the doubled class: a subclass\ninstance carries overrides the doubled class's own bodies do not\ncontain, and it keeps the proxy form so that those overrides answer. A `final` or `private` method\nis not overridden either, so a self-call to one of those runs the real\nbody directly — it works, over the double's state, but it is not\nintercepted and the log does not show it. And `static::class` inside a\nreal body names the generated class, not the doubled one.\n\nCalling this twice with the same instance changes nothing; re-pointing\nan inline double at a *different* instance is refused, because the state\ncopy cannot be taken twice — a `readonly` property is already\ninitialized, and whatever the real code has written since would be\ndiscarded.", + "throws": [ + { + "type": "\\Rasuvaeff\\Understudy\\Exception\\ForwardingTargetMismatch", + "description": "when `$real` is an understudy, misses a\ncontract, or re-points an inline double" + } + ], "throwsInBody": true, "inheritedFrom": null, "see": [], "deprecated": null, "attributes": [], - "startLine": 679 + "startLine": 704 }, { "name": "delegate", @@ -14888,7 +15058,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 730 + "startLine": 785 }, { "name": "wire", @@ -14918,7 +15088,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 762 + "startLine": 817 }, { "name": "bypassFinals", @@ -14946,7 +15116,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 799 + "startLine": 854 }, { "name": "defaults", @@ -14976,7 +15146,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 880 + "startLine": 935 }, { "name": "label", @@ -15006,7 +15176,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 899 + "startLine": 954 }, { "name": "unused", @@ -15029,7 +15199,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 909 + "startLine": 964 }, { "name": "forget", @@ -15052,7 +15222,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 949 + "startLine": 1004 }, { "name": "nothingElse", @@ -15082,7 +15252,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 969 + "startLine": 1024 }, { "name": "allVerified", @@ -15105,7 +15275,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1012 + "startLine": 1067 }, { "name": "expectSequence", @@ -15128,7 +15298,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1095 + "startLine": 1150 }, { "name": "verifySequence", @@ -15151,7 +15321,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1135 + "startLine": 1190 }, { "name": "transcript", @@ -15174,7 +15344,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1161 + "startLine": 1216 }, { "name": "scope", @@ -15204,7 +15374,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1297 + "startLine": 1352 }, { "name": "checkpoint", @@ -15227,7 +15397,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1330 + "startLine": 1385 }, { "name": "reset", @@ -15242,7 +15412,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1357 + "startLine": 1412 }, { "name": "idle", @@ -15257,7 +15427,7 @@ "see": [], "deprecated": null, "attributes": [], - "startLine": 1369 + "startLine": 1424 } ], "constants": [], @@ -15267,7 +15437,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\VerificationFailure", "kind": "class", @@ -15374,7 +15544,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\WhenBuilder", "kind": "class", @@ -15590,7 +15760,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "class": "Rasuvaeff\\Understudy\\Wiring\\Wire", "kind": "class", @@ -15655,7 +15825,7 @@ "functions": [ { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expect", @@ -15679,7 +15849,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\expectSequence", @@ -15703,7 +15873,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\verify", @@ -15762,7 +15932,7 @@ }, { "root": "core", - "rootVersion": "v1.0.1-1-g9f93626", + "rootVersion": "v1.0.1-3-gc4d60e5", "rootReference": null, "kind": "function", "function": "Rasuvaeff\\Understudy\\when", diff --git a/docs/scripts/check-integrity.mjs b/docs/scripts/check-integrity.mjs index 924e341..6f9f47b 100644 --- a/docs/scripts/check-integrity.mjs +++ b/docs/scripts/check-integrity.mjs @@ -42,7 +42,7 @@ const COMPLETENESS_BUDGET = { 'method without a summary': 0, 'parameter without a description': 154, 'constructor parameter without a description': 10, - 'throwing method without @throws': 8, + 'throwing method without @throws': 7, } const errors = [] diff --git a/docs/src/api/classes/Arg.md b/docs/src/api/classes/Arg.md index 506970f..bb155e4 100644 --- a/docs/src/api/classes/Arg.md +++ b/docs/src/api/classes/Arg.md @@ -9,7 +9,7 @@ description: "Argument matchers, usable only inside a specification closure:" `Rasuvaeff\Understudy\Arg` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Arg.php#L50) — **Version:** v1.0.1-3-gc4d60e5 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 07cffcd..d28de17 100644 --- a/docs/src/api/classes/Captor.md +++ b/docs/src/api/classes/Captor.md @@ -9,7 +9,7 @@ description: "A typed argument captor, built by Arg::captor()." `Rasuvaeff\Understudy\Captor` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Captor.php#L38) — **Version:** v1.0.1-3-gc4d60e5 **Type parameters:** diff --git a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md index abd19ee..86ce910 100644 --- a/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md +++ b/docs/src/api/classes/Exception/AmbiguousDefaultFactory.md @@ -9,7 +9,7 @@ description: "Two registered default factories are equally close to the requeste `Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/AmbiguousDefaultFactory.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/BypassUnavailable.md b/docs/src/api/classes/Exception/BypassUnavailable.md index 99ce16a..eef7e0f 100644 --- a/docs/src/api/classes/Exception/BypassUnavailable.md +++ b/docs/src/api/classes/Exception/BypassUnavailable.md @@ -9,7 +9,7 @@ description: "`bypassFinals()` cannot do what was asked of it." `Rasuvaeff\Understudy\Exception\BypassUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/BypassUnavailable.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/CannotWire.md b/docs/src/api/classes/Exception/CannotWire.md index a7525b7..3a64382 100644 --- a/docs/src/api/classes/Exception/CannotWire.md +++ b/docs/src/api/classes/Exception/CannotWire.md @@ -9,7 +9,7 @@ description: "`wire()` cannot build the subject, or cannot decide what to pass i `Rasuvaeff\Understudy\Exception\CannotWire` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/CannotWire.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/ConflictingExpectation.md b/docs/src/api/classes/Exception/ConflictingExpectation.md index c3616f1..b6d0d09 100644 --- a/docs/src/api/classes/Exception/ConflictingExpectation.md +++ b/docs/src/api/classes/Exception/ConflictingExpectation.md @@ -9,7 +9,7 @@ description: "A `when()` or `expect()` names a call another registration already `Rasuvaeff\Understudy\Exception\ConflictingExpectation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ConflictingExpectation.php#L21) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ContextOwnershipViolation.md b/docs/src/api/classes/Exception/ContextOwnershipViolation.md index 5832c68..3a99f82 100644 --- a/docs/src/api/classes/Exception/ContextOwnershipViolation.md +++ b/docs/src/api/classes/Exception/ContextOwnershipViolation.md @@ -9,7 +9,7 @@ description: "Configuration and verification belong to the context that created `Rasuvaeff\Understudy\Exception\ContextOwnershipViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ContextOwnershipViolation.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForgottenDouble.md b/docs/src/api/classes/Exception/ForgottenDouble.md index 1065ffe..e31eb1b 100644 --- a/docs/src/api/classes/Exception/ForgottenDouble.md +++ b/docs/src/api/classes/Exception/ForgottenDouble.md @@ -9,7 +9,7 @@ description: "A double outlived the context that created it — almost always a `Rasuvaeff\Understudy\Exception\ForgottenDouble` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForgottenDouble.php#L16) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md index 61f11e2..231af0b 100644 --- a/docs/src/api/classes/Exception/ForwardingTargetMismatch.md +++ b/docs/src/api/classes/Exception/ForwardingTargetMismatch.md @@ -9,7 +9,7 @@ description: "The instance offered as a forwarding target does not satisfy what `Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/ForwardingTargetMismatch.php#L13) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `InvalidArgumentException` @@ -40,3 +40,26 @@ static missingContract( Builds the error for a forwarding target missing a contract. +### alreadyInline() + +```php +static alreadyInline( + non-empty-string $label, + class-string $current, + class-string $given, +): Exception\ForwardingTargetMismatch +``` + +Builds the error for re-pointing a double that already forwards inline. + +- `$label` — the double's own name in reports +- `$current` — the class whose bodies the double already runs +- `$given` — the class of the instance offered instead + +The state of the first donor was copied onto the double, and a copy +cannot be taken twice: a `readonly` property is initialized, and +whatever the inline calls have written since would be overwritten by a +second donor's reading. Re-pointing is refused rather than silently +leaving the double running the first donor's state against the second +one's instance. + diff --git a/docs/src/api/classes/Exception/InvalidCallSpecification.md b/docs/src/api/classes/Exception/InvalidCallSpecification.md index b0b4370..0e90070 100644 --- a/docs/src/api/classes/Exception/InvalidCallSpecification.md +++ b/docs/src/api/classes/Exception/InvalidCallSpecification.md @@ -9,7 +9,7 @@ description: "A specification of the wrong SHAPE: the closure handed to when()/v `Rasuvaeff\Understudy\Exception\InvalidCallSpecification` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidCallSpecification.php#L25) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/InvalidDefaultValue.md b/docs/src/api/classes/Exception/InvalidDefaultValue.md index ec4d70d..eaa9e24 100644 --- a/docs/src/api/classes/Exception/InvalidDefaultValue.md +++ b/docs/src/api/classes/Exception/InvalidDefaultValue.md @@ -9,7 +9,7 @@ description: "A registered default factory produced a value the contract cannot `Rasuvaeff\Understudy\Exception\InvalidDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidDefaultValue.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md index b82ef44..9c07d3b 100644 --- a/docs/src/api/classes/Exception/InvalidSpecificationArgument.md +++ b/docs/src/api/classes/Exception/InvalidSpecificationArgument.md @@ -9,7 +9,7 @@ description: "A VALUE inside a specification that no run could act on: a maximum `Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/InvalidSpecificationArgument.php#L24) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `InvalidArgumentException` diff --git a/docs/src/api/classes/Exception/MatcherLeaked.md b/docs/src/api/classes/Exception/MatcherLeaked.md index 786a3d2..899bd61 100644 --- a/docs/src/api/classes/Exception/MatcherLeaked.md +++ b/docs/src/api/classes/Exception/MatcherLeaked.md @@ -9,7 +9,7 @@ description: "A matcher reached a real call instead of a specification closure." `Rasuvaeff\Understudy\Exception\MatcherLeaked` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/MatcherLeaked.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/NeverMethodCalled.md b/docs/src/api/classes/Exception/NeverMethodCalled.md index b0c2fde..b54f2aa 100644 --- a/docs/src/api/classes/Exception/NeverMethodCalled.md +++ b/docs/src/api/classes/Exception/NeverMethodCalled.md @@ -9,7 +9,7 @@ description: "A method declared `: never` was called without an expectation that `Rasuvaeff\Understudy\Exception\NeverMethodCalled` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NeverMethodCalled.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NoDefaultValue.md b/docs/src/api/classes/Exception/NoDefaultValue.md index 9167035..55cb0c4 100644 --- a/docs/src/api/classes/Exception/NoDefaultValue.md +++ b/docs/src/api/classes/Exception/NoDefaultValue.md @@ -9,7 +9,7 @@ description: "A loose understudy had to answer a call, but the declared return t `Rasuvaeff\Understudy\Exception\NoDefaultValue` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NoDefaultValue.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/NothingCaptured.md b/docs/src/api/classes/Exception/NothingCaptured.md index 661f69b..e95ccea 100644 --- a/docs/src/api/classes/Exception/NothingCaptured.md +++ b/docs/src/api/classes/Exception/NothingCaptured.md @@ -9,7 +9,7 @@ description: "`Captor::last()` was read before any matched call carried a value `Rasuvaeff\Understudy\Exception\NothingCaptured` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/NothingCaptured.php#L13) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalCallUnavailable.md b/docs/src/api/classes/Exception/OriginalCallUnavailable.md index 51904ff..7337584 100644 --- a/docs/src/api/classes/Exception/OriginalCallUnavailable.md +++ b/docs/src/api/classes/Exception/OriginalCallUnavailable.md @@ -9,7 +9,7 @@ description: "`callOriginal()` was asked to delegate, and there is nothing to de `Rasuvaeff\Understudy\Exception\OriginalCallUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalCallUnavailable.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md index 51da8fe..2e77888 100644 --- a/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md +++ b/docs/src/api/classes/Exception/OriginalReturnTypeViolation.md @@ -9,7 +9,7 @@ description: "A forwarded call returned an object the double cannot stand in for `Rasuvaeff\Understudy\Exception\OriginalReturnTypeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OriginalReturnTypeViolation.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` @@ -31,3 +31,24 @@ static foreignInstance( Builds the error for a forwarded fluent call returning another object. +### foreignSelf() + +```php +static foreignSelf( + non-empty-string $label, + non-empty-string $method, +): Exception\OriginalReturnTypeViolation +``` + +Builds the error for an inline call whose real body built a new instance +of the double's own class — a `new static`. + +- `$label` — the double's own name in reports +- `$method` — the method whose real body returned it + +It is of the generated class, so it satisfies the override's `: static` +and the language lets it through; no context knows it, so every call on +it would fail as a double that was never registered. A `clone $this` +does not reach here: the copy inherits the original's forwarding and +answers for real. + diff --git a/docs/src/api/classes/Exception/OutcomeUnavailable.md b/docs/src/api/classes/Exception/OutcomeUnavailable.md index 6155203..b4e7ddf 100644 --- a/docs/src/api/classes/Exception/OutcomeUnavailable.md +++ b/docs/src/api/classes/Exception/OutcomeUnavailable.md @@ -9,7 +9,7 @@ description: "An invocation's outcome was read as the wrong kind: a returned val `Rasuvaeff\Understudy\Exception\OutcomeUnavailable` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/OutcomeUnavailable.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/StrictModeViolation.md b/docs/src/api/classes/Exception/StrictModeViolation.md index 9f0a235..b3961e1 100644 --- a/docs/src/api/classes/Exception/StrictModeViolation.md +++ b/docs/src/api/classes/Exception/StrictModeViolation.md @@ -9,7 +9,7 @@ description: "A strict understudy received a call no expectation matched." `Rasuvaeff\Understudy\Exception\StrictModeViolation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/StrictModeViolation.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/Exception/UnderstudyError.md b/docs/src/api/classes/Exception/UnderstudyError.md index e0e4907..2176147 100644 --- a/docs/src/api/classes/Exception/UnderstudyError.md +++ b/docs/src/api/classes/Exception/UnderstudyError.md @@ -9,7 +9,7 @@ description: "Implemented by every exception this library throws, so a test can `Rasuvaeff\Understudy\Exception\UnderstudyError` -**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v1.0.1-1-g9f93626 +**Interface** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnderstudyError.php#L14) — **Version:** v1.0.1-3-gc4d60e5 **Implements:** `Throwable`, `Stringable` diff --git a/docs/src/api/classes/Exception/UnsupportedTarget.md b/docs/src/api/classes/Exception/UnsupportedTarget.md index edb1351..52b76b8 100644 --- a/docs/src/api/classes/Exception/UnsupportedTarget.md +++ b/docs/src/api/classes/Exception/UnsupportedTarget.md @@ -9,7 +9,7 @@ description: "The requested target cannot be doubled, and no option would make i `Rasuvaeff\Understudy\Exception\UnsupportedTarget` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/UnsupportedTarget.php#L13) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `LogicException` diff --git a/docs/src/api/classes/Exception/VerificationFailed.md b/docs/src/api/classes/Exception/VerificationFailed.md index 38ae159..895771c 100644 --- a/docs/src/api/classes/Exception/VerificationFailed.md +++ b/docs/src/api/classes/Exception/VerificationFailed.md @@ -9,7 +9,7 @@ description: "A verification about what the code under test did (or did not) do `Rasuvaeff\Understudy\Exception\VerificationFailed` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Exception/VerificationFailed.php#L20) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** `RuntimeException` diff --git a/docs/src/api/classes/ExpectBuilder.md b/docs/src/api/classes/ExpectBuilder.md index eb1f19b..6e57fb1 100644 --- a/docs/src/api/classes/ExpectBuilder.md +++ b/docs/src/api/classes/ExpectBuilder.md @@ -9,7 +9,7 @@ description: "Configures a call the code under test is expected to make." `Rasuvaeff\Understudy\ExpectBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/ExpectBuilder.php#L19) — **Version:** v1.0.1-3-gc4d60e5 **Extends:** [`WhenBuilder`](/api/classes/WhenBuilder) diff --git a/docs/src/api/classes/FailureKind.md b/docs/src/api/classes/FailureKind.md index 42a186b..84b8c80 100644 --- a/docs/src/api/classes/FailureKind.md +++ b/docs/src/api/classes/FailureKind.md @@ -9,7 +9,7 @@ description: "What kind of verification claim a VerificationFailure reports." `Rasuvaeff\Understudy\FailureKind` -**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v1.0.1-1-g9f93626 +**Enum** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/FailureKind.php#L12) — **Version:** v1.0.1-3-gc4d60e5 **Implements:** `UnitEnum` diff --git a/docs/src/api/classes/Invocation.md b/docs/src/api/classes/Invocation.md index 66f78b1..c31d0bf 100644 --- a/docs/src/api/classes/Invocation.md +++ b/docs/src/api/classes/Invocation.md @@ -9,7 +9,7 @@ description: "One recorded call on an understudy." `Rasuvaeff\Understudy\Invocation` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Invocation.php#L21) — **Version:** v1.0.1-3-gc4d60e5 One recorded call on an understudy. @@ -98,17 +98,22 @@ Checks whether this invocation belongs to a given double. callOriginal(): mixed ``` -Delegates this call to the double's real instance and returns what it -answered — the `answers()` escape hatch for "behave normally, except -here". +Runs the real implementation of this call and returns what it answered — +the `answers()` escape hatch for "behave normally, except here". **Throws:** - [`Exception\OriginalCallUnavailable`](/api/classes/Exception/OriginalCallUnavailable) +Where that implementation runs follows the double's own form. A class +double that forwards runs the real body on itself, over the state +`forwarding()` copied there; every other double delegates to the real +instance it remembers, where the body's own self-calls stay inside that +object. + Without a forwarding target it raises `OriginalCallUnavailable` rather -than reaching for the parent implementation: the double's constructor -never ran, so the parent body would work over state that does not exist. +than reaching for the real body anyway: nothing copied the state, so the +body would work over properties the skipped constructor never filled. ### recordOutcome() diff --git a/docs/src/api/classes/Understudy.md b/docs/src/api/classes/Understudy.md index c4aa8be..281cea6 100644 --- a/docs/src/api/classes/Understudy.md +++ b/docs/src/api/classes/Understudy.md @@ -9,7 +9,7 @@ description: "The whole public surface, as static methods so that an understudy `Rasuvaeff\Understudy\Understudy` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L41) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/Understudy.php#L41) — **Version:** v1.0.1-3-gc4d60e5 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 @@ -164,7 +164,13 @@ lifecycle teardown runs. static forwarding(object $double, ?object $real = NULL): void ``` -Delegates unmatched calls to a real instance, recording each one. +Delegates unmatched calls to the real implementation, recording each +one. + +**Throws:** + +- [`Exception\ForwardingTargetMismatch`](/api/classes/Exception/ForwardingTargetMismatch) — when `$real` is an understudy, misses a +contract, or re-points an inline double With `$real`, the double starts standing in front of that object; without it, the mode is turned on for a double built from an instance by @@ -172,9 +178,30 @@ it, the mode is turned on for a double built from an instance by double that remembers where it came from, and until you say so it still answers with defaults rather than running real code. -Only the call at the boundary is recorded. A real method that calls -another method on itself does so inside the real object; understudy -proxies an object, it does not instrument one. +A double of a **class** runs the real bodies inline, on itself: the +state the real instance held is copied onto the double, and an unmatched +call executes the parent implementation with `$this` still the double — +so a self-call inside the real code re-enters the double, meets stubs, +an armed protocol and the call log, and is recorded like the boundary +call. `Invocation::callOriginal()` runs inline there too, over the state +the double now holds. A double of an **interface** has no bodies to +reach and proxies to the real instance — only the boundary call is +recorded, and self-calls happen inside the real object where nothing can +see them. + +Inline needs `$real` to be *exactly* the doubled class: a subclass +instance carries overrides the doubled class's own bodies do not +contain, and it keeps the proxy form so that those overrides answer. A `final` or `private` method +is not overridden either, so a self-call to one of those runs the real +body directly — it works, over the double's state, but it is not +intercepted and the log does not show it. And `static::class` inside a +real body names the generated class, not the doubled one. + +Calling this twice with the same instance changes nothing; re-pointing +an inline double at a *different* instance is refused, because the state +copy cannot be taken twice — a `readonly` property is already +initialized, and whatever the real code has written since would be +discarded. ### delegate() diff --git a/docs/src/api/classes/VerificationFailure.md b/docs/src/api/classes/VerificationFailure.md index d9f32a1..398d47b 100644 --- a/docs/src/api/classes/VerificationFailure.md +++ b/docs/src/api/classes/VerificationFailure.md @@ -9,7 +9,7 @@ description: "The structured half of one verification failure — the same facts `Rasuvaeff\Understudy\VerificationFailure` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/VerificationFailure.php#L34) — **Version:** v1.0.1-3-gc4d60e5 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 859c74a..8e3b635 100644 --- a/docs/src/api/classes/WhenBuilder.md +++ b/docs/src/api/classes/WhenBuilder.md @@ -9,7 +9,7 @@ description: "Configures what a stubbed call does." `Rasuvaeff\Understudy\WhenBuilder` -**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L39) — **Version:** v1.0.1-1-g9f93626 +**Class** — **Package:** [rasuvaeff/understudy](https://github.com/rasuvaeff/understudy) — [Source](https://github.com/rasuvaeff/understudy/blob/master/src/WhenBuilder.php#L39) — **Version:** v1.0.1-3-gc4d60e5 **Type parameters:** diff --git a/docs/src/guide/forwarding.md b/docs/src/guide/forwarding.md index 33dc092..45657f7 100644 --- a/docs/src/guide/forwarding.md +++ b/docs/src/guide/forwarding.md @@ -38,11 +38,33 @@ when(fn () => $spy->get('key')) ->answers(fn (Invocation $call) => strtoupper((string) $call->callOriginal())); ``` -## Five things worth knowing first +## Eight things worth knowing first -- **Only the call at the boundary is recorded.** If the real method calls - another method on itself, that happens inside the real object. Understudy - proxies an object; it does not instrument one. +- **A class double runs the real bodies on itself.** The state the real + instance held is copied onto the double when `forwarding()` is called, and an + unmatched call executes the parent implementation with `$this` still the + double — so a self-call inside the real code re-enters the double: it meets + stubs, an armed protocol and the call log, and every call is recorded, the + boundary one and the self-calls alike. Two consequences to hold on to: + `static::class` inside a real body names the generated class rather than + yours, and a `final` or `private` method is not overridden, so a self-call to + one of those runs the real body directly — it works, but nothing intercepts + or records it. The instance itself stops receiving the calls: after + `forwarding()` it holds the state it had at that moment, and assertions + belong on the double. The copy is shallow, which is the reach the instance + already had — an object-valued property is the same object on both sides, so + writes *through* one still land on the donor's collaborators. +- **An interface double proxies instead.** It has no bodies to reach: the call + runs on the real instance, only the call at the boundary is recorded, and + self-calls happen inside the real object where nothing can see them. +- **Running inline needs the instance to be exactly the doubled class.** A + donor that is a *subclass* carries overrides the doubled class's own bodies + do not contain, and running those bodies in their place would change which + code executes — such a double keeps the proxy form, where the overrides answer. + An inline double also cannot be re-pointed at a second instance: its state + was copied from the first, a `readonly` property cannot be written twice, and + a second copy would discard everything the real code has written since. Build + a second double for the other instance. - **A `: never` method reaches the real implementation.** Its throw lives there, and a forwarding double has something that can answer for itself. - **An understudy is not a valid target.** Forwarding to one — itself included @@ -51,11 +73,27 @@ when(fn () => $spy->get('key')) - **A by-reference argument is the caller's variable.** A forwarded method writes to it, and the call log keeps both readings — what was passed and what it became — so a verification still sees the value the caller handed over. -- **A fluent method comes back as the double.** When the real instance returns - itself, the double is returned instead, so a chain stays doubled. A `static` - method that returns a *different* instance of the real class is refused — - that object is not a double, and returning it would break the override's own - `: static`. +- **A fluent method comes back as the double.** When the real code returns + `$this`, that already *is* the double — the parent body ran on it — so a + chain stays doubled by itself. A `clone $this` works too: the copy is + adopted while the body runs and inherits this double's forwarding, so a + wither comes back as a working double — carrying none of this one's stubs, + the same rule a `clone $double` in the test follows. Adoption asks for a + copy of *this* double, holding its state: a body that clones another double + it was handed gets a fresh double of the right class instead, and so does a + `clone $double` written in your own `answers()` closure, even when a real + body is what reached it. Two doubles of one class in the same state cannot + be told apart — `__clone()` is given no reference to what it copied — so a + copy of one of those is adopted and answers for real; it does so over the + state it already holds, which is the other double's. What is refused, with + `OriginalReturnTypeViolation`, is a real body handing out a `new static`: + that object is of the generated class, so the return type accepts it, but no + context knows it and every call on it would fail as an unregistered + double — under the proxy the same shape was refused as a foreign instance. +- **`callOriginal()` follows the double's own form.** On an inline double it + runs the parent body on the double, over the state the double holds — the + donor's copy stopped being the current one at the first inline call. On every + other double it runs on the real instance, where self-calls stay inside it. ::: warning Retention A forwarding double returns whatever the real object returns, including values diff --git a/docs/src/guide/migrating-from-mockery.md b/docs/src/guide/migrating-from-mockery.md index 9219a18..b254ab0 100644 --- a/docs/src/guide/migrating-from-mockery.md +++ b/docs/src/guide/migrating-from-mockery.md @@ -35,7 +35,7 @@ the site still gets them. | `$mock->shouldNotHaveReceived('save')` | `Understudy::unused($mock)` | or `verify(…, never: true)` for one call | | `$mock->shouldHaveReceived('save')` | `verify(fn () => $mock->save(…))` | after the fact | | `->ordered()` | `expect(…)->ordered()` | or `verifySequence()` / `expectSequence()` for a whole protocol | -| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded | +| `->makePartial()` / `Mockery::spy($real)` | `Understudy::delegate(Contract::class, $real)` + stubs on top | a stub wins, everything else runs for real — and is recorded. Pass the **class** to get what `makePartial()` does with a self-call: the bodies run on the double, so `$this->other()` meets your stub. An interface there proxies instead | | `Mockery::close()` | the adapter's `reset()`, or your own teardown | | ## The traps diff --git a/llms.txt b/llms.txt index d9c68a6..e4d8973 100644 --- a/llms.txt +++ b/llms.txt @@ -82,13 +82,24 @@ No runtime deps; `ext-mbstring` is not needed. - `Understudy::delegate(Contract::class, $real)` is `for()` + `forwarding()` in one expression and returns the double. The target is validated the same way. - `Invocation::callOriginal()` delegates one call from inside `answers()`, - whatever the mode. Without a target it raises `OriginalCallUnavailable` — the - parent implementation is never reached, because the double's constructor never - ran. -- Forwarding records the boundary call only: a real method calling itself is not - visible. A fluent method that returned the real instance comes back as the - double; a `static` method returning another instance of the real class raises - `OriginalReturnTypeViolation`. + whatever the mode. On an inline double it runs the parent body on the double; + otherwise on the real instance. Without a target it raises + `OriginalCallUnavailable` — nothing copied the state, so the parent + implementation would read properties the skipped constructor never filled. +- A CLASS double forwards inline: the doubled class's bodies run on the double, + over state copied from the instance when `forwarding()` was called, so a + self-call re-enters the double and meets stubs, protocols and the call log. + It needs the instance to be exactly the doubled class — a subclass instance + keeps the proxy form — and an inline double cannot be re-pointed at a second + instance (`ForwardingTargetMismatch`). `static::class` inside a real body is + the generated class; `final` and `private` methods are not intercepted. +- An INTERFACE double proxies: it records the boundary call only, and a real + method calling itself is not visible. A fluent method that returned the real + instance comes back as the double; a `: static` method returning another + instance of the real class raises `OriginalReturnTypeViolation`. Inline, a + `clone $this` is adopted while the body runs and comes back forwarding (no + stubs copied); a `new static` raises `OriginalReturnTypeViolation`, because + no context knows that object. - `Understudy::bypassFinals(FinalX::class)` lifts `final` off one class before it loads; `Understudy::bypassFinals()` does it for every class the process loads from then on. Opt-in, and order-dependent: it works only for classes not diff --git a/spikes/08-inline-forwarding/spike.php b/spikes/08-inline-forwarding/spike.php new file mode 100644 index 0000000..2094d43 --- /dev/null +++ b/spikes/08-inline-forwarding/spike.php @@ -0,0 +1,138 @@ +()` sibling that + * calls `parent::()` — is what jasonmccreary/double does. It is not + * open to us: every member added to a generated double is a name the doubled + * type can no longer use. + */ + +declare(strict_types=1); + +namespace Understudy\Spikes\InlineForwarding; + +use function Understudy\Spikes\assertSame; +use function Understudy\Spikes\assertTrue; + +require __DIR__ . '/../lib.php'; + +class Doubled +{ + public function __construct(private readonly string $name = 'real') {} + + /** The template method: everything it does goes through itself. */ + public function run(string $input): string + { + return $this->apply($input); + } + + public function apply(string $input): string + { + return $input . ':' . $this->name; + } + + protected function hidden(): string + { + return 'hidden:' . $this->name; + } + + public function fill(string &$slot, string $value): void + { + $slot = $value . '!'; + } + + public function self(): static + { + return $this; + } +} + +/** Stands in for the generated double: every method is an interception point. */ +final class Generated extends Doubled +{ + /** @var list */ + public array $seen = []; + + public function run(string $input): string + { + $this->seen[] = 'run'; + + return 'intercepted run'; + } + + public function apply(string $input): string + { + $this->seen[] = 'apply'; + + return 'intercepted apply'; + } + + protected function hidden(): string + { + $this->seen[] = 'hidden'; + + return 'intercepted hidden'; + } +} + +/** + * @param list $args + */ +function inline(object $double, string $class, string $method, array $args): mixed +{ + return (new \ReflectionMethod($class, $method))->invokeArgs($double, $args); +} + +$double = new Generated(); + +// The real `run()` body ran — its own override was not entered — and the +// self-call inside it DID enter the override. +assertSame( + inline($double, Doubled::class, 'run', ['in']), + 'intercepted apply', + 'a reflection on the doubled class enters its body, and the self-call inside it is intercepted', +); +assertSame($double->seen, ['apply'], 'only the self-call was intercepted, not the entry'); + +assertSame( + inline($double, Doubled::class, 'apply', ['in']), + 'in:real', + 'the real body reads $this state copied onto the subclass instance', +); + +assertSame( + inline($double, Doubled::class, 'hidden', []), + 'hidden:real', + 'a protected body is reachable the same way', +); + +$slot = 'empty'; +$args = [&$slot, 'written']; +inline($double, Doubled::class, 'fill', $args); +assertSame($slot, 'written!', 'a by-reference parameter still writes to the caller variable'); + +assertTrue( + inline($double, Doubled::class, 'self', []) === $double, + 'a body returning $this hands back the double, so a fluent chain stays doubled', +); + +$unchanged = new Generated(); +assertSame( + $unchanged->run('in'), + 'intercepted run', + 'an ordinary call still goes to the override — the reflection is the only non-virtual entry', +); diff --git a/spikes/run.sh b/spikes/run.sh index cf87bb7..c679669 100644 --- a/spikes/run.sh +++ b/spikes/run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Runs feasibility spikes 01-06 with the PHP binary on PATH. +# Runs feasibility spikes 01-06 and 08 with the PHP binary on PATH. # Spike 07 (Psalm plugin) has its own runner: spikes/07-psalm-builder/run.sh. set -u @@ -8,7 +8,7 @@ cd "$(dirname "$0")" php -v | head -1 status=0 -for spike in 01-sentinel 02-matcher-union 03-byref-return 04-dnf-multitarget 05-fiber-contexts 06-bypass-finals; do +for spike in 01-sentinel 02-matcher-union 03-byref-return 04-dnf-multitarget 05-fiber-contexts 06-bypass-finals 08-inline-forwarding; do if php "$spike/spike.php"; then echo "PASS $spike" else diff --git a/src/Exception/ForwardingTargetMismatch.php b/src/Exception/ForwardingTargetMismatch.php index bfcd4c1..5f6ab9a 100644 --- a/src/Exception/ForwardingTargetMismatch.php +++ b/src/Exception/ForwardingTargetMismatch.php @@ -44,4 +44,31 @@ public static function missingContract(string $label, string $contract, string $ $given, )); } + + /** + * Builds the error for re-pointing a double that already forwards inline. + * + * The state of the first donor was copied onto the double, and a copy + * cannot be taken twice: a `readonly` property is initialized, and + * whatever the inline calls have written since would be overwritten by a + * second donor's reading. Re-pointing is refused rather than silently + * leaving the double running the first donor's state against the second + * one's instance. + * + * @param non-empty-string $label the double's own name in reports + * @param class-string $current the class whose bodies the double already runs + * @param class-string $given the class of the instance offered instead + */ + public static function alreadyInline(string $label, string $current, string $given): self + { + return new self(sprintf( + "Understudy `%s` already runs the bodies of `%s` on itself, and cannot be re-pointed at `%s`.\n" + . 'The first instance\'s state was copied onto the double when forwarding was turned on; a second ' + . 'copy would overwrite everything the real code has written since, and a `readonly` property ' + . 'cannot be written twice at all. Build a second double for the other instance.', + $label, + $current, + $given, + )); + } } diff --git a/src/Exception/OriginalReturnTypeViolation.php b/src/Exception/OriginalReturnTypeViolation.php index 1efe3c5..322d576 100644 --- a/src/Exception/OriginalReturnTypeViolation.php +++ b/src/Exception/OriginalReturnTypeViolation.php @@ -31,4 +31,31 @@ public static function foreignInstance(string $label, string $method, string $re $method, )); } + + /** + * Builds the error for an inline call whose real body built a new instance + * of the double's own class — a `new static`. + * + * It is of the generated class, so it satisfies the override's `: static` + * and the language lets it through; no context knows it, so every call on + * it would fail as a double that was never registered. A `clone $this` + * does not reach here: the copy inherits the original's forwarding and + * answers for real. + * + * @param non-empty-string $label the double's own name in reports + * @param non-empty-string $method the method whose real body returned it + */ + public static function foreignSelf(string $label, string $method): self + { + return new self(sprintf( + "Understudy `%s` ran `%s()` inline, and the real body built a new instance of itself — a " + . "`new static` or `new self`.\n" + . 'It is of the double\'s own class, so the return type accepts it, but no context knows it and ' + . 'every call on it would fail as an unregistered double. Configure the method instead: ' + . 'when(fn () => $double->%s(...))->returns($double).', + $label, + $method, + $method, + )); + } } diff --git a/src/Invocation.php b/src/Invocation.php index cecc42f..5cf832c 100644 --- a/src/Invocation.php +++ b/src/Invocation.php @@ -127,13 +127,18 @@ public function belongsTo(object $double): bool } /** - * Delegates this call to the double's real instance and returns what it - * answered — the `answers()` escape hatch for "behave normally, except - * here". + * Runs the real implementation of this call and returns what it answered — + * the `answers()` escape hatch for "behave normally, except here". + * + * Where that implementation runs follows the double's own form. A class + * double that forwards runs the real body on itself, over the state + * `forwarding()` copied there; every other double delegates to the real + * instance it remembers, where the body's own self-calls stay inside that + * object. * * Without a forwarding target it raises `OriginalCallUnavailable` rather - * than reaching for the parent implementation: the double's constructor - * never ran, so the parent body would work over state that does not exist. + * than reaching for the real body anyway: nothing copied the state, so the + * body would work over properties the skipped constructor never filled. * * @throws OriginalCallUnavailable */ diff --git a/src/Runtime/DoubleState.php b/src/Runtime/DoubleState.php index a3001fc..fd734c8 100644 --- a/src/Runtime/DoubleState.php +++ b/src/Runtime/DoubleState.php @@ -74,6 +74,9 @@ public function __construct( private bool $lean = false; + /** @var class-string|null */ + private ?string $inlineParent = null; + public function mode(): Mode { return $this->mode; @@ -100,6 +103,45 @@ public function setMode(Mode $mode): void $this->mode = $mode; } + /** + * Whether forwarding runs the real bodies on the double itself. + * + * True only for a class double whose parent is exactly the donor's class, + * and only once its state has been copied over: the inline form reads + * `$this->` properties, so turning it on without the copy would run real + * code against a double the constructor never touched. Every other + * forwarding double — an interface double, a donor that is a subclass of + * the doubled class — proxies instead. + * + * It survives a later `setMode()`: the state copy cannot be undone, and + * `callOriginal()` still has to know which form to use. + */ + public function forwardsInline(): bool + { + return $this->inlineParent !== null; + } + + /** + * The class whose bodies an inline double runs: the one the generated + * class extends, which is also the donor's own class. + * + * @return class-string|null + */ + public function inlineParent(): ?string + { + return $this->inlineParent; + } + + /** + * One-way, and called only after the state copy succeeded. + * + * @param class-string $parent + */ + public function forwardInline(string $parent): void + { + $this->inlineParent = $parent; + } + /** * @return non-empty-string */ diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 8d1bc76..f58cdaa 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -31,6 +31,42 @@ final class Runtime /** How deep an argument snapshot follows nested arrays. */ private const int SNAPSHOT_DEPTH = 8; + /** + * The doubled classes' own method bodies, by `Class::method`, for inline + * forwarding. Class metadata, so it outlives a context: `reset()` drops + * doubles, not the shape of the types they stood in for. + * + * @var array + */ + private static array $inlineMethods = []; + + /** + * The doubles whose real bodies are running right now, innermost last. + * + * A body that clones `$this` gets a copy of a double, and the generated + * `__clone()` cannot tell what it was cloned from — PHP hands it no + * reference to the original. Without this frame the copy would be a + * default-answering double in the middle of real code that, proxied, + * would have gone on working: `withTag()` returning `clone $this`, or a + * body computing over an internal copy. + * + * @var list + */ + private static array $inlineFrames = []; + + /** + * How much of {@see $inlineFrames} a `clone` is allowed to see. + * + * A test's own `answers()`/`returns()` closure runs inside dispatch, which + * can be reached from a real body running inline — and a `clone $double` + * written there is the test's clone, which the documented rule makes a + * fresh double. The frames below the floor are the bodies that happen to + * be on the stack underneath; the floor is raised for the span of the + * closure, so only what the closure itself enters — `callOriginal()`, + * which pushes a frame of its own — is visible to a clone made inside it. + */ + private static int $inlineFloor = 0; + /** * A stack, not a single context: `scope()` nests one inside another, and * the outer one must come back intact when the inner ends. @@ -270,7 +306,41 @@ public static function adoptClone(object $clone): void } $context = self::current(); - $context->register($clone, new DoubleState($blueprint)); + $state = new DoubleState($blueprint); + + // Cloned by real code running inline: the copy carries the original's + // forwarding, because PHP already copied the state those bodies read + // and the alternative is real code holding a double that answers + // defaults. Expectations are still not copied — that part of the rule + // above stands, and a clone the TEST makes is untouched by this, + // whether no body is running at all or the test's own closure was + // reached from one ({@see $inlineFloor}). + $frames = self::$inlineFrames; + $frame = $frames === [] || \count($frames) <= self::$inlineFloor + ? null + : $frames[\count($frames) - 1]; + + // The class is not enough, and the same generated class stands in for + // every double of a type: a body handed another double of its own + // class — `merge(Basket $other)` — and cloning THAT would otherwise + // adopt it on the running double's behalf, giving it bodies to run and + // no state copy to run them over. What PHP leaves to go on is the copy + // itself, which at this moment is property-identical to whatever it + // was cloned from, because the generated `__clone()` does nothing but + // call this method. + if ($frame !== null && $frame::class === $clone::class && self::isCopyOf($clone, $frame)) { + $origin = (self::ownerOf($frame) ?? $context)->stateOf($frame); + $target = $origin?->forwardingTarget(); + $parent = $origin?->inlineParent(); + + if ($target !== null && $parent !== null) { + $state->setForwardingTarget($target); + $state->setMode(Mode::Forwarding); + $state->forwardInline($parent); + } + } + + $context->register($clone, $state); self::owners()->offsetSet($clone, $context); } @@ -660,8 +730,19 @@ private static function answer( break; } - /** @var mixed $answer */ - $answer = $expectation->performAction($invocation); + // The test's own closure, so a `clone $double` inside it is the + // test's clone even when a real body is on the stack underneath. + // Two integers rather than a saved copy of the stack: this is the + // dispatch path, and every stubbed call in a suite pays for it. + $floor = self::$inlineFloor; + self::$inlineFloor = \count(self::$inlineFrames); + + try { + /** @var mixed $answer */ + $answer = $expectation->performAction($invocation); + } finally { + self::$inlineFloor = $floor; + } if ($signature !== null && $signature->returnsNever) { // The expectation returned instead of throwing; returning from @@ -684,6 +765,10 @@ private static function answer( // `expect(...)->times(1)` with no action — still needs one. Returning a // default there would make counting a call change what it answers. if ($state->mode() === Mode::Forwarding) { + if ($state->forwardsInline()) { + return self::runInline($state, $double, $method, $args); + } + // The live arguments, not the log's reading of them: a by-reference // parameter is the caller's variable, and the whole point of // forwarding is that the real method can write to it. @@ -959,12 +1044,18 @@ public static function referenceSlot(object $double, string $method, array $args } /** - * Delegates one recorded call to the real instance, from inside an answer. + * Runs the real implementation of one recorded call, from inside an answer. * * Explicit rather than mode-driven: `answers(fn (Invocation $i) => * $i->callOriginal())` says "this one call goes through", and works whether * or not the double is forwarding by default. * + * Whichever form the double forwards in: an inline double runs the real + * body on itself, because the state that body reads lives on the double + * from the moment `forwarding()` copied it — the donor's own copy is + * whatever the first inline call left behind. Everything else proxies to + * the remembered instance. + * * @param non-empty-string $method * @param list $args */ @@ -977,16 +1068,94 @@ public static function callOriginal(object $double, string $method, array $args) throw ForgottenDouble::afterReset($method); } + if ($state->forwardsInline()) { + return self::runInline($state, $double, $method, $args); + } + return self::forward($state, $double, $method, $args); } + /** + * Runs the real body on the double itself. + * + * `ReflectionMethod::invokeArgs()` is what makes this possible without + * adding anything to the generated class: a reflection taken on the + * *doubled* class enters that class's body even though the double + * overrides the method — the same non-virtual entry `parent::` gives — + * while `$this` inside the body stays the double, so its self-calls + * dispatch virtually and land back in the override. Verified on 8.3, 8.4 + * and 8.5; `spikes/08-inline-forwarding` is the executable form of the + * claim. A generated `__understudyReal_*()` sibling would be the other + * way, and it is the way this library does not take: every member added + * to a double is a name the doubled type can no longer use. + * + * The arguments are the live ones — a reference survives the array, so a + * by-reference parameter still writes to the caller's variable — and the + * body runs inside dispatch, so its outcome is recorded like any other + * answer's. + * + * The one thing the proxy form refused and this form has to refuse too: a + * real body that builds another instance of itself and hands it out. Under + * the proxy it was an instance of the real class the double could not + * stand in for; here it is an instance of the generated class, which + * satisfies the override's `: static` and so gets past the language. A + * `clone $this` is fine — `adoptClone()` gave the copy this double's + * forwarding, so it answers for real — but a `new static` belongs to no + * context at all, and the next call on it would fail as a double that was + * never registered. That is what is refused, by registration rather than + * by return type: an unknown instance is broken for every caller, not only + * for one whose signature says `static`. + * + * @param non-empty-string $method + * @param list $args + */ + private static function runInline(DoubleState $state, object $double, string $method, array $args): mixed + { + $parent = $state->inlineParent(); + \assert($parent !== null); + + // Cached across the suite: the reflection describes a class, and a + // fresh one per call is reflection on the dispatch path. Every contract + // of the double is satisfied by the donor's class, which IS this class, + // so the method is always there to be found. + $real = self::$inlineMethods[$parent . '::' . $method] ??= new \ReflectionMethod($parent, $method); + + self::$inlineFrames[] = $double; + + try { + /** @var mixed $value */ + $value = $real->invokeArgs($double, $args); + } finally { + array_pop(self::$inlineFrames); + } + + if (!\is_object($value)) { + // Only an object can be the double, its clone, or a `new static`. + return $value; + } + + if ($value === $double) { + // The body's own `return $this`, which already IS the double. + return $value; + } + + if ($value instanceof $double && self::ownerOf($value) === null) { + throw OriginalReturnTypeViolation::foreignSelf($state->label(), $method); + } + + return $value; + } + /** * Delegates a call to the double's real instance and adopts the result. * - * Only the call at the boundary is recorded. If the real method calls - * another method on itself, that happens inside the real object and never - * reaches this dispatcher: understudy proxies an object, it does not - * instrument one. + * The proxy form of forwarding — the one an interface double has no choice + * about, and the one a donor that is a subclass of the doubled class keeps. + * The call runs on the remembered instance, `$this` inside it is that + * instance, and its self-calls stay inside it. A class double standing in + * for exactly the donor's class runs the real bodies on itself instead + * ({@see runInline()}), where `$this` stays the double and self-calls + * re-enter dispatch. * * @param non-empty-string $method * @param list $args @@ -1048,6 +1217,143 @@ private static function adoptForwardedResult( return $value; } + /** + * Copies the donor instance's initialized state onto the double, so a + * parent body run inline works over the values the real object held. + * + * The double's constructor never ran — beyond the empty scalars + * `PropertyDefaults` wrote, nothing on it is initialized — while the + * parent bodies inline forwarding runs read `$this->` properties. Every + * property the donor has initialized is copied; one the donor left + * uninitialized is left (or made) uninitialized on the double, so the + * real body sees exactly what it would have seen on the donor — including + * PHP's own "must not be accessed before initialization" for a property + * nobody set. + * + * Each property is handled through a reflection against its **declaring** + * class: before PHP 8.4 a readonly property can only be initialized from + * the scope of the class that declares it, and a reflection reached + * through the subclass writes from the subclass's scope. + */ + public static function copyState(object $double, object $donor): void + { + for ($class = new \ReflectionClass($donor); $class !== false; $class = $class->getParentClass()) { + foreach ($class->getProperties() as $property) { + if ($property->isStatic() || $property->getDeclaringClass()->getName() !== $class->getName()) { + continue; + } + + if (\PHP_VERSION_ID >= 80400 && self::propertyFlag($property, 'hasHooks')) { + // A virtual property has no state to copy; a backed one + // has its backing store, reachable without running the + // hooks — reading through them would execute the donor's + // code during what is test setup. + if (!self::propertyFlag($property, 'isVirtual') && $property->isInitialized($donor)) { + self::propertyMember($property, 'setRawValue', [ + $double, + self::propertyMember($property, 'getRawValue', [$donor]), + ]); + } + + continue; + } + + if ($property->isInitialized($donor)) { + $property->setValue($double, $property->getValue($donor)); + + continue; + } + + if ($property->isInitialized($double)) { + // The donor's truth is "uninitialized"; the double is + // holding one of the empty-scalar property defaults. + // Restore the donor's reading, or the real body would see + // a value the real object never had. + // + // No visibility dance and no `readonly` guard, because + // only one thing writes a property on a double the + // constructor skipped: `PropertyDefaults`, which writes + // public, writable, non-readonly properties and nothing + // else. That is exactly the set `unset()` from out here + // can reach, and the rest cannot be in this position. + unset($double->{$property->getName()}); + } + } + } + } + + /** + * Whether `$clone` is the copy PHP just took of `$frame`. + * + * Asked because `__clone()` is handed no reference to what it was cloned + * from, and the running body's double is a guess: one generated class + * serves every double of a type, so a body that was given another double + * of its own class and cloned that one looks identical from here. What + * separates them is the state, and this runs before anything could have + * changed it — a generated `__clone()` only calls {@see adoptClone()}, + * never the doubled class's own `__clone()` body, so the copy still holds + * exactly what it was copied from. + * + * `get_mangled_object_vars()` is the whole comparison, and it is the + * right reading rather than a shortcut: it answers with the object's own + * property table — private properties of every parent under their mangled + * names, which a reflection of the double's class cannot reach at all — + * and it answers without running anything. A hooked property comes back + * as its backing store, not through its `get`, which is the doubled + * class's code and must not run while `__clone()` is still deciding what + * this copy is; a virtual property has no entry, because it has no state; + * a static one belongs to the class both objects share; and a property + * nobody initialized is absent, so "the donor never set this" differs + * from any value at all. Two tables compare `===`: same keys in the same + * order — a copy cannot reorder them — and the same values, which for an + * object-valued property means the same object, as a copy holds. + * + * Two doubles in the same state stay indistinguishable, and that is the + * residual of PHP's blind spot rather than something this can close: such + * a copy is adopted and runs real bodies where a plain double would have + * answered defaults. It runs them over the state the copy already holds, + * so nothing reads a value the object never had — the readings are real, + * they are simply the other double's. + */ + private static function isCopyOf(object $clone, object $frame): bool + { + return get_mangled_object_vars($clone) === get_mangled_object_vars($frame); + } + + /** + * Asks a `ReflectionProperty` member that exists only from PHP 8.4. + * + * By name rather than as `$property->hasHooks()`, and the name arrives as + * a parameter so that nothing can resolve it: Psalm reflects the real + * `ReflectionProperty` of the PHP it runs on, so a direct call is an + * `UndefinedMethod` under 8.3 — green on a machine with 8.5 and red in + * the 8.3 matrix job, which is how this reached CI once. Same idiom as + * `Codegen\PropertyDefaults::isWritableFromOutside()`. + * + * @param non-empty-string $member + */ + private static function propertyFlag(\ReflectionProperty $property, string $member): bool + { + /** @var mixed $answer */ + $answer = $property->{$member}(); + + return $answer === true; + } + + /** + * Calls an 8.4-only `ReflectionProperty` member, for the same reason. + * + * @param non-empty-string $member + * @param list $args + */ + private static function propertyMember(\ReflectionProperty $property, string $member, array $args): mixed + { + /** @var mixed $value */ + $value = $property->{$member}(...$args); + + return $value; + } + /** * The same values, without the references. * diff --git a/src/Understudy.php b/src/Understudy.php index 6ff5fe3..afb0d00 100644 --- a/src/Understudy.php +++ b/src/Understudy.php @@ -664,7 +664,8 @@ public static function lean(object $double): void } /** - * Delegates unmatched calls to a real instance, recording each one. + * Delegates unmatched calls to the real implementation, recording each + * one. * * With `$real`, the double starts standing in front of that object; without * it, the mode is turned on for a double built from an instance by @@ -672,9 +673,33 @@ public static function lean(object $double): void * double that remembers where it came from, and until you say so it still * answers with defaults rather than running real code. * - * Only the call at the boundary is recorded. A real method that calls - * another method on itself does so inside the real object; understudy - * proxies an object, it does not instrument one. + * A double of a **class** runs the real bodies inline, on itself: the + * state the real instance held is copied onto the double, and an unmatched + * call executes the parent implementation with `$this` still the double — + * so a self-call inside the real code re-enters the double, meets stubs, + * an armed protocol and the call log, and is recorded like the boundary + * call. `Invocation::callOriginal()` runs inline there too, over the state + * the double now holds. A double of an **interface** has no bodies to + * reach and proxies to the real instance — only the boundary call is + * recorded, and self-calls happen inside the real object where nothing can + * see them. + * + * Inline needs `$real` to be *exactly* the doubled class: a subclass + * instance carries overrides the doubled class's own bodies do not + * contain, and it keeps the proxy form so that those overrides answer. A `final` or `private` method + * is not overridden either, so a self-call to one of those runs the real + * body directly — it works, over the double's state, but it is not + * intercepted and the log does not show it. And `static::class` inside a + * real body names the generated class, not the doubled one. + * + * Calling this twice with the same instance changes nothing; re-pointing + * an inline double at a *different* instance is refused, because the state + * copy cannot be taken twice — a `readonly` property is already + * initialized, and whatever the real code has written since would be + * discarded. + * + * @throws ForwardingTargetMismatch when `$real` is an understudy, misses a + * contract, or re-points an inline double */ public static function forwarding(object $double, ?object $real = null): void { @@ -693,12 +718,42 @@ public static function forwarding(object $double, ?object $real = null): void } } + $current = $state->forwardingTarget(); + + if ($state->forwardsInline() && $current !== $real) { + \assert($current !== null); + + throw ForwardingTargetMismatch::alreadyInline($state->label(), $current::class, $real::class); + } + $state->setForwardingTarget($real); } elseif ($state->forwardingTarget() === null) { throw OriginalCallUnavailable::forMode($state->label()); } $state->setMode(Mode::Forwarding); + + $target = $state->forwardingTarget(); + \assert($target !== null); + + // Inline only when the bodies the double would run are the bodies this + // instance would have run — the generated class extends exactly the + // donor's class. An interface double has no parent class at all, and a + // donor that is a *subclass* of the doubled class carries overrides + // that are not in the doubled class's bodies; running those bodies + // instead would change which code executes, so it keeps the proxy + // form, where its own overrides answer. + // + // The real bodies an inline double runs read `$this->` properties, and + // the double's own state is empty by construction: whatever the donor + // initialized has to be on the double before the first inline call, + // not lazily at some later point the test would have to know about. + $parent = get_parent_class($double); + + if (!$state->forwardsInline() && $parent === $target::class) { + Runtime::copyState($double, $target); + $state->forwardInline($parent); + } } /** diff --git a/tests/Fixture/Fwd/BasePipeline.php b/tests/Fixture/Fwd/BasePipeline.php new file mode 100644 index 0000000..a6e8dfd --- /dev/null +++ b/tests/Fixture/Fwd/BasePipeline.php @@ -0,0 +1,21 @@ +tag; + } +} diff --git a/tests/Fixture/Fwd/OtherPipeline.php b/tests/Fixture/Fwd/OtherPipeline.php new file mode 100644 index 0000000..cb3b646 --- /dev/null +++ b/tests/Fixture/Fwd/OtherPipeline.php @@ -0,0 +1,18 @@ +normalize($this->apply($input)); + } + + public function apply(string $input): string + { + return $input . '+' . implode(',', $this->steps); + } + + protected function normalize(string $raw): string + { + return trim($raw); + } + + /** + * Reads the state the constructor set up: promoted readonly, promoted + * array — exactly what a state copy has to carry onto the double. + */ + public function describe(): string + { + return $this->name . ':' . count($this->steps); + } + + public function recall(): ?string + { + return $this->memo; + } + + public function addStep(string $step): static + { + $this->steps[] = $step; + + return $this; + } + + public function fill(string &$slot, string $value): void + { + $slot = $value . ':' . $this->name; + } + + public function fail(string $reason): never + { + throw new \DomainException($reason); + } + + /** + * `: static` resolving to another object — the shape the proxy form + * refused as a foreign instance, and the inline form has to refuse as a + * double nobody configured. + */ + public function duplicate(): static + { + return new static($this->name); + } + + /** + * Not `: static`, so the override's return type is satisfied by any + * `Pipeline` and the refusal does not apply. + */ + public function mirror(): self + { + return clone $this; + } + + /** + * Computes over an internal copy of itself and never hands it out: the + * copy has to keep working, or real code breaks where nothing returned a + * double at all. + */ + public function preview(string $step): string + { + $copy = clone $this; + $copy->addStep($step); + + return $copy->describe(); + } + + public function copy(): static + { + $copy = clone $this; + $copy->memo = 'copied'; + + return $copy; + } + + /** + * Clones the instance it was handed rather than itself: the copy belongs + * to that double, and nothing copied state onto it — `$name` is readonly + * and uninitialized there, so a copy adopted on this double's behalf + * raises PHP's own "must not be accessed before initialization". + */ + public function mergeWith(Pipeline $other): string + { + $copy = clone $other; + + return $copy->describe(); + } + + /** + * What `static` resolves to inside a body running inline: the generated + * class, not this one. + */ + public function className(): string + { + return static::class; + } +} diff --git a/tests/Fixture/Fwd/SpecialPipeline.php b/tests/Fixture/Fwd/SpecialPipeline.php new file mode 100644 index 0000000..325d288 --- /dev/null +++ b/tests/Fixture/Fwd/SpecialPipeline.php @@ -0,0 +1,19 @@ +count = $count; + } + + public function total(): int + { + return $this->count * 10; + } + + /** Clones the double it was handed, not itself. */ + public function mergeWith(Tally $other): int + { + $copy = clone $other; + + return $copy->total(); + } +} diff --git a/tests/ForwardingTest.php b/tests/ForwardingTest.php index dff541a..d91e857 100644 --- a/tests/ForwardingTest.php +++ b/tests/ForwardingTest.php @@ -85,9 +85,11 @@ public function forwardedCallsAndTheirOutcomesAreRecorded(): void } /** - * Understudy proxies an object; it does not instrument one. `describe()` - * calls `label()` on the real instance, and that call happens inside the - * real object, where no dispatcher can see it. + * An interface double proxies: there is no body for it to run, so + * `describe()` executes on the real instance and the `label()` it calls + * there happens inside that object, where no dispatcher can see it. A + * class double runs the bodies on itself instead and does see it — see + * InlineForwardingTest. */ public function onlyTheCallAtTheBoundaryIsRecorded(): void { diff --git a/tests/InlineForwardingTest.php b/tests/InlineForwardingTest.php new file mode 100644 index 0000000..f6af74d --- /dev/null +++ b/tests/InlineForwardingTest.php @@ -0,0 +1,707 @@ + $double->apply(Arg::any()))->returns(' stubbed '); + + // run() is unmatched, runs the real template body — which calls + // $this->apply(), and that self-call re-enters the double and lands + // on the stub rather than in the real body. + Assert::same($double->run('x'), 'stubbed'); + } + + public function selfCallsAreRecordedLikeAnyOtherCall(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + // The real bodies, all of them: apply() appends the steps, normalize() + // trims what it was handed. + Assert::same($double->run(' x '), 'x +a'); + + // The boundary call and the self-call are both in the log — a verify() + // or nothingElse() sees them, which is what a partial-mock port + // expects of makePartial(). + Assert::same(count(Understudy::calls(fn() => $double->run(Arg::any()))), 1); + Assert::same(count(Understudy::calls(fn() => $double->apply(Arg::any()))), 1); + + $run = Understudy::calls(fn() => $double->run(Arg::any())); + Assert::same($run[0]->args, [' x ']); + + // The self-call was recorded with what the real body passed it, not + // with what the boundary received. + $apply = Understudy::calls(fn() => $double->apply(Arg::any())); + Assert::same($apply[0]->args, [' x ']); + Assert::same($apply[0]->returned(), ' x +a'); + } + + /** + * The behaviour change this brings: a self-call is a call, so it counts + * toward an expectation the test made about that method. + */ + public function aSelfCallCountsTowardAnExpectation(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + expect(fn() => $double->apply(Arg::any()))->times(1); + + // apply() is never called from the test — only from inside run(). + $double->run('x'); + + Assert::same(Understudy::verifyAll(), 1); + } + + /** + * An armed protocol judges every call the dispatcher sees, so a self-call + * now fills a step the test never made from the outside. The step is + * satisfied by the real code's own call, which is what a protocol over a + * template method means once the bodies run inline. + */ + public function aSelfCallFillsAStepOfAnArmedProtocol(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + expectSequence( + fn() => $double->run('x'), + fn() => $double->apply('x'), + ); + + // Only the boundary call is made here; apply() arrives from inside + // the real template body and completes the protocol. + $double->run('x'); + + Assert::same(Understudy::verifyAll(), 1); + } + + public function theRealStateIsCopiedOntoTheDouble(): void + { + // The constructor never ran on the double; the bodies it now runs + // inline read `$this->` state, and what they see has to be the real + // instance's — promoted readonly included, which is the pre-8.4 + // initialization-scope trap. + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a', 'b'])); + + Assert::same($double->describe(), 'prod:2'); + Assert::same($double->apply('x'), 'x+a,b'); + } + + /** + * The scope trap itself: `tag` is declared `protected readonly` on the + * parent class, and before PHP 8.4 it can only be initialized from + * there — a reflection reached through the double's own class writes from + * the wrong scope. + */ + public function anInheritedReadonlyPropertyIsCopied(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', [], 'inherited')); + + Assert::same($double->tag(), 'inherited'); + } + + /** + * Two classes declaring the same method name, doubled inline in one + * process: each double has to run its own class's body. The dispatcher + * caches the bodies it enters, and a cache keyed by the method name alone + * would answer the second double with the first one's implementation. + */ + public function twoClassesSharingAMethodNameKeepTheirOwnBodies(): void + { + $pipeline = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + $other = Understudy::delegate(OtherPipeline::class, new OtherPipeline()); + + Assert::same($pipeline->apply('x'), 'x+a'); + Assert::same($other->apply('x'), 'other(x)'); + + // And in the other order, so the test does not depend on which body + // was cached first. + Assert::same($other->apply('y'), 'other(y)'); + Assert::same($pipeline->apply('y'), 'y+a'); + } + + public function whatTheDonorLeftUninitializedStaysUninitialized(): void + { + $real = new Pipeline('prod'); + $real->memo = 'remembered'; + unset($real->memo); + + $double = Understudy::delegate(Pipeline::class, $real); + + // Not a default-filled null: the donor's truth is "never + // initialized", and PHP's own error is the diagnosis — the same one + // the real object would have raised. + Expect::exception(\Error::class)->withMessageContaining('must not be accessed before initialization'); + + $double->recall(); + } + + /** + * A hooked property carries its state in a backing store, and a copy must + * reach it without running the hooks: reading through them would execute + * the donor's code during what is test setup, and writing through them + * would run a `set` the real object already ran. A virtual property has no + * state at all, so there is nothing to copy — only its hooks, which the + * generated class inherits. + * + * Built by eval: the syntax is a parse error on 8.3, where a file carrying + * it would take the whole suite down rather than skip a test. + */ + public function aHookedPropertyIsCopiedThroughItsBackingStore(): void + { + if (PHP_VERSION_ID < 80400) { + // Nothing to skip: the language cannot express a hook yet. + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'HookedDonor_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { public int $scaled { get => $this->scaled * 2; ' + . 'set { $this->scaled = $value; } } public string $virtual { get => \'computed\'; } ' + . 'public string $plain; ' + . 'public function __construct() { $this->scaled = 5; $this->plain = \'carried\'; } ' + . 'public function read(): int { return $this->scaled; } ' + . 'public function readPlain(): string { return $this->plain; } ' + . 'public function readVirtual(): string { return $this->virtual; } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + $double = Understudy::delegate($fqcn, new $fqcn()); + + // The backing store holds 5, and the inherited `get` hook doubles it: + // the state arrived, and the hooks were not run to put it there. + Assert::same($double->read(), 10); + Assert::same($double->readVirtual(), 'computed'); + + // And the copy carried on past the hooked properties to the plain one + // declared after them. + Assert::same($double->readPlain(), 'carried'); + } + + /** + * The comparison decides on every property, not on the first few: a + * hooked one, a virtual one and then a plain one that is the only thing + * telling the two doubles apart. Stopping at either of the first two + * would adopt a copy of the wrong double and run real bodies on it. + * + * Built by eval for the reason + * {@see aHookedPropertyIsCopiedThroughItsBackingStore()} gives. + */ + public function aPropertyDeclaredAfterAHookedOneStillDecidesTheComparison(): void + { + if (PHP_VERSION_ID < 80400) { + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'HookedOrder_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { ' + . 'public int $scaled { get => $this->scaled * 2; set { $this->scaled = $value; } } ' + . 'public string $virtual { get => \'computed\'; } ' + . 'public string $plain = \'declared\'; ' + . 'public function __construct(int $scaled, string $plain) ' + . '{ $this->scaled = $scaled; $this->plain = $plain; } ' + . 'public function tagline(): string { return \'real\'; } ' + . 'public function mergeWith(%2$s $other): string ' + . '{ $copy = clone $other; return $copy->tagline(); } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + + // The backing store of the hooked property agrees with what a + // constructor-less double holds, so only `$plain` — declared after + // both the hooked and the virtual property — separates them. + $double = Understudy::delegate($fqcn, new $fqcn(0, 'carried')); + $other = Understudy::for($fqcn); + + // A fresh double of that class: `tagline()` answers the empty string + // rather than the 'real' its body returns. + Assert::same($double->mergeWith($other), ''); + } + + /** + * The floor comes back down even when the test's closure throws. The + * stub's exception unwinds through dispatch and out of the real body that + * called it, and a floor left raised there would quietly stop adopting + * every clone a later body makes of itself. + */ + public function aThrowingStubLeavesCloneAdoptionWorking(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + when(fn() => $double->apply(Arg::any()))->throws(new \RuntimeException('from the stub')); + + try { + $double->run('x'); + } catch (\RuntimeException) { + // The throw is the point: it leaves dispatch from inside a real + // body, which is the path that has to put the floor back. + } + + Assert::same($double->preview('b'), 'prod:2'); + } + + /** + * The claim both the state copy and the clone comparison rest on: neither + * runs a hook. A `get` hook is the doubled class's own code — reading + * through it would execute real code during what is setup, and during a + * `__clone()` that has not decided yet whether this copy is a double of + * anything. The hook counts its own calls, so a reading taken through it + * is visible rather than merely reasoned about. + * + * Built by eval for the reason + * {@see aHookedPropertyIsCopiedThroughItsBackingStore()} gives. + */ + public function neitherTheStateCopyNorTheCloneComparisonRunsAHook(): void + { + if (PHP_VERSION_ID < 80400) { + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'CountingHooks_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { public static int $reads = 0; ' + . 'public int $scaled { get { self::$reads++; return $this->scaled * 2; } ' + . 'set { $this->scaled = $value; } } ' + . 'public function __construct(int $scaled) { $this->scaled = $scaled; } ' + . 'public function mirror(): int { $copy = clone $this; return 1; } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + $donor = new $fqcn(5); + $fqcn::$reads = 0; + + $double = Understudy::delegate($fqcn, $donor); + + // The copy reached the backing store, not the `get`. + Assert::same($fqcn::$reads, 0); + + // And neither did the comparison that adopted the body's own clone. + Assert::same($double->mirror(), 1); + Assert::same($fqcn::$reads, 0); + } + + public function aFluentSelfReturnIsTheDoubleItself(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + // The parent body's `return $this;` already *is* the double — no + // adoption needed, the chain stays doubled by itself. + Assert::same($double->addStep('x'), $double); + } + + public function aByReferenceArgumentIsWrittenThrough(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + $slot = 'empty'; + $double->fill($slot, 'v'); + + // The real body wrote through the caller's variable, using state + // (the promoted name) copied from the donor. + Assert::same($slot, 'v:prod'); + } + + public function aNeverMethodThrowsFromTheRealBody(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + Expect::exception(\DomainException::class)->withMessage('refused'); + + $double->fail('refused'); + } + + /** + * `static` inside a body running inline is the generated class: the body + * runs on the double, and the double is a subclass of the real one. + */ + public function staticInsideARealBodyIsTheGeneratedClass(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + Assert::same($double->className(), $double::class); + Assert::true($double instanceof Pipeline); + Assert::false($double->className() === Pipeline::class); + } + + /** + * `new static` satisfies the override's `: static` — the object is of the + * generated class — so the language no longer refuses it. No context + * knows that object, every call on it would fail as an unregistered + * double, and the refusal the proxy path used to raise now comes from + * here. + */ + public function aNewStaticFromARealBodyIsRefused(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod')); + + Expect::exception(OriginalReturnTypeViolation::class) + ->withMessageContaining('built a new instance of itself') + ->withMessageContaining('unregistered double') + ->withMessageContaining('->returns($double)'); + + $double->duplicate(); + } + + /** + * The refusal is about an object no context knows, not about the object + * being another instance: a clone was adopted while the body ran, so it + * comes back — and it comes back *working*. PHP copied the state the real bodies read, and the + * clone inherits the original's forwarding, so it answers for real where + * the proxy form would have handed over a real clone. What it does not + * inherit is this double's expectations: a stub written here does not + * follow the copy, exactly as for a clone the test makes itself. + */ + public function aCloneFromANonStaticMethodComesBackForwarding(): void + { + $real = new Pipeline('prod', ['a']); + $double = Understudy::delegate(Pipeline::class, $real); + + $mirror = $double->mirror(); + + Assert::instanceOf($mirror, Pipeline::class); + Assert::false($mirror === $double); + Assert::same($mirror->describe(), 'prod:1'); + + // Its own object from there on: a step added to the copy is not the + // original's, and neither is the donor's. + $mirror->addStep('b'); + Assert::same($mirror->describe(), 'prod:2'); + Assert::same($double->describe(), 'prod:1'); + Assert::same($real->describe(), 'prod:1'); + + // A complete forwarding double, instance and all: turning the mode on + // again finds the instance it came from instead of refusing for want + // of one. + Understudy::forwarding($mirror); + Assert::same($mirror->describe(), 'prod:2'); + } + + /** + * The case nothing returns: a body that clones itself and computes over + * the copy. Under the proxy that copy was a real object; inline it is a + * double, and it has to go on running real code or the body silently + * computes over defaults. + */ + public function aCloneMadeInsideARealBodyKeepsWorking(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + Assert::same($double->preview('b'), 'prod:2'); + + // The original is untouched by what the body did to its copy. + Assert::same($double->describe(), 'prod:1'); + } + + /** + * The other side of that: a body handed another double of its own class + * and cloning THAT one. The class is the same generated class — every + * double of a type shares it — so only the state separates the copy from + * a `clone $this`, and here nothing copied any state onto the double the + * body was given. Adopting it would run `describe()` over a readonly + * `$name` the skipped constructor never filled, which is PHP's own fatal + * rather than anything a test could read. + */ + public function aCloneOfAnotherDoubleInsideARealBodyIsAFreshDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + $other = Understudy::for(Pipeline::class); + + Assert::same($double->mergeWith($other), ''); + + // Handed its own double, the same body clones the frame itself, and + // that copy is adopted as before. + Assert::same($double->mergeWith($double), 'prod:1'); + } + + /** + * The same mistake where nothing raises: a class whose every property has + * a declared default leaves a constructor-less double readable, so an + * adopted copy of it would quietly compute from the declaration's value + * instead of the donor's — 70 where the test set up 42. + */ + public function aCloneOfAnotherDoubleWithDeclaredDefaultsIsAFreshDouble(): void + { + $double = Understudy::delegate(Tally::class, new Tally(42)); + $other = Understudy::for(Tally::class); + + // A fresh double of `Tally`: `total()` is unconfigured, so it answers + // the type's default rather than running the real body at all. + Assert::same($double->mergeWith($other), 0); + Assert::same($double->mergeWith($double), 420); + } + + /** + * A clone the test writes in its own `answers()` closure, reached from a + * real body. The closure is test code — the rule for `clone $double` is + * the test's, not the body's — and the frames underneath it are hidden + * for exactly its span. + */ + public function aCloneMadeByATestClosureUnderARealBodyIsAFreshDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + when(fn() => $double->apply(Arg::any())) + ->answers(fn(): string => (clone $double)->describe()); + + // run() is the real body; it calls apply() on itself, the stub + // answers, and the clone made there is a fresh double whose + // describe() is the empty default rather than 'prod:1'. + Assert::same($double->run('ignored'), ''); + + // And the floor went back down with the closure: the next body to + // clone itself is adopted as before. + Assert::same($double->preview('b'), 'prod:2'); + } + + /** + * Comparing a copy against the frame reads a hooked property the way the + * state copy writes it — through the backing store. A `get` hook is the + * doubled class's own code, and running it to decide whether to adopt a + * clone would execute real code in the middle of `__clone()`. + * + * Built by eval for the reason + * {@see aHookedPropertyIsCopiedThroughItsBackingStore()} gives. + */ + public function aHookedPropertyIsComparedThroughItsBackingStore(): void + { + if (PHP_VERSION_ID < 80400) { + Assert::false(method_exists(\ReflectionProperty::class, 'hasHooks')); + + return; + } + + $class = 'HookedMerger_' . PHP_VERSION_ID; + + eval(sprintf( + 'namespace %s; class %s { public int $scaled { get => $this->scaled * 2; ' + . 'set { $this->scaled = $value; } } public string $virtual { get => \'computed\'; } ' + . 'public function __construct(int $scaled) { $this->scaled = $scaled; } ' + . 'public function read(): int { return $this->scaled; } ' + . 'public function mirror(): int { $copy = clone $this; return $copy->read(); } ' + . 'public function mergeWith(%2$s $other): int { $copy = clone $other; return $copy->read(); } }', + __NAMESPACE__, + $class, + )); + + /** @var class-string $fqcn */ + $fqcn = __NAMESPACE__ . '\\' . $class; + $double = Understudy::delegate($fqcn, new $fqcn(5)); + $other = Understudy::for($fqcn); + + // Its own copy: the backing stores match, the copy is adopted, and + // the inherited `get` hook doubles the 5 that was copied over. + Assert::same($double->mirror(), 10); + + // A copy of the other double: the backing store is not the frame's, + // so it stays a fresh double and answers the default. + Assert::same($double->mergeWith($other), 0); + } + + /** + * A clone the TEST makes is not inside any body, so nothing propagates: + * it is a fresh double of the right class, which is the documented rule + * for `clone $double`. Real bodies ran before the clone — one of them + * threw — and neither left the double looking like it was still running. + */ + public function aCloneMadeByTheTestIsAFreshDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + $double->describe(); + + try { + $double->fail('thrown from the real body'); + } catch (\DomainException) { + // The throw is the point: an unwound body must not stay on the + // stack of bodies that are running. + } + + $copy = clone $double; + + Assert::same($copy->describe(), ''); + } + + /** + * A static property is not state the double can hold a copy of — it + * belongs to the class, and both the donor and the double read the same + * one. The copy skips it and carries on to the properties declared after + * it. + */ + public function aStaticPropertyIsLeftToTheClass(): void + { + $before = Pipeline::$built; + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + // One construction, the donor's: the double's constructor never ran. + Assert::same(Pipeline::$built, $before + 1); + + // And the instance properties declared after the static one arrived. + Assert::same($double->describe(), 'prod:1'); + } + + /** + * A `clone`-based wither declared `: static` is the other side of that: + * the copy was adopted while the body ran, with this double's forwarding, + * so the chain comes back working rather than refused. + */ + public function aClonedStaticWitherComesBackForwarding(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('prod', ['a'])); + + $copy = $double->copy(); + + Assert::false($copy === $double); + Assert::same($copy->recall(), 'copied'); + Assert::same($copy->describe(), 'prod:1'); + } + + /** + * `callOriginal()` on an inline double runs the parent body on the double, + * not on the donor: the donor's copy of the state stopped being the + * current one at the first inline call. + */ + public function callOriginalRunsInlineOnAnInlineDouble(): void + { + $real = new Pipeline('prod', ['a']); + $double = Understudy::delegate(Pipeline::class, $real); + + when(fn() => $double->addStep(Arg::any())) + ->answers(fn(Invocation $call) => $call->callOriginal()); + + // Fluent through the escape hatch: the parent body returned `$this`, + // which is the double. + Assert::same($double->addStep('b'), $double); + + // The double grew a step; the donor did not. + Assert::same($double->describe(), 'prod:2'); + Assert::same($real->describe(), 'prod:1'); + } + + /** + * An interface double has no bodies to reach, so `callOriginal()` — and + * forwarding itself — stays the proxy form, where a self-call happens + * inside the real object and nothing sees it. + */ + public function callOriginalStaysTheProxyFormOnAnInterfaceDouble(): void + { + $double = Understudy::delegate(Chainable::class, new RealChain()); + + when(fn() => $double->describe()) + ->answers(fn(Invocation $call) => $call->callOriginal()); + + Assert::same($double->describe(), 'described by real'); + Assert::same(count(Understudy::calls(fn() => $double->label())), 0); + } + + /** + * A donor that is a subclass carries overrides the doubled class's bodies + * do not contain. Running those bodies instead would change which code + * executes, so such a double keeps the proxy form: the override answers, + * and only the boundary call is recorded. + */ + public function aSubclassDonorKeepsTheProxyForm(): void + { + $double = Understudy::delegate(Pipeline::class, new SpecialPipeline('prod')); + + // SpecialPipeline::apply(), reached through the real object's own + // self-call — not Pipeline::apply() run on the double. + Assert::same($double->run(' x '), 'X !'); + Assert::same(count(Understudy::calls(fn() => $double->apply(Arg::any()))), 0); + } + + public function aSecondDonorIsRefusedOnAnInlineDouble(): void + { + $double = Understudy::delegate(Pipeline::class, new Pipeline('first')); + + Expect::exception(ForwardingTargetMismatch::class) + ->withMessageContaining('already runs the bodies of') + ->withMessageContaining('cannot be re-pointed') + ->withMessageContaining('Build a second double for the other instance'); + + Understudy::forwarding($double, new Pipeline('second')); + } + + /** + * The same instance twice is the test saying the same thing twice: the + * state copy already happened, and repeating it would be the write a + * `readonly` property refuses. + */ + public function pointingAnInlineDoubleAtTheSameDonorAgainIsIdempotent(): void + { + $real = new Pipeline('prod', ['a']); + $double = Understudy::delegate(Pipeline::class, $real); + + $double->addStep('b'); + Understudy::forwarding($double, $real); + + Assert::same($double->describe(), 'prod:2'); + } +}