From 5c5369328a469c884fbbcf89a6ddb4ae4a97c1e3 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 22 Jun 2026 00:54:23 +0200 Subject: [PATCH] 32 - Add ProjectionPreviewOutput DTO with fromResult mapping --- src/ApiResource/ProjectionPreviewOutput.php | 41 ++++++ .../ProjectionPreviewOutputTest.php | 124 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/ApiResource/ProjectionPreviewOutput.php create mode 100644 tests/ApiResource/ProjectionPreviewOutputTest.php diff --git a/src/ApiResource/ProjectionPreviewOutput.php b/src/ApiResource/ProjectionPreviewOutput.php new file mode 100644 index 0000000..9c457da --- /dev/null +++ b/src/ApiResource/ProjectionPreviewOutput.php @@ -0,0 +1,41 @@ +allocations = $this->setAllocationsFromResult($result); + $this->totalAllocated = $result->getTotalAllocated(); + $this->unallocated = $result->getUnallocated(); + } + + public static function fromResult(Result $result): static + { + return new self($result); + } + + private function setAllocationsFromResult(Result $result): array + { + return new ArrayCollection($result->getAllocations()) + ->map(static function (array $allocation) { + return [ + 'bucket_id' => (string) $allocation['bucket']->getId(), + 'bucket_name' => $allocation['bucket']->getName(), + 'bucket_type' => $allocation['bucket']->getType()->value, + 'allocated_amount' => $allocation['amount'], + 'remaining_capacity' => null, + ]; + })->toArray(); + } +} diff --git a/tests/ApiResource/ProjectionPreviewOutputTest.php b/tests/ApiResource/ProjectionPreviewOutputTest.php new file mode 100644 index 0000000..d8b495c --- /dev/null +++ b/tests/ApiResource/ProjectionPreviewOutputTest.php @@ -0,0 +1,124 @@ + string (uuid, RFC4122), 'bucket_name' => + * string, 'bucket_type' => string (enum backing value), 'allocated_amount' => int, + * 'remaining_capacity' => null] + * - totalAllocated: int (Result::getTotalAllocated()) + * - unallocated: int (Result::getUnallocated()) + * + * `remaining_capacity` is ALWAYS null in this contract — a deliberate simplification. + * The anemic Bucket entity has no getEffectiveCapacity(); computing real remaining + * capacity here would couple this DTO to BucketRoomCalculator/FillStage/income, which + * doesn't belong in a pure mapping function. Real capacity is a follow-up, not this story. + */ +final class ProjectionPreviewOutputTest extends TestCase +{ + public function testFromResultMapsAllocationsToTheOutputShape(): void + { + $scenario = $this->makeScenario(); + + $need = $this->makeBucket( + $scenario, + BucketType::NEED, + BucketAllocationType::FIXED_LIMIT, + priority: 1, + allocationValue: 5000, + name: 'Groceries', + ); + $overflow = $this->makeBucket( + $scenario, + BucketType::OVERFLOW, + BucketAllocationType::UNLIMITED, + priority: 2, + allocationValue: null, + name: 'Overflow', + ); + + $result = new Result( + [ + ['bucket' => $need, 'amount' => 5000], + ['bucket' => $overflow, 'amount' => 3000], + ], + 8000, + 0, + ); + + $output = ProjectionPreviewOutput::fromResult($result); + + self::assertCount(2, $output->allocations); + + self::assertSame((string) $need->getId(), $output->allocations[0]['bucket_id']); + self::assertSame('Groceries', $output->allocations[0]['bucket_name']); + self::assertSame('need', $output->allocations[0]['bucket_type']); + self::assertSame(5000, $output->allocations[0]['allocated_amount']); + self::assertNull($output->allocations[0]['remaining_capacity']); + + self::assertSame((string) $overflow->getId(), $output->allocations[1]['bucket_id']); + self::assertSame('Overflow', $output->allocations[1]['bucket_name']); + self::assertSame('overflow', $output->allocations[1]['bucket_type']); + self::assertSame(3000, $output->allocations[1]['allocated_amount']); + self::assertNull($output->allocations[1]['remaining_capacity']); + + self::assertSame(8000, $output->totalAllocated); + self::assertSame(0, $output->unallocated); + } + + public function testFromResultWithNoAllocationsProducesEmptyAllocationsList(): void + { + $result = new Result([], 0, 5000); + + $output = ProjectionPreviewOutput::fromResult($result); + + self::assertSame([], $output->allocations); + self::assertSame(0, $output->totalAllocated); + self::assertSame(5000, $output->unallocated); + } + + public function testFromResultReturnsAProjectionPreviewOutputInstance(): void + { + $result = new Result([], 0, 1000); + + $output = ProjectionPreviewOutput::fromResult($result); + + self::assertInstanceOf(ProjectionPreviewOutput::class, $output); + } + + private function makeScenario(): Scenario + { + return (new Scenario())->setName('Household Budget'); + } + + private function makeBucket( + Scenario $scenario, + BucketType $type, + BucketAllocationType $allocationType, + int $priority, + ?int $allocationValue, + string $name, + ): Bucket { + return (new Bucket()) + ->setScenario($scenario) + ->setType($type) + ->setName($name) + ->setPriority($priority) + ->setAllocationType($allocationType) + ->setAllocationValue($allocationValue) + ->setStartingAmount(0) + ->setBufferMultiplier('0.00'); + } +}