service()->fold($opening, [$week], $this->payments()); // 10h report + 5h earned = 15h carried (NOT 5h). self::assertSame(900, $closing->totalMinutes); self::assertSame(600 + 240, $closing->base25Minutes); self::assertSame(60, $closing->bonus25Minutes); } public function testPaymentsAreDeductedFromClosing(): void { $opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600); $week = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300); // 7h paid out of the 25% base bucket. $closing = $this->service()->fold($opening, [$week], $this->payments(b25: 420)); self::assertSame(900 - 420, $closing->totalMinutes); self::assertSame(600 + 240 - 420, $closing->base25Minutes); } public function testDeficitWeekConsumesFiftyTierBeforeTwentyFiveTier(): void { // Opening: 60min in 50%-base, 120min in 25%-base. $opening = new WeekRecoveryDetail(base25Minutes: 120, base50Minutes: 60, totalMinutes: 180); // Deficit week of 100min (worked less than reference): buckets 0, negative total. $deficit = new WeekRecoveryDetail(totalMinutes: -100); $closing = $this->service()->fold($opening, [$deficit], $this->payments()); // 50%-base absorbs 60 first, the remaining 40 hits the 25%-base. self::assertSame(0, $closing->base50Minutes); self::assertSame(80, $closing->base25Minutes); self::assertSame(80, $closing->totalMinutes); } public function testCustomRecoveryWithoutBucketsStillCountsInTotal(): void { // CUSTOM contract: positive total recovery (1h=1h) but every 25/50 bucket is 0. $custom = new WeekRecoveryDetail(totalMinutes: 180); // 3h plain recovery $closing = $this->service()->fold(new WeekRecoveryDetail(), [$custom], $this->payments()); // The 3h must survive into the carried report (sum of buckets == total). self::assertSame(180, $closing->totalMinutes); self::assertSame( 180, $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes, ); } public function testCustomDeficitWeekReducesClosingBalance(): void { // CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h // (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture. $recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h $deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h $closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments()); // 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total. self::assertSame(120, $closing->totalMinutes); self::assertSame( 120, $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes, ); } public function testBucketSumAlwaysEqualsTotalInvariant(): void { $opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400); $weeks = [ new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300), new WeekRecoveryDetail(totalMinutes: -500), // deeper deficit than tiers hold new WeekRecoveryDetail(totalMinutes: 90), // custom-style recovery ]; $closing = $this->service()->fold($opening, $weeks, $this->payments(b25: 120, b50: 30)); $bucketSum = $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes; self::assertSame($closing->totalMinutes, $bucketSum); // opening 400 + earned (300 - 500 + 90 = -110) - paid 150 = 140 self::assertSame(140, $closing->totalMinutes); } private function service(): RttClosingBalanceService { return new ReflectionClass(RttClosingBalanceService::class)->newInstanceWithoutConstructor(); } private function payments(int $b25 = 0, int $bo25 = 0, int $b50 = 0, int $bo50 = 0): WeekRecoveryDetail { return new WeekRecoveryDetail( base25Minutes: $b25, bonus25Minutes: $bo25, base50Minutes: $b50, bonus50Minutes: $bo50, totalMinutes: $b25 + $bo25 + $b50 + $bo50, ); } }