newInstanceWithoutConstructor(); $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); $result = $method->invoke( $provider, 25.0, 25.0 / 12.0, new DateTimeImmutable('2025-06-10'), new DateTimeImmutable('2026-02-28') ); self::assertEqualsWithDelta(18.125, $result, 0.0001); } public function testComputeAccruingDaysTotalMatchesAlainCase(): void { $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor(); $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); $days = $method->invoke( $provider, 25.0, 25.0 / 12.0, new DateTimeImmutable('2025-06-10'), new DateTimeImmutable('2026-02-28') ); $saturdays = $method->invoke( $provider, 5.0, 5.0 / 12.0, new DateTimeImmutable('2025-06-10'), new DateTimeImmutable('2026-02-28') ); self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001); } public function testComputeAccruedDaysFromStartIncludesLastDayOfMonth(): void { $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor(); $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); $result = $method->invoke( $provider, 25.0, 25.0 / 12.0, new DateTimeImmutable('2026-02-01 12:50:18'), new DateTimeImmutable('2026-02-28 00:00:00') ); self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001); } // ----------------------------------------------------------------------- // Phase resolution tests (Task 3 — phaseId support). // The repository / service dependencies are typed against final classes // which PHPUnit cannot double, so phase resolution is exercised via // reflection on private methods to avoid instantiating the full DI graph. // ----------------------------------------------------------------------- public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; // oldest = 39h $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee); self::assertInstanceOf(ContractPhase::class, $resolved); self::assertSame($h39Phase->id, $resolved->id); self::assertSame(ContractType::H39, $resolved->contractType); self::assertFalse($resolved->isCurrent); } public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; // most recent = FORFAIT $provider = $this->buildProvider([]); $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee); self::assertSame($currentPhase->id, $resolved->id); self::assertSame(ContractType::FORFAIT, $resolved->contractType); self::assertTrue($resolved->isCurrent); } public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void { // Verifies resolveLeavePolicy uses the phase's contractType (not the current contract). $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); $from = new DateTimeImmutable('2025-06-01'); $to = new DateTimeImmutable('2026-04-30'); $leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to); self::assertNotNull($leavePolicy); self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']); self::assertSame(25.0, $leavePolicy['acquiredDays']); self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001); } public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void { // 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026. // The phase cap should clip the upper bound to April 30 2026. $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']); [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase); self::assertSame('2025-06-01', $from->format('Y-m-d')); self::assertSame('2026-04-30', $to->format('Y-m-d')); } public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void { // 11 full months of accrual at 25/12 ≈ 22.917 days. $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']); $method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart'); // Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026. [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase); $acquired = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to); self::assertEqualsWithDelta(22.92, $acquired, 0.1); } public function testNonForfaitPhaseStartingMidExerciseUsesFullExerciseFromAsStart(): void { // Scenario: 35h CDI from 2014-07-01 to 2025-10-31, then 39h CDI from 2025-11-01. // Both phases are non-forfait (same leave rule CDI_CDD_NON_FORFAIT). // Viewing exercise 2026 on the current 39h phase, accrual must run from the // exercise start (June 1, 2025), NOT from the phase start (November 1, 2025). // Otherwise the 5 months of June-October under 35h would be lost from the // annual CP accrual, which is wrong (CP exercise is annual, not per-phase). $employee = $this->buildH35ToH39Transition('2014-07-01', '2025-10-31', '2025-11-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[0]; // current $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']); [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase); self::assertSame('2025-06-01', $from->format('Y-m-d')); self::assertSame('2026-05-31', $to->format('Y-m-d')); } public function testForfaitPhaseStartingMidYearCapsFromAtPhaseStart(): void { // Scenario: 39h CDI ends 2026-04-30, FORFAIT from 2026-05-01. // Viewing year 2026 on the FORFAIT phase, the period must be capped at // phase start (May 1) so that only the FORFAIT portion of the calendar // year is counted. $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $forfaitPhase = $phases[0]; // current FORFAIT $provider = $this->buildProvider(['phaseId' => (string) $forfaitPhase->id, 'year' => '2026']); [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $forfaitPhase); self::assertSame('2026-05-01', $from->format('Y-m-d')); self::assertSame('2026-12-31', $to->format('Y-m-d')); } public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']); $year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase); self::assertSame(2026, $year); } public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; // Phase starts 2020-06-01 → first exercise (non-forfait) = 2021 (since month >=6 = year+1). $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']); $year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase); self::assertSame(2021, $year); } public function testInvalidPhaseIdReturns422(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $provider = $this->buildProvider(['phaseId' => '99999']); $this->expectException(UnprocessableEntityHttpException::class); $this->invokePrivate($provider, 'resolveTargetPhase', $employee); } public function testNonNumericPhaseIdReturns422(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $provider = $this->buildProvider(['phaseId' => 'abc']); $this->expectException(UnprocessableEntityHttpException::class); $this->invokePrivate($provider, 'resolveTargetPhase', $employee); } public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void { // No `year` param + explicit phaseId → default year is derived from $phase->endDate. // H39 phase ends 2026-04-30 → non-forfait exercise containing that date = 2026. $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); $year = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase); self::assertSame(2026, $year); } public function testNoQueryParamsKeepsLegacyYearDefaulting(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; $provider = $this->buildProvider([]); $year = $this->invokePrivate($provider, 'resolveYear', $employee, $currentPhase); // Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026). self::assertSame(2026, $year); } // ----------------------------------------------------------------------- // Regression: terminated-employee path through `computeYearSummary` without // an explicit phase (legacy callers: LeaveRecapRowBuilder, // DumpVerificationSnapshotCommand). Before the phase-aware refactor, the // period bounds were NOT capped at the contract end for terminated // employees (because Employee::getCurrentContractEndDate() returns null // when no period covers "today"). The new code resolves a fallback phase // whose `isCurrent` is false, which would otherwise cap `to` at the phase // end — a behavior change for legacy callers. The flag `applyPhaseEndCap` // toggles this cap so legacy callers get the pre-refactor behavior. // ----------------------------------------------------------------------- public function testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap(): void { // Terminated employee: H39 phase ending 2024-12-31 (well in the past). $employee = $this->buildTerminatedEmployee('2020-06-01', '2024-12-31'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); self::assertCount(1, $phases); $phase = $phases[0]; self::assertFalse($phase->isCurrent, 'Sanity: terminated phase must not be flagged as current.'); $provider = $this->buildProvider([]); // applyPhaseEndCap=false → mimics legacy callers (no explicit phase): // the upper bound MUST stay at the natural leave-year end (May 31). [$fromLegacy, $toLegacy] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, false); self::assertSame('2024-06-01', $fromLegacy->format('Y-m-d')); self::assertSame('2025-05-31', $toLegacy->format('Y-m-d')); // applyPhaseEndCap=true → explicit-phase callers get the cap at phase end. [$fromCap, $toCap] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, true); self::assertSame('2024-06-01', $fromCap->format('Y-m-d')); self::assertSame('2024-12-31', $toCap->format('Y-m-d')); } // ----------------------------------------------------------------------- // Test harness helpers. // ----------------------------------------------------------------------- /** * Build a terminated-employee fixture: a single H39 period ending before today. */ private function buildTerminatedEmployee(string $start, string $end): Employee { $employee = new Employee(); $this->setEntityId($employee, 2); $contract = new Contract(); $contract->setName('39H'); $contract->setTrackingMode(TrackingMode::TIME->value); $contract->setWeeklyHours(39); $period = new EmployeeContractPeriod(); $this->setEntityId($period, 10); $period->setEmployee($employee); $period->setContract($contract); $period->setStartDate(new DateTimeImmutable($start)); $period->setEndDate(new DateTimeImmutable($end)); $period->setContractNature(ContractNature::CDI); $period->setIsDriver(false); $employee->getContractPeriods()->add($period); return $employee; } /** * Build a two-period employee transitioning from H39 to FORFAIT. */ private function buildH35ToH39Transition(string $h35Start, string $h35End, string $h39Start): Employee { $employee = new Employee(); $this->setEntityId($employee, 1); $h35Contract = new Contract(); $h35Contract->setName('35H'); $h35Contract->setTrackingMode(TrackingMode::TIME->value); $h35Contract->setWeeklyHours(35); $h39Contract = new Contract(); $h39Contract->setName('39H'); $h39Contract->setTrackingMode(TrackingMode::TIME->value); $h39Contract->setWeeklyHours(39); $h35Period = new EmployeeContractPeriod(); $this->setEntityId($h35Period, 1); $h35Period->setEmployee($employee); $h35Period->setContract($h35Contract); $h35Period->setStartDate(new DateTimeImmutable($h35Start)); $h35Period->setEndDate(new DateTimeImmutable($h35End)); $h35Period->setContractNature(ContractNature::CDI); $h35Period->setIsDriver(false); $h39Period = new EmployeeContractPeriod(); $this->setEntityId($h39Period, 2); $h39Period->setEmployee($employee); $h39Period->setContract($h39Contract); $h39Period->setStartDate(new DateTimeImmutable($h39Start)); $h39Period->setEndDate(null); $h39Period->setContractNature(ContractNature::CDI); $h39Period->setIsDriver(false); $employee->getContractPeriods()->add($h35Period); $employee->getContractPeriods()->add($h39Period); return $employee; } private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee { $employee = new Employee(); $this->setEntityId($employee, 1); $h39Contract = new Contract(); $h39Contract->setName('39H'); $h39Contract->setTrackingMode(TrackingMode::TIME->value); $h39Contract->setWeeklyHours(39); $forfaitContract = new Contract(); $forfaitContract->setName('Forfait'); $forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value); $forfaitContract->setWeeklyHours(null); $h39Period = new EmployeeContractPeriod(); $this->setEntityId($h39Period, 1); $h39Period->setEmployee($employee); $h39Period->setContract($h39Contract); $h39Period->setStartDate(new DateTimeImmutable($h39Start)); $h39Period->setEndDate(new DateTimeImmutable($h39End)); $h39Period->setContractNature(ContractNature::CDI); $h39Period->setIsDriver(false); $forfaitPeriod = new EmployeeContractPeriod(); $this->setEntityId($forfaitPeriod, 2); $forfaitPeriod->setEmployee($employee); $forfaitPeriod->setContract($forfaitContract); $forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart)); $forfaitPeriod->setEndDate(null); $forfaitPeriod->setContractNature(ContractNature::CDI); $forfaitPeriod->setIsDriver(false); $employee->getContractPeriods()->add($h39Period); $employee->getContractPeriods()->add($forfaitPeriod); return $employee; } /** * Build an uninitialized provider with a RequestStack pre-loaded with the given query. * * The provider's repository/service dependencies are typed against final classes * (EmployeeRepository, LeaveBalanceComputationService, etc.) which PHPUnit cannot * double. We bypass full instantiation by using newInstanceWithoutConstructor and * only setting the properties that the tested private methods actually read: * `requestStack` and `phaseResolver`. Tests targeting heavier code paths exercise * private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.). * * @param array $request query parameters (year, phaseId, ...) */ private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider { $requestStack = new RequestStack(); $requestStack->push(new Request(query: $request)); $reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class); $provider = $reflection->newInstanceWithoutConstructor(); $this->setReadonlyProperty($provider, 'requestStack', $requestStack); $this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver()); $this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver()); $this->setReadonlyProperty($provider, 'dataStartDate', null); return $provider; } private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { $reflection = new ReflectionClass($obj::class); $m = $reflection->getMethod($method); return $m->invoke($obj, ...$args); } private function setReadonlyProperty(object $obj, string $property, mixed $value): void { $reflection = new ReflectionProperty($obj::class, $property); $reflection->setValue($obj, $value); } private function setEntityId(object $entity, int $id): void { $reflection = new ReflectionProperty($entity::class, 'id'); $reflection->setValue($entity, $id); } }