diff --git a/bridge/double/.github/workflows/close-prs.yml b/bridge/double/.github/workflows/close-prs.yml
new file mode 100644
index 00000000..7640d59f
--- /dev/null
+++ b/bridge/double/.github/workflows/close-prs.yml
@@ -0,0 +1,14 @@
+name: Close PRs
+
+on:
+ pull_request_target:
+ types: [opened, reopened]
+
+permissions:
+ pull-requests: write
+
+jobs:
+ close:
+ uses: php-testo/gh-actions/.github/workflows/close-foreign-prs.yml@v1
+ with:
+ upstream-url: https://github.com/php-testo/testo
diff --git a/bridge/double/CHANGELOG.md b/bridge/double/CHANGELOG.md
new file mode 100644
index 00000000..417f2d60
--- /dev/null
+++ b/bridge/double/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+## Changelog
diff --git a/bridge/double/README.md b/bridge/double/README.md
new file mode 100644
index 00000000..441a1b98
--- /dev/null
+++ b/bridge/double/README.md
@@ -0,0 +1,62 @@
+
+
+
+
+Double bridge
+
+
+
+[](https://php-testo.github.io)
+[](https://boosty.to/roxblnfk)
+
+
+
+
+
+> [!IMPORTANT]
+> ## 🪞 This is a read-only mirror.
+>
+> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `bridge/double/`. This repository is **automatically synchronized** from there on every release.
+>
+> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here.
+
+## About
+
+[Double](https://github.com/jasonmccreary/double) is a modern PHP test-double library — one unified `Double` type covers mocks, stubs and spies. This bridge wires its verification into Testo: register `DoublePlugin` and `Double::verifyAll()` is called after every test, so `expects()` and `received()` assertions are always verified and the pending doubles are cleared between tests — no per-test `verify()` boilerplate.
+
+```php
+// testo.php
+use Testo\Application\Config\ApplicationConfig;
+use Testo\Application\Config\SuiteConfig;
+use Testo\Bridge\Double\DoublePlugin;
+
+return new ApplicationConfig(
+ plugins: [new DoublePlugin()],
+ suites: [new SuiteConfig(name: 'Unit', location: ['tests/Unit'])],
+);
+```
+
+```php
+use JMac\Testing\Double;
+
+$repository = Double::for(BookRepository::class);
+$repository->expects('find')->with(123)->returns($book);
+
+$service = new CatalogService($repository);
+$service->lookup(123);
+// The plugin verifies `find` was called as expected once the test returns.
+```
+
+## Install
+
+```bash
+composer require --dev testo/bridge-double
+```
+
+[](https://packagist.org/packages/testo/bridge-double)
+[](https://packagist.org/packages/testo/bridge-double)
+[](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
+[](https://packagist.org/packages/testo/bridge-double/stats)
diff --git a/bridge/double/composer.json b/bridge/double/composer.json
new file mode 100644
index 00000000..4d4d7544
--- /dev/null
+++ b/bridge/double/composer.json
@@ -0,0 +1,55 @@
+{
+ "name": "testo/bridge-double",
+ "description": "Double bridge for the Testo testing framework.",
+ "license": "BSD-3-Clause",
+ "type": "library",
+ "keywords": [
+ "testo",
+ "double",
+ "mock",
+ "stub",
+ "spy",
+ "testing"
+ ],
+ "authors": [
+ {
+ "name": "Aleksei Gagarin (roxblnfk)",
+ "homepage": "https://github.com/roxblnfk"
+ }
+ ],
+ "funding": [
+ {
+ "type": "boosty",
+ "url": "https://boosty.to/roxblnfk"
+ }
+ ],
+ "require": {
+ "php": ">=8.3",
+ "jasonmccreary/double": "dev-master",
+ "testo/testo": "0.10.39 - 1"
+ },
+ "require-dev": {
+ "testo/assert": "^0.1.13",
+ "testo/bridge-revolt": "^0.1.1",
+ "testo/codecov": "^0.1.12",
+ "testo/fiber": "^0.1.2",
+ "testo/test": "^0.1.6"
+ },
+ "autoload": {
+ "psr-4": {
+ "Testo\\Bridge\\Double\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\Bridge\\Double\\": "tests/"
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev"
+ }
+ }
+}
diff --git a/bridge/double/src/DoublePlugin.php b/bridge/double/src/DoublePlugin.php
new file mode 100644
index 00000000..44fe0093
--- /dev/null
+++ b/bridge/double/src/DoublePlugin.php
@@ -0,0 +1,37 @@
+get(InterceptorCollector::class)->addInterceptor(new DoubleInterceptor());
+ }
+}
diff --git a/bridge/double/src/Internal/DoubleInterceptor.php b/bridge/double/src/Internal/DoubleInterceptor.php
new file mode 100644
index 00000000..91d37511
--- /dev/null
+++ b/bridge/double/src/Internal/DoubleInterceptor.php
@@ -0,0 +1,148 @@
+run($info, $next);
+ } finally {
+ try {
+ Double::verifyAll();
+ } catch (\Throwable $e) {
+ # The unmet expectation was already recorded by the listener. Turn it into a normal failure
+ # here — an exception escaping would abort the pipeline (Status::Aborted) instead. Leave an
+ # already-failed result alone; a null $result means $next() threw, let it propagate.
+ $result?->status === Status::Passed and $result = $result
+ ->with(status: Status::Failed)
+ ->withFailure($e);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Register the check recorder with Double once per process. Double's listener registry is process-wide
+ * and long-lived by design, so registering per test would pile up duplicates. No-op without the Assert
+ * plugin: there is no history to write to, and {@see Double::verifyAll()} still fails tests on its own.
+ */
+ private static function ensureListening(): void
+ {
+ static $listening = false;
+ if ($listening || !\class_exists(StaticState::class)) {
+ return;
+ }
+
+ $listening = true;
+ Double::listen(self::record(...));
+ }
+
+ /**
+ * Mirror one resolved Double check into the current test's assertion history: a fulfilled record when
+ * it passed, a failed one carrying the diagnostic when it did not.
+ */
+ private static function record(CheckEvent $event): void
+ {
+ $state = StaticState::current();
+ if ($state === null) {
+ return;
+ }
+
+ $subject = $event->method === null
+ ? \sprintf('Double `%s`', $event->label)
+ : \sprintf('Double `%s`->%s()', $event->label, $event->method);
+
+ $state->history[] = $event->passed
+ ? new ExpectationFulfilled($subject . ' passed its check', '')
+ : new ExpectationFailed(
+ expectation: $subject . ' passed its check',
+ context: '',
+ reason: $event->failure?->getMessage() ?? '',
+ details: '',
+ );
+ }
+
+ /**
+ * Run the test, keeping this test's pending doubles bound to it across fiber suspensions.
+ *
+ * Double's pending doubles live in process-global state, so under concurrent (fiber-based) execution
+ * sibling tests would sweep each other's doubles into the wrong teardown. On every suspension we park
+ * this test's state with {@see Double::pauseAutoVerify()} and hand a fresh slate to the sibling; on
+ * resumption we reinstall it with {@see Double::resumeAutoVerify()}.
+ *
+ * @param callable(TestInfo): TestResult $next
+ */
+ private function run(TestInfo $info, callable $next): TestResult
+ {
+ if (\Fiber::getCurrent() === null) {
+ return $next($info);
+ }
+
+ $fiber = new \Fiber(static fn(): TestResult => $next($info));
+ $value = $fiber->start();
+ while (!$fiber->isTerminated()) {
+ $snapshot = Double::pauseAutoVerify();
+ try {
+ $resume = \Fiber::suspend($value);
+ } catch (\Throwable $e) {
+ Double::resumeAutoVerify($snapshot);
+ $value = $fiber->throw($e);
+ continue;
+ }
+
+ Double::resumeAutoVerify($snapshot);
+ $value = $fiber->resume($resume);
+ }
+
+ /** @var TestResult $result */
+ $result = $fiber->getReturn();
+ return $result;
+ }
+}
diff --git a/bridge/double/tests/Acceptance/DoubleBridgeTest.php b/bridge/double/tests/Acceptance/DoubleBridgeTest.php
new file mode 100644
index 00000000..f4a22a92
--- /dev/null
+++ b/bridge/double/tests/Acceptance/DoubleBridgeTest.php
@@ -0,0 +1,55 @@
+expects('count')->returns(7);
+
+ Assert::same($double->count(), 7);
+ }
+
+ public function expectedCallCountIsVerifiedOnTeardown(): void
+ {
+ /** @var DoubleInterface&\Countable $double */
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->times(2)->returns(2);
+
+ $double->count();
+ $double->count();
+ }
+
+ public function spyRecordsCallsWithReceived(): void
+ {
+ /** @var DoubleInterface&\Countable $spy */
+ $spy = Double::for(\Countable::class);
+ $spy->allows('count')->returns(3);
+
+ Assert::same($spy->count(), 3);
+
+ $spy->received('count')->times(1);
+ }
+}
diff --git a/bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php b/bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php
new file mode 100644
index 00000000..522f3433
--- /dev/null
+++ b/bridge/double/tests/Feature/DoubleInterleaveAttributionTest.php
@@ -0,0 +1,75 @@
+status, Status::Passed);
+ Assert::same($second->status, Status::Passed);
+ }
+
+ public function bodyAssertionsLandInEachTestsHistory(): void
+ {
+ // Each stub test makes two Assert::same() calls of its own; the bridge adds one record for the
+ // verified double. A transparent bridge leaves all three in the test's history.
+ $first = TestRunner::runTest([DoubleAssertConcurrencyScenarios::class, 'firstAssertsAroundItsDouble']);
+ $second = TestRunner::runTest([DoubleAssertConcurrencyScenarios::class, 'secondAssertsAroundItsDouble']);
+
+ Assert::same(self::historyCount($first), 3, 'first test: 2 body asserts + 1 double verification');
+ Assert::same(self::historyCount($second), 3, 'second test: 2 body asserts + 1 double verification');
+ }
+
+ public function expectExceptionSurvivesTheInterleave(): void
+ {
+ // Each stub test declares Expect::exception() up front and throws after its yield — with a
+ // transparent bridge both expectations are fulfilled and both tests pass.
+ $first = TestRunner::runTest([DoubleExpectConcurrencyScenarios::class, 'firstExpectsItsException']);
+ $second = TestRunner::runTest([DoubleExpectConcurrencyScenarios::class, 'secondExpectsItsException']);
+
+ Assert::same($first->status, Status::Passed);
+ Assert::same($second->status, Status::Passed);
+ }
+
+ private static function historyCount(TestResult $result): int
+ {
+ $state = $result->getAttribute(TestState::class);
+
+ return $state instanceof TestState ? \count($state->history) : -1;
+ }
+}
diff --git a/bridge/double/tests/Feature/DoubleStatusTest.php b/bridge/double/tests/Feature/DoubleStatusTest.php
new file mode 100644
index 00000000..feb20a16
--- /dev/null
+++ b/bridge/double/tests/Feature/DoubleStatusTest.php
@@ -0,0 +1,196 @@
+status, Status::Passed);
+ Assert::true(self::hasRecord($result, success: true));
+ }
+
+ public function unfulfilledExpectationFailsTheTest(): void
+ {
+ $result = TestRunner::runTest([DoubleScenarios::class, 'unfulfilledExpectation']);
+ Assert::same($result->status, Status::Failed);
+ Assert::true(self::hasRecord($result, success: false));
+ }
+
+ public function noDoublesNoAssertionsStaysRisky(): void
+ {
+ $result = TestRunner::runTest([DoubleScenarios::class, 'noDoublesNoAssertions']);
+ Assert::same($result->status, Status::Risky);
+ }
+
+ public function receivedVerificationCountsAsAssertion(): void
+ {
+ $result = TestRunner::runTest([DoubleScenarios::class, 'receivedVerificationOnly']);
+ Assert::same($result->status, Status::Passed);
+ }
+
+ public function verifiedChecksNameTheDoubleAndMethod(): void
+ {
+ // Each recorded check names its double, and a received() check names the method it verified.
+ $result = TestRunner::runTest([DoubleScenarios::class, 'receivedVerificationOnly']);
+ Assert::string(self::successExpectations($result))
+ ->contains('Double `Countable`')
+ ->contains('count()');
+ }
+
+ public function doubleAndAssertCoexist(): void
+ {
+ $result = TestRunner::runTest([DoubleScenarios::class, 'doubleAndAssertMixed']);
+ Assert::same($result->status, Status::Passed);
+ }
+
+ public function checksAreRecordedInChronologicalOrder(): void
+ {
+ // The scenario asserts, runs a passing Double check, then asserts again. Because checks are recorded
+ // the moment they resolve (not batched at teardown), a plain assertion lands after the Double check
+ // in the history.
+ $result = TestRunner::runTest([DoubleScenarios::class, 'checkInterleavesWithAssertions']);
+ $order = self::orderedExpectations($result);
+
+ $firstDouble = null;
+ foreach ($order as $i => $expectation) {
+ if (\str_contains($expectation, 'Double `')) {
+ $firstDouble = $i;
+ break;
+ }
+ }
+ Assert::true($firstDouble !== null, 'a Double check was recorded');
+
+ $assertionAfterDouble = false;
+ foreach ($order as $i => $expectation) {
+ if ($i > $firstDouble && !\str_contains($expectation, 'Double `')) {
+ $assertionAfterDouble = true;
+ break;
+ }
+ }
+ Assert::true($assertionAfterDouble, 'a plain assertion is recorded after a Double check');
+ }
+
+ public function bodyCheckFailureIsRecordedButResultLeftAsIs(): void
+ {
+ // A Double check that throws in the body (here: unused() on a called spy) with no #[ExpectException]
+ // to catch it: the bridge records the failure in the history but does not touch the result, so it
+ // stays the Error the runner produced from the uncaught throw.
+ $result = TestRunner::runTest([DoubleScenarios::class, 'bodyCheckFailsUncaught']);
+ Assert::same($result->status, Status::Error);
+ Assert::true(self::hasRecord($result, success: false));
+ Assert::string(self::failReason($result))->contains('expected no calls');
+ }
+
+ public function stateIsDrainedAfterAFailingTest(): void
+ {
+ // leavesUnmetExpectation fails on verifyAll(); seesCleanSlate runs right after it and would
+ // fail too if that unmet expectation had leaked into the global pending list. Both statuses
+ // being as expected proves the drain happens on the failure path, not only when a test passes.
+ $failed = TestRunner::runTest([DoubleResetScenarios::class, 'leavesUnmetExpectation']);
+ Assert::same($failed->status, Status::Failed);
+
+ $next = TestRunner::runTest([DoubleResetScenarios::class, 'seesCleanSlate']);
+ Assert::same($next->status, Status::Passed);
+ }
+
+ /**
+ * Whether the test's assertion history holds a record with the given success flag — i.e. the
+ * bridge reported the double verification (fulfilled or failed) to the Assert plugin.
+ */
+ private static function hasRecord(TestResult $result, bool $success): bool
+ {
+ $state = $result->getAttribute(TestState::class);
+ if (!$state instanceof TestState) {
+ return false;
+ }
+
+ foreach ($state->history as $record) {
+ if ($record->isSuccess() === $success) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * The rendered text of every assertion record, in the order they were recorded.
+ *
+ * @return list
+ */
+ private static function orderedExpectations(TestResult $result): array
+ {
+ $state = $result->getAttribute(TestState::class);
+ if (!$state instanceof TestState) {
+ return [];
+ }
+
+ return \array_map(
+ static fn(\Stringable $record): string => (string) $record,
+ $state->history,
+ );
+ }
+
+ /**
+ * The expectation texts of every fulfilled (success) assertion record, joined by newlines.
+ */
+ private static function successExpectations(TestResult $result): string
+ {
+ $state = $result->getAttribute(TestState::class);
+ if (!$state instanceof TestState) {
+ return '';
+ }
+
+ $expectations = [];
+ foreach ($state->history as $record) {
+ $record->isSuccess() and $expectations[] = (string) $record;
+ }
+
+ return \implode("\n", $expectations);
+ }
+
+ /**
+ * The fail reason of the test's first failed (unsuccessful) assertion record, or '' if none.
+ */
+ private static function failReason(TestResult $result): string
+ {
+ $state = $result->getAttribute(TestState::class);
+ if (!$state instanceof TestState) {
+ return '';
+ }
+
+ foreach ($state->history as $record) {
+ if (!$record->isSuccess()) {
+ return $record->getFailReason();
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/bridge/double/tests/Self/DoubleAndAssertCombinations.php b/bridge/double/tests/Self/DoubleAndAssertCombinations.php
new file mode 100644
index 00000000..b8c3b5f2
--- /dev/null
+++ b/bridge/double/tests/Self/DoubleAndAssertCombinations.php
@@ -0,0 +1,134 @@
+expects('count')->returns(3);
+
+ $double->count();
+ }
+
+ public function expectationThenAssert(): void
+ {
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->returns(9);
+
+ Assert::same($double->count(), 9);
+ Assert::instanceOf($double, \Countable::class);
+ }
+
+ public function multipleDoublesAndAsserts(): void
+ {
+ $counter = Double::for(\Countable::class);
+ $counter->expects('count')->times(2)->returns(1);
+ $other = Double::for(\Countable::class);
+ $other->expects('count')->returns(4);
+
+ Assert::same($counter->count(), 1);
+ $counter->count();
+ Assert::same($other->count(), 4);
+ }
+
+ public function spyWithAssert(): void
+ {
+ $spy = Double::for(\Countable::class);
+ $spy->allows('count')->returns(7);
+
+ Assert::same($spy->count(), 7);
+
+ $spy->received('count')->times(1);
+ }
+
+ public function looseStubWithAssert(): void
+ {
+ $stub = Double::for(\Countable::class);
+ $stub->allows('count')->returns(0);
+
+ Assert::same($stub->count(), 0);
+ }
+
+ public function doubleThrowsWithExpectException(): void
+ {
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->throws(new \RuntimeException('boom'));
+
+ Expect::exception(\RuntimeException::class)->withMessageContaining('boom');
+ $double->count();
+ }
+
+ #[ExpectException(UnusedAssertionException::class)]
+ public function unusedAssertionThrowsAndIsCaught(): void
+ {
+ $double = Double::for(\Countable::class);
+ $double->allows('count')->returns(0);
+ $double->count();
+
+ // A Double check that fails in the test body (here: unused() on a called double) throws like any
+ // other exception, so #[ExpectException] catches it. Unlike an unmet expects(), which surfaces only
+ // from the teardown verifyAll() and leaves nothing for the attribute to see.
+ $double->unused();
+ }
+
+ #[ExpectException(UnexpectedCallException::class)]
+ public function strictUnexpectedCallThrowsAndIsCaught(): void
+ {
+ // A strict double rejects any call it was not configured for, right at the call site.
+ $double = Double::for(\Countable::class)->strict();
+
+ $double->count();
+ }
+
+ #[ExpectException(ExpectationCallLimitExceededException::class)]
+ public function neverExpectationExceededThrowsAndIsCaught(): void
+ {
+ // never() allows zero calls; the first call breaks the limit at the call site.
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->never();
+
+ $double->count();
+ }
+
+ #[ExpectException(ExpectationCallLimitExceededException::class)]
+ public function callCountExceededThrowsAndIsCaught(): void
+ {
+ // times(1) allows a single call; the second call exceeds the limit at the call site.
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->times(1)->returns(0);
+
+ $double->count();
+ $double->count();
+ }
+}
diff --git a/bridge/double/tests/Self/DoubleUnderFibersTest.php b/bridge/double/tests/Self/DoubleUnderFibersTest.php
new file mode 100644
index 00000000..2b007078
--- /dev/null
+++ b/bridge/double/tests/Self/DoubleUnderFibersTest.php
@@ -0,0 +1,53 @@
+expects('count')->returns(4);
+
+ Assert::same($double->count(), 4);
+ }
+
+ #[RunInRevolt]
+ public function doubleVerifiesAcrossARevoltAwait(): void
+ {
+ /** @var DoubleInterface&\Countable $double */
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->returns(7);
+
+ $suspension = EventLoop::getSuspension();
+ EventLoop::delay(0.001, static fn() => $suspension->resume());
+ $suspension->suspend();
+
+ Assert::same($double->count(), 7);
+ }
+}
diff --git a/bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php b/bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php
new file mode 100644
index 00000000..f1b39578
--- /dev/null
+++ b/bridge/double/tests/Stub/DoubleAssertConcurrencyScenarios.php
@@ -0,0 +1,50 @@
+expects('count')->returns(1);
+
+ Assert::same(1, 1);
+
+ \Fiber::suspend();
+
+ Assert::same($double->count(), 1);
+ }
+
+ public function secondAssertsAroundItsDouble(): void
+ {
+ /** @var DoubleInterface&\Countable $double */
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->returns(2);
+
+ Assert::same(2, 2);
+
+ \Fiber::suspend();
+
+ Assert::same($double->count(), 2);
+ }
+}
diff --git a/bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php b/bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php
new file mode 100644
index 00000000..7b350d68
--- /dev/null
+++ b/bridge/double/tests/Stub/DoubleExpectConcurrencyScenarios.php
@@ -0,0 +1,52 @@
+expects('count')->returns(1);
+
+ \Fiber::suspend();
+
+ $double->count();
+ throw new \DomainException('first');
+ }
+
+ public function secondExpectsItsException(): never
+ {
+ Expect::exception(\DomainException::class);
+
+ /** @var DoubleInterface&\Countable $double */
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->returns(2);
+
+ \Fiber::suspend();
+
+ $double->count();
+ throw new \DomainException('second');
+ }
+}
diff --git a/bridge/double/tests/Stub/DoubleResetScenarios.php b/bridge/double/tests/Stub/DoubleResetScenarios.php
new file mode 100644
index 00000000..9a7332f3
--- /dev/null
+++ b/bridge/double/tests/Stub/DoubleResetScenarios.php
@@ -0,0 +1,35 @@
+expects('count');
+ }
+
+ #[Test]
+ public function seesCleanSlate(): void
+ {
+ // If the previous test's unmet expectation had leaked, verifyAll() would fail this test too.
+ // This double's own expectation is fulfilled, so a Passed status proves the slate was drained.
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->returns(1);
+
+ Assert::same($double->count(), 1);
+ }
+}
diff --git a/bridge/double/tests/Stub/DoubleScenarios.php b/bridge/double/tests/Stub/DoubleScenarios.php
new file mode 100644
index 00000000..1999a543
--- /dev/null
+++ b/bridge/double/tests/Stub/DoubleScenarios.php
@@ -0,0 +1,83 @@
+expects('count')->returns(1);
+
+ $double->count();
+ }
+
+ #[Test]
+ public function unfulfilledExpectation(): void
+ {
+ /** @var DoubleInterface&\Countable $double */
+ $double = Double::for(\Countable::class);
+ $double->expects('count');
+ }
+
+ #[Test]
+ public function noDoublesNoAssertions(): void {}
+
+ #[Test]
+ public function receivedVerificationOnly(): void
+ {
+ /** @var DoubleInterface&\Countable $spy */
+ $spy = Double::for(\Countable::class);
+ $spy->allows('count')->returns(0);
+
+ $spy->count();
+
+ $spy->received('count')->times(1);
+ }
+
+ #[Test]
+ public function doubleAndAssertMixed(): void
+ {
+ /** @var DoubleInterface&\Countable $double */
+ $double = Double::for(\Countable::class);
+ $double->expects('count')->returns(5);
+
+ Assert::same($double->count(), 5);
+ }
+
+ #[Test]
+ public function bodyCheckFailsUncaught(): void
+ {
+ /** @var DoubleInterface&\Countable $spy */
+ $spy = Double::for(\Countable::class);
+ $spy->allows('count')->returns(0);
+ $spy->count();
+
+ $spy->unused();
+ }
+
+ #[Test]
+ public function checkInterleavesWithAssertions(): void
+ {
+ /** @var DoubleInterface&\Countable $spy */
+ $spy = Double::for(\Countable::class);
+
+ Assert::same(1, 1);
+ $spy->unused(); // passes immediately (never called), recorded here rather than at teardown
+ Assert::same(2, 2);
+ }
+}
diff --git a/bridge/double/tests/suites.php b/bridge/double/tests/suites.php
new file mode 100644
index 00000000..adc51070
--- /dev/null
+++ b/bridge/double/tests/suites.php
@@ -0,0 +1,33 @@
+