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'); } }