assertSame('5.0000', $line); } public function test_filament_line_includes_explicit_waste_without_double_counting(): void { // 250g consumed + 10g explicit purge/support waste = 260g at £20.00/kg $line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000', waste: '10'); $this->assertSame('5.2000', $line); } public function test_packaging_line_is_quantity_times_unit_rate(): void { $line = ProjectCosting::lineTotal('packaging', '3', 'unit', '0.750000'); $this->assertSame('2.2500', $line); } public function test_shipping_line_total_is_quantity_times_parcel_rate(): void { $line = ProjectCosting::lineTotal('shipping', '1', 'parcel', '4.990000'); $this->assertSame('4.9900', $line); } public function test_shipping_unit_contribution_divides_rate_by_units_per_parcel_not_batch_yield(): void { // One £4.99 parcel covering 2 saleable units ships at £2.495 per unit, // independent of however many units the manufacturing batch itself yielded. $this->assertSame('2.4950', ProjectCosting::shippingUnitContribution('4.990000', 2)); } public function test_shipping_unit_contribution_rejects_zero_or_negative_units_per_parcel(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::shippingUnitContribution('4.990000', 0); } public function test_filament_rejects_a_quantity_unit_not_in_the_category_allowlist(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('filament', '250', 'G', '20.000000'); } public function test_filament_rejects_kilograms_as_the_quantity_unit(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('filament', '0.25', 'kg', '20.000000'); } public function test_packaging_rejects_a_quantity_unit_not_in_the_category_allowlist(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('packaging', '1', 'units', '0.650000'); } public function test_shipping_rejects_a_quantity_unit_not_in_the_category_allowlist(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('shipping', '1', 'box', '4.990000'); } public function test_aggregate_rejects_a_category_total_exceeding_the_storable_decimal_precision(): void { // decimal(14,4) tops out around 9,999,999,999.9999; two large-but-individually-valid // lines can still sum past that bound, so the aggregate step must re-check it. $this->expectException(InvalidArgumentException::class); ProjectCosting::aggregate([ ['category' => 'filament', 'line_total' => '9999999999.0000'], ['category' => 'filament', 'line_total' => '9999999999.0000'], ]); } public function test_aggregate_rejects_a_grand_total_exceeding_the_storable_decimal_precision(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::aggregate([ ['category' => 'filament', 'line_total' => '9999999999.0000'], ['category' => 'packaging', 'line_total' => '9999999999.0000'], ]); } public function test_zero_quantity_is_a_valid_intentional_zero(): void { $this->assertSame('0.0000', ProjectCosting::lineTotal('packaging', '0', 'unit', '5.000000')); } public function test_negative_quantity_is_rejected(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('packaging', '-1', 'unit', '5.000000'); } public function test_negative_rate_is_rejected(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('packaging', '1', 'unit', '-5.000000'); } public function test_absurd_quantity_overflows_and_is_rejected(): void { $this->expectException(\Throwable::class); ProjectCosting::lineTotal('filament', '999999999999', 'g', '999999999999.000000'); } public function test_aggregate_distinguishes_missing_category_from_intentional_zero(): void { $totals = ProjectCosting::aggregate([ ['category' => 'filament', 'line_total' => '5.0000'], ['category' => 'packaging', 'line_total' => '0.0000'], ]); $this->assertSame('5.0000', $totals['filament_total']); $this->assertSame('0.0000', $totals['packaging_total']); $this->assertNull($totals['shipping_total']); $this->assertSame('5.0000', $totals['grand_total']); } public function test_aggregate_grand_total_is_null_when_nothing_costed(): void { $totals = ProjectCosting::aggregate([]); $this->assertNull($totals['filament_total']); $this->assertNull($totals['packaging_total']); $this->assertNull($totals['shipping_total']); $this->assertNull($totals['grand_total']); } public function test_aggregate_sums_multiple_lines_per_category(): void { $totals = ProjectCosting::aggregate([ ['category' => 'filament', 'line_total' => '5.0000'], ['category' => 'filament', 'line_total' => '2.5000'], ]); $this->assertSame('7.5000', $totals['filament_total']); } public function test_per_unit_divides_grand_total_by_batch_yield_with_rounding(): void { $this->assertSame('3.3333', ProjectCosting::perUnit('10.0000', 3)); } public function test_per_unit_rejects_zero_or_negative_yield(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::perUnit('10.0000', 0); } public function test_rejects_unknown_category(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::lineTotal('labour', '1', 'unit', '1.000000'); } public function test_rejects_mixed_currency_summation(): void { $this->expectException(InvalidArgumentException::class); ProjectCosting::assertSameCurrency(['GBP', 'USD']); } public function test_allows_single_consistent_currency(): void { ProjectCosting::assertSameCurrency(['GBP', 'GBP', 'GBP']); $this->addToAssertionCount(1); } public function test_validates_iso_currency_allowlist(): void { $this->assertTrue(ProjectCosting::isValidCurrency('GBP')); $this->assertFalse(ProjectCosting::isValidCurrency('XYZ')); } }