feat(stats): show gross earned leave by rule (#67)
This commit is contained in:
1 parent
3fbf9c3172
commit
da08ee054d
10 files changed
+571
-9
No files matched your search
@@ -25,6 +25,7 @@ import {
|
|||||||
PlanningOutcome,
|
PlanningOutcome,
|
||||||
PlanningObjectiveVector,
|
PlanningObjectiveVector,
|
||||||
PlanningTrace,
|
PlanningTrace,
|
||||||
|
PlanningTraceUpdate,
|
||||||
} from '../../services/leave-planner.service';
|
} from '../../services/leave-planner.service';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
@@ -408,7 +409,10 @@ function jointTrace(): PlanningTrace {
|
|||||||
|
|
||||||
describe('CalendarYear planning outcome boundary', () => {
|
describe('CalendarYear planning outcome boundary', () => {
|
||||||
beforeEach(() => localStorage.clear());
|
beforeEach(() => localStorage.clear());
|
||||||
afterEach(() => localStorage.clear());
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ partial: false, placements: 2 },
|
{ partial: false, placements: 2 },
|
||||||
@@ -418,11 +422,31 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
])(
|
])(
|
||||||
'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
|
'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
|
||||||
async ({ partial, placements }) => {
|
async ({ partial, placements }) => {
|
||||||
|
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
|
||||||
const pendingTrace = deferred<PlanningTrace | undefined>();
|
const pendingTrace = deferred<PlanningTrace | undefined>();
|
||||||
const validPlan: PlanOutcome = {
|
const validPlan: PlanOutcome = {
|
||||||
...plan,
|
...plan,
|
||||||
|
calculationDate: '2024-05-01',
|
||||||
placements: placements ? plan.placements : [],
|
placements: placements ? plan.placements : [],
|
||||||
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
|
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
|
||||||
|
ifThenContributions: [
|
||||||
|
{
|
||||||
|
ruleId: 'original-rule',
|
||||||
|
label: 'Original generating rule',
|
||||||
|
occurrence: '2024-05-08',
|
||||||
|
requested: 1,
|
||||||
|
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
|
||||||
|
placementDates: [],
|
||||||
|
earnedEntitlement: {
|
||||||
|
id: 'generated-entitlement',
|
||||||
|
type: 'cp',
|
||||||
|
quantity: 3,
|
||||||
|
availableFrom: '2024-05-09',
|
||||||
|
expiryDate: '2025-05-09',
|
||||||
|
added: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
metrics: {
|
metrics: {
|
||||||
...plan.metrics,
|
...plan.metrics,
|
||||||
averageWorkSequence: 10 / 3,
|
averageWorkSequence: 10 / 3,
|
||||||
@@ -485,6 +509,8 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
['Longest work sequence (working days)', '6'],
|
['Longest work sequence (working days)', '6'],
|
||||||
['Average time-off period (calendar days)', '2.5'],
|
['Average time-off period (calendar days)', '2.5'],
|
||||||
['Longest time-off period (calendar days)', '4'],
|
['Longest time-off period (calendar days)', '4'],
|
||||||
|
['Earned leave before use (days)', '3'],
|
||||||
|
['Original generating rule', '3'],
|
||||||
];
|
];
|
||||||
expect(statistics()).toEqual(expectedStatistics);
|
expect(statistics()).toEqual(expectedStatistics);
|
||||||
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
||||||
@@ -504,6 +530,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
rules.deleteRule(rules.rules[0].id);
|
rules.deleteRule(rules.rules[0].id);
|
||||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14');
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14');
|
||||||
input.declarationEntrySignal.set(true);
|
input.declarationEntrySignal.set(true);
|
||||||
|
vi.setSystemTime(new Date('2024-05-10T12:00:00Z'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(statistics()).toEqual(expectedStatistics);
|
expect(statistics()).toEqual(expectedStatistics);
|
||||||
@@ -512,6 +539,423 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each([2, 3])('shows 3 gross earned days after consuming %s', async (used) => {
|
||||||
|
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
|
||||||
|
try {
|
||||||
|
const core = await import('../../services/leave-planner.core');
|
||||||
|
const outcome = core.calculatePlanningInternal({
|
||||||
|
horizon: { start: '2024-04-29', end: used === 2 ? '2024-05-07' : '2024-05-09' },
|
||||||
|
balances: [],
|
||||||
|
declaredDays: ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'samedi',
|
||||||
|
name: 'Samedi Malin',
|
||||||
|
type: 'if_then',
|
||||||
|
force: 'preference',
|
||||||
|
dayOfWeek: 3,
|
||||||
|
placement: 'before',
|
||||||
|
placeDays: 2,
|
||||||
|
placeType: 'cp',
|
||||||
|
rewardDays: 3,
|
||||||
|
rewardType: 'other',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'use',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: '2024-05-02',
|
||||||
|
endDate: '2024-05-08',
|
||||||
|
placeDays: used,
|
||||||
|
placeType: 'other',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (outcome.kind !== 'Plan') throw new Error('Expected an earned-leave plan.');
|
||||||
|
expect(outcome.placements).toHaveLength(used);
|
||||||
|
expect(outcome.remainingBalances).toMatchObject(used === 2 ? [{ quantity: 1 }] : []);
|
||||||
|
const { calendar } = calendarFor(outcome);
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
TestBed.inject(TranslationService).setLanguage('en');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.setSystemTime(new Date('2024-05-02T12:00:00Z'));
|
||||||
|
fixture.nativeElement.querySelector('.statistics-button').click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const dialog = document.querySelector('[role="dialog"]')!;
|
||||||
|
expect(
|
||||||
|
dialog
|
||||||
|
.querySelector('.statistics-summary > div:last-child dd')
|
||||||
|
?.textContent?.trim(),
|
||||||
|
).toBe('3');
|
||||||
|
expect(
|
||||||
|
Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||||||
|
label.textContent?.trim(),
|
||||||
|
label.nextElementSibling?.textContent?.trim(),
|
||||||
|
]),
|
||||||
|
).toEqual([['Samedi Malin', '3']]);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gross earned leave from finalized occurrences', () => {
|
||||||
|
const producer = {
|
||||||
|
id: 'samedi',
|
||||||
|
name: 'Samedi Malin',
|
||||||
|
type: 'if_then',
|
||||||
|
force: 'preference',
|
||||||
|
dayOfWeek: 3,
|
||||||
|
placement: 'before',
|
||||||
|
placeDays: 2,
|
||||||
|
placeType: 'cp',
|
||||||
|
rewardDays: 2,
|
||||||
|
rewardType: 'other',
|
||||||
|
};
|
||||||
|
const declaredDays = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' }));
|
||||||
|
const submitted = {
|
||||||
|
horizon: { start: '2024-04-29', end: '2024-05-07' },
|
||||||
|
balances: [],
|
||||||
|
rules: [producer],
|
||||||
|
declaredDays,
|
||||||
|
};
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
scenario: 'availability after the calculation date',
|
||||||
|
today: '2024-05-01',
|
||||||
|
request: submitted,
|
||||||
|
total: 2,
|
||||||
|
availableFrom: '2024-05-02',
|
||||||
|
rows: [['Samedi Malin', '2']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'availability on the calculation date',
|
||||||
|
today: '2024-05-02',
|
||||||
|
request: submitted,
|
||||||
|
total: 0,
|
||||||
|
availableFrom: '2024-05-02',
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'availability before the calculation date',
|
||||||
|
today: '2024-05-03',
|
||||||
|
request: submitted,
|
||||||
|
total: 0,
|
||||||
|
availableFrom: '2024-05-02',
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'future earnings after the horizon without consumption',
|
||||||
|
today: '2024-05-01',
|
||||||
|
request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-02' } },
|
||||||
|
total: 2,
|
||||||
|
availableFrom: '2024-05-02',
|
||||||
|
rows: [['Samedi Malin', '2']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'a later extension makes a provisional historical right future',
|
||||||
|
today: '2024-05-03',
|
||||||
|
request: {
|
||||||
|
...submitted,
|
||||||
|
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
||||||
|
declaredDays: [...declaredDays, { date: '2024-05-03', type: 'other' }],
|
||||||
|
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }],
|
||||||
|
rules: [
|
||||||
|
producer,
|
||||||
|
{
|
||||||
|
id: 'extend',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: '2024-05-02',
|
||||||
|
endDate: '2024-05-02',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 2,
|
||||||
|
availableFrom: '2024-05-06',
|
||||||
|
rows: [['Samedi Malin', '2']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: 'repeated shared occurrences attributed to distinct same-name rules',
|
||||||
|
today: '2024-05-01',
|
||||||
|
request: {
|
||||||
|
...submitted,
|
||||||
|
horizon: { start: '2024-04-29', end: '2024-05-09' },
|
||||||
|
declaredDays: ['2024-04-30', '2024-05-07'].map((date) => ({
|
||||||
|
date,
|
||||||
|
type: 'cp',
|
||||||
|
})),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
...producer,
|
||||||
|
id: 'first',
|
||||||
|
name: 'Same name',
|
||||||
|
placeDays: 1,
|
||||||
|
rewardDays: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...producer,
|
||||||
|
id: 'second',
|
||||||
|
name: 'Same name',
|
||||||
|
placeDays: 1,
|
||||||
|
rewardDays: 2,
|
||||||
|
},
|
||||||
|
{ ...producer, placeDays: 1, rewardDays: 3 },
|
||||||
|
{ ...producer, id: 'disabled', enabled: false, rewardDays: 99 },
|
||||||
|
{ ...producer, id: 'zero', rewardDays: 0, placeDays: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 12,
|
||||||
|
availableFrom: '2024-05-02',
|
||||||
|
rows: [
|
||||||
|
['Same name', '2'],
|
||||||
|
['Same name', '4'],
|
||||||
|
['Samedi Malin', '6'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])('renders $scenario', async ({ today, request, total, availableFrom, rows }) => {
|
||||||
|
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||||
|
try {
|
||||||
|
const core = await import('../../services/leave-planner.core');
|
||||||
|
const outcome = core.calculatePlanningInternal(request);
|
||||||
|
if (outcome.kind !== 'Plan') throw new Error('Expected a valid generating plan.');
|
||||||
|
expect(outcome.ifThenContributions?.[0].earnedEntitlement?.availableFrom).toBe(
|
||||||
|
availableFrom,
|
||||||
|
);
|
||||||
|
const { calendar } = calendarFor(outcome);
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
TestBed.inject(TranslationService).setLanguage('en');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.setSystemTime(new Date('2025-01-01T12:00:00Z'));
|
||||||
|
fixture.nativeElement.querySelector('.statistics-button').click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const dialog = document.querySelector('[role="dialog"]')!;
|
||||||
|
expect(
|
||||||
|
dialog
|
||||||
|
.querySelector('.statistics-summary > div:last-child dd')
|
||||||
|
?.textContent?.trim(),
|
||||||
|
).toBe(String(total));
|
||||||
|
expect(
|
||||||
|
Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||||||
|
label.textContent?.trim(),
|
||||||
|
label.nextElementSibling?.textContent?.trim(),
|
||||||
|
]),
|
||||||
|
).toEqual(rows);
|
||||||
|
expect(!!dialog.querySelector('#earned-leave-title')).toBe(total > 0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['declarations', 'placements', 'both'])(
|
||||||
|
'renders an occurrence funded by %s',
|
||||||
|
async (source) => {
|
||||||
|
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
|
||||||
|
try {
|
||||||
|
const core = await import('../../services/leave-planner.core');
|
||||||
|
const fixed = source === 'declarations' ? 2 : source === 'both' ? 1 : 0;
|
||||||
|
const outcome = core.calculatePlanningInternal({
|
||||||
|
...submitted,
|
||||||
|
declaredDays: declaredDays.slice(0, fixed),
|
||||||
|
balances:
|
||||||
|
fixed === 2
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'cp',
|
||||||
|
type: 'cp',
|
||||||
|
quantity: 2 - fixed,
|
||||||
|
expiryDate: '2025-12-31',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (outcome.kind !== 'Plan')
|
||||||
|
throw new Error('Expected a valid generating plan.');
|
||||||
|
expect(outcome.ifThenContributions?.[0].declaredDays).toHaveLength(fixed);
|
||||||
|
expect(outcome.ifThenContributions?.[0].placementDates).toHaveLength(2 - fixed);
|
||||||
|
const { calendar } = calendarFor(outcome);
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
TestBed.inject(TranslationService).setLanguage('en');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.statistics-button').click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector(
|
||||||
|
'[role="dialog"] .statistics-summary > div:last-child dd',
|
||||||
|
)
|
||||||
|
?.textContent?.trim(),
|
||||||
|
).toBe('2');
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll('[role="dialog"] .earned-leave-breakdown dt'),
|
||||||
|
(label) => [
|
||||||
|
label.textContent?.trim(),
|
||||||
|
label.nextElementSibling?.textContent?.trim(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([['Samedi Malin', '2']]);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the retained earnings when cancellation interrupts a different generating candidate', async () => {
|
||||||
|
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
|
||||||
|
// Flush the original trace deterministically so cancellation occurs at the observed branch.
|
||||||
|
let elapsed = 0;
|
||||||
|
const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30));
|
||||||
|
let checkpoint: PlanOutcome | undefined;
|
||||||
|
let interrupted = false;
|
||||||
|
let latestGenerator: string | undefined;
|
||||||
|
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
|
||||||
|
try {
|
||||||
|
const core = await import('../../services/leave-planner.core');
|
||||||
|
const outcome = await core.calculatePlanningAsyncInternal(
|
||||||
|
{
|
||||||
|
horizon: { start: '2024-04-29', end: '2024-05-11' },
|
||||||
|
declaredDays: [
|
||||||
|
{ date: '2024-04-29', type: 'cp' },
|
||||||
|
{ date: '2024-05-03', type: 'cp' },
|
||||||
|
{ date: '2024-05-06', type: 'other' },
|
||||||
|
],
|
||||||
|
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'consumer',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: '2024-05-10',
|
||||||
|
endDate: '2024-05-10',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'other',
|
||||||
|
},
|
||||||
|
...['before', 'after'].map((placement, index) => ({
|
||||||
|
id: placement,
|
||||||
|
name: placement,
|
||||||
|
type: 'if_then',
|
||||||
|
force: 'preference',
|
||||||
|
dayOfWeek: 3,
|
||||||
|
placement,
|
||||||
|
placeDays: 2,
|
||||||
|
placeType: 'cp',
|
||||||
|
rewardDays: index + 1,
|
||||||
|
rewardType: 'other',
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
traceProgress: (_: number, update: PlanningTraceUpdate) => {
|
||||||
|
if (interrupted) return;
|
||||||
|
for (const operation of update.operations) {
|
||||||
|
const deltas =
|
||||||
|
operation.kind === 'delta'
|
||||||
|
? [operation.delta]
|
||||||
|
: operation.kind === 'decision'
|
||||||
|
? (operation.decision.branch?.deltas ?? [])
|
||||||
|
: [];
|
||||||
|
for (const delta of deltas) {
|
||||||
|
if (delta.kind !== 'earned-activated') continue;
|
||||||
|
latestGenerator = delta.activation?.ruleId;
|
||||||
|
if (checkpoint && latestGenerator === 'before') interrupted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(value) => (checkpoint = value),
|
||||||
|
(value) => (artifacts = value),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => interrupted,
|
||||||
|
);
|
||||||
|
clock.mockRestore();
|
||||||
|
if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
|
||||||
|
throw new Error('Expected a real cancellation with a retained valid plan.');
|
||||||
|
expect(interrupted).toBe(true);
|
||||||
|
expect(latestGenerator).toBe('before');
|
||||||
|
expect(outcome.incumbent.ifThenContributions).toMatchObject([
|
||||||
|
{ ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } },
|
||||||
|
]);
|
||||||
|
expect(outcome.incumbent.ifThenContributions).toHaveLength(1);
|
||||||
|
expect(outcome.incumbent.placements.map(({ date }) => date)).toEqual([
|
||||||
|
'2024-05-02',
|
||||||
|
'2024-05-10',
|
||||||
|
]);
|
||||||
|
const latestBranch = artifacts.trace?.phases
|
||||||
|
.flatMap(({ decisions }) => decisions)
|
||||||
|
.filter(({ branch }) => branch)
|
||||||
|
.at(-1);
|
||||||
|
expect(latestBranch?.candidates).toMatchObject([
|
||||||
|
{ status: 'evaluated-rejected', dates: ['2024-04-29', '2024-04-30'] },
|
||||||
|
]);
|
||||||
|
expect(latestBranch?.branch?.deltas).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'earned-activated',
|
||||||
|
activation: expect.objectContaining({ ruleId: 'before' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { calendar } = calendarFor(outcome, artifacts.explanation, artifacts.trace);
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
TestBed.inject(TranslationService).setLanguage('en');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.nativeElement.querySelector('.statistics-button').click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const dialog = document.querySelector('[role="dialog"]')!;
|
||||||
|
expect(dialog.textContent).toContain('Partial plan');
|
||||||
|
expect(
|
||||||
|
dialog
|
||||||
|
.querySelector('.statistics-summary > div:last-child dd')
|
||||||
|
?.textContent?.trim(),
|
||||||
|
).toBe('2');
|
||||||
|
expect(
|
||||||
|
Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||||||
|
label.textContent?.trim(),
|
||||||
|
label.nextElementSibling?.textContent?.trim(),
|
||||||
|
]),
|
||||||
|
).toEqual([['after', '2']]);
|
||||||
|
} finally {
|
||||||
|
clock.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it.each([false, true])(
|
it.each([false, true])(
|
||||||
'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)',
|
'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)',
|
||||||
async (allDaysOff) => {
|
async (allDaysOff) => {
|
||||||
@@ -547,7 +991,11 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
|
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
|
||||||
value.textContent?.trim(),
|
value.textContent?.trim(),
|
||||||
),
|
),
|
||||||
).toEqual(allDaysOff ? ['0', '4', '—', '0', '9', '9'] : ['0', '0', '5', '5', '—', '—']);
|
).toEqual(
|
||||||
|
allDaysOff
|
||||||
|
? ['0', '4', '—', '0', '9', '9', '0']
|
||||||
|
: ['0', '0', '5', '5', '—', '—', '0'],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -595,7 +1043,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
expect(dialog.textContent).toContain(calendarUnit);
|
expect(dialog.textContent).toContain(calendarUnit);
|
||||||
expect(
|
expect(
|
||||||
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
|
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
|
||||||
).toEqual(['2', '4', '3,333', '6', '2,5', '4']);
|
).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0']);
|
||||||
}
|
}
|
||||||
expect(dialog.textContent).toContain('Statistiques du planning');
|
expect(dialog.textContent).toContain('Statistiques du planning');
|
||||||
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
|
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
|
||||||
@@ -2891,7 +3339,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
|
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
|
||||||
value.textContent?.trim(),
|
value.textContent?.trim(),
|
||||||
),
|
),
|
||||||
).toEqual(['2', '4', '6', '6', '—', '—']);
|
).toEqual(['2', '4', '6', '6', '—', '—', '0']);
|
||||||
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
|
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
|
||||||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||||||
activateWithKeyboard(afterButton);
|
activateWithKeyboard(afterButton);
|
||||||
|
|||||||
@@ -1295,7 +1295,35 @@
|
|||||||
</dt>
|
</dt>
|
||||||
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
|
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>
|
||||||
|
{{ tr().planning.statistics.earnedLeave }} ({{
|
||||||
|
tr().planning.statistics.leaveDaysUnit
|
||||||
|
}})
|
||||||
|
</dt>
|
||||||
|
<dd>{{ formatStatistic(earnedLeave().total) }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
@if (earnedLeave().contributions.length) {
|
||||||
|
<section aria-labelledby="earned-leave-title">
|
||||||
|
<h3 id="earned-leave-title">
|
||||||
|
{{ tr().planning.statistics.earnedLeaveByRule }} ({{
|
||||||
|
tr().planning.statistics.leaveDaysUnit
|
||||||
|
}})
|
||||||
|
</h3>
|
||||||
|
<dl class="earned-leave-breakdown">
|
||||||
|
@for (
|
||||||
|
contribution of earnedLeave().contributions;
|
||||||
|
track contribution.ruleId
|
||||||
|
) {
|
||||||
|
<div>
|
||||||
|
<dt>{{ contribution.label }}</dt>
|
||||||
|
<dd>{{ formatStatistic(contribution.quantity) }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
|
|||||||
@@ -228,6 +228,25 @@ summary:focus-visible {
|
|||||||
.statistics-summary {
|
.statistics-summary {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
.earned-leave-breakdown {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.placement-explanation,
|
.placement-explanation,
|
||||||
.outcome-explanation,
|
.outcome-explanation,
|
||||||
|
|||||||
@@ -202,6 +202,25 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
|
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
|
||||||
|
protected readonly earnedLeave = computed(() => {
|
||||||
|
const byRule = new Map<string, { ruleId: string; label: string; quantity: number }>();
|
||||||
|
for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) {
|
||||||
|
const entitlement = contribution.earnedEntitlement;
|
||||||
|
if (!entitlement?.added || entitlement.quantity <= 0) continue;
|
||||||
|
const entry = byRule.get(contribution.ruleId) ?? {
|
||||||
|
ruleId: contribution.ruleId,
|
||||||
|
label: contribution.label,
|
||||||
|
quantity: 0,
|
||||||
|
};
|
||||||
|
entry.quantity += entitlement.quantity;
|
||||||
|
byRule.set(entry.ruleId, entry);
|
||||||
|
}
|
||||||
|
const contributions = [...byRule.values()];
|
||||||
|
return {
|
||||||
|
total: contributions.reduce((total, contribution) => total + contribution.quantity, 0),
|
||||||
|
contributions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
protected readonly planExplanation = computed(() => {
|
protected readonly planExplanation = computed(() => {
|
||||||
const explanation = this.planningExplanation();
|
const explanation = this.planningExplanation();
|
||||||
|
|||||||
@@ -214,9 +214,36 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
]);
|
]);
|
||||||
if (outcome.kind === 'PlanningIncomplete')
|
if (outcome.kind === 'PlanningIncomplete')
|
||||||
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||||
|
expect(plan.remainingBalances).toEqual([]);
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const dialog = document.querySelector('[role="dialog"]')!;
|
||||||
|
const earnedStatistics = () => [
|
||||||
|
dialog
|
||||||
|
.querySelector('.statistics-summary > div:last-child dt')
|
||||||
|
?.textContent?.replace(/\s+/g, ' ')
|
||||||
|
.trim(),
|
||||||
|
dialog
|
||||||
|
.querySelector('.statistics-summary > div:last-child dd')
|
||||||
|
?.textContent?.trim(),
|
||||||
|
...Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||||||
|
label.textContent?.trim(),
|
||||||
|
label.nextElementSibling?.textContent?.trim(),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
expect(earnedStatistics()).toEqual([
|
||||||
|
'Congés acquis avant consommation (jours)',
|
||||||
|
'1',
|
||||||
|
['CP autour du férié', '1'],
|
||||||
|
]);
|
||||||
|
expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
|
||||||
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
|
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
|
||||||
input.toggleDeclaredDay('2024-04-30', 'rtt');
|
input.toggleDeclaredDay('2024-04-30', 'rtt');
|
||||||
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
|
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
|
||||||
|
rules.deleteRule('producer');
|
||||||
input.updateBalances(() => []);
|
input.updateBalances(() => []);
|
||||||
if (worker) {
|
if (worker) {
|
||||||
await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0));
|
await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0));
|
||||||
@@ -231,6 +258,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
const trace = await calculation.trace;
|
const trace = await calculation.trace;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
expect(earnedStatistics()).toEqual([
|
||||||
|
'Congés acquis avant consommation (jours)',
|
||||||
|
'1',
|
||||||
|
['CP autour du férié', '1'],
|
||||||
|
]);
|
||||||
|
dialog.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||||||
expect(explanation).toMatchObject({
|
expect(explanation).toMatchObject({
|
||||||
kind: 'PlanExplanation',
|
kind: 'PlanExplanation',
|
||||||
calculationDate: '2024-05-01',
|
calculationDate: '2024-05-01',
|
||||||
@@ -258,7 +292,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
expect(plan.placements).toEqual(direct.placements);
|
expect(plan.placements).toEqual(direct.placements);
|
||||||
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
|
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
|
||||||
expect(plan.metrics).toEqual(direct.metrics);
|
expect(plan.metrics).toEqual(direct.metrics);
|
||||||
const root: HTMLElement = fixture.nativeElement;
|
|
||||||
expect(root.textContent).toContain('Droit futur généré');
|
expect(root.textContent).toContain('Droit futur généré');
|
||||||
expect(root.textContent).toContain('CP autour du férié');
|
expect(root.textContent).toContain('CP autour du férié');
|
||||||
expect(root.textContent).not.toContain('Règle modifiée après calcul');
|
expect(root.textContent).not.toContain('Règle modifiée après calcul');
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ export interface AppTranslations {
|
|||||||
averageWorkSequence: string;
|
averageWorkSequence: string;
|
||||||
averageTimeOffPeriod: string;
|
averageTimeOffPeriod: string;
|
||||||
longestTimeOffPeriod: string;
|
longestTimeOffPeriod: string;
|
||||||
|
earnedLeave: string;
|
||||||
|
earnedLeaveByRule: string;
|
||||||
|
leaveDaysUnit: string;
|
||||||
workingDays: string;
|
workingDays: string;
|
||||||
calendarDays: string;
|
calendarDays: string;
|
||||||
partialPlan: string;
|
partialPlan: string;
|
||||||
|
|||||||
+4
-1
@@ -146,7 +146,10 @@
|
|||||||
"workingDays": "Arbeitstage",
|
"workingDays": "Arbeitstage",
|
||||||
"calendarDays": "Kalendertage",
|
"calendarDays": "Kalendertage",
|
||||||
"partialPlan": "Teilplan",
|
"partialPlan": "Teilplan",
|
||||||
"help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen."
|
"help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen.",
|
||||||
|
"earnedLeave": "Erworbener Urlaub vor Verbrauch",
|
||||||
|
"leaveDaysUnit": "Tage",
|
||||||
|
"earnedLeaveByRule": "Erworbener Urlaub nach Regel"
|
||||||
},
|
},
|
||||||
"calculationDate": "Berechnungsdatum",
|
"calculationDate": "Berechnungsdatum",
|
||||||
"futureEntitlement": "Erzeugter künftiger Anspruch",
|
"futureEntitlement": "Erzeugter künftiger Anspruch",
|
||||||
|
|||||||
+4
-1
@@ -146,7 +146,10 @@
|
|||||||
"workingDays": "working days",
|
"workingDays": "working days",
|
||||||
"calendarDays": "calendar days",
|
"calendarDays": "calendar days",
|
||||||
"partialPlan": "Partial plan",
|
"partialPlan": "Partial plan",
|
||||||
"help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances."
|
"help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances.",
|
||||||
|
"earnedLeave": "Earned leave before use",
|
||||||
|
"leaveDaysUnit": "days",
|
||||||
|
"earnedLeaveByRule": "Earned leave by rule"
|
||||||
},
|
},
|
||||||
"calculationDate": "Calculation date",
|
"calculationDate": "Calculation date",
|
||||||
"futureEntitlement": "Future entitlement generated",
|
"futureEntitlement": "Future entitlement generated",
|
||||||
|
|||||||
+4
-1
@@ -146,7 +146,10 @@
|
|||||||
"workingDays": "días laborables",
|
"workingDays": "días laborables",
|
||||||
"calendarDays": "días naturales",
|
"calendarDays": "días naturales",
|
||||||
"partialPlan": "Plan parcial",
|
"partialPlan": "Plan parcial",
|
||||||
"help": "Estadísticas del plan calculado. Los días ya reservados definen los períodos de descanso y trabajo sin consumir de nuevo los saldos restantes."
|
"help": "Estadísticas del plan calculado. Los días ya reservados definen los períodos de descanso y trabajo sin consumir de nuevo los saldos restantes.",
|
||||||
|
"earnedLeave": "Permisos adquiridos antes de su uso",
|
||||||
|
"leaveDaysUnit": "días",
|
||||||
|
"earnedLeaveByRule": "Permisos adquiridos por regla"
|
||||||
},
|
},
|
||||||
"calculationDate": "Fecha del cálculo",
|
"calculationDate": "Fecha del cálculo",
|
||||||
"futureEntitlement": "Derecho futuro generado",
|
"futureEntitlement": "Derecho futuro generado",
|
||||||
|
|||||||
+4
-1
@@ -146,7 +146,10 @@
|
|||||||
"workingDays": "jours ouvrés",
|
"workingDays": "jours ouvrés",
|
||||||
"calendarDays": "jours calendaires",
|
"calendarDays": "jours calendaires",
|
||||||
"partialPlan": "Planning partiel",
|
"partialPlan": "Planning partiel",
|
||||||
"help": "Statistiques du planning calculé. Les jours déjà posés contribuent aux périodes de repos et de travail sans consommer à nouveau les soldes."
|
"help": "Statistiques du planning calculé. Les jours déjà posés contribuent aux périodes de repos et de travail sans consommer à nouveau les soldes.",
|
||||||
|
"earnedLeave": "Congés acquis avant consommation",
|
||||||
|
"leaveDaysUnit": "jours",
|
||||||
|
"earnedLeaveByRule": "Congés acquis par règle"
|
||||||
},
|
},
|
||||||
"calculationDate": "Date du calcul",
|
"calculationDate": "Date du calcul",
|
||||||
"futureEntitlement": "Droit futur généré",
|
"futureEntitlement": "Droit futur généré",
|
||||||
|
|||||||
Reference in new issue
Block a user