From eae7abe61209ab1d82ff336ebecdfc35bceddef2 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Tue, 3 Nov 2026 20:59:00 +0100 Subject: [PATCH 1/3] Count uncovered code in mutation testing and pin the wording of failure messages Run Infection with --with-uncovered (template command, signature-default and visibility mutators as in the template). Overall MSI with the flag is about 94% on PHP 8.4 against the unchanged gate of 92. Tests added: exact wording of every failure-message factory, in-process coverage of bypassFinals(), BypassUnavailable and the explanations for a final class that is still standing, and direct cases for default rendering, the parent keyword, intersection overlap, wire() edge types, TypeDefaultResolver type strings, DefaultFactories ancestry and the by-reference snapshot cap. Codegen classes are named in #[Covers] by the tests that compile doubles. src/ changes are behaviour-neutral: messages assembled from several literals are built into a local before use, and six one-line signatures with a default are split one parameter per line. --- AGENTS.md | 31 ++ CHANGELOG.md | 6 + composer.json | 5 +- infection.json5 | 42 ++ src/Codegen/DoubleFactory.php | 26 +- src/Codegen/TargetUnifier.php | 25 +- src/Codegen/TypeRenderer.php | 3 +- src/Exception/AmbiguousDefaultFactory.php | 8 +- src/Exception/BypassUnavailable.php | 28 +- src/Exception/CannotWire.php | 67 +-- src/Exception/ConflictingExpectation.php | 30 +- src/Exception/ContextOwnershipViolation.php | 8 +- src/Exception/ForgottenDouble.php | 53 +- src/Exception/ForwardingTargetMismatch.php | 31 +- src/Exception/InvalidCallSpecification.php | 193 +++---- src/Exception/InvalidDefaultValue.php | 10 +- .../InvalidSpecificationArgument.php | 18 +- src/Exception/OriginalCallUnavailable.php | 19 +- src/Exception/OriginalReturnTypeViolation.php | 23 +- src/Expectation/ArgumentFormatter.php | 6 +- src/Expectation/Expectation.php | 13 +- src/Runtime/Runtime.php | 6 +- src/Understudy.php | 25 +- tests/ByReferenceTest.php | 38 ++ tests/Bypass/BypassFinalsTest.php | 164 ++++++ tests/Bypass/FileWrapperTest.php | 22 + tests/Bypass/FinalStripperTest.php | 45 ++ tests/CaptorTest.php | 10 + tests/CardinalityTest.php | 10 + tests/Codegen/DefaultRenderingTest.php | 8 + tests/Codegen/DefaultSourceRenderingTest.php | 141 +++++ tests/Codegen/FinalReasonTest.php | 133 +++++ tests/Codegen/ParentKeywordTest.php | 84 +++ tests/Codegen/TargetUnifierEdgeTest.php | 8 + tests/Codegen/TargetUnifierShapesTest.php | 82 +++ tests/Codegen/TargetUnifierTest.php | 5 + tests/Codegen/UnreproducibleDefaultTest.php | 109 ++++ tests/ConflictingExpectationTest.php | 10 + tests/DefaultFactoriesTest.php | 57 ++ tests/Defaults/IntersectionDefaultTest.php | 10 + tests/Defaults/TypeDefaultResolverTest.php | 104 ++++ tests/DispatchIndexPropertyTest.php | 10 + tests/DispatchTierTest.php | 10 + tests/EngineLifecyclePropertyTest.php | 10 + tests/EnginePropertyTest.php | 10 + tests/ErrorPathTest.php | 58 ++ tests/Exception/ExceptionMessageTest.php | 527 ++++++++++++++++++ tests/Expectation/ArgumentFormatterTest.php | 18 + .../ExpectationDescribeAgainstTest.php | 58 ++ tests/ExpectationsTest.php | 10 + tests/Fixture/Def/BaseMarker.php | 7 + tests/Fixture/Def/Companion.php | 7 + tests/Fixture/Def/Derived.php | 7 + tests/Fixture/Def/Marked.php | 7 + tests/Fixture/Def/Tagged.php | 8 + tests/Fixture/Wire/MissingClassSubject.php | 11 + .../Fixture/Wire/NeedsSealedCollaborator.php | 10 + tests/Fixture/Wire/ObjectOrScalarSubject.php | 10 + tests/Fixture/Wire/SealedCollaborator.php | 8 + tests/Fixture/Wire/UntypedSubject.php | 10 + tests/ForwardingTest.php | 6 + tests/InlineForwardingTest.php | 8 + tests/InvocationDirectTest.php | 31 ++ tests/LastCallAndForgetTest.php | 10 + tests/LeanTest.php | 10 + tests/LedgerTest.php | 28 + tests/Matcher/TailMatcherVerdictTest.php | 60 ++ tests/OutcomeTest.php | 44 ++ tests/PropertyHooksTest.php | 6 + tests/RestArgumentsTest.php | 10 + tests/RuntimeDirectTest.php | 40 ++ tests/UnderstudyTest.php | 40 ++ tests/VerificationFailureTest.php | 10 + tests/VerifyAllCountTest.php | 10 + tests/WhereTest.php | 10 + tests/WireTest.php | 67 +++ 76 files changed, 2564 insertions(+), 338 deletions(-) create mode 100644 tests/Bypass/BypassFinalsTest.php create mode 100644 tests/Codegen/DefaultSourceRenderingTest.php create mode 100644 tests/Codegen/FinalReasonTest.php create mode 100644 tests/Codegen/ParentKeywordTest.php create mode 100644 tests/Codegen/TargetUnifierShapesTest.php create mode 100644 tests/Codegen/UnreproducibleDefaultTest.php create mode 100644 tests/Exception/ExceptionMessageTest.php create mode 100644 tests/Expectation/ExpectationDescribeAgainstTest.php create mode 100644 tests/Fixture/Def/BaseMarker.php create mode 100644 tests/Fixture/Def/Companion.php create mode 100644 tests/Fixture/Def/Derived.php create mode 100644 tests/Fixture/Def/Marked.php create mode 100644 tests/Fixture/Def/Tagged.php create mode 100644 tests/Fixture/Wire/MissingClassSubject.php create mode 100644 tests/Fixture/Wire/NeedsSealedCollaborator.php create mode 100644 tests/Fixture/Wire/ObjectOrScalarSubject.php create mode 100644 tests/Fixture/Wire/SealedCollaborator.php create mode 100644 tests/Fixture/Wire/UntypedSubject.php create mode 100644 tests/InvocationDirectTest.php create mode 100644 tests/Matcher/TailMatcherVerdictTest.php create mode 100644 tests/OutcomeTest.php create mode 100644 tests/RuntimeDirectTest.php diff --git a/AGENTS.md b/AGENTS.md index 91e97c5..b0cb165 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,13 @@ seconds instead of the full run's minute — and the full run stays the gate. scenarios in one process prove only whichever ran first — and coverage cannot see into a subprocess, which is why the decisions are unit tested separately from the end-to-end claims. + What does not need a process is every decision around the claim: + `BypassFinalsTest` drives `Understudy::bypassFinals()` (registration, + refusals, a foreign `file://` wrapper) and `FinalReasonTest` the four ways a + final class is explained, with the wrapper installed and removed inside each + test — the global is the same one `FileWrapperTest` has always toggled. + `understudy#148` is about installing it in `testo.php` for the whole run, + which is a different thing and still kills Infection's mutants. - **Assert on declarations, not on phrases.** `str_contains($source, 'final class')` matched the words "final classes" in a fixture's own docblock and turned three passing tests red for the wrong reason. @@ -483,6 +490,30 @@ seconds instead of the full run's minute — and the full run stays the gate. Infection's mutant-to-test mapping is `#[Covers]`-driven: with one test class covering one class, the run reported 56 mutants at 93% MSI while the honest figures were 406 and 68%. Check the mutant count, not just the MSI. + `composer mutation` runs with `--with-uncovered` since 2026-11-03, and three + more things decide the figure there: + - A test that builds its doubles in a **data provider** executes nothing under + coverage: the provider runs at collection time, so the line is Not Covered + however many tests list it. Build inside the test body (the message table + in `tests/Exception/ExceptionMessageTest.php` yields closures for that + reason). + - A message built as `sprintf('a' . 'b', ...)` or a `throw` over several + lines puts its operand mutants on a line with no opcode, so they are Not + Covered. Assign the literal to a local first (`$format = 'a' . 'b';`) and + pass the variable; the text is byte-identical. Rector's + `SimplifyUselessVariable` undoes this for a plain `return`, which is fine: + there the first operand shares the statement's line already. + - Multi-line parameter lists put `int $x = 0,` on a line the template's + ignore pattern matches; a one-line signature does not match and keeps its + mutants. + Known equivalents, so nobody hunts them again: `DoubleState` literal-key + index (`serialize(0.0)` constants, `array_reverse`, the index itself is an + optimisation of a walk that still runs), `FinalStripper`'s two fast paths and + the loop bounds of its token scans (an out-of-range `$tokens[$i]` is `null` + and falls through), `Wire`'s `$resolved[0]`/`[1]` (both are the same double), + `Runtime`'s `?->` on a state that is never null at that point and its + `inlineMethods` cache keys, `ArgumentFormatter`'s `null` and `NAN` match arms + (the default arm renders both identically on PHP 8.4). - **Rector and Psalm pull in opposite directions here.** `RemoveUselessVarTag` and `FlipTypeControlToUseExclusiveType` are skipped in `rector.php` with the reasons written down; do not add skips beyond those without the same. diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f59bd..3ff7f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Mutation testing now counts code the tests do not name in `#[Covers]` (`infection --with-uncovered`), with the signature-default and visibility mutators configured as in the package template. Overall MSI with the flag is about 94% against the unchanged gate of 92. +- Tests: the wording of every failure message that has a factory is pinned word for word (`ExceptionMessageTest`); `Understudy::bypassFinals()`, `BypassUnavailable` and the explanations for a final class that is still standing are covered in-process (`BypassFinalsTest`, `FinalReasonTest`); defaults, `parent`, intersection overlap, `wire()` edge types, `TypeDefaultResolver` type strings and `DefaultFactories` ancestry have direct cases. +- No behaviour change. In `src/`, failure messages assembled from several literals are built into a local `$format`/`$reason` before use (same text), and six one-line signatures with a defaulted parameter are split one parameter per line. + ## 1.1.1 — 2026-10-14 - **Fixed.** Doubling a type whose method declares a parameter as `self` no diff --git a/composer.json b/composer.json index 6f2f425..683af09 100644 --- a/composer.json +++ b/composer.json @@ -95,7 +95,10 @@ "@php examples/structured-failures.php", "@php examples/where-guard.php" ], - "mutation": "infection --threads=max", + "mutation": [ + "Composer\\Config::disableProcessTimeout", + "infection --threads=max --with-uncovered" + ], "psalm": "psalm --no-cache", "rector": "rector process --dry-run", "rector:fix": "rector process", diff --git a/infection.json5 b/infection.json5 index 5e3509d..a5dedbc 100644 --- a/infection.json5 +++ b/infection.json5 @@ -38,9 +38,51 @@ // environment, not the suite (see AGENTS.md), so the number stays at 92 // until the escaped list — TargetUnifier 45, FinalStripper 29, Runtime 28 // — has been hunted down far enough to give 94 real headroom. + // + // Re-measured 2026-11-03 with `--with-uncovered` (PHP 8.4, pcov, zend.assertions off like CI): + // 4016 mutants, 14 ignored by the signature-default rule below, Covered Code MSI 95%, overall MSI + // 93.8% (94.1% with the ignored ones out of the denominator) against a gate of 92. Before the + // flag the same command showed 3093 mutants and 94.1% (Covered Code MSI). What is left, 173 + // Escaped and 62 Not Covered: + // - About 20 Not Covered sit inside `\assert()`, which CI compiles out (FileWrapper 9, TargetUnifier 5, + // Runtime 2, the rest one each). Unkillable at zend.assertions=-1. + // - Defensive branches no input reaches: `Arg::reason(null)`, `renderConstantDefault()` with no + // declaring class or no `parent`, `Understudy::foreignFileWrapper()` with no source file, the + // `file_get_contents() === false` return in `FileWrapper::stream_open()`. + // - Equivalents named in AGENTS.md: `DoubleState` literal-key index (an optimisation of a walk that + // still exists), `FinalStripper` fast paths, `?->` on values that are never null, cache keys. + // `bypassFinals()`, `BypassUnavailable` and `FileWrapper` are covered in-process by + // `BypassFinalsTest`, `FinalReasonTest` and `FileWrapperTest` (wrapper installed and removed inside + // each test, as `FileWrapperTest` always did); only load-order claims need the subprocess suite. + // Unit only: every package declares a Benchmarks suite (testo.php), and Infection's initial + // test run would execute it. A timing-sensitive benchmark fails under host load and aborts + // the whole mutation run (result, 2026-11-03). Benchmarks mutate nothing and kill nothing. + "testFrameworkOptions": "--suite=Unit", "minMsi": 92, "mutators": { "@default": true, + // Constructor/method parameter declarations with a default value + // (`private int $x = 100,`, `bool $strict = false,`): the mutant sits on a + // signature line with no executable opcode under pcov, so it is reported Not + // Covered whatever the tests do. Only a type-prefixed `$name = default,` line + // matches; a body line cannot (no type before the variable). Do NOT add ^ or $: + // Infection wraps the pattern as ^-\s*REGEX$ over the removed diff line. + // Verified 2026-11-01 by diffing the survivor lists with and without it: + // bulkhead 6, circuit-breaker 13, json-ld 19 mutants removed, all of them + // such signature lines; Escaped and Timed Out unchanged. + "global-ignoreSourceCodeByRegex": [ + "(?:(?:private|protected|public|readonly)\\s+)*\\??[A-Za-z_\\\\][\\w\\\\|]*\\s+\\$\\w+\\s*=\\s*[^,;]+," + ], + // Off: with --with-uncovered a PublicVisibility mutant sits on a parameterless + // method's signature line, which has no executable opcode under pcov/xdebug, + // so it is reported Not Covered and can never be killed, whatever #[Covers] + // says. Measured on pinned-http-client 2026-11-01: 4 of 4 such mutants were + // Not Covered, none killed. + "PublicVisibility": false, + // Off for the same reason: a ProtectedVisibility mutant on a protected method's + // signature line is Not Covered and cannot be killed. Measured on + // understudy-phpunit 2026-11-01: 4 of 4 such mutants were Not Covered. + "ProtectedVisibility": false, // Raw-I/O passthroughs. Each of these forwards one call to the native // filesystem and returns what it answered; a mutant either changes // nothing observable or needs a real side effect on disk to see. The diff --git a/src/Codegen/DoubleFactory.php b/src/Codegen/DoubleFactory.php index 8fe0592..15a5531 100644 --- a/src/Codegen/DoubleFactory.php +++ b/src/Codegen/DoubleFactory.php @@ -277,15 +277,13 @@ private static function unifyAbstractHooks(array $targets): array $name = $property->getName(); if (!$primary->isInterface() && $primary->isReadOnly()) { + $format = 'the class is readonly and `%s::$%s` is an abstract property hook. A readonly class ' + . 'may only be extended by a readonly class, and a hooked property cannot be readonly ' + . '— so no double can implement it. Double an interface of the contract instead.'; + throw UnsupportedTarget::notDoublable( $primary->getName(), - sprintf( - 'the class is readonly and `%s::$%s` is an abstract property hook. A readonly class ' - . 'may only be extended by a readonly class, and a hooked property cannot be readonly ' - . '— so no double can implement it. Double an interface of the contract instead.', - $property->getDeclaringClass()->getName(), - $name, - ), + sprintf($format, $property->getDeclaringClass()->getName(), $name), ); } @@ -294,15 +292,13 @@ private static function unifyAbstractHooks(array $targets): array $get = $hooks['get'] ?? null; if ($get !== null && $get->returnsReference()) { + $format = '`%s::$%s` declares a by-reference `&get` hook, and a double dispatches property ' + . 'reads by value — the reference it handed back would not be the one the contract ' + . 'promises. Expose the value through a method, or pass a real object.'; + throw UnsupportedTarget::notDoublable( $target->getName(), - sprintf( - '`%s::$%s` declares a by-reference `&get` hook, and a double dispatches property ' - . 'reads by value — the reference it handed back would not be the one the contract ' - . 'promises. Expose the value through a method, or pass a real object.', - $property->getDeclaringClass()->getName(), - $name, - ), + sprintf($format, $property->getDeclaringClass()->getName(), $name), ); } @@ -436,7 +432,7 @@ private static function whyFinalStillStands(string $contract, \ReflectionClass $ $origin = match (true) { $file === false => 'it has no source file of its own', str_starts_with($file, 'phar://') => 'it was read out of a PHAR, and `phar://` is not `file://`', - default => 'it was already loaded, from `' . $file . '`, before the wrapper was installed', + default => sprintf('it was already loaded, from `%s`, before the wrapper was installed', $file), }; return "the class is final, and bypass was asked for it but could not reach it: {$origin}.\n" diff --git a/src/Codegen/TargetUnifier.php b/src/Codegen/TargetUnifier.php index d52ad6f..143e4e5 100644 --- a/src/Codegen/TargetUnifier.php +++ b/src/Codegen/TargetUnifier.php @@ -1238,17 +1238,14 @@ private static function withoutStringLiterals(string $source): string private static function renderSourceDefault(\ReflectionParameter $parameter, string $method, string $source): string { if (preg_match('/\b(self|static|parent)\s*::/i', self::withoutStringLiterals($source)) === 1) { + $format = 'the default of `$%s` in `%s()` is `%s`, which resolves `self`, `static` or `parent` ' + . 'against the declaring class. The generated double is a different class, so the value ' + . 'cannot be reproduced. Give the parameter a default the double can build, or double ' + . 'an interface.'; + throw UnsupportedTarget::notDoublable( $parameter->getDeclaringClass()?->getName() ?? $method, - sprintf( - 'the default of `$%s` in `%s()` is `%s`, which resolves `self`, `static` or `parent` ' - . 'against the declaring class. The generated double is a different class, so the value ' - . 'cannot be reproduced. Give the parameter a default the double can build, or double ' - . 'an interface.', - $parameter->getName(), - $method, - $source, - ), + sprintf($format, $parameter->getName(), $method, $source), ); } @@ -1340,14 +1337,12 @@ private static function renderValueDefault(\ReflectionParameter $parameter, stri $value = $parameter->getDefaultValue(); if (self::holdsPlainObject($value)) { + $format = 'the default of `$%s` in `%s()` holds an object that cannot be written back as source. ' + . 'Give the parameter a scalar, array or enum default, or double an interface.'; + throw UnsupportedTarget::notDoublable( $parameter->getDeclaringClass()?->getName() ?? $method, - sprintf( - 'the default of `$%s` in `%s()` holds an object that cannot be written back as source. ' - . 'Give the parameter a scalar, array or enum default, or double an interface.', - $parameter->getName(), - $method, - ), + sprintf($format, $parameter->getName(), $method), ); } diff --git a/src/Codegen/TypeRenderer.php b/src/Codegen/TypeRenderer.php index e821567..c0b8ba8 100644 --- a/src/Codegen/TypeRenderer.php +++ b/src/Codegen/TypeRenderer.php @@ -122,9 +122,10 @@ private static function resolveParent(string $rendered, ?\ReflectionClass $decla // Word boundaries, and not already qualified: a class genuinely named // `Parent`, or `\App\Parent`, is somebody's own name and not the // keyword. + $replacement = '\\\\' . $parent->getName(); $resolved = preg_replace( '/(?getName(), + $replacement, $rendered, ); diff --git a/src/Exception/AmbiguousDefaultFactory.php b/src/Exception/AmbiguousDefaultFactory.php index 13b5823..016e2a4 100644 --- a/src/Exception/AmbiguousDefaultFactory.php +++ b/src/Exception/AmbiguousDefaultFactory.php @@ -21,10 +21,12 @@ public static function between(string $requested, array $candidates): self { sort($candidates); - return new self(sprintf( - "More than one default factory is equally close to `%s`: %s.\n" + $format = "More than one default factory is equally close to `%s`: %s.\n" . 'Register one for `%s` itself — resolution by distance is what keeps the answer independent ' - . 'of the order the factories were registered in, and a tie has no order to fall back on.', + . 'of the order the factories were registered in, and a tie has no order to fall back on.'; + + return new self(sprintf( + $format, $requested, implode(', ', array_map(static fn(string $c): string => '`' . $c . '`', $candidates)), $requested, diff --git a/src/Exception/BypassUnavailable.php b/src/Exception/BypassUnavailable.php index ebad78c..69ad1bc 100644 --- a/src/Exception/BypassUnavailable.php +++ b/src/Exception/BypassUnavailable.php @@ -18,13 +18,12 @@ final class BypassUnavailable extends \LogicException implements UnderstudyError */ public static function alreadyLoaded(string $class): self { - return new self(sprintf( - "`%s` is already loaded, so its `final` cannot be lifted any more.\n" + $format = "`%s` is already loaded, so its `final` cannot be lifted any more.\n" . "A class is read from disk once per process, and the transform happens as it is read.\n" . "- Call Understudy::bypassFinals() from your test bootstrap, before Composer autoloads it.\n" - . '- Or double an interface it implements, which needs no bypass at all.', - $class, - )); + . '- Or double an interface it implements, which needs no bypass at all.'; + + return new self(sprintf($format, $class)); } /** @@ -34,12 +33,10 @@ public static function alreadyLoaded(string $class): self */ public static function notAClass(string $class, string $kind): self { - return new self(sprintf( - "`%s` is %s, and lifting `final` would not make it extendable.\n" - . 'Bypass exists for a final *class* standing between a test and the code under test.', - $class, - $kind, - )); + $format = "`%s` is %s, and lifting `final` would not make it extendable.\n" + . 'Bypass exists for a final *class* standing between a test and the code under test.'; + + return new self(sprintf($format, $class, $kind)); } /** @@ -50,12 +47,11 @@ public static function notAClass(string $class, string $kind): self */ public static function foreignWrapper(string $owner): self { - return new self(sprintf( - "Something else is already serving `file://` through a user-space wrapper (%s).\n" + $format = "Something else is already serving `file://` through a user-space wrapper (%s).\n" . "Replacing that wrapper would silently disable whatever it does, so understudy will not.\n" . "- Call Understudy::bypassFinals() before the other wrapper is installed.\n" - . '- Or double an interface, which needs no wrapper at all.', - $owner, - )); + . '- Or double an interface, which needs no wrapper at all.'; + + return new self(sprintf($format, $owner)); } } diff --git a/src/Exception/CannotWire.php b/src/Exception/CannotWire.php index 2066b09..3918448 100644 --- a/src/Exception/CannotWire.php +++ b/src/Exception/CannotWire.php @@ -19,12 +19,10 @@ final class CannotWire extends \InvalidArgumentException implements UnderstudyEr */ public static function notAConcreteClass(string $sut, string $reason): self { - return new self(sprintf( - "Cannot wire `%s`: %s\n" - . 'wire() builds a real subject out of doubled dependencies; the subject itself is not a double.', - $sut, - $reason, - )); + $format = "Cannot wire `%s`: %s\n" + . 'wire() builds a real subject out of doubled dependencies; the subject itself is not a double.'; + + return new self(sprintf($format, $sut, $reason)); } /** @@ -34,13 +32,11 @@ public static function notAConcreteClass(string $sut, string $reason): self */ public static function inaccessibleConstructor(string $sut, string $visibility): self { - return new self(sprintf( - "Cannot wire `%s`: its constructor is %s.\n" + $format = "Cannot wire `%s`: its constructor is %s.\n" . 'Use the named constructor the class offers, or build the subject yourself and double its ' - . 'dependencies with Understudy::for().', - $sut, - $visibility, - )); + . 'dependencies with Understudy::for().'; + + return new self(sprintf($format, $sut, $visibility)); } /** @@ -50,13 +46,10 @@ public static function inaccessibleConstructor(string $sut, string $visibility): */ public static function unknownOverride(string $sut, string $name, string $known): self { - return new self(sprintf( - "Cannot wire `%s`: there is no constructor parameter named `%s`.\n" - . 'The constructor takes: %s.', - $sut, - $name, - $known, - )); + $format = "Cannot wire `%s`: there is no constructor parameter named `%s`.\n" + . 'The constructor takes: %s.'; + + return new self(sprintf($format, $sut, $name, $known)); } /** @@ -66,15 +59,11 @@ public static function unknownOverride(string $sut, string $name, string $known) */ public static function incompatibleOverride(string $sut, string $name, string $expected, string $given): self { - return new self(sprintf( - "Cannot wire `%s`: the override for `\$%s` has type `%s`, and the constructor declares `%s`.\n" + $format = "Cannot wire `%s`: the override for `\$%s` has type `%s`, and the constructor declares `%s`.\n" . 'The check happens before the constructor runs, so a wrong type is reported here rather than ' - . 'as a TypeError from inside the subject.', - $sut, - $name, - $given, - $expected, - )); + . 'as a TypeError from inside the subject.'; + + return new self(sprintf($format, $sut, $name, $given, $expected)); } /** @@ -84,16 +73,10 @@ public static function incompatibleOverride(string $sut, string $name, string $e */ public static function undecidableParameter(string $sut, string $name, string $type, string $reason): self { - return new self(sprintf( - "Cannot wire `%s`: nothing can be decided for `\$%s` (`%s`) — %s\n" - . 'Pass it yourself: Understudy::wire(%s::class, [\'%s\' => $value]).', - $sut, - $name, - $type, - $reason, - self::shortName($sut), - $name, - )); + $format = "Cannot wire `%s`: nothing can be decided for `\$%s` (`%s`) — %s\n" + . 'Pass it yourself: Understudy::wire(%s::class, [\'%s\' => $value]).'; + + return new self(sprintf($format, $sut, $name, $type, $reason, self::shortName($sut), $name)); } /** @@ -103,13 +86,11 @@ public static function undecidableParameter(string $sut, string $name, string $t */ public static function referenceParameter(string $sut, string $name): self { - return new self(sprintf( - "Cannot wire `%s`: `\$%s` is taken by reference.\n" + $format = "Cannot wire `%s`: `\$%s` is taken by reference.\n" . 'Overrides are values, and passing one would quietly promise a reference semantics wire() does ' - . 'not have. Build the subject yourself.', - $sut, - $name, - )); + . 'not have. Build the subject yourself.'; + + return new self(sprintf($format, $sut, $name)); } private static function shortName(string $class): string diff --git a/src/Exception/ConflictingExpectation.php b/src/Exception/ConflictingExpectation.php index 00157a5..3749bb2 100644 --- a/src/Exception/ConflictingExpectation.php +++ b/src/Exception/ConflictingExpectation.php @@ -28,14 +28,12 @@ final class ConflictingExpectation extends \LogicException implements Understudy */ public static function claimAfterStub(string $label, string $spec): self { - return new self(sprintf( - 'Understudy `%s` already has `%s` stubbed, and a separate expect() for the same call would not ' + $format = 'Understudy `%s` already has `%s` stubbed, and a separate expect() for the same call would not ' . 'compose with it: the expectation would take the dispatch and answer the mode default, silently ' . 'discarding the stub. Declare one expectation — expect(...)->returns(...), or count the stub ' - . 'with when(...)->times(...).', - $label, - $spec, - )); + . 'with when(...)->times(...).'; + + return new self(sprintf($format, $label, $spec)); } /** @@ -46,14 +44,12 @@ public static function claimAfterStub(string $label, string $spec): self */ public static function stubAfterCountedExpectation(string $label, string $spec): self { - return new self(sprintf( - 'Understudy `%s` already counts `%s`, and a later stub for the same call would not compose with ' + $format = 'Understudy `%s` already counts `%s`, and a later stub for the same call would not compose with ' . 'it: the stub would take the dispatch and starve the count, which verifyAll() then reports as ' . '"called never" about a call that did happen. Give the expectation its behaviour instead: ' - . 'expect(...)->returns(...).', - $label, - $spec, - )); + . 'expect(...)->returns(...).'; + + return new self(sprintf($format, $label, $spec)); } /** @@ -64,12 +60,10 @@ public static function stubAfterCountedExpectation(string $label, string $spec): */ public static function duplicateCountedExpectation(string $label, string $spec): self { - return new self(sprintf( - 'Understudy `%s` already counts `%s`, and a second expectation for the same call would not ' + $format = 'Understudy `%s` already counts `%s`, and a second expectation for the same call would not ' . 'compose with it: the newer one would take every match and the older count is starved. Declare ' - . 'the count once — expect(...)->times(...).', - $label, - $spec, - )); + . 'the count once — expect(...)->times(...).'; + + return new self(sprintf($format, $label, $spec)); } } diff --git a/src/Exception/ContextOwnershipViolation.php b/src/Exception/ContextOwnershipViolation.php index ddbd326..46fbcb7 100644 --- a/src/Exception/ContextOwnershipViolation.php +++ b/src/Exception/ContextOwnershipViolation.php @@ -21,10 +21,10 @@ final class ContextOwnershipViolation extends \LogicException implements Underst */ public static function forDouble(): self { - return new self( - 'This understudy belongs to a different runtime context. ' + $message = 'This understudy belongs to a different runtime context. ' . 'Configure and verify it in the scope or Fiber that created it; ' - . 'only normal method calls may cross context boundaries.', - ); + . 'only normal method calls may cross context boundaries.'; + + return new self($message); } } diff --git a/src/Exception/ForgottenDouble.php b/src/Exception/ForgottenDouble.php index ce811af..03dac50 100644 --- a/src/Exception/ForgottenDouble.php +++ b/src/Exception/ForgottenDouble.php @@ -22,12 +22,11 @@ final class ForgottenDouble extends \LogicException implements UnderstudyError */ public static function afterReset(string $method): self { - return new self(sprintf( - "This understudy is no longer known to Understudy, but `%s()` was called on it.\n" + $format = "This understudy is no longer known to Understudy, but `%s()` was called on it.\n" . 'It was created before a reset(); create doubles inside the test that uses them ' - . 'rather than sharing one across tests.', - $method, - )); + . 'rather than sharing one across tests.'; + + return new self(sprintf($format, $method)); } /** @@ -38,12 +37,11 @@ public static function afterReset(string $method): self */ public static function afterScope(string $member): self { - return new self(sprintf( - "This understudy is no longer known to Understudy, but `%s()` was called on it.\n" + $format = "This understudy is no longer known to Understudy, but `%s()` was called on it.\n" . 'It was created inside a scope() that has since closed, and a scope drops its doubles when ' - . 'it ends; build the double in the scope that will use it, or outside the scope altogether.', - $member, - )); + . 'it ends; build the double in the scope that will use it, or outside the scope altogether.'; + + return new self(sprintf($format, $member)); } /** @@ -53,11 +51,10 @@ public static function afterScope(string $member): self */ public static function onPurpose(string $method): self { - return new self(sprintf( - "This understudy was retired with Understudy::forget(), but `%s()` was called on it.\n" - . 'A forgotten double is gone for good; build a new one instead of calling this one.', - $method, - )); + $format = "This understudy was retired with Understudy::forget(), but `%s()` was called on it.\n" + . 'A forgotten double is gone for good; build a new one instead of calling this one.'; + + return new self(sprintf($format, $method)); } /** @@ -67,12 +64,11 @@ public static function onPurpose(string $method): self */ public static function propertyAfterReset(string $property): self { - return new self(sprintf( - "This understudy is no longer known to Understudy, but its property `\$%s` was touched.\n" + $format = "This understudy is no longer known to Understudy, but its property `\$%s` was touched.\n" . 'It was created before a reset(); create doubles inside the test that uses them ' - . 'rather than sharing one across tests.', - $property, - )); + . 'rather than sharing one across tests.'; + + return new self(sprintf($format, $property)); } /** @@ -83,11 +79,11 @@ public static function propertyAfterReset(string $property): self */ public static function retired(): self { - return new self( - 'This understudy was retired with Understudy::forget() and can no longer be asked ' + $message = 'This understudy was retired with Understudy::forget() and can no longer be asked ' . 'anything — not calls, not verification. A replacement built afterwards is a ' - . 'different object; ask that one.', - ); + . 'different object; ask that one.'; + + return new self($message); } /** @@ -97,11 +93,10 @@ public static function retired(): self */ public static function fromDefaultFactory(string $contract): self { - return new self(sprintf( - "The default factory for `%s` answered with an understudy that is no longer known to Understudy.\n" + $format = "The default factory for `%s` answered with an understudy that is no longer known to Understudy.\n" . 'It was created before a reset(); register the factory inside the test that uses it ' - . 'rather than sharing one double across tests.', - $contract, - )); + . 'rather than sharing one double across tests.'; + + return new self(sprintf($format, $contract)); } } diff --git a/src/Exception/ForwardingTargetMismatch.php b/src/Exception/ForwardingTargetMismatch.php index 5f6ab9a..bbab42d 100644 --- a/src/Exception/ForwardingTargetMismatch.php +++ b/src/Exception/ForwardingTargetMismatch.php @@ -19,12 +19,11 @@ final class ForwardingTargetMismatch extends \InvalidArgumentException implement */ public static function understudyTarget(string $label): self { - return new self(sprintf( - "Understudy `%s` cannot forward to an understudy.\n" + $format = "Understudy `%s` cannot forward to an understudy.\n" . 'Every call would come straight back to a dispatcher, and an unmatched one would keep coming ' - . 'back until the stack runs out. Forward to the real object instead.', - $label, - )); + . 'back until the stack runs out. Forward to the real object instead.'; + + return new self(sprintf($format, $label)); } /** @@ -35,14 +34,11 @@ public static function understudyTarget(string $label): self */ public static function missingContract(string $label, string $contract, string $given): self { - return new self(sprintf( - "Understudy `%s` stands in for `%s`, and `%s` is not one.\n" + $format = "Understudy `%s` stands in for `%s`, and `%s` is not one.\n" . 'A forwarding target has to satisfy every contract of the double, or a call the double ' - . 'accepts would not exist on the instance it delegates to.', - $label, - $contract, - $given, - )); + . 'accepts would not exist on the instance it delegates to.'; + + return new self(sprintf($format, $label, $contract, $given)); } /** @@ -61,14 +57,11 @@ public static function missingContract(string $label, string $contract, string $ */ 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" + $format = "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, - )); + . 'cannot be written twice at all. Build a second double for the other instance.'; + + return new self(sprintf($format, $label, $current, $given)); } } diff --git a/src/Exception/InvalidCallSpecification.php b/src/Exception/InvalidCallSpecification.php index d00bdc4..cca19bd 100644 --- a/src/Exception/InvalidCallSpecification.php +++ b/src/Exception/InvalidCallSpecification.php @@ -30,11 +30,11 @@ final class InvalidCallSpecification extends \LogicException implements Understu */ public static function noCallRecorded(): self { - return new self( - 'The specification closure did not call a method on an understudy. ' + $message = 'The specification closure did not call a method on an understudy. ' . 'It must contain exactly one direct call, for example: ' - . 'when(fn () => $repository->find(123))', - ); + . 'when(fn () => $repository->find(123))'; + + return new self($message); } /** @@ -50,16 +50,12 @@ public static function noCallRecorded(): self */ public static function moreThanOneCall(string $firstLabel, string $firstMethod, string $lastLabel, string $lastMethod): self { - return new self(sprintf( - 'The specification closure calls `%s()` on understudy `%s` and then `%s()` on understudy `%s`. ' + $format = 'The specification closure calls `%s()` on understudy `%s` and then `%s()` on understudy `%s`. ' . 'A closure must contain exactly one direct call on a double; with two, one of them would be specified ' . 'silently and the other not at all. Stub each call in a when() of its own, and where one call feeds the ' - . 'arguments of another pass a literal or a matcher instead, for example: when(fn () => $repository->find(Arg::any()))', - $firstMethod, - $firstLabel, - $lastMethod, - $lastLabel, - )); + . 'arguments of another pass a literal or a matcher instead, for example: when(fn () => $repository->find(Arg::any()))'; + + return new self(sprintf($format, $firstMethod, $firstLabel, $lastMethod, $lastLabel)); } /** @@ -71,13 +67,11 @@ public static function moreThanOneCall(string $firstLabel, string $firstMethod, */ public static function returnsOnVoid(string $label, string $method): self { - return new self(sprintf( - 'Understudy `%s`: `%s()` is declared `: void`, so returns() has nothing to answer with — the value ' + $format = 'Understudy `%s`: `%s()` is declared `: void`, so returns() has nothing to answer with — the value ' . 'would never be observed. Drop the value (returns(null) is allowed), or use answers()/throws() if the ' - . 'call should do something.', - $label, - $method, - )); + . 'call should do something.'; + + return new self(sprintf($format, $label, $method)); } /** @@ -88,13 +82,10 @@ public static function returnsOnVoid(string $label, string $method): self */ public static function returnsOnNever(string $label, string $method): self { - return new self(sprintf( - 'Understudy `%s`: `%s()` is declared `: never` and cannot return. Configure what it throws: ' - . 'when(fn () => $double->%s(...))->throws(new SomeException())', - $label, - $method, - $method, - )); + $format = 'Understudy `%s`: `%s()` is declared `: never` and cannot return. Configure what it throws: ' + . 'when(fn () => $double->%s(...))->throws(new SomeException())'; + + return new self(sprintf($format, $label, $method, $method)); } /** @@ -105,11 +96,10 @@ public static function returnsOnNever(string $label, string $method): self */ public static function notADouble(string $facade): self { - return new self(sprintf( - 'Understudy::%s() expects an understudy created by Understudy::for(). ' - . 'This object is not one.', - $facade, - )); + $format = 'Understudy::%s() expects an understudy created by Understudy::for(). ' + . 'This object is not one.'; + + return new self(sprintf($format, $facade)); } /** @@ -121,11 +111,10 @@ public static function notADouble(string $facade): self */ public static function neverBesideACount(string $bound): self { - return new self(sprintf( - '`never: true` says the call never happened, and `%s` says how often it did. ' - . 'Keep the one you mean.', - $bound, - )); + $format = '`never: true` says the call never happened, and `%s` says how often it did. ' + . 'Keep the one you mean.'; + + return new self(sprintf($format, $bound)); } /** @@ -134,10 +123,10 @@ public static function neverBesideACount(string $bound): self */ public static function exactCountBesideABound(): self { - return new self( - '`times` is an exact count, so a `minimum` or `maximum` beside it has nothing left ' - . 'to constrain. Use one or the other.', - ); + $message = '`times` is an exact count, so a `minimum` or `maximum` beside it has nothing left ' + . 'to constrain. Use one or the other.'; + + return new self($message); } /** @@ -148,14 +137,11 @@ public static function exactCountBesideABound(): self */ public static function misplacedTailMatcher(string $method, int $position, string $matcher): self { - return new self(sprintf( - "`%s` stands for the whole variadic tail, so it may only be the last argument, " + $format = "`%s` stands for the whole variadic tail, so it may only be the last argument, " . "but it was given as argument #%d of `%s()`.\n" - . 'Move it to the end, or use Arg::any() to match that one argument.', - $matcher, - $position + 1, - $method, - )); + . 'Move it to the end, or use Arg::any() to match that one argument.'; + + return new self(sprintf($format, $matcher, $position + 1, $method)); } /** @@ -165,12 +151,10 @@ public static function misplacedTailMatcher(string $method, int $position, strin */ public static function emptyCombinator(string $matcher): self { - return new self(sprintf( - "`Arg::%s()` was given no operands, so it would match %s.\n" - . 'Pass what the argument has to satisfy, or use Arg::any() to accept anything.', - $matcher, - $matcher === 'allOf' ? 'every argument' : 'no argument at all', - )); + $format = "`Arg::%s()` was given no operands, so it would match %s.\n" + . 'Pass what the argument has to satisfy, or use Arg::any() to accept anything.'; + + return new self(sprintf($format, $matcher, $matcher === 'allOf' ? 'every argument' : 'no argument at all')); } /** @@ -183,12 +167,10 @@ public static function emptyCombinator(string $matcher): self */ public static function guardAlreadyNarrowed(string $method, ?string $description): self { - return new self(sprintf( - "`%s` already carries a `where()` guard%s. One call, one guard: put both " - . 'conditions in the one closure.', - $method, - $description === null ? '' : " (`$description`)", - )); + $format = "`%s` already carries a `where()` guard%s. One call, one guard: put both " + . 'conditions in the one closure.'; + + return new self(sprintf($format, $method, $description === null ? '' : " (`$description`)")); } /** @@ -199,13 +181,11 @@ public static function guardAlreadyNarrowed(string $method, ?string $description */ public static function tailMatcherInCombinator(string $matcher, string $operand): self { - return new self(sprintf( - "`%s` stands for the whole variadic tail, and `Arg::%s()` combines matchers for one " + $format = "`%s` stands for the whole variadic tail, and `Arg::%s()` combines matchers for one " . "argument, so it cannot hold one.\n" - . 'Put the tail matcher last on its own, and combine the arguments before it.', - $operand, - $matcher, - )); + . 'Put the tail matcher last on its own, and combine the arguments before it.'; + + return new self(sprintf($format, $operand, $matcher)); } /** @@ -217,15 +197,12 @@ public static function tailMatcherInCombinator(string $matcher, string $operand) */ public static function matcherInsideArray(string $method, int $position, string $matcher): self { - return new self(sprintf( - "`%s` sits inside the array given as argument #%d of `%s()`, where it is compared by " + $format = "`%s` sits inside the array given as argument #%d of `%s()`, where it is compared by " . "identity and can never match.\n" . 'Describe the array with Arg::containing([...]), which reads matchers in its entries, ' - . 'or the whole argument with Arg::satisfies().', - $matcher, - $position + 1, - $method, - )); + . 'or the whole argument with Arg::satisfies().'; + + return new self(sprintf($format, $matcher, $position + 1, $method)); } /** @@ -235,13 +212,12 @@ public static function matcherInsideArray(string $method, int $position, string */ public static function captorInCombinator(string $matcher): self { - return new self(sprintf( - "A captor records the argument it stands for, and `Arg::%s()` builds one matcher out of " + $format = "A captor records the argument it stands for, and `Arg::%s()` builds one matcher out of " . "others, so a captor inside it would match without ever recording.\n" . 'Put the captor in the argument position itself, or read the calls with ' - . 'Understudy::calls().', - $matcher, - )); + . 'Understudy::calls().'; + + return new self(sprintf($format, $matcher)); } /** @@ -250,10 +226,10 @@ public static function captorInCombinator(string $matcher): self */ public static function emptySequence(): self { - return new self( - 'Understudy::expectSequence() needs at least one call: an empty protocol claims nothing ' - . 'and would refuse every call on no double at all', - ); + $message = 'Understudy::expectSequence() needs at least one call: an empty protocol claims nothing ' + . 'and would refuse every call on no double at all'; + + return new self($message); } /** @@ -264,13 +240,11 @@ public static function emptySequence(): self */ public static function protocolAlreadyArmed(int $position, int $length): self { - return new self(sprintf( - 'A protocol is already armed and is waiting on step %d of %d. Two of them naming the same ' + $format = 'A protocol is already armed and is waiting on step %d of %d. Two of them naming the same ' . 'understudy would each judge every call on it and could disagree about the same call — ' - . 'finish this one, or describe both phases as a single protocol', - $position, - $length, - )); + . 'finish this one, or describe both phases as a single protocol'; + + return new self(sprintf($format, $position, $length)); } /** @@ -282,14 +256,11 @@ public static function protocolAlreadyArmed(int $position, int $length): self */ public static function incompleteSpecification(string $method, int $given, int $declared): self { - return new self(sprintf( - "The specification for `%s()` passed %d of its %d arguments, and the ones it left out " + $format = "The specification for `%s()` passed %d of its %d arguments, and the ones it left out " . "are not all optional.\n" - . 'Spell every required argument, or say the rest does not matter by ending with Arg::rest().', - $method, - $given, - $declared, - )); + . 'Spell every required argument, or say the rest does not matter by ending with Arg::rest().'; + + return new self(sprintf($format, $method, $given, $declared)); } /** @@ -301,14 +272,11 @@ public static function incompleteSpecification(string $method, int $given, int $ */ public static function omittedBeforeSpecified(string $method, int $omitted, int $specified): self { - return new self(sprintf( - "The specification for `%s()` omitted argument #%d — which the contract declares required — " + $format = "The specification for `%s()` omitted argument #%d — which the contract declares required — " . "but specified argument #%d after it.\n" - . 'A specification spells its required arguments in order — use Arg::any() for one that does not matter.', - $method, - $omitted + 1, - $specified + 1, - )); + . 'A specification spells its required arguments in order — use Arg::any() for one that does not matter.'; + + return new self(sprintf($format, $method, $omitted + 1, $specified + 1)); } /** @@ -319,13 +287,11 @@ public static function omittedBeforeSpecified(string $method, int $omitted, int */ public static function omittedTailNeedsRest(string $method, string $matcher): self { - return new self(sprintf( - "`%s` describes a variadic tail, not parameters left unspelled, and the specification " + $format = "`%s` describes a variadic tail, not parameters left unspelled, and the specification " . "for `%s()` stopped before its parameters ran out.\n" - . 'End with Arg::rest() to say the remaining parameters do not matter.', - $matcher, - $method, - )); + . 'End with Arg::rest() to say the remaining parameters do not matter.'; + + return new self(sprintf($format, $matcher, $method)); } /** @@ -334,9 +300,11 @@ public static function omittedTailNeedsRest(string $method, string $matcher): se */ public static function closureFailed(\Throwable $previous): self { + $message = 'The specification closure threw before it reached an understudy: ' + . $previous->getMessage(); + return new self( - 'The specification closure threw before it reached an understudy: ' - . $previous->getMessage(), + $message, previous: $previous, ); } @@ -348,10 +316,9 @@ public static function closureFailed(\Throwable $previous): self */ public static function staticMethodCalled(string $method): self { - return new self(sprintf( - "Static method `%s()` cannot be called on an understudy because static calls have no instance state.\n" - . 'Inject an instance dependency and double that contract instead.', - $method, - )); + $format = "Static method `%s()` cannot be called on an understudy because static calls have no instance state.\n" + . 'Inject an instance dependency and double that contract instead.'; + + return new self(sprintf($format, $method)); } } diff --git a/src/Exception/InvalidDefaultValue.php b/src/Exception/InvalidDefaultValue.php index d5cdeba..08178a8 100644 --- a/src/Exception/InvalidDefaultValue.php +++ b/src/Exception/InvalidDefaultValue.php @@ -18,12 +18,10 @@ final class InvalidDefaultValue extends \RuntimeException implements UnderstudyE */ public static function ofWrongType(string $requested, string $produced): self { - return new self(sprintf( - "The default factory registered for `%s` produced a value of type `%s`.\n" + $format = "The default factory registered for `%s` produced a value of type `%s`.\n" . 'A factory has to return something the requested type can hold, or the double answers with a ' - . 'value the code under test cannot use.', - $requested, - $produced, - )); + . 'value the code under test cannot use.'; + + return new self(sprintf($format, $requested, $produced)); } } diff --git a/src/Exception/InvalidSpecificationArgument.php b/src/Exception/InvalidSpecificationArgument.php index 8c97bc6..5b27497 100644 --- a/src/Exception/InvalidSpecificationArgument.php +++ b/src/Exception/InvalidSpecificationArgument.php @@ -121,10 +121,12 @@ public static function unknownType(string $type): self */ public static function invertedBounds(string $matcher, int|float $minimum, int|float $maximum): self { - return new self(sprintf( - "`Arg::%s()` was given a minimum of %s and a maximum of %s, so it describes an empty " + $format = "`Arg::%s()` was given a minimum of %s and a maximum of %s, so it describes an empty " . "range and would match no argument at all.\n" - . 'Order the bounds the other way, or leave one of them out to keep that side open.', + . 'Order the bounds the other way, or leave one of them out to keep that side open.'; + + return new self(sprintf( + $format, $matcher, var_export($minimum, return: true), var_export($maximum, return: true), @@ -139,12 +141,10 @@ public static function invertedBounds(string $matcher, int|float $minimum, int|f */ public static function invalidPattern(string $pattern, ?string $reason): self { - return new self(sprintf( - "`Arg::string()` was given `%s`, which is not a valid PCRE pattern%s\n" + $format = "`Arg::string()` was given `%s`, which is not a valid PCRE pattern%s\n" . 'It would match no string and would raise a warning inside the code under test on ' - . 'every call. Check the delimiters and the escaping.', - $pattern, - $reason === null ? '.' : ': ' . $reason . '.', - )); + . 'every call. Check the delimiters and the escaping.'; + + return new self(sprintf($format, $pattern, $reason === null ? '.' : ': ' . $reason . '.')); } } diff --git a/src/Exception/OriginalCallUnavailable.php b/src/Exception/OriginalCallUnavailable.php index f730c96..7ec9486 100644 --- a/src/Exception/OriginalCallUnavailable.php +++ b/src/Exception/OriginalCallUnavailable.php @@ -18,13 +18,12 @@ final class OriginalCallUnavailable extends \LogicException implements Understud */ public static function forMode(string $label): self { - return new self(sprintf( - "Understudy `%s` was asked to forward, and has no real instance to forward to.\n" + $format = "Understudy `%s` was asked to forward, and has no real instance to forward to.\n" . 'Pass one: Understudy::forwarding($double, $real). The shorthand ' . 'Understudy::forwarding($double) only turns the mode on for a double built from an ' - . 'instance with Understudy::for($real).', - $label, - )); + . 'instance with Understudy::for($real).'; + + return new self(sprintf($format, $label)); } /** @@ -35,12 +34,10 @@ public static function forMode(string $label): self */ public static function withoutTarget(string $label, string $method): self { - return new self(sprintf( - "Understudy `%s` has no real instance to delegate `%s()` to.\n" + $format = "Understudy `%s` has no real instance to delegate `%s()` to.\n" . 'Give it one: Understudy::forwarding($double, $real) — calling the parent implementation on a ' - . 'double whose constructor never ran is not a safe substitute.', - $label, - $method, - )); + . 'double whose constructor never ran is not a safe substitute.'; + + return new self(sprintf($format, $label, $method)); } } diff --git a/src/Exception/OriginalReturnTypeViolation.php b/src/Exception/OriginalReturnTypeViolation.php index 322d576..9757d93 100644 --- a/src/Exception/OriginalReturnTypeViolation.php +++ b/src/Exception/OriginalReturnTypeViolation.php @@ -19,17 +19,13 @@ final class OriginalReturnTypeViolation extends \RuntimeException implements Und */ public static function foreignInstance(string $label, string $method, string $returned): self { - return new self(sprintf( - "Understudy `%s` forwarded `%s()`, which returned a different `%s` instead of the instance it " + $format = "Understudy `%s` forwarded `%s()`, which returned a different `%s` instead of the instance it " . "was called on.\n" . 'The method declares `static`, so the double must return itself, and another object of the ' . 'real class is not a double. Configure the method instead: ' - . 'when(fn () => $double->%s(...))->returns($double).', - $label, - $method, - $returned, - $method, - )); + . 'when(fn () => $double->%s(...))->returns($double).'; + + return new self(sprintf($format, $label, $method, $returned, $method)); } /** @@ -47,15 +43,12 @@ public static function foreignInstance(string $label, string $method, string $re */ 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 " + $format = "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, - )); + . 'when(fn () => $double->%s(...))->returns($double).'; + + return new self(sprintf($format, $label, $method, $method)); } } diff --git a/src/Expectation/ArgumentFormatter.php b/src/Expectation/ArgumentFormatter.php index e6aea7d..2a41de8 100644 --- a/src/Expectation/ArgumentFormatter.php +++ b/src/Expectation/ArgumentFormatter.php @@ -76,8 +76,10 @@ public static function scope(callable $render): mixed /** * @return non-empty-string */ - public static function format(mixed $value, int $depth = 0): string - { + public static function format( + mixed $value, + int $depth = 0, + ): string { if ($depth > self::MAX_DEPTH) { return '…'; } diff --git a/src/Expectation/Expectation.php b/src/Expectation/Expectation.php index f5855c0..a4d0b50 100644 --- a/src/Expectation/Expectation.php +++ b/src/Expectation/Expectation.php @@ -230,8 +230,10 @@ public function captureFrom(array $args): array * * @param int<0, max> $slot */ - public function setAction(Action $action, int $slot = 0): void - { + public function setAction( + Action $action, + int $slot = 0, + ): void { // A builder only ever writes to the current slot or opens the next // one, so the chain grows by one at a time and never develops holes. if ($slot >= count($this->actions)) { @@ -523,9 +525,10 @@ private function describeGuardAgainst(array $args): string \assert($this->guardDescription !== null); - return $this->acceptsGuardDefensively($args) - ? ' ' . $this->guardDescription - : ' *' . $this->guardDescription . '*'; + $plain = ' ' . $this->guardDescription; + $marked = ' *' . $this->guardDescription . '*'; + + return $this->acceptsGuardDefensively($args) ? $plain : $marked; } /** diff --git a/src/Runtime/Runtime.php b/src/Runtime/Runtime.php index 9e9117a..d936342 100644 --- a/src/Runtime/Runtime.php +++ b/src/Runtime/Runtime.php @@ -1561,8 +1561,10 @@ private static function forgetOrphans(): void * live until collection and the map they sit in keeps growing. Eager * removal here is what keeps that map the size of one test. */ - private static function retire(RuntimeContext $context, bool $byScope = false): void - { + private static function retire( + RuntimeContext $context, + bool $byScope = false, + ): void { self::unremember($context); // Captured values live exactly as long as the call log of the context diff --git a/src/Understudy.php b/src/Understudy.php index f923b82..75a7c80 100644 --- a/src/Understudy.php +++ b/src/Understudy.php @@ -110,12 +110,11 @@ private static function rejectUnwrappableInstance(object $real): void return; } - throw UnsupportedTarget::notDoublable( - $real::class, - 'a final class cannot be extended, and an instance of one is already the class it is. Pass an ' + $reason = 'a final class cannot be extended, and an instance of one is already the class it is. Pass an ' . 'interface it implements to Understudy::for() and give the instance to ' - . 'Understudy::forwarding($double, $real).', - ); + . 'Understudy::forwarding($double, $real).'; + + throw UnsupportedTarget::notDoublable($real::class, $reason); } /** @@ -193,8 +192,9 @@ private static function returnContract(DoubleState $state, string $method): ?Ret * * @return int<0, max> checks verified by this call */ - public static function verifyAll(bool $strictStubs = false): int - { + public static function verifyAll( + bool $strictStubs = false, + ): int { // Every context the test put understudies in, not only the one this // call happens to stand in: a body run in a Fiber owns a context of // its own, and skipping it let an unmet `expect()` pass unnoticed. @@ -1349,8 +1349,10 @@ private static function describeOutcome(Invocation $invocation): string * * @return T */ - public static function scope(callable $callback, bool $strictStubs = false): mixed - { + public static function scope( + callable $callback, + bool $strictStubs = false, + ): mixed { $context = Runtime::pushScope(); $succeeded = false; @@ -1382,8 +1384,9 @@ public static function scope(callable $callback, bool $strictStubs = false): mix * reads it; `reset()`, `scope()` and `lean()` are the ways to let go of * everything. */ - public static function checkpoint(bool $strictStubs = false): void - { + public static function checkpoint( + bool $strictStubs = false, + ): void { self::verifyAll($strictStubs); foreach (Runtime::liveContexts() as $context) { diff --git a/tests/ByReferenceTest.php b/tests/ByReferenceTest.php index b00f6d0..215a010 100644 --- a/tests/ByReferenceTest.php +++ b/tests/ByReferenceTest.php @@ -5,8 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Runtime\DoubleState; use Rasuvaeff\Understudy\Runtime\Runtime; use Rasuvaeff\Understudy\Tests\Fixture\Ref\RealRegistry; @@ -20,6 +23,9 @@ use function Rasuvaeff\Understudy\when; #[Test] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Runtime::class)] #[Covers(DoubleState::class)] #[Covers(TargetUnifier::class)] @@ -230,6 +236,38 @@ public function theSnapshotFollowsNestedArraysToItsCapAndStops(): void Assert::true(\is_array($call->args[0]['far']['l1']['l2']['l3'])); } + /** + * The cap, read where it shows: a reference inside an array is detached when + * that array is copied, so the array at depth seven is the last one whose + * references the reading no longer shares, and the one at depth eight is kept + * as it is. + */ + public function theCapFallsBetweenTheSeventhAndTheEighthLevel(): void + { + $snapshot = new \ReflectionMethod(Runtime::class, 'detached'); + + foreach ([0 => false, 7 => false, 8 => true, 9 => true] as $depth => $shared) { + $leaf = 'before'; + $value = ['reference' => &$leaf]; + + for ($level = 0; $level < $depth; $level++) { + $value = ['inner' => $value]; + } + + /** @var list> $copy */ + $copy = $snapshot->invoke(null, [$value]); + $leaf = 'after'; + + $found = $copy[0]; + + for ($level = 0; $level < $depth; $level++) { + $found = $found['inner']; + } + + Assert::same($found['reference'], $shared ? 'after' : 'before', 'depth ' . $depth); + } + } + /** * A method that cannot move its arguments pays nothing for the two * readings, so there is only one. diff --git a/tests/Bypass/BypassFinalsTest.php b/tests/Bypass/BypassFinalsTest.php new file mode 100644 index 0000000..a1511bf --- /dev/null +++ b/tests/Bypass/BypassFinalsTest.php @@ -0,0 +1,164 @@ +getMessage())->contains('`' . Book::class . '` is already loaded'); + } + + Assert::false(FileWrapper::isInstalled()); + } + + public function anEnumIsRefusedAndNothingIsInstalled(): void + { + enum_exists(Suit::class); + + try { + Understudy::bypassFinals(Suit::class); + Assert::fail('an enum must be refused'); + } catch (BypassUnavailable $refused) { + Assert::string($refused->getMessage())->contains('`' . Suit::class . '` is an enum'); + } + + Assert::false(FileWrapper::isInstalled()); + } + + public function anInterfaceIsRefusedAndNothingIsInstalled(): void + { + interface_exists(BookRepository::class); + + try { + Understudy::bypassFinals(BookRepository::class); + Assert::fail('an interface must be refused'); + } catch (BypassUnavailable $refused) { + Assert::string($refused->getMessage())->contains('`' . BookRepository::class . '` is an interface'); + } + + Assert::false(FileWrapper::isInstalled()); + } + + public function anotherUserSpaceWrapperOnFileIsRefusedForEveryShapeOfTheRequest(): void + { + // Everything the refusal needs is in memory before the other wrapper + // takes `file://`, so reading it back is not what is being tested. + class_exists(BypassUnavailable::class); + + foreach ([null, self::NAMESPACE . '\\NeverLoadedGate'] as $class) { + PassiveWrapper::install(); + + try { + Understudy::bypassFinals($class); + Assert::fail('another wrapper on `file://` must be refused'); + } catch (BypassUnavailable $refused) { + Assert::string($refused->getMessage())->contains('(a user-space wrapper already owns `file://`)'); + } finally { + stream_wrapper_restore('file'); + } + + Assert::false(FileWrapper::isInstalled()); + } + } + + public function probingForAnotherWrapperLeavesNoStreamOpen(): void + { + $before = \count(get_resources('stream')); + + Understudy::bypassFinals(self::NAMESPACE . '\\NeverLoadedGate'); + + Assert::same(\count(get_resources('stream')), $before); + } +} diff --git a/tests/Bypass/FileWrapperTest.php b/tests/Bypass/FileWrapperTest.php index fb0a07f..728eb97 100644 --- a/tests/Bypass/FileWrapperTest.php +++ b/tests/Bypass/FileWrapperTest.php @@ -314,6 +314,28 @@ public function theStreamReportsPositionSizeAndEnd(): void $wrapper->stream_close(); } + /** + * PHP closes a stream it never managed to open, and closes one twice when a + * destructor and an explicit `fclose()` meet. Neither may reach the native + * `fclose()` with nothing to close. + */ + public function closingTwiceOrBeforeOpeningIsHarmless(): void + { + FileWrapper::targetOnly([]); + $wrapper = new FileWrapper(); + $opened = null; + + $wrapper->stream_close(); + + Assert::true($wrapper->stream_open(__FILE__, 'r', 0, $opened)); + $wrapper->stream_close(); + $wrapper->stream_close(); + + Assert::true($wrapper->stream_open(__FILE__, 'r', 0, $opened)); + Assert::same($wrapper->stream_tell(), 0); + $wrapper->stream_close(); + } + public function lockingTruncatingAndCastingDelegateToNativeStream(): void { $path = sys_get_temp_dir() . '/understudy-stream-' . getmypid() . '.txt'; diff --git a/tests/Bypass/FinalStripperTest.php b/tests/Bypass/FinalStripperTest.php index 3ed95d4..cf66bd8 100644 --- a/tests/Bypass/FinalStripperTest.php +++ b/tests/Bypass/FinalStripperTest.php @@ -147,6 +147,51 @@ public static function sourceProvider(): iterable ' [ + ' '', 'class' => 'Gate']], + ' [ + ' 'Gate', 'class' => 'Gate']], + ' [ + ' '', 'class' => 'Gate']], + ' [ + ' 'App', 'class' => 'Gate']], + ' [ + ' [ + ' [ + ' [ ' 'App', 'class' => 'Money']], diff --git a/tests/CaptorTest.php b/tests/CaptorTest.php index 178e364..5ec8040 100644 --- a/tests/CaptorTest.php +++ b/tests/CaptorTest.php @@ -6,6 +6,11 @@ use Rasuvaeff\Understudy\Arg; use Rasuvaeff\Understudy\Captor; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\MatcherLeaked; use Rasuvaeff\Understudy\Exception\NothingCaptured; use Rasuvaeff\Understudy\Expectation\Expectation; @@ -31,6 +36,11 @@ * log. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Captor::class)] #[Covers(Understudy::class)] #[Covers(Capturing::class)] diff --git a/tests/CardinalityTest.php b/tests/CardinalityTest.php index 3c7afc8..b6b492e 100644 --- a/tests/CardinalityTest.php +++ b/tests/CardinalityTest.php @@ -21,6 +21,16 @@ public function decidesWhetherACountIsAllowed(Cardinality $cardinality, int $cou Assert::same($cardinality->allows($count), $expected); } + public function anyIsUnboundedFromZero(): void + { + $any = Cardinality::any(); + + Assert::same($any->minimum, 0); + Assert::null($any->maximum); + Assert::true($any->allows(0)); + Assert::true($any->allows(1_000)); + } + /** * @return iterable */ diff --git a/tests/Codegen/DefaultRenderingTest.php b/tests/Codegen/DefaultRenderingTest.php index c7654c8..ff066b2 100644 --- a/tests/Codegen/DefaultRenderingTest.php +++ b/tests/Codegen/DefaultRenderingTest.php @@ -5,7 +5,11 @@ namespace Rasuvaeff\Understudy\Tests\Codegen; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Tests\Fixture\Defaults\EnumInArray; use Rasuvaeff\Understudy\Tests\Fixture\Defaults\GlobalConstant; use Rasuvaeff\Understudy\Tests\Fixture\Defaults\HiddenConstant; @@ -28,6 +32,10 @@ * produces something else is wrong however plausible it reads. */ #[Test] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(TargetUnifier::class)] final class DefaultRenderingTest { diff --git a/tests/Codegen/DefaultSourceRenderingTest.php b/tests/Codegen/DefaultSourceRenderingTest.php new file mode 100644 index 0000000..72a8161 --- /dev/null +++ b/tests/Codegen/DefaultSourceRenderingTest.php @@ -0,0 +1,141 @@ +getMessage(), sprintf( + "Cannot create one understudy for these targets: method `f()` has no implementation that satisfies all of them.\n" + . " `%s\\A::f()` defaults parameter #1 to %s\n" + . ' `%s\\Z::f()` defaults it to 99999', + $namespace, + str_replace('{ns}', $namespace, $expected), + $namespace, + )); + } + } + + /** + * @return iterable + */ + public static function defaultProvider(): iterable + { + yield 'a constant of the declaring interface' => [ + 'SelfConstant', + 'interface A { const LIMIT = 3; public function f(int $x = self::LIMIT): void; }', + '\\{ns}\\A::LIMIT', + ]; + + yield 'the keyword in any case' => [ + 'SelfConstantCased', + 'interface A { const LIMIT = 3; public function f(int $x = Self::LIMIT): void; }', + '\\{ns}\\A::LIMIT', + ]; + + yield 'a constant of another class' => [ + 'OtherClassConstant', + 'class Other { const OT = 5; } interface A { public function f(int $x = Other::OT): void; }', + '\\{ns}\\Other::OT', + ]; + + yield 'a constant of another interface' => [ + 'OtherInterfaceConstant', + 'interface Base { const B = 2; } interface A { public function f(int $x = Base::B): void; }', + '\\{ns}\\Base::B', + ]; + + yield 'an enum case' => [ + 'EnumCase', + 'enum E: string { case A = "a"; } interface A { public function f(E $x = E::A): void; }', + '\\{ns}\\E::A', + ]; + + yield 'a constant of the parent, named through the parent' => [ + 'ParentConstant', + 'class Base { const B = 1; } abstract class A extends Base { abstract public function f(int $x = parent::B): void; }', + '\\{ns}\\Base::B', + ]; + + yield 'the parent keyword in any case' => [ + 'ParentConstantCased', + 'class Base { const B = 1; } abstract class A extends Base { abstract public function f(int $x = PARENT::B): void; }', + '\\{ns}\\Base::B', + ]; + + yield 'a protected constant of the parent is its value' => [ + 'ProtectedParentConstant', + 'class Base { protected const B = 1; } abstract class A extends Base { abstract public function f(int $x = parent::B): void; }', + '1', + ]; + + yield 'a protected constant of the class itself is its value' => [ + 'ProtectedSelfConstant', + 'abstract class A { protected const B = 1; abstract public function f(int $x = self::B): void; }', + '1', + ]; + + yield 'a global constant is its value' => [ + 'GlobalConstant', + 'interface A { public function f(int $x = PHP_INT_MAX): void; }', + '9223372036854775807', + ]; + + yield 'a new expression is its source' => [ + 'NewExpression', + 'class Foo { public function __construct(public int $n = 0) {} } interface A { public function f(Foo $x = new Foo(3)): void; }', + 'new \\{ns}\\Foo(3)', + ]; + + yield 'an expression over a constant is its value' => [ + 'ConstantExpression', + 'interface A { const LIMIT = 3; public function f(int $x = self::LIMIT + 1): void; }', + '4', + ]; + + yield 'an array of scalars is its value' => [ + 'ArrayDefault', + 'interface A { public function f(array $x = [1, \'a\' => true, 2 => null]): void; }', + "array (\n 0 => 1,\n 'a' => true,\n 2 => NULL,\n)", + ]; + + yield 'null is written in lower case' => [ + 'NullDefault', + 'interface A { public function f(?int $x = null): void; }', + 'null', + ]; + } +} diff --git a/tests/Codegen/FinalReasonTest.php b/tests/Codegen/FinalReasonTest.php new file mode 100644 index 0000000..51bd271 --- /dev/null +++ b/tests/Codegen/FinalReasonTest.php @@ -0,0 +1,133 @@ +withMessage( + 'Cannot create an understudy for `' . FinalLedger::class . "`: the class is final, and bypass is enabled for other classes but not this one.\n" + . "- Name it too: Understudy::bypassFinals(FinalLedger::class)\n" + . "- Or ask for every class with Understudy::bypassFinals(), before any of them loads.\n" + . $this->recipe(), + ); + + Understudy::for(FinalLedger::class); + } + + public function aClassBypassWasAskedForButAlreadyLoadedNamesItsFile(): void + { + Understudy::bypassFinals(); + $file = (new \ReflectionClass(FinalLedger::class))->getFileName(); + + Expect::exception(UnsupportedTarget::class)->withMessage( + 'Cannot create an understudy for `' . FinalLedger::class . '`: ' + . 'the class is final, and bypass was asked for it but could not reach it: ' + . 'it was already loaded, from `' . $file . "`, before the wrapper was installed.\n" + . $this->unreachable(), + ); + + Understudy::for(FinalLedger::class); + } + + public function aClassWithoutASourceFileSaysSo(): void + { + Understudy::bypassFinals(); + + Expect::exception(UnsupportedTarget::class)->withMessage( + 'Cannot create an understudy for `Closure`: ' + . "the class is final, and bypass was asked for it but could not reach it: it has no source file of its own.\n" + . $this->unreachable('Closure'), + ); + + Understudy::for(\Closure::class); + } + + public function aClassReadOutOfAPharSaysSo(): void + { + FileWrapper::install(null); + + $phar = new class (FinalLedger::class) extends \ReflectionClass { + public function getFileName(): string + { + return 'phar:///tmp/gates.phar/Gate.php'; + } + }; + + $reason = (new \ReflectionMethod(DoubleFactory::class, 'whyFinalStillStands')) + ->invoke(null, FinalLedger::class, $phar); + + Assert::same( + $reason, + 'the class is final, and bypass was asked for it but could not reach it: ' + . "it was read out of a PHAR, and `phar://` is not `file://`.\n" + . $this->unreachable(), + ); + } + + public function anAnonymousClassHasNoNameToExtendBy(): void + { + $anonymous = new class {}; + + Expect::exception(UnsupportedTarget::class)->withMessage( + 'Cannot create an understudy for `' . $anonymous::class . '`: ' + . 'an anonymous class has no name to extend by. Give it one, or double the interface it implements.', + ); + + Understudy::for($anonymous::class); + } + + private function unreachable(string $short = 'FinalLedger'): string + { + return "- Bypass rewrites a class as `file://` hands it to PHP. A source that arrives another way\n" + . " — out of a PHAR, through an OPcache preload, from eval(), or past another wrapper that\n" + . " owns `file://` — is out of reach whatever bypass was asked for.\n" + . $this->recipe($short); + } + + private function recipe(string $short = 'FinalLedger'): string + { + return "- Preferred: if it implements an interface, double the interface.\n" + . "- If it is a value object, prefer a real instance.\n" + . "- If it is a concrete dependency you cannot change, enable bypass before the class is\n" + . ' first loaded: Understudy::bypassFinals(' . $short . "::class)\n" + . '- Introducing an interface remains the cleanest long-term fix.'; + } +} diff --git a/tests/Codegen/ParentKeywordTest.php b/tests/Codegen/ParentKeywordTest.php new file mode 100644 index 0000000..52e548d --- /dev/null +++ b/tests/Codegen/ParentKeywordTest.php @@ -0,0 +1,84 @@ +invoke(null, $rendered, $declaring === null ? null : new \ReflectionClass($declaring)), + $expected, + ); + } + + /** + * @return iterable + */ + public static function renderedTypeProvider(): iterable + { + $base = '\\' . ParentReturnBase::class; + + yield 'the bare keyword' => ['parent', ParentReturnChild::class, $base]; + yield 'the keyword in any case' => ['PARENT', ParentReturnChild::class, $base]; + yield 'a nullable keyword' => ['?parent', ParentReturnChild::class, '?' . $base]; + yield 'a keyword inside a union' => ['parent|null', ParentReturnChild::class, $base . '|null']; + yield 'a keyword inside an intersection' => ['parent&\\Countable', ParentReturnChild::class, $base . '&\\Countable']; + yield 'a qualified class named Parent' => ['\\App\\Parent', ParentReturnChild::class, '\\App\\Parent']; + yield 'a namespace named Parent' => ['Parent\\Foo', ParentReturnChild::class, 'Parent\\Foo']; + yield 'a longer word that starts with it' => ['Parented', ParentReturnChild::class, 'Parented']; + yield 'a type without the keyword' => ['int', ParentReturnChild::class, 'int']; + yield 'a class without a parent keeps the keyword' => ['parent', ParentReturnBase::class, 'parent']; + yield 'no declaring class keeps the keyword' => ['parent', null, 'parent']; + } + + public function theUnifierResolvesTheKeywordForAClassWithAParent(): void + { + $atom = new \ReflectionMethod(TargetUnifier::class, 'returnTypeAtom'); + $keyword = new class extends \ReflectionNamedType { + #[\Override] + public function getName(): string + { + return 'parent'; + } + + #[\Override] + public function isBuiltin(): bool + { + return false; + } + }; + + Assert::same( + $atom->invoke(null, $keyword, new \ReflectionClass(ParentReturnChild::class)), + '\\' . ParentReturnBase::class, + ); + Assert::same($atom->invoke(null, $keyword, new \ReflectionClass(ParentReturnBase::class)), 'parent'); + } +} diff --git a/tests/Codegen/TargetUnifierEdgeTest.php b/tests/Codegen/TargetUnifierEdgeTest.php index 994c698..d9b0e15 100644 --- a/tests/Codegen/TargetUnifierEdgeTest.php +++ b/tests/Codegen/TargetUnifierEdgeTest.php @@ -5,7 +5,11 @@ namespace Rasuvaeff\Understudy\Tests\Codegen; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\UnsupportedTarget; use Rasuvaeff\Understudy\Tests\Fixture\Unify\MixedNullDefault; use Rasuvaeff\Understudy\Tests\Fixture\Unify\NarrowThenWideReturn; @@ -42,7 +46,11 @@ * @internal */ #[Test] +#[Covers(DoubleFactory::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(TargetUnifier::class)] +#[Covers(TypeRenderer::class)] #[Covers(UnsupportedTarget::class)] final class TargetUnifierEdgeTest { diff --git a/tests/Codegen/TargetUnifierShapesTest.php b/tests/Codegen/TargetUnifierShapesTest.php new file mode 100644 index 0000000..f12d98c --- /dev/null +++ b/tests/Codegen/TargetUnifierShapesTest.php @@ -0,0 +1,82 @@ +getReturnType(); + + Assert::instanceOf($return, \ReflectionIntersectionType::class); + $names = array_map(static fn(\ReflectionNamedType $type): string => $type->getName(), $return->getTypes()); + sort($names); + + Assert::same($names, [ + $namespace . '\\Child', + $namespace . '\\Other', + $namespace . '\\Third', + ]); + } + + /** + * @param list $expected + */ + #[DataProvider('unionProvider')] + public function aRenderedUnionIsSplitKeepingGroupsIntact(string $rendered, array $expected): void + { + Assert::same((new \ReflectionMethod(TargetUnifier::class, 'splitUnion'))->invoke(null, $rendered), $expected); + } + + /** + * @return iterable}> + */ + public static function unionProvider(): iterable + { + yield 'a nullable shorthand' => ['?Foo', ['Foo', 'null']]; + yield 'a plain union' => ['int|string', ['int', 'string']]; + yield 'a DNF group with null' => ['(A&B)|null', ['(A&B)', 'null']]; + yield 'two DNF groups' => ['(A&B)|(C&D)', ['(A&B)', '(C&D)']]; + yield 'a single type' => ['Foo', ['Foo']]; + yield 'empty parts are dropped' => ['int||string', ['int', 'string']]; + } +} diff --git a/tests/Codegen/TargetUnifierTest.php b/tests/Codegen/TargetUnifierTest.php index f19d793..5b4f8a6 100644 --- a/tests/Codegen/TargetUnifierTest.php +++ b/tests/Codegen/TargetUnifierTest.php @@ -4,6 +4,8 @@ namespace Rasuvaeff\Understudy\Tests\Codegen; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Codegen\TargetUnifier; use Rasuvaeff\Understudy\Codegen\TypeRenderer; @@ -127,6 +129,9 @@ use Testo\Test; #[Test] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] #[Covers(TargetUnifier::class)] #[Covers(MethodSignature::class)] #[Covers(UnsupportedTarget::class)] diff --git a/tests/Codegen/UnreproducibleDefaultTest.php b/tests/Codegen/UnreproducibleDefaultTest.php new file mode 100644 index 0000000..df9f20c --- /dev/null +++ b/tests/Codegen/UnreproducibleDefaultTest.php @@ -0,0 +1,109 @@ +declare( + 'SelfNew', + 'class Foo { public function __construct(public int $n = 0) {} }' + . ' interface Contract { const LIMIT = 3; public function m(Foo $x = new Foo(self::LIMIT)): void; }', + ); + + Expect::exception(UnsupportedTarget::class)->withMessage( + 'Cannot create an understudy for `' . self::NAMESPACE . '\\SelfNew\\Contract`: ' + . 'the default of `$x` in `m()` is `new \\' . self::NAMESPACE . '\\SelfNew\\Foo(self::LIMIT)`, ' + . 'which resolves `self`, `static` or `parent` against the declaring class. ' + . 'The generated double is a different class, so the value cannot be reproduced. ' + . 'Give the parameter a default the double can build, or double an interface.', + ); + + Understudy::for(self::NAMESPACE . '\\SelfNew\\Contract'); + } + + public function aNewExpressionNamingParentIsRefused(): void + { + $this->declare( + 'ParentNew', + 'class Foo { public function __construct(public int $n = 0) {} }' + . ' class Base { const B = 1; }' + . ' class Contract extends Base { public function m(Foo $x = new Foo(parent::B)): void {} }', + ); + + Expect::exception(UnsupportedTarget::class)->withMessageContaining( + 'Cannot create an understudy for `' . self::NAMESPACE . '\\ParentNew\\Contract`: ' + . 'the default of `$x` in `m()` is `new \\' . self::NAMESPACE . '\\ParentNew\\Foo(parent::B)`, ' + . 'which resolves `self`, `static` or `parent` against the declaring class.', + ); + + Understudy::for(self::NAMESPACE . '\\ParentNew\\Contract'); + } + + public function aGlobalConstantHoldingAnObjectIsRefused(): void + { + $this->declare( + 'ObjectConstant', + 'const OBJ = new \\stdClass(); interface Contract { public function m(object $o = OBJ): void; }', + ); + + Expect::exception(UnsupportedTarget::class)->withMessage( + 'Cannot create an understudy for `' . self::NAMESPACE . '\\ObjectConstant\\Contract`: ' + . 'the default of `$o` in `m()` holds an object that cannot be written back as source. ' + . 'Give the parameter a scalar, array or enum default, or double an interface.', + ); + + Understudy::for(self::NAMESPACE . '\\ObjectConstant\\Contract'); + } + + public function anObjectBuriedInAnArrayConstantIsRefusedToo(): void + { + $this->declare( + 'ObjectInList', + 'const LST = [new \\stdClass()]; interface Contract { public function m(array $o = LST): void; }', + ); + + Expect::exception(UnsupportedTarget::class)->withMessageContaining('holds an object that cannot be written back as source'); + + Understudy::for(self::NAMESPACE . '\\ObjectInList\\Contract'); + } + + private const string NAMESPACE = 'Rasuvaeff\\Understudy\\Tests\\Codegen\\Evald'; + + private function declare(string $suffix, string $declarations): void + { + $namespace = self::NAMESPACE . '\\' . $suffix; + + if (!interface_exists($namespace . '\\Contract', autoload: false) && !class_exists($namespace . '\\Contract', autoload: false)) { + eval('namespace ' . $namespace . '; ' . $declarations); + } + } +} diff --git a/tests/ConflictingExpectationTest.php b/tests/ConflictingExpectationTest.php index 92b0a22..5f16934 100644 --- a/tests/ConflictingExpectationTest.php +++ b/tests/ConflictingExpectationTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\ConflictingExpectation; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Runtime\DoubleState; @@ -28,6 +33,11 @@ * stays exactly as documented. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(ConflictingExpectation::class)] #[Covers(DoubleState::class)] #[Covers(Expectation::class)] diff --git a/tests/DefaultFactoriesTest.php b/tests/DefaultFactoriesTest.php index d1a94d4..9b3c328 100644 --- a/tests/DefaultFactoriesTest.php +++ b/tests/DefaultFactoriesTest.php @@ -4,6 +4,11 @@ namespace Rasuvaeff\Understudy\Tests; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Defaults\DefaultFactories; use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; use Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory; @@ -13,11 +18,15 @@ use Rasuvaeff\Understudy\Exception\NoDefaultValue; use Rasuvaeff\Understudy\Tests\Fixture\Def\Audited; use Rasuvaeff\Understudy\Tests\Fixture\Def\AuditedLogger; +use Rasuvaeff\Understudy\Tests\Fixture\Def\BaseMarker; use Rasuvaeff\Understudy\Tests\Fixture\Def\Chained; +use Rasuvaeff\Understudy\Tests\Fixture\Def\Companion; use Rasuvaeff\Understudy\Tests\Fixture\Def\Concrete; +use Rasuvaeff\Understudy\Tests\Fixture\Def\Derived; use Rasuvaeff\Understudy\Tests\Fixture\Def\Logger; use Rasuvaeff\Understudy\Tests\Fixture\Def\NullLogger; use Rasuvaeff\Understudy\Tests\Fixture\Def\Sealed; +use Rasuvaeff\Understudy\Tests\Fixture\Def\Tagged; use Rasuvaeff\Understudy\Tests\Fixture\Def\Timestamped; use Rasuvaeff\Understudy\Tests\Fixture\Def\Workspace; use Rasuvaeff\Understudy\Understudy; @@ -28,6 +37,11 @@ use Testo\Test; #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(DefaultFactories::class)] #[Covers(\Rasuvaeff\Understudy\Runtime\Runtime::class)] #[Covers(ForgottenDouble::class)] @@ -69,6 +83,49 @@ public function theNearestRegisteredAncestorWins(): void Assert::instanceOf($answered, AuditedLogger::class); } + /** + * `Derived` reaches `Tagged` twice: through its parent, and through the + * `Marked` it lists itself. The parent is one step away and `Tagged` two, + * and the second route to it must not count as a tie with itself. + */ + public function aParentClassIsOneStepAndASharedAncestorIsCountedOnce(): void + { + $factories = new DefaultFactories(); + $factories->register(Tagged::class, static fn(): Derived => new Derived()); + + Assert::instanceOf($factories->valueFor(Derived::class)[0] ?? null, Derived::class); + + $nearer = new Derived(); + $factories->register(BaseMarker::class, static fn(): Derived => $nearer); + + Assert::same($factories->valueFor(Derived::class), [$nearer]); + } + + /** + * `Companion` sits behind `Marked` next to `Tagged`, which the parent route + * already reached. Meeting a name twice must not end the walk for the names + * listed after it. + */ + public function aNameMetTwiceDoesNotHideTheOnesListedAfterIt(): void + { + $factories = new DefaultFactories(); + $companion = new Derived(); + $factories->register(Companion::class, static fn(): Derived => $companion); + + Assert::same($factories->valueFor(Derived::class), [$companion]); + } + + public function aFactoryAnswerOwnedByAnotherContextThanTheAskerIsRefused(): void + { + $double = Understudy::for(Concrete::class); + $factories = new DefaultFactories(); + $factories->register(Concrete::class, static fn(): Concrete => $double); + + Expect::exception(ContextOwnershipViolation::class)->withMessageContaining('belongs to a different runtime context'); + + $factories->valueFor(Concrete::class, new \Rasuvaeff\Understudy\Runtime\RuntimeContext()); + } + public function registrationOrderDoesNotDecide(): void { Understudy::defaults(Audited::class, static fn(): AuditedLogger => new class extends AuditedLogger {}); diff --git a/tests/Defaults/IntersectionDefaultTest.php b/tests/Defaults/IntersectionDefaultTest.php index 620f9cc..7eb44a6 100644 --- a/tests/Defaults/IntersectionDefaultTest.php +++ b/tests/Defaults/IntersectionDefaultTest.php @@ -4,6 +4,11 @@ namespace Rasuvaeff\Understudy\Tests\Defaults; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; use Rasuvaeff\Understudy\Exception\NoDefaultValue; use Rasuvaeff\Understudy\Runtime\Runtime; @@ -31,6 +36,11 @@ * @internal */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(TypeDefaultResolver::class)] #[Covers(Runtime::class)] #[Covers(NoDefaultValue::class)] diff --git a/tests/Defaults/TypeDefaultResolverTest.php b/tests/Defaults/TypeDefaultResolverTest.php index 752ae44..92757a9 100644 --- a/tests/Defaults/TypeDefaultResolverTest.php +++ b/tests/Defaults/TypeDefaultResolverTest.php @@ -8,7 +8,11 @@ use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; use Rasuvaeff\Understudy\Exception\NoDefaultValue; use Rasuvaeff\Understudy\Runtime\RuntimeContext; +use Rasuvaeff\Understudy\Tests\Fixture\Cls\SealedLedger; +use Rasuvaeff\Understudy\Tests\Fixture\Def\Audited; +use Rasuvaeff\Understudy\Tests\Fixture\Def\Timestamped; use Rasuvaeff\Understudy\Tests\Fixture\FluentBuilder; +use Rasuvaeff\Understudy\Tests\Fixture\Wire\Repository; use Rasuvaeff\Understudy\Understudy; use Testo\Assert; use Testo\Codecov\Covers; @@ -222,6 +226,106 @@ private function resolve(string $returnType): mixed return TypeDefaultResolver::forSignature('Contract', $this->signature($returnType), 'method', new RuntimeContext()); } + public function aNullableShorthandStillAnswersAFactoryRegisteredForTheType(): void + { + $context = new RuntimeContext(); + $registered = new \ArrayObject(); + $context->defaultFactories()->register(\ArrayObject::class, static fn(): \ArrayObject => $registered); + + Assert::same( + TypeDefaultResolver::forSignature('Contract', $this->signature('?ArrayObject'), 'method', $context), + $registered, + ); + } + + public function aBranchThatCannotBeDoubledIsSkippedNotFatal(): void + { + // `SealedLedger` has a final method: refused by the factory with + // `UnsupportedTarget`, which the union treats like any other branch with + // no safe default. + Assert::same( + TypeDefaultResolver::forSignature('Contract', $this->signature('\\' . SealedLedger::class . '|string'), 'method', new RuntimeContext()), + '', + ); + } + + public function aUnionOfBranchesWithNoSafeDefaultNamesTheWholeType(): void + { + Expect::exception(NoDefaultValue::class)->withMessageContaining('\\DateTimeImmutable|\\DateTimeZone'); + + TypeDefaultResolver::forSignature('Contract', $this->signature('\\DateTimeImmutable|\\DateTimeZone'), 'method', new RuntimeContext()); + } + + #[DataProvider('intersectionProvider')] + public function anIntersectionIsADoubleOfEveryContractItNames(string $returnType): void + { + $value = TypeDefaultResolver::forSignature('Contract', $this->signature($returnType), 'method', new RuntimeContext()); + + Assert::instanceOf($value, Repository::class); + Assert::instanceOf($value, \Countable::class); + } + + /** + * @return iterable + */ + public static function intersectionProvider(): iterable + { + $repository = Repository::class; + + yield 'plain' => [$repository . '&Countable']; + yield 'qualified with a leading backslash' => ['\\' . $repository . '&\\Countable']; + yield 'padded with spaces' => [' ' . $repository . ' & Countable ']; + yield 'with an empty operand' => [$repository . '&Countable&']; + yield 'as the only branch of a DNF union' => ['(' . $repository . '&Countable)|(' . Audited::class . '&' . Timestamped::class . ')']; + } + + public function aDnfUnionTriesEachIntersectionUntilOneCanBeDoubled(): void + { + $value = TypeDefaultResolver::forSignature( + 'Contract', + $this->signature('(\\DateTimeImmutable&\\Countable)|(' . Repository::class . '&\\Countable)'), + 'method', + new RuntimeContext(), + ); + + Assert::instanceOf($value, Repository::class); + Assert::instanceOf($value, \Countable::class); + } + + public function aDnfUnionWhoseIntersectionsCannotBeDoubledNamesTheType(): void + { + Expect::exception(NoDefaultValue::class)->withMessageContaining('(\\DateTimeImmutable&\\Countable)|(\\DateTimeZone&\\Countable)'); + + TypeDefaultResolver::forSignature( + 'Contract', + $this->signature('(\\DateTimeImmutable&\\Countable)|(\\DateTimeZone&\\Countable)'), + 'method', + new RuntimeContext(), + ); + } + + #[DataProvider('untypedPropertyProvider')] + public function anUntypedPropertyAnswersNull(?string $type): void + { + Assert::null(TypeDefaultResolver::forPropertyType('Contract', $type, 'name', new RuntimeContext())); + } + + /** + * @return iterable + */ + public static function untypedPropertyProvider(): iterable + { + yield 'no type at all' => [null]; + yield 'an empty type' => ['']; + } + + public function anIntersectionOfNothingNamesTheType(): void + { + Expect::exception(NoDefaultValue::class)->withMessageContaining('`&`'); + + TypeDefaultResolver::forSignature('Contract', $this->signature('&'), 'method', new RuntimeContext()); + } + private function signature(string $returnType): MethodSignature { return new MethodSignature( diff --git a/tests/DispatchIndexPropertyTest.php b/tests/DispatchIndexPropertyTest.php index d61debd..b545f3a 100644 --- a/tests/DispatchIndexPropertyTest.php +++ b/tests/DispatchIndexPropertyTest.php @@ -9,6 +9,11 @@ use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\Property; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Runtime\DoubleState; use Rasuvaeff\Understudy\Tests\Fixture\Ledger; use Rasuvaeff\Understudy\Tests\Fixture\Tally; @@ -34,6 +39,11 @@ * @internal */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(DoubleState::class)] final class DispatchIndexPropertyTest { diff --git a/tests/DispatchTierTest.php b/tests/DispatchTierTest.php index b626549..315cb0d 100644 --- a/tests/DispatchTierTest.php +++ b/tests/DispatchTierTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Runtime\DoubleState; use Rasuvaeff\Understudy\Tests\Fixture\Book; @@ -26,6 +31,11 @@ * describes. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(DoubleState::class)] #[Covers(Expectation::class)] final class DispatchTierTest diff --git a/tests/EngineLifecyclePropertyTest.php b/tests/EngineLifecyclePropertyTest.php index ae69b60..46458ee 100644 --- a/tests/EngineLifecyclePropertyTest.php +++ b/tests/EngineLifecyclePropertyTest.php @@ -12,6 +12,11 @@ use Rasuvaeff\PropertyTesting\StateMachine\StateMachine; use Rasuvaeff\Understudy\Arg; use Rasuvaeff\Understudy\Cardinality; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Invocation; @@ -49,6 +54,11 @@ * loop. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Understudy::class)] #[Covers(Runtime::class)] #[Covers(DoubleState::class)] diff --git a/tests/EnginePropertyTest.php b/tests/EnginePropertyTest.php index 6b1a657..1102c4f 100644 --- a/tests/EnginePropertyTest.php +++ b/tests/EnginePropertyTest.php @@ -10,6 +10,11 @@ use Rasuvaeff\PropertyTesting\Property; use Rasuvaeff\Understudy\Arg; use Rasuvaeff\Understudy\Cardinality; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\StrictModeViolation; use Rasuvaeff\Understudy\Exception\VerificationFailed; use Rasuvaeff\Understudy\Expectation\Expectation; @@ -41,6 +46,11 @@ * them. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Understudy::class)] #[Covers(Cardinality::class)] #[Covers(Runtime::class)] diff --git a/tests/ErrorPathTest.php b/tests/ErrorPathTest.php index 9bb230c..66367db 100644 --- a/tests/ErrorPathTest.php +++ b/tests/ErrorPathTest.php @@ -6,19 +6,47 @@ use Rasuvaeff\Understudy\Arg; use Rasuvaeff\Understudy\Cardinality; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; +use Rasuvaeff\Understudy\Defaults\DefaultFactories; +use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; +use Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory; use Rasuvaeff\Understudy\Exception\CannotWire; +use Rasuvaeff\Understudy\Exception\ConflictingExpectation; +use Rasuvaeff\Understudy\Exception\ContextOwnershipViolation; +use Rasuvaeff\Understudy\Exception\ForgottenDouble; +use Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; +use Rasuvaeff\Understudy\Exception\InvalidDefaultValue; +use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Exception\NoDefaultValue; +use Rasuvaeff\Understudy\Exception\OriginalCallUnavailable; +use Rasuvaeff\Understudy\Exception\OriginalReturnTypeViolation; use Rasuvaeff\Understudy\Exception\OutcomeUnavailable; use Rasuvaeff\Understudy\Exception\UnderstudyError; use Rasuvaeff\Understudy\Exception\VerificationFailed; +use Rasuvaeff\Understudy\Expectation\ArgumentFormatter; +use Rasuvaeff\Understudy\Expectation\Expectation; +use Rasuvaeff\Understudy\ExpectBuilder; use Rasuvaeff\Understudy\Invocation; +use Rasuvaeff\Understudy\Matcher\AnyRest; +use Rasuvaeff\Understudy\Matcher\AnyTail; +use Rasuvaeff\Understudy\Matcher\EmptyTail; +use Rasuvaeff\Understudy\Matcher\InstanceOfType; +use Rasuvaeff\Understudy\Matcher\Unspelled; +use Rasuvaeff\Understudy\Outcome; +use Rasuvaeff\Understudy\Runtime\ArmedSequence; +use Rasuvaeff\Understudy\Runtime\InvocationSignal; +use Rasuvaeff\Understudy\Runtime\Runtime; use Rasuvaeff\Understudy\Tests\Fixture\Book; use Rasuvaeff\Understudy\Tests\Fixture\BookRepository; use Rasuvaeff\Understudy\Tests\Fixture\Credentials; use Rasuvaeff\Understudy\Tests\Fixture\Defaults\NullableShapes; use Rasuvaeff\Understudy\Tests\Fixture\Librarian; use Rasuvaeff\Understudy\Understudy; +use Rasuvaeff\Understudy\WhenBuilder; use Rasuvaeff\Understudy\Wiring\Wire; use Testo\Assert; use Testo\Assert\ExpectNoAssertions; @@ -48,6 +76,36 @@ #[Covers(Invocation::class)] #[Covers(OutcomeUnavailable::class)] #[Covers(InvalidCallSpecification::class)] +#[Covers(Arg::class)] +#[Covers(Cardinality::class)] +#[Covers(Blueprint::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TargetUnifier::class)] +#[Covers(TypeRenderer::class)] +#[Covers(DefaultFactories::class)] +#[Covers(TypeDefaultResolver::class)] +#[Covers(AmbiguousDefaultFactory::class)] +#[Covers(ConflictingExpectation::class)] +#[Covers(ContextOwnershipViolation::class)] +#[Covers(ForgottenDouble::class)] +#[Covers(ForwardingTargetMismatch::class)] +#[Covers(InvalidDefaultValue::class)] +#[Covers(InvalidSpecificationArgument::class)] +#[Covers(OriginalCallUnavailable::class)] +#[Covers(OriginalReturnTypeViolation::class)] +#[Covers(ArgumentFormatter::class)] +#[Covers(Expectation::class)] +#[Covers(ExpectBuilder::class)] +#[Covers(AnyRest::class)] +#[Covers(AnyTail::class)] +#[Covers(EmptyTail::class)] +#[Covers(InstanceOfType::class)] +#[Covers(Unspelled::class)] +#[Covers(Outcome::class)] +#[Covers(ArmedSequence::class)] +#[Covers(InvocationSignal::class)] +#[Covers(Runtime::class)] +#[Covers(WhenBuilder::class)] final class ErrorPathTest { #[AfterTest] diff --git a/tests/Exception/ExceptionMessageTest.php b/tests/Exception/ExceptionMessageTest.php new file mode 100644 index 0000000..ce0778b --- /dev/null +++ b/tests/Exception/ExceptionMessageTest.php @@ -0,0 +1,527 @@ +getMessage(), $expected); + } + + /** + * @return iterable + */ + public static function messageProvider(): iterable + { + yield 'InvalidCallSpecification::noCallRecorded' => [ + InvalidCallSpecification::noCallRecorded(...), + <<<'TXT' + The specification closure did not call a method on an understudy. It must contain exactly one direct call, for example: when(fn () => $repository->find(123)) + TXT, + ]; + + yield 'InvalidCallSpecification::moreThanOneCall' => [ + static fn(): \Throwable => InvalidCallSpecification::moreThanOneCall('first', 'one', 'last', 'two'), + <<<'TXT' + The specification closure calls `one()` on understudy `first` and then `two()` on understudy `last`. A closure must contain exactly one direct call on a double; with two, one of them would be specified silently and the other not at all. Stub each call in a when() of its own, and where one call feeds the arguments of another pass a literal or a matcher instead, for example: when(fn () => $repository->find(Arg::any())) + TXT, + ]; + + yield 'InvalidCallSpecification::returnsOnVoid' => [ + static fn(): \Throwable => InvalidCallSpecification::returnsOnVoid('mailer', 'send'), + <<<'TXT' + Understudy `mailer`: `send()` is declared `: void`, so returns() has nothing to answer with — the value would never be observed. Drop the value (returns(null) is allowed), or use answers()/throws() if the call should do something. + TXT, + ]; + + yield 'InvalidCallSpecification::returnsOnNever' => [ + static fn(): \Throwable => InvalidCallSpecification::returnsOnNever('mailer', 'halt'), + <<<'TXT' + Understudy `mailer`: `halt()` is declared `: never` and cannot return. Configure what it throws: when(fn () => $double->halt(...))->throws(new SomeException()) + TXT, + ]; + + yield 'InvalidCallSpecification::notADouble' => [ + static fn(): \Throwable => InvalidCallSpecification::notADouble('verify'), + <<<'TXT' + Understudy::verify() expects an understudy created by Understudy::for(). This object is not one. + TXT, + ]; + + yield 'InvalidCallSpecification::neverBesideACount' => [ + static fn(): \Throwable => InvalidCallSpecification::neverBesideACount('times'), + <<<'TXT' + `never: true` says the call never happened, and `times` says how often it did. Keep the one you mean. + TXT, + ]; + + yield 'InvalidCallSpecification::exactCountBesideABound' => [ + InvalidCallSpecification::exactCountBesideABound(...), + <<<'TXT' + `times` is an exact count, so a `minimum` or `maximum` beside it has nothing left to constrain. Use one or the other. + TXT, + ]; + + yield 'InvalidCallSpecification::misplacedTailMatcher' => [ + static fn(): \Throwable => InvalidCallSpecification::misplacedTailMatcher('save', 1, 'Arg::rest()'), + <<<'TXT' + `Arg::rest()` stands for the whole variadic tail, so it may only be the last argument, but it was given as argument #2 of `save()`. + Move it to the end, or use Arg::any() to match that one argument. + TXT, + ]; + + yield 'InvalidCallSpecification::emptyCombinator allOf' => [ + static fn(): \Throwable => InvalidCallSpecification::emptyCombinator('allOf'), + <<<'TXT' + `Arg::allOf()` was given no operands, so it would match every argument. + Pass what the argument has to satisfy, or use Arg::any() to accept anything. + TXT, + ]; + + yield 'InvalidCallSpecification::emptyCombinator anyOf' => [ + static fn(): \Throwable => InvalidCallSpecification::emptyCombinator('anyOf'), + <<<'TXT' + `Arg::anyOf()` was given no operands, so it would match no argument at all. + Pass what the argument has to satisfy, or use Arg::any() to accept anything. + TXT, + ]; + + yield 'InvalidCallSpecification::guardAlreadyNarrowed without description' => [ + static fn(): \Throwable => InvalidCallSpecification::guardAlreadyNarrowed('find', null), + <<<'TXT' + `find` already carries a `where()` guard. One call, one guard: put both conditions in the one closure. + TXT, + ]; + + yield 'InvalidCallSpecification::guardAlreadyNarrowed with description' => [ + static fn(): \Throwable => InvalidCallSpecification::guardAlreadyNarrowed('find', 'id > 0'), + <<<'TXT' + `find` already carries a `where()` guard (`id > 0`). One call, one guard: put both conditions in the one closure. + TXT, + ]; + + yield 'InvalidCallSpecification::tailMatcherInCombinator' => [ + static fn(): \Throwable => InvalidCallSpecification::tailMatcherInCombinator('allOf', 'Arg::rest()'), + <<<'TXT' + `Arg::rest()` stands for the whole variadic tail, and `Arg::allOf()` combines matchers for one argument, so it cannot hold one. + Put the tail matcher last on its own, and combine the arguments before it. + TXT, + ]; + + yield 'InvalidCallSpecification::matcherInsideArray' => [ + static fn(): \Throwable => InvalidCallSpecification::matcherInsideArray('save', 1, 'Arg::any()'), + <<<'TXT' + `Arg::any()` sits inside the array given as argument #2 of `save()`, where it is compared by identity and can never match. + Describe the array with Arg::containing([...]), which reads matchers in its entries, or the whole argument with Arg::satisfies(). + TXT, + ]; + + yield 'InvalidCallSpecification::captorInCombinator' => [ + static fn(): \Throwable => InvalidCallSpecification::captorInCombinator('anyOf'), + <<<'TXT' + A captor records the argument it stands for, and `Arg::anyOf()` builds one matcher out of others, so a captor inside it would match without ever recording. + Put the captor in the argument position itself, or read the calls with Understudy::calls(). + TXT, + ]; + + yield 'InvalidCallSpecification::emptySequence' => [ + InvalidCallSpecification::emptySequence(...), + <<<'TXT' + Understudy::expectSequence() needs at least one call: an empty protocol claims nothing and would refuse every call on no double at all + TXT, + ]; + + yield 'InvalidCallSpecification::protocolAlreadyArmed' => [ + static fn(): \Throwable => InvalidCallSpecification::protocolAlreadyArmed(2, 5), + <<<'TXT' + A protocol is already armed and is waiting on step 2 of 5. Two of them naming the same understudy would each judge every call on it and could disagree about the same call — finish this one, or describe both phases as a single protocol + TXT, + ]; + + yield 'InvalidCallSpecification::incompleteSpecification' => [ + static fn(): \Throwable => InvalidCallSpecification::incompleteSpecification('save', 1, 3), + <<<'TXT' + The specification for `save()` passed 1 of its 3 arguments, and the ones it left out are not all optional. + Spell every required argument, or say the rest does not matter by ending with Arg::rest(). + TXT, + ]; + + yield 'InvalidCallSpecification::omittedBeforeSpecified' => [ + static fn(): \Throwable => InvalidCallSpecification::omittedBeforeSpecified('save', 0, 2), + <<<'TXT' + The specification for `save()` omitted argument #1 — which the contract declares required — but specified argument #3 after it. + A specification spells its required arguments in order — use Arg::any() for one that does not matter. + TXT, + ]; + + yield 'InvalidCallSpecification::omittedTailNeedsRest' => [ + static fn(): \Throwable => InvalidCallSpecification::omittedTailNeedsRest('save', 'Arg::rest()'), + <<<'TXT' + `Arg::rest()` describes a variadic tail, not parameters left unspelled, and the specification for `save()` stopped before its parameters ran out. + End with Arg::rest() to say the remaining parameters do not matter. + TXT, + ]; + + yield 'InvalidCallSpecification::closureFailed' => [ + static fn(): \Throwable => InvalidCallSpecification::closureFailed(new \RuntimeException('boom')), + <<<'TXT' + The specification closure threw before it reached an understudy: boom + TXT, + ]; + + yield 'InvalidCallSpecification::staticMethodCalled' => [ + static fn(): \Throwable => InvalidCallSpecification::staticMethodCalled('create'), + <<<'TXT' + Static method `create()` cannot be called on an understudy because static calls have no instance state. + Inject an instance dependency and double that contract instead. + TXT, + ]; + + yield 'ForgottenDouble::afterReset' => [ + static fn(): \Throwable => ForgottenDouble::afterReset('find'), + <<<'TXT' + This understudy is no longer known to Understudy, but `find()` was called on it. + It was created before a reset(); create doubles inside the test that uses them rather than sharing one across tests. + TXT, + ]; + + yield 'ForgottenDouble::afterScope' => [ + static fn(): \Throwable => ForgottenDouble::afterScope('find'), + <<<'TXT' + This understudy is no longer known to Understudy, but `find()` was called on it. + It was created inside a scope() that has since closed, and a scope drops its doubles when it ends; build the double in the scope that will use it, or outside the scope altogether. + TXT, + ]; + + yield 'ForgottenDouble::onPurpose' => [ + static fn(): \Throwable => ForgottenDouble::onPurpose('find'), + <<<'TXT' + This understudy was retired with Understudy::forget(), but `find()` was called on it. + A forgotten double is gone for good; build a new one instead of calling this one. + TXT, + ]; + + yield 'ForgottenDouble::propertyAfterReset' => [ + static fn(): \Throwable => ForgottenDouble::propertyAfterReset('title'), + <<<'TXT' + This understudy is no longer known to Understudy, but its property `$title` was touched. + It was created before a reset(); create doubles inside the test that uses them rather than sharing one across tests. + TXT, + ]; + + yield 'ForgottenDouble::retired' => [ + ForgottenDouble::retired(...), + <<<'TXT' + This understudy was retired with Understudy::forget() and can no longer be asked anything — not calls, not verification. A replacement built afterwards is a different object; ask that one. + TXT, + ]; + + yield 'ForgottenDouble::fromDefaultFactory' => [ + static fn(): \Throwable => ForgottenDouble::fromDefaultFactory(Book::class), + <<<'TXT' + The default factory for `Rasuvaeff\Understudy\Tests\Fixture\Book` answered with an understudy that is no longer known to Understudy. + It was created before a reset(); register the factory inside the test that uses it rather than sharing one double across tests. + TXT, + ]; + + yield 'BypassUnavailable::alreadyLoaded' => [ + static fn(): \Throwable => BypassUnavailable::alreadyLoaded(Book::class), + <<<'TXT' + `Rasuvaeff\Understudy\Tests\Fixture\Book` is already loaded, so its `final` cannot be lifted any more. + A class is read from disk once per process, and the transform happens as it is read. + - Call Understudy::bypassFinals() from your test bootstrap, before Composer autoloads it. + - Or double an interface it implements, which needs no bypass at all. + TXT, + ]; + + yield 'BypassUnavailable::notAClass' => [ + static fn(): \Throwable => BypassUnavailable::notAClass(Book::class, 'an enum'), + <<<'TXT' + `Rasuvaeff\Understudy\Tests\Fixture\Book` is an enum, and lifting `final` would not make it extendable. + Bypass exists for a final *class* standing between a test and the code under test. + TXT, + ]; + + yield 'BypassUnavailable::foreignWrapper' => [ + static fn(): \Throwable => BypassUnavailable::foreignWrapper('somebody'), + <<<'TXT' + Something else is already serving `file://` through a user-space wrapper (somebody). + Replacing that wrapper would silently disable whatever it does, so understudy will not. + - Call Understudy::bypassFinals() before the other wrapper is installed. + - Or double an interface, which needs no wrapper at all. + TXT, + ]; + + yield 'ConflictingExpectation::claimAfterStub' => [ + static fn(): \Throwable => ConflictingExpectation::claimAfterStub('repo', 'find(1)'), + <<<'TXT' + Understudy `repo` already has `find(1)` stubbed, and a separate expect() for the same call would not compose with it: the expectation would take the dispatch and answer the mode default, silently discarding the stub. Declare one expectation — expect(...)->returns(...), or count the stub with when(...)->times(...). + TXT, + ]; + + yield 'ConflictingExpectation::stubAfterCountedExpectation' => [ + static fn(): \Throwable => ConflictingExpectation::stubAfterCountedExpectation('repo', 'find(1)'), + <<<'TXT' + Understudy `repo` already counts `find(1)`, and a later stub for the same call would not compose with it: the stub would take the dispatch and starve the count, which verifyAll() then reports as "called never" about a call that did happen. Give the expectation its behaviour instead: expect(...)->returns(...). + TXT, + ]; + + yield 'ConflictingExpectation::duplicateCountedExpectation' => [ + static fn(): \Throwable => ConflictingExpectation::duplicateCountedExpectation('repo', 'find(1)'), + <<<'TXT' + Understudy `repo` already counts `find(1)`, and a second expectation for the same call would not compose with it: the newer one would take every match and the older count is starved. Declare the count once — expect(...)->times(...). + TXT, + ]; + + yield 'CannotWire::notAConcreteClass' => [ + static fn(): \Throwable => CannotWire::notAConcreteClass(Book::class, 'it is an interface.'), + <<<'TXT' + Cannot wire `Rasuvaeff\Understudy\Tests\Fixture\Book`: it is an interface. + wire() builds a real subject out of doubled dependencies; the subject itself is not a double. + TXT, + ]; + + yield 'CannotWire::inaccessibleConstructor' => [ + static fn(): \Throwable => CannotWire::inaccessibleConstructor(Book::class, 'private'), + <<<'TXT' + Cannot wire `Rasuvaeff\Understudy\Tests\Fixture\Book`: its constructor is private. + Use the named constructor the class offers, or build the subject yourself and double its dependencies with Understudy::for(). + TXT, + ]; + + yield 'CannotWire::unknownOverride' => [ + static fn(): \Throwable => CannotWire::unknownOverride(Book::class, 'nope', '$title, $author'), + <<<'TXT' + Cannot wire `Rasuvaeff\Understudy\Tests\Fixture\Book`: there is no constructor parameter named `nope`. + The constructor takes: $title, $author. + TXT, + ]; + + yield 'CannotWire::incompatibleOverride' => [ + static fn(): \Throwable => CannotWire::incompatibleOverride(Book::class, 'title', 'string', 'int'), + <<<'TXT' + Cannot wire `Rasuvaeff\Understudy\Tests\Fixture\Book`: the override for `$title` has type `int`, and the constructor declares `string`. + The check happens before the constructor runs, so a wrong type is reported here rather than as a TypeError from inside the subject. + TXT, + ]; + + yield 'CannotWire::undecidableParameter' => [ + static fn(): \Throwable => CannotWire::undecidableParameter(Book::class, 'title', 'string', 'a scalar has no double.'), + <<<'TXT' + Cannot wire `Rasuvaeff\Understudy\Tests\Fixture\Book`: nothing can be decided for `$title` (`string`) — a scalar has no double. + Pass it yourself: Understudy::wire(Book::class, ['title' => $value]). + TXT, + ]; + + yield 'CannotWire::referenceParameter' => [ + static fn(): \Throwable => CannotWire::referenceParameter(Book::class, 'title'), + <<<'TXT' + Cannot wire `Rasuvaeff\Understudy\Tests\Fixture\Book`: `$title` is taken by reference. + Overrides are values, and passing one would quietly promise a reference semantics wire() does not have. Build the subject yourself. + TXT, + ]; + + yield 'OriginalReturnTypeViolation::foreignInstance' => [ + static fn(): \Throwable => OriginalReturnTypeViolation::foreignInstance('ledger', 'copy', Book::class), + <<<'TXT' + Understudy `ledger` forwarded `copy()`, which returned a different `Rasuvaeff\Understudy\Tests\Fixture\Book` instead of the instance it was called on. + The method declares `static`, so the double must return itself, and another object of the real class is not a double. Configure the method instead: when(fn () => $double->copy(...))->returns($double). + TXT, + ]; + + yield 'OriginalReturnTypeViolation::foreignSelf' => [ + static fn(): \Throwable => OriginalReturnTypeViolation::foreignSelf('ledger', 'copy'), + <<<'TXT' + Understudy `ledger` ran `copy()` inline, and the real body built a new instance of itself — a `new static` or `new self`. + 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->copy(...))->returns($double). + TXT, + ]; + + yield 'OriginalCallUnavailable::forMode' => [ + static fn(): \Throwable => OriginalCallUnavailable::forMode('ledger'), + <<<'TXT' + Understudy `ledger` was asked to forward, and has no real instance to forward to. + Pass one: Understudy::forwarding($double, $real). The shorthand Understudy::forwarding($double) only turns the mode on for a double built from an instance with Understudy::for($real). + TXT, + ]; + + yield 'OriginalCallUnavailable::withoutTarget' => [ + static fn(): \Throwable => OriginalCallUnavailable::withoutTarget('ledger', 'copy'), + <<<'TXT' + Understudy `ledger` has no real instance to delegate `copy()` to. + Give it one: Understudy::forwarding($double, $real) — calling the parent implementation on a double whose constructor never ran is not a safe substitute. + TXT, + ]; + + yield 'ForwardingTargetMismatch::understudyTarget' => [ + static fn(): \Throwable => ForwardingTargetMismatch::understudyTarget('ledger'), + <<<'TXT' + Understudy `ledger` cannot forward to an understudy. + Every call would come straight back to a dispatcher, and an unmatched one would keep coming back until the stack runs out. Forward to the real object instead. + TXT, + ]; + + yield 'ForwardingTargetMismatch::missingContract' => [ + static fn(): \Throwable => ForwardingTargetMismatch::missingContract('ledger', Book::class, Draft::class), + <<<'TXT' + Understudy `ledger` stands in for `Rasuvaeff\Understudy\Tests\Fixture\Book`, and `Rasuvaeff\Understudy\Tests\Fixture\Draft` is not one. + A forwarding target has to satisfy every contract of the double, or a call the double accepts would not exist on the instance it delegates to. + TXT, + ]; + + yield 'ForwardingTargetMismatch::alreadyInline' => [ + static fn(): \Throwable => ForwardingTargetMismatch::alreadyInline('ledger', Book::class, Draft::class), + <<<'TXT' + Understudy `ledger` already runs the bodies of `Rasuvaeff\Understudy\Tests\Fixture\Book` on itself, and cannot be re-pointed at `Rasuvaeff\Understudy\Tests\Fixture\Draft`. + 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. + TXT, + ]; + + yield 'AmbiguousDefaultFactory::between' => [ + static fn(): \Throwable => AmbiguousDefaultFactory::between(Book::class, [Draft::class, Order::class]), + <<<'TXT' + More than one default factory is equally close to `Rasuvaeff\Understudy\Tests\Fixture\Book`: `Rasuvaeff\Understudy\Tests\Fixture\Draft`, `Rasuvaeff\Understudy\Tests\Fixture\Order`. + Register one for `Rasuvaeff\Understudy\Tests\Fixture\Book` itself — resolution by distance is what keeps the answer independent of the order the factories were registered in, and a tie has no order to fall back on. + TXT, + ]; + + yield 'InvalidDefaultValue::ofWrongType' => [ + static fn(): \Throwable => InvalidDefaultValue::ofWrongType(Book::class, 'int'), + <<<'TXT' + The default factory registered for `Rasuvaeff\Understudy\Tests\Fixture\Book` produced a value of type `int`. + A factory has to return something the requested type can hold, or the double answers with a value the code under test cannot use. + TXT, + ]; + + yield 'ContextOwnershipViolation::forDouble' => [ + ContextOwnershipViolation::forDouble(...), + <<<'TXT' + This understudy belongs to a different runtime context. Configure and verify it in the scope or Fiber that created it; only normal method calls may cross context boundaries. + TXT, + ]; + + yield 'InvalidSpecificationArgument::unknownArgument positional' => [ + static fn(): \Throwable => InvalidSpecificationArgument::unknownArgument('save', 2, ['title', 'author']), + <<<'TXT' + `save()` has no argument #3. It takes: $title, $author + TXT, + ]; + + yield 'InvalidSpecificationArgument::unknownArgument named' => [ + static fn(): \Throwable => InvalidSpecificationArgument::unknownArgument('save', 'isbn', ['title']), + <<<'TXT' + `save()` has no argument named `$isbn`. It takes: $title + TXT, + ]; + + yield 'InvalidSpecificationArgument::unknownArgument none' => [ + static fn(): \Throwable => InvalidSpecificationArgument::unknownArgument('save', 0, []), + <<<'TXT' + `save()` has no argument #1. It takes: none + TXT, + ]; + + yield 'InvalidSpecificationArgument::maximumBelowMinimum' => [ + static fn(): \Throwable => InvalidSpecificationArgument::maximumBelowMinimum(3, 1), + <<<'TXT' + A maximum call count cannot be below the minimum, got minimum 3 and maximum 1 + TXT, + ]; + + yield 'InvalidSpecificationArgument::negativeCount' => [ + static fn(): \Throwable => InvalidSpecificationArgument::negativeCount(-2), + <<<'TXT' + A call count cannot be negative, got -2 + TXT, + ]; + + yield 'InvalidSpecificationArgument::noReturnValues' => [ + InvalidSpecificationArgument::noReturnValues(...), + <<<'TXT' + returns() needs at least one value + TXT, + ]; + + yield 'InvalidSpecificationArgument::returnValueOfWrongType' => [ + static fn(): \Throwable => InvalidSpecificationArgument::returnValueOfWrongType('repo', 'find', 'int', 'string'), + <<<'TXT' + Understudy `repo`: returns() was given string, but `find()` is declared `: int` and cannot answer with it. + TXT, + ]; + + yield 'InvalidSpecificationArgument::unknownType' => [ + static fn(): \Throwable => InvalidSpecificationArgument::unknownType('Nope\\Missing'), + <<<'TXT' + Arg::instanceOf(`Nope\Missing`) can never match: no such class or interface is loadable. + TXT, + ]; + + yield 'InvalidSpecificationArgument::invertedBounds' => [ + static fn(): \Throwable => InvalidSpecificationArgument::invertedBounds('between', 5, 1.5), + <<<'TXT' + `Arg::between()` was given a minimum of 5 and a maximum of 1.5, so it describes an empty range and would match no argument at all. + Order the bounds the other way, or leave one of them out to keep that side open. + TXT, + ]; + + yield 'InvalidSpecificationArgument::invalidPattern without reason' => [ + static fn(): \Throwable => InvalidSpecificationArgument::invalidPattern('/(/', null), + <<<'TXT' + `Arg::string()` was given `/(/`, which is not a valid PCRE pattern. + It would match no string and would raise a warning inside the code under test on every call. Check the delimiters and the escaping. + TXT, + ]; + + yield 'InvalidSpecificationArgument::invalidPattern with reason' => [ + static fn(): \Throwable => InvalidSpecificationArgument::invalidPattern('/(/', 'missing closing parenthesis'), + <<<'TXT' + `Arg::string()` was given `/(/`, which is not a valid PCRE pattern: missing closing parenthesis. + It would match no string and would raise a warning inside the code under test on every call. Check the delimiters and the escaping. + TXT, + ]; + } +} diff --git a/tests/Expectation/ArgumentFormatterTest.php b/tests/Expectation/ArgumentFormatterTest.php index 7c5f311..deec461 100644 --- a/tests/Expectation/ArgumentFormatterTest.php +++ b/tests/Expectation/ArgumentFormatterTest.php @@ -278,6 +278,24 @@ public function aStringThatIsNotUtf8FallsBackToBytes(): void ); } + public function aBinaryStringOfExactlyTheLimitIsNotCut(): void + { + Assert::same( + ArgumentFormatter::format(str_repeat("\xFF", 40)), + "'" . str_repeat('\\xFF', 40) . "'", + ); + } + + public function aBinaryStringCutKeepsItsOwnBeginning(): void + { + $value = implode('', array_map(static fn(int $byte): string => \chr(0x80 + $byte), range(0, 49))); + + Assert::same( + ArgumentFormatter::format($value), + "'" . implode('', array_map(static fn(int $byte): string => sprintf('\\x%02X', 0x80 + $byte), range(0, 39))) . "…'", + ); + } + public function aShortStringThatIsNotUtf8IsEscapedByteByByte(): void { Assert::same(ArgumentFormatter::format("\xFF\xFE"), "'\\xFF\\xFE'"); diff --git a/tests/Expectation/ExpectationDescribeAgainstTest.php b/tests/Expectation/ExpectationDescribeAgainstTest.php new file mode 100644 index 0000000..f438183 --- /dev/null +++ b/tests/Expectation/ExpectationDescribeAgainstTest.php @@ -0,0 +1,58 @@ +describeAgainst([1, 2, 3]), 'find(any(), rest())'); + Assert::same($rest->describeAgainst([1]), 'find(any(), rest())'); + Assert::same($none->describeAgainst([1]), 'find(any(), none())'); + Assert::same($none->describeAgainst([1, 2]), 'find(any(), *none()*)'); + } + + public function anArgumentTheCallNeverCarriedIsMarked(): void + { + $expectation = new Expectation('find', [1, 2]); + + Assert::same($expectation->describeAgainst([1]), 'find(1, *2*)'); + Assert::same($expectation->describeAgainst([]), 'find(*1*, *2*)'); + Assert::same($expectation->describeAgainst([1, 2]), 'find(1, 2)'); + Assert::same($expectation->describeAgainst([1, 3]), 'find(1, *2*)'); + } + + public function aGuardThatThrowsCountsAsOneThatRefused(): void + { + $expectation = new Expectation('find', [Arg::any()]); + $expectation->setGuard(static fn(): never => throw new \RuntimeException('boom'), 'id is valid'); + + Assert::same($expectation->describeAgainst([1]), 'find(any()) *id is valid*'); + } + + public function aGuardThatAcceptsIsNotMarked(): void + { + $expectation = new Expectation('find', [Arg::any()]); + $expectation->setGuard(static fn(): bool => true, 'id is valid'); + + Assert::same($expectation->describeAgainst([1]), 'find(any()) id is valid'); + } +} diff --git a/tests/ExpectationsTest.php b/tests/ExpectationsTest.php index 44c1f8c..87f846b 100644 --- a/tests/ExpectationsTest.php +++ b/tests/ExpectationsTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Exception\NeverMethodCalled; use Rasuvaeff\Understudy\Exception\VerificationFailed; @@ -33,6 +38,11 @@ use function Rasuvaeff\Understudy\when; #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(ExpectBuilder::class)] #[Covers(WhenBuilder::class)] #[Covers(Runtime::class)] diff --git a/tests/Fixture/Def/BaseMarker.php b/tests/Fixture/Def/BaseMarker.php new file mode 100644 index 0000000..b4dbf84 --- /dev/null +++ b/tests/Fixture/Def/BaseMarker.php @@ -0,0 +1,7 @@ +withMessage( + "Understudy `understudy` has no real instance to delegate `find()` to.\n" + . 'Give it one: Understudy::forwarding($double, $real) — calling the parent implementation on a ' + . 'double whose constructor never ran is not a safe substitute.', + ); + + (new Invocation('find', [1], 1))->callOriginal(); + } +} diff --git a/tests/LastCallAndForgetTest.php b/tests/LastCallAndForgetTest.php index 0bb6de4..89921de 100644 --- a/tests/LastCallAndForgetTest.php +++ b/tests/LastCallAndForgetTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\ForgottenDouble; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\VerificationFailed; @@ -29,6 +34,11 @@ * call, and a deliberate retirement of a replaced double. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Understudy::class)] #[Covers(Runtime::class)] #[Covers(RuntimeContext::class)] diff --git a/tests/LeanTest.php b/tests/LeanTest.php index 8483455..55a7f6c 100644 --- a/tests/LeanTest.php +++ b/tests/LeanTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\OutcomeUnavailable; use Rasuvaeff\Understudy\Invocation; use Rasuvaeff\Understudy\Runtime\DoubleState; @@ -28,6 +33,11 @@ * loops where per-call retention is the cost. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Understudy::class)] #[Covers(Runtime::class)] #[Covers(DoubleState::class)] diff --git a/tests/LedgerTest.php b/tests/LedgerTest.php index bef2016..4d2e87b 100644 --- a/tests/LedgerTest.php +++ b/tests/LedgerTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\ContextOwnershipViolation; use Rasuvaeff\Understudy\Exception\ForgottenDouble; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; @@ -43,6 +48,11 @@ * described?" — so these tests are mostly about what counts as an answer. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Understudy::class)] #[Covers(Runtime::class)] #[Covers(RuntimeContext::class)] @@ -905,6 +915,24 @@ public function anOrderComplaintNamesTwoObjectsApart(): void ); } + public function anOrderedExpectationThatNeverHappenedDoesNotHideAViolationAfterIt(): void + { + $repository = Understudy::for(BookRepository::class); + $dune = new Book('Dune'); + $herbert = new Book('Herbert'); + + expect(fn() => $repository->count())->times(0, 1)->ordered(); + expect(fn() => $repository->save($dune))->ordered(); + expect(fn() => $repository->save($herbert))->ordered(); + + $repository->save($herbert); + $repository->save($dune); + + Expect::exception(VerificationFailed::class)->withMessageContaining('to be called after'); + + Understudy::verifyAll(); + } + #[ExpectNoAssertions] public function unrelatedCallsMayHappenBetweenOrderedExpectations(): void { diff --git a/tests/Matcher/TailMatcherVerdictTest.php b/tests/Matcher/TailMatcherVerdictTest.php new file mode 100644 index 0000000..b68f05c --- /dev/null +++ b/tests/Matcher/TailMatcherVerdictTest.php @@ -0,0 +1,60 @@ +matches(null)); + Assert::true((new AnyRest())->matches('x')); + Assert::true((new AnyTail())->matches(null)); + Assert::true((new AnyTail())->matches(0)); + } + + public function noneRefusesAnySingleArgument(): void + { + Assert::false((new EmptyTail())->matches(null)); + Assert::false((new EmptyTail())->matches('x')); + } + + public function anUnspelledPositionAcceptsAnything(): void + { + Assert::true((new Unspelled())->matches(null)); + Assert::true((new Unspelled())->matches(argument: false)); + } + + public function eachTailMatcherSaysWhatItStandsFor(): void + { + Assert::true((new AnyRest())->matchesTail([1, 2])); + Assert::true((new AnyRest())->matchesTail([])); + Assert::true((new AnyTail())->matchesTail([1])); + Assert::true((new EmptyTail())->matchesTail([])); + Assert::false((new EmptyTail())->matchesTail([1])); + Assert::same((new AnyRest())->describe(), 'rest()'); + Assert::same((new AnyTail())->describe(), 'remaining()'); + Assert::same((new EmptyTail())->describe(), 'none()'); + Assert::same((new Unspelled())->describe(), '…'); + } +} diff --git a/tests/OutcomeTest.php b/tests/OutcomeTest.php new file mode 100644 index 0000000..e51d3cd --- /dev/null +++ b/tests/OutcomeTest.php @@ -0,0 +1,44 @@ +didReturn()); + Assert::false($outcome->didThrow()); + Assert::null($outcome->returned('find')); + Assert::null($outcome->thrown()); + } + + public function aThrowableIsReadBackAndHasNoReturnValue(): void + { + $error = new \RuntimeException('boom'); + $outcome = Outcome::thrownError($error); + + Assert::false($outcome->didReturn()); + Assert::true($outcome->didThrow()); + Assert::same($outcome->thrown(), $error); + + Expect::exception(OutcomeUnavailable::class)->withMessage( + 'Call to `find()` threw RuntimeException and has no return value. Check didReturn() first, or read thrown().', + ); + + $outcome->returned('find'); + } +} diff --git a/tests/PropertyHooksTest.php b/tests/PropertyHooksTest.php index c4c6e34..6b6fae9 100644 --- a/tests/PropertyHooksTest.php +++ b/tests/PropertyHooksTest.php @@ -6,7 +6,10 @@ use Rasuvaeff\Understudy\Codegen\Blueprint; use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Codegen\PropertySignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; use Rasuvaeff\Understudy\Exception\ForgottenDouble; use Rasuvaeff\Understudy\Exception\UnsupportedTarget; @@ -31,6 +34,9 @@ * down there rather than skip a test. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(TypeRenderer::class)] +#[Covers(MethodSignature::class)] #[Covers(DoubleFactory::class)] #[Covers(Blueprint::class)] #[Covers(PropertySignature::class)] diff --git a/tests/RestArgumentsTest.php b/tests/RestArgumentsTest.php index a08bb63..ad5be5d 100644 --- a/tests/RestArgumentsTest.php +++ b/tests/RestArgumentsTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\ConflictingExpectation; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\VerificationFailed; @@ -33,6 +38,11 @@ * required parameters run out. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Arg::class)] #[Covers(Understudy::class)] #[Covers(\Rasuvaeff\Understudy\Expectation\Expectation::class)] diff --git a/tests/RuntimeDirectTest.php b/tests/RuntimeDirectTest.php new file mode 100644 index 0000000..a4d6f04 --- /dev/null +++ b/tests/RuntimeDirectTest.php @@ -0,0 +1,40 @@ +withMessageContaining('but `find()` was called on it'); + + Runtime::callOriginal($double, 'find', [1]); + } +} diff --git a/tests/UnderstudyTest.php b/tests/UnderstudyTest.php index d0399e7..922ce22 100644 --- a/tests/UnderstudyTest.php +++ b/tests/UnderstudyTest.php @@ -5,25 +5,43 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Cardinality; use Rasuvaeff\Understudy\Codegen\Blueprint; use Rasuvaeff\Understudy\Codegen\DoubleFactory; use Rasuvaeff\Understudy\Codegen\MethodSignature; use Rasuvaeff\Understudy\Codegen\TargetUnifier; use Rasuvaeff\Understudy\Codegen\TypeRenderer; +use Rasuvaeff\Understudy\Defaults\DefaultFactories; +use Rasuvaeff\Understudy\Defaults\TypeDefaultResolver; +use Rasuvaeff\Understudy\Exception\AmbiguousDefaultFactory; +use Rasuvaeff\Understudy\Exception\CannotWire; +use Rasuvaeff\Understudy\Exception\ConflictingExpectation; +use Rasuvaeff\Understudy\Exception\ContextOwnershipViolation; use Rasuvaeff\Understudy\Exception\ForgottenDouble; +use Rasuvaeff\Understudy\Exception\ForwardingTargetMismatch; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; +use Rasuvaeff\Understudy\Exception\InvalidDefaultValue; use Rasuvaeff\Understudy\Exception\InvalidSpecificationArgument; use Rasuvaeff\Understudy\Exception\MatcherLeaked; use Rasuvaeff\Understudy\Exception\NeverMethodCalled; +use Rasuvaeff\Understudy\Exception\OriginalCallUnavailable; +use Rasuvaeff\Understudy\Exception\OriginalReturnTypeViolation; use Rasuvaeff\Understudy\Exception\StrictModeViolation; use Rasuvaeff\Understudy\Exception\UnsupportedTarget; use Rasuvaeff\Understudy\Exception\VerificationFailed; +use Rasuvaeff\Understudy\Expectation\ArgumentFormatter; use Rasuvaeff\Understudy\Expectation\Expectation; use Rasuvaeff\Understudy\Expectation\ReturnContract; use Rasuvaeff\Understudy\ExpectBuilder; use Rasuvaeff\Understudy\FailureReport; use Rasuvaeff\Understudy\Invocation; +use Rasuvaeff\Understudy\Matcher\AnyRest; +use Rasuvaeff\Understudy\Matcher\AnyTail; +use Rasuvaeff\Understudy\Matcher\EmptyTail; +use Rasuvaeff\Understudy\Matcher\InstanceOfType; +use Rasuvaeff\Understudy\Matcher\Unspelled; use Rasuvaeff\Understudy\Outcome; +use Rasuvaeff\Understudy\Runtime\ArmedSequence; use Rasuvaeff\Understudy\Runtime\DoubleState; use Rasuvaeff\Understudy\Runtime\InvocationSignal; use Rasuvaeff\Understudy\Runtime\Mode; @@ -52,6 +70,7 @@ use Rasuvaeff\Understudy\Tests\Support\GoldenMessage; use Rasuvaeff\Understudy\Understudy; use Rasuvaeff\Understudy\WhenBuilder; +use Rasuvaeff\Understudy\Wiring\Wire; use Testo\Assert; use Testo\Assert\ExpectNoAssertions; use Testo\Codecov\Covers; @@ -90,6 +109,27 @@ #[Covers(VerificationFailed::class)] #[Covers(TargetUnifier::class)] #[Covers(TypeRenderer::class)] +#[Covers(Arg::class)] +#[Covers(Cardinality::class)] +#[Covers(DefaultFactories::class)] +#[Covers(TypeDefaultResolver::class)] +#[Covers(AmbiguousDefaultFactory::class)] +#[Covers(CannotWire::class)] +#[Covers(ConflictingExpectation::class)] +#[Covers(ContextOwnershipViolation::class)] +#[Covers(ForwardingTargetMismatch::class)] +#[Covers(InvalidDefaultValue::class)] +#[Covers(InvalidSpecificationArgument::class)] +#[Covers(OriginalCallUnavailable::class)] +#[Covers(OriginalReturnTypeViolation::class)] +#[Covers(ArgumentFormatter::class)] +#[Covers(AnyRest::class)] +#[Covers(AnyTail::class)] +#[Covers(EmptyTail::class)] +#[Covers(InstanceOfType::class)] +#[Covers(Unspelled::class)] +#[Covers(ArmedSequence::class)] +#[Covers(Wire::class)] final class UnderstudyTest { #[AfterTest] diff --git a/tests/VerificationFailureTest.php b/tests/VerificationFailureTest.php index 5cea1cf..d314b8d 100644 --- a/tests/VerificationFailureTest.php +++ b/tests/VerificationFailureTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\VerificationFailed; use Rasuvaeff\Understudy\FailureKind; use Rasuvaeff\Understudy\Tests\Fixture\Book; @@ -27,6 +32,11 @@ * the two disagree. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(VerificationFailed::class)] #[Covers(Understudy::class)] #[Covers(VerificationFailure::class)] diff --git a/tests/VerifyAllCountTest.php b/tests/VerifyAllCountTest.php index 17ef5de..d124598 100644 --- a/tests/VerifyAllCountTest.php +++ b/tests/VerifyAllCountTest.php @@ -4,6 +4,11 @@ namespace Rasuvaeff\Understudy\Tests; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Tests\Fixture\Book; use Rasuvaeff\Understudy\Tests\Fixture\BookRepository; use Rasuvaeff\Understudy\Understudy; @@ -21,6 +26,11 @@ * real assertions with, rather than a flat one per test. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Understudy::class)] final class VerifyAllCountTest { diff --git a/tests/WhereTest.php b/tests/WhereTest.php index 90411f4..3af438a 100644 --- a/tests/WhereTest.php +++ b/tests/WhereTest.php @@ -5,6 +5,11 @@ namespace Rasuvaeff\Understudy\Tests; use Rasuvaeff\Understudy\Arg; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\InvalidCallSpecification; use Rasuvaeff\Understudy\Exception\StrictModeViolation; use Rasuvaeff\Understudy\Exception\VerificationFailed; @@ -30,6 +35,11 @@ * no positional matcher can express. */ #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(WhenBuilder::class)] #[Covers(Expectation::class)] #[Covers(InvalidCallSpecification::class)] diff --git a/tests/WireTest.php b/tests/WireTest.php index b2812e8..1ce6a3b 100644 --- a/tests/WireTest.php +++ b/tests/WireTest.php @@ -4,7 +4,13 @@ namespace Rasuvaeff\Understudy\Tests; +use Rasuvaeff\Understudy\Codegen\Blueprint; +use Rasuvaeff\Understudy\Codegen\DoubleFactory; +use Rasuvaeff\Understudy\Codegen\MethodSignature; +use Rasuvaeff\Understudy\Codegen\TargetUnifier; +use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Exception\CannotWire; +use Rasuvaeff\Understudy\Exception\UnsupportedTarget; use Rasuvaeff\Understudy\Tests\Fixture\Wire\AbstractSubject; use Rasuvaeff\Understudy\Tests\Fixture\Wire\ArrayVariadic; use Rasuvaeff\Understudy\Tests\Fixture\Wire\BoolVariadic; @@ -18,19 +24,24 @@ use Rasuvaeff\Understudy\Tests\Fixture\Wire\IntersectionDependency; use Rasuvaeff\Understudy\Tests\Fixture\Wire\IntVariadic; use Rasuvaeff\Understudy\Tests\Fixture\Wire\IterableVariadic; +use Rasuvaeff\Understudy\Tests\Fixture\Wire\MissingClassSubject; use Rasuvaeff\Understudy\Tests\Fixture\Wire\MixedVariadic; +use Rasuvaeff\Understudy\Tests\Fixture\Wire\NeedsSealedCollaborator; use Rasuvaeff\Understudy\Tests\Fixture\Wire\NoConstructor; use Rasuvaeff\Understudy\Tests\Fixture\Wire\NullableDependency; use Rasuvaeff\Understudy\Tests\Fixture\Wire\NullableScalar; use Rasuvaeff\Understudy\Tests\Fixture\Wire\ObjectDefaultSubject; +use Rasuvaeff\Understudy\Tests\Fixture\Wire\ObjectOrScalarSubject; use Rasuvaeff\Understudy\Tests\Fixture\Wire\ObjectVariadic; use Rasuvaeff\Understudy\Tests\Fixture\Wire\PrivateConstructor; use Rasuvaeff\Understudy\Tests\Fixture\Wire\ReferenceConstructor; use Rasuvaeff\Understudy\Tests\Fixture\Wire\Reporter; use Rasuvaeff\Understudy\Tests\Fixture\Wire\Repository; use Rasuvaeff\Understudy\Tests\Fixture\Wire\ScalarWithoutDefault; +use Rasuvaeff\Understudy\Tests\Fixture\Wire\SealedCollaborator; use Rasuvaeff\Understudy\Tests\Fixture\Wire\TrueVariadic; use Rasuvaeff\Understudy\Tests\Fixture\Wire\TwoObjectUnion; +use Rasuvaeff\Understudy\Tests\Fixture\Wire\UntypedSubject; use Rasuvaeff\Understudy\Tests\Fixture\Wire\VariadicObjectDefaultSubject; use Rasuvaeff\Understudy\Tests\Fixture\Wire\VariadicService; use Rasuvaeff\Understudy\Understudy; @@ -45,8 +56,14 @@ use function Rasuvaeff\Understudy\when; #[Test] +#[Covers(TargetUnifier::class)] +#[Covers(DoubleFactory::class)] +#[Covers(TypeRenderer::class)] +#[Covers(Blueprint::class)] +#[Covers(MethodSignature::class)] #[Covers(Wire::class)] #[Covers(CannotWire::class)] +#[Covers(UnsupportedTarget::class)] final class WireTest { #[AfterTest] @@ -106,6 +123,56 @@ public function anIntersectionBecomesOneDoubleOfBothContracts(): void Assert::instanceOf($subject->both, \Countable::class); } + public function anOverrideSatisfyingEveryBranchOfAnIntersectionIsAccepted(): void + { + $both = Understudy::for(Repository::class, \Countable::class); + + ['sut' => $subject] = Understudy::wire(IntersectionDependency::class, ['both' => $both]); + + Assert::same($subject->both, $both); + } + + public function anOverrideMissingOneBranchOfAnIntersectionIsRefused(): void + { + Expect::exception(CannotWire::class) + ->withMessageContaining('the override for `$both`') + ->withMessageContaining('and the constructor declares `' . Repository::class . '&Countable`'); + + Understudy::wire(IntersectionDependency::class, ['both' => Understudy::for(Repository::class)]); + } + + public function anUntypedParameterTakesAnyOverrideAsIs(): void + { + ['sut' => $subject] = Understudy::wire(UntypedSubject::class, ['untyped' => 'whatever', 'anything' => 7]); + + Assert::same($subject->untyped, 'whatever'); + Assert::same($subject->anything, 7); + } + + public function aUnionOfOneObjectTypeAndAScalarIsADoubleOfTheObject(): void + { + ['sut' => $subject, 'doubles' => $doubles] = Understudy::wire(ObjectOrScalarSubject::class); + + Assert::instanceOf($subject->dependency, Repository::class); + Assert::same($doubles['dependency'], $subject->dependency); + } + + public function aClassThatCannotBeLoadedIsNotAContractToDouble(): void + { + ['sut' => $subject, 'doubles' => $doubles] = Understudy::wire(MissingClassSubject::class); + + Assert::null($subject->collaborator); + Assert::same($doubles, []); + } + + public function anUndoublableCollaboratorWithoutADefaultRefusesTheWholeSubject(): void + { + Expect::exception(UnsupportedTarget::class) + ->withMessageContaining('Cannot create an understudy for `' . SealedCollaborator::class . '`: the class is final'); + + Understudy::wire(NeedsSealedCollaborator::class); + } + /** * A variadic tail is optional by definition. Inventing entries for it would * invent collaborators the constructor never asked for. From c9c5678e28b9aa8d2d536886414012746f55560d Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Tue, 3 Nov 2026 21:05:16 +0100 Subject: [PATCH 2/3] Make the message expectations independent of the checkout's line endings --- tests/Exception/ExceptionMessageTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Exception/ExceptionMessageTest.php b/tests/Exception/ExceptionMessageTest.php index ce0778b..dbf3860 100644 --- a/tests/Exception/ExceptionMessageTest.php +++ b/tests/Exception/ExceptionMessageTest.php @@ -49,7 +49,9 @@ final class ExceptionMessageTest #[DataProvider('messageProvider')] public function eachFactoryWordsItsMessageAsPublished(\Closure $build, string $expected): void { - Assert::same($build()->getMessage(), $expected); + // The expectations are nowdocs: a checkout that converts line endings + // turns their newlines into CRLF, while the messages always carry LF. + Assert::same($build()->getMessage(), str_replace("\r\n", "\n", $expected)); } /** From 420417871d0268459e0261ec8bd6ff02f748a1be Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Tue, 3 Nov 2026 21:05:40 +0100 Subject: [PATCH 3/3] Apply rector 2.8.4 ExplicitNullableObjectInstanceRector --- src/Codegen/TargetUnifier.php | 2 +- src/Codegen/TypeRenderer.php | 2 +- src/Wiring/Wire.php | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Codegen/TargetUnifier.php b/src/Codegen/TargetUnifier.php index 143e4e5..c7d331c 100644 --- a/src/Codegen/TargetUnifier.php +++ b/src/Codegen/TargetUnifier.php @@ -1282,7 +1282,7 @@ private static function renderConstantDefault(\ReflectionParameter $parameter): $declaring = $parameter->getDeclaringClass(); if (\in_array(strtolower($class), ['self', 'static', 'parent'], strict: true)) { - if ($declaring === null) { + if (!$declaring instanceof \ReflectionClass) { return null; } diff --git a/src/Codegen/TypeRenderer.php b/src/Codegen/TypeRenderer.php index c0b8ba8..8baf674 100644 --- a/src/Codegen/TypeRenderer.php +++ b/src/Codegen/TypeRenderer.php @@ -115,7 +115,7 @@ private static function resolveParent(string $rendered, ?\ReflectionClass $decla $parent = $declaringClass?->getParentClass(); - if ($parent === false || $parent === null) { + if ($parent === false || !$parent instanceof \ReflectionClass) { return $rendered; } diff --git a/src/Wiring/Wire.php b/src/Wiring/Wire.php index a9dc94b..7cc18d6 100644 --- a/src/Wiring/Wire.php +++ b/src/Wiring/Wire.php @@ -38,7 +38,7 @@ public static function build(string $sut, array $overrides): array $reflection = self::reflectSubject($sut); $constructor = $reflection->getConstructor(); - if ($constructor === null) { + if (!$constructor instanceof \ReflectionMethod) { if ($overrides !== []) { throw CannotWire::unknownOverride( $sut, @@ -224,7 +224,7 @@ private static function rejectIncompatibleOverride(string $sut, \ReflectionParam { $type = $parameter->getType(); - if ($type === null || ($type instanceof \ReflectionNamedType && $type->isBuiltin())) { + if (!$type instanceof \ReflectionType || ($type instanceof \ReflectionNamedType && $type->isBuiltin())) { // Builtin types are left to PHP's own coercion rules, which are // the ones the real caller would meet anyway. return; @@ -251,7 +251,7 @@ private static function rejectVariadicOverride(string $sut, \ReflectionParameter $type = $parameter->getType(); foreach ($value as $item) { - if ($type !== null && !self::matchesType($type, $item)) { + if ($type instanceof \ReflectionType && !self::matchesType($type, $item)) { throw CannotWire::incompatibleOverride($sut, $parameter->getName(), 'list<' . self::typeName($type) . '>', get_debug_type($item)); } } @@ -318,7 +318,7 @@ private static function typeName(?\ReflectionType $type): string private static function resolve(string $sut, \ReflectionParameter $parameter): ?array { $type = $parameter->getType(); - $contracts = $type === null ? [] : self::doublableContracts($type); + $contracts = $type instanceof \ReflectionType ? self::doublableContracts($type) : []; if (count($contracts) === 1) { return self::doubleOrDefault($parameter, $contracts); @@ -341,14 +341,14 @@ private static function resolve(string $sut, \ReflectionParameter $parameter): ? return null; } - if ($type !== null && $type->allowsNull()) { + if ($type instanceof \ReflectionType && $type->allowsNull()) { return [null, null]; } throw CannotWire::undecidableParameter( $sut, $parameter->getName(), - $type === null ? 'no type' : (string) $type, + $type instanceof \ReflectionType ? (string) $type : 'no type', 'it is not an object type and has no default value.', ); }