Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Replace the old touchmark/PNG identity with the supplied lockup and monogram, segmented role-aware navigation, Live/Draft listing filters with generation-based cache invalidation, and admin Projects covering immutable file revisions, listing live pointers, and exact-decimal filament/packaging/shipping costing. Additive schema only; no Etsy publication. Isolated clone tests: 301 tests, 1162 assertions.
185 lines
6.9 KiB
PHP
185 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Services\ProjectCosting;
|
|
use InvalidArgumentException;
|
|
use Tests\TestCase;
|
|
|
|
class ProjectCostingTest extends TestCase
|
|
{
|
|
public function test_filament_line_converts_grams_to_kilograms_before_pricing(): void
|
|
{
|
|
// 250g at £20.00/kg = £5.00
|
|
$line = ProjectCosting::lineTotal('filament', '250', 'g', '20.000000');
|
|
$this->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'));
|
|
}
|
|
}
|