newInstanceWithoutConstructor(); $contract = new Contract(); $weekDays = ['2026-03-16', '2026-03-17', '2026-03-18', '2026-03-19', '2026-03-20', '2026-03-21', '2026-03-22']; $contractsByDate = [ '2026-03-16' => null, '2026-03-17' => null, '2026-03-18' => null, '2026-03-19' => $contract, '2026-03-20' => $contract, '2026-03-21' => $contract, '2026-03-22' => $contract, ]; $anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate); self::assertSame('2026-03-19', $anchor); } public function testResolveWeekAnchorDateReturnsFirstDayWhenItIsContracted(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $contract = new Contract(); $weekDays = ['2026-03-23', '2026-03-24', '2026-03-25']; $contractsByDate = [ '2026-03-23' => $contract, '2026-03-24' => $contract, '2026-03-25' => $contract, ]; $anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate); self::assertSame('2026-03-23', $anchor); } public function testResolveWeekAnchorDateFallsBackToFirstDayWhenNoContract(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $weekDays = ['2026-03-16', '2026-03-17']; $contractsByDate = ['2026-03-16' => null, '2026-03-17' => null]; $anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate); self::assertSame('2026-03-16', $anchor); } public function testResolveOvertime25BandWidthIs4hForH39(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $contract = new Contract()->setWeeklyHours(39); self::assertSame(4 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract)); } public function testResolveOvertime25BandWidthIs8hForH35(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $contract = new Contract()->setWeeklyHours(35); self::assertSame(8 * 60, $this->invokePrivate($service, 'resolveOvertime25BandWidthMinutes', $contract)); } /** * Dylan Chaboisson, semaine 12 : embauché le jeudi sur un contrat 39h. * Total travaillé 22h (1320 min), départ 25 % proraté aux jours contractés = 15h (900 min), * plafond 25 %/50 % = 15h + bande 4h = 19h (1140 min). Le plafond se décale avec * l'embauche au lieu de rester bloqué à 43h, ouvrant la tranche 50 %. */ public function testMidWeekHireSplitsOvertimeAcross25And50(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); [$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 1320, 900, 1140); self::assertSame(4 * 60, $base25); self::assertSame(3 * 60, $base50); } /** * Régression : semaine pleine 39h (départ 39h, plafond 43h), 46h travaillées → * 4h à 25 % (39→43) et 3h à 50 % (43→46), comportement inchangé. */ public function testFullWeekOvertimeSplitUnchanged(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); [$base25, $base50] = $this->invokePrivate($service, 'computeOvertimeBaseMinutes', 2760, 2340, 2580); self::assertSame(4 * 60, $base25); self::assertSame(3 * 60, $base50); } public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); // CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h). $detail = $this->invokePrivate( $service, 'buildWeekRecoveryDetail', false, // isPresence false, // disableBonuses true, // isCustom -120, // overtimeTotalMinutes 0, // rawBase25 0, // rawBase50 [], // dailyMinutes ); self::assertSame(-120, $detail->totalMinutes); self::assertTrue($detail->isFlatRecovery); self::assertSame(0, $detail->base25Minutes); self::assertSame(0, $detail->base50Minutes); } public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []); self::assertSame(180, $detail->totalMinutes); // 1h = 1h self::assertTrue($detail->isFlatRecovery); } public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); // 39h : overtime 300, base25 240, base50 60. $detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []); self::assertFalse($detail->isFlatRecovery); self::assertSame(240, $detail->base25Minutes); self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25) self::assertSame(60, $detail->base50Minutes); self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5) self::assertSame(300 + 60 + 30, $detail->totalMinutes); } /** * CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu − 0) − prorata. * attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 − 48 = 72. * (Combiné au naturel −120 de la semaine, donne −48 min.). */ public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $delta = $this->invokePrivate( $service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, // expectedMinutes (workDaysHours du lundi) 0, // workedMinutes (RTT posé / vide) ); self::assertSame(72, $delta); } /** * CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 − 120) − 48 = −48. */ public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120); self::assertSame(-48, $delta); } /** * CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 − 240) − 48 = −168. * Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à −prorata). */ public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240); self::assertSame(-168, $delta); } /** * CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0. */ public function testSolidarityAdjustmentCustom28hUsesProrata(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0); self::assertSame(0, $delta); } /** * CUSTOM ≥ 35h (36h) : hors périmètre → delta 0. */ public function testSolidarityAdjustmentCustom36hOutOfScope(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0); self::assertSame(0, $delta); } /** * 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi). */ public function testSolidarityAdjustment35hOutOfScope(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35); $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0); self::assertSame(0, $delta); } /** * Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0. */ public function testSolidarityAdjustmentNoContractIsZero(): void { $service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor(); $delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0); self::assertSame(0, $delta); } private static function customContract(int $weeklyHours): Contract { return new Contract() ->setName('Temps partiel') ->setTrackingMode(TrackingMode::TIME) ->setWeeklyHours($weeklyHours) ; } private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args); } }