Files
SIRH/tests/State/EmployeeLeaveSummaryProviderTest.php
tristan 03add0d45a fix(leave) : forfait work-target ignores bonus/fractioned days
The header target subtracted summary.acquiredDays, which includes fractionedDays
(and bonusDays via the full-year acquired), so a full-year forfait with weekend
work or HR fractioned days showed <218. Full year = contractual 218 (capped at
period business days); entry year = businessDays − entry acquired (repos + carried
CP, excluding bonus/fractioned). Extracted computeForfaitWorkTargetDays + 3 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:04:39 +02:00

606 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\State;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class EmployeeLeaveSummaryProviderTest extends TestCase
{
// -----------------------------------------------------------------------
// Existing tests (unchanged) — verify accrual prorating arithmetic.
// -----------------------------------------------------------------------
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): 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('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);
}
public function testComputeForfaitWorkTargetDaysEntryYearProrates(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Grégory : 168 jours ouvrés sur la période 12.94 congés acquis (repos + CP reportés) ≈ 155.06
$result = $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 168, true, 12.94);
self::assertEqualsWithDelta(155.06, $result, 0.001);
}
public function testComputeForfaitWorkTargetDaysFullYearIs218IgnoringBonusAndFractioned(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année pleine : la cible reste 218 quelle que soit la valeur des congés acquis
// (les bonus week-end/férié et jours fractionnés ne réduisent pas la cible).
self::assertSame(218.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 252, false, 34.0));
self::assertSame(218.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 252, false, 40.0));
}
public function testComputeForfaitWorkTargetDaysFullYearCapsAtBusinessDaysWhenFewer(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Période avec moins de 218 jours ouvrés (phase forfait clôturée en cours d'année) → cap aux jours ouvrés.
self::assertSame(200.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 200, false, 5.0));
}
public function testComputeProratedForfaitRepoDaysGregoryCase(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
// repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);
self::assertEqualsWithDelta(6.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année pleine : 9 × 252/252 = 9.0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);
self::assertEqualsWithDelta(9.0, $result, 0.001);
}
public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
// Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);
self::assertSame(0.0, $result);
}
public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
{
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
}
// -----------------------------------------------------------------------
// 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 testIsForfaitEntryYearTrueOnStartYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
}
public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
$provider = $this->buildProvider();
// Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
}
public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
$provider = $this->buildProvider();
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
}
public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$forfaitPhase = $phases[0]; // current FORFAIT
$h39Phase = $phases[1];
$provider = $this->buildProvider();
$prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);
self::assertNotNull($prior);
self::assertSame($h39Phase->id, $prior->id);
self::assertSame(ContractType::H39, $prior->contractType);
}
public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
{
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
$firstPhase = $phases[1]; // the H39 (earliest)
$provider = $this->buildProvider();
self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
}
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<string, string> $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);
}
}