andReturn(id)) * - FakeChangeLog::partialsOnly() recordPartial() only (expects('recordPartial')) * - FakeChangeLog::any(id) both * - FakeChangeLog::forbidden() nothing (a mock without expectations) * A call that is not allowed throws – and, because the code under test often swallows * change-log failures (FollowUp), it is also noted and fails the test in TestCase::tearDown() * via verify(). */ class FakeChangeLog extends ChangeLog { /** * @var list */ public array $records = []; /** * @var list */ public array $partials = []; /** @var list Calls that were not allowed, across all fakes of the running test. */ private static array $unexpected = []; private static bool $used = false; public function __construct( private int $firstId = 1, private ?Throwable $failure = null, private bool $allowsRecord = true, private bool $allowsPartial = false ) { parent::__construct(); self::$used = true; } public static function forbidden(): self { return new self(1, null, false, false); } public static function partialsOnly(): self { return new self(1, null, false, true); } public static function any(int $firstId = 1): self { return new self($firstId, null, true, true); } /** * Fails the running test if any fake got a call it does not allow; resets for the next test. * Counts as one assertion in tests that created a fake (like a Mockery never() expectation). */ public static function verify(): void { $unexpected = self::$unexpected; $used = self::$used; self::$unexpected = []; self::$used = false; if ($used || $unexpected !== []) { Assert::assertSame([], $unexpected, 'Unexpected change log calls: ' . implode('; ', $unexpected)); } } public function record( string $ability, string $objectType, string $objectId, string $summary, ?int $revisionId = null, ?string $snapshotId = null, string $result = 'ok' ): ?int { $this->refuseUnless($this->allowsRecord, sprintf('record(%s, %s, %s)', $ability, $objectType, $objectId)); $this->records[] = [ 'ability' => $ability, 'object_type' => $objectType, 'object_id' => $objectId, 'summary' => $summary, 'revision_id' => $revisionId, 'snapshot_id' => $snapshotId, 'result' => $result, ]; if ($this->failure !== null) { throw $this->failure; } return $this->firstId + count($this->records) - 1; } public function recordPartial( string $ability, string $objectType, string $objectId, string $summary, ?int $revisionId = null ): void { $this->refuseUnless( $this->allowsPartial, sprintf('recordPartial(%s, %s, %s)', $ability, $objectType, $objectId) ); $this->partials[] = [ 'ability' => $ability, 'object_type' => $objectType, 'object_id' => $objectId, 'summary' => $summary, 'revision_id' => $revisionId, ]; } /** * Asserts the record() calls: one $expected per call, in order; each names only the * arguments it checks (ability, object_type, object_id, summary, revision_id, …). * * @param array ...$expected */ public function assertRecorded(array ...$expected): void { self::assertCalls('record()', $expected, $this->records); } /** * Asserts the recordPartial() calls like assertRecorded(). * * @param array ...$expected */ public function assertPartials(array ...$expected): void { self::assertCalls('recordPartial()', $expected, $this->partials); } /** * @param list> $expected * @param list> $actual */ private static function assertCalls(string $method, array $expected, array $actual): void { Assert::assertCount(count($expected), $actual, 'Number of ChangeLog::' . $method . ' calls'); foreach ($expected as $i => $call) { $checked = []; foreach (array_keys($call) as $key) { Assert::assertArrayHasKey($key, $actual[$i], 'Unknown ChangeLog argument'); $checked[$key] = $actual[$i][$key]; } Assert::assertSame($call, $checked, 'ChangeLog::' . $method . ' call ' . $i); } } /** @return list The summaries of record(), in order. */ public function summaries(): array { return array_column($this->records, 'summary'); } private function refuseUnless(bool $allowed, string $call): void { if (! $allowed) { self::$unexpected[] = $call; throw new LogicException('Unexpected ChangeLog::' . $call); } } }