fix(planning): react only to calculations started by the mounted calendar
Re-mounting the calendar no longer records a duration sample or announces the previous result, explanation or rejected explanation. Starting a calculation during a cancellation announces loading and keeps focus where it is. Only rejected explanation deliveries are announced, as before. Replacement tests now replace a real plan. Refs #275, #274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
43677ff434
commit
feb651148e
4 files changed
+216
-8
No files matched your search
@@ -1992,6 +1992,146 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('does not calibrate or announce a previous result when the planner is remounted', async () => {
|
||||||
|
const { calendar } = calendarFor(plan, explanation);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
...calculationFor(plan, explanation),
|
||||||
|
outcome: new Promise(() => undefined),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const record = vi.spyOn(TestBed.inject(PlanningDurationService), 'recordCompletion');
|
||||||
|
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
const first = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(first);
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
first.destroy();
|
||||||
|
|
||||||
|
const second = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(second);
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second.componentInstance['liveMessage']()).toBe(
|
||||||
|
second.componentInstance['tr']().loading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not announce a previous rejected explanation when the planner is remounted', async () => {
|
||||||
|
const { calendar } = calendarFor(plan, explanation);
|
||||||
|
const rejected = {
|
||||||
|
...calculationFor(plan, explanation),
|
||||||
|
explanation: Promise.reject(new Error('explanation exploded')),
|
||||||
|
};
|
||||||
|
rejected.explanation.catch(() => undefined);
|
||||||
|
calendar.createPlanningCalculation.mockReturnValueOnce(rejected).mockReturnValueOnce({
|
||||||
|
...calculationFor(plan, explanation),
|
||||||
|
outcome: new Promise(() => undefined),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
const first = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(first);
|
||||||
|
expect(first.componentInstance['liveMessage']()).toContain('explanation exploded');
|
||||||
|
first.destroy();
|
||||||
|
|
||||||
|
const second = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(second);
|
||||||
|
expect(second.componentInstance['liveMessage']()).toBe(
|
||||||
|
second.componentInstance['tr']().loading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces loading and keeps focus when a calculation starts during a cancellation', async () => {
|
||||||
|
const first = deferred<PlanningOutcome>();
|
||||||
|
const { calendar, calculation } = calendarFor(plan, undefined);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce({ ...calculation, outcome: first.promise })
|
||||||
|
.mockReturnValueOnce({ ...calculation, outcome: new Promise(() => undefined) });
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const live = () => fixture.componentInstance['liveMessage']();
|
||||||
|
const tr = fixture.componentInstance['tr']();
|
||||||
|
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.cancel-calculation')!);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(live()).toBe(tr.planning.cancelling);
|
||||||
|
const outside = document.createElement('button');
|
||||||
|
document.body.append(outside);
|
||||||
|
outside.focus();
|
||||||
|
|
||||||
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
first.resolve(plan);
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(outside);
|
||||||
|
expect(live()).toBe(tr.loading);
|
||||||
|
outside.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a delivered explanation error', false],
|
||||||
|
['a rejected explanation', true],
|
||||||
|
])('announces only %s when it rejected', async (_, rejected) => {
|
||||||
|
const reason = { code: 'explanation-error', message: 'Explanation boom' };
|
||||||
|
const { calendar } = calendarFor(plan, undefined);
|
||||||
|
calendar.createPlanningCalculation.mockReturnValue({
|
||||||
|
...calculationFor(plan, undefined),
|
||||||
|
explanation: rejected
|
||||||
|
? Promise.reject(new Error(reason.message))
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
explanationError: Promise.resolve(rejected ? undefined : reason),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const service = TestBed.inject(PlanningCalculationService);
|
||||||
|
expect(service.explanationError()).toMatchObject(reason);
|
||||||
|
expect(service.explanationRejected()).toBe(rejected);
|
||||||
|
expect(fixture.componentInstance['liveMessage']().includes(reason.message)).toBe(rejected);
|
||||||
|
});
|
||||||
|
|
||||||
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
@@ -2190,6 +2330,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
['PlanningIncomplete without incumbent', incomplete],
|
['PlanningIncomplete without incumbent', incomplete],
|
||||||
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
|
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
|
||||||
const { calendar } = calendarFor(outcome);
|
const { calendar } = calendarFor(outcome);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||||
|
.mockReturnValueOnce(calculationFor(outcome));
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -2198,10 +2341,26 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
||||||
|
const service = TestBed.inject(PlanningCalculationService);
|
||||||
|
const settle = async () => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
await settle();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(service.displayedPlan()).toBeDefined();
|
||||||
|
expect(
|
||||||
|
Array.from(root.querySelectorAll('.plan-summary dd')).map((e) => e.textContent?.trim()),
|
||||||
|
).toEqual(['2', '4', '6']);
|
||||||
|
|
||||||
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
|
service.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(service.displayedPlan()).toBeUndefined();
|
||||||
|
expect(root.querySelector('.plan-summary')).toBeNull();
|
||||||
|
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps and summarizes a returned result', async () => {
|
it('keeps and summarizes a returned result', async () => {
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ export class CalendarYear {
|
|||||||
|
|
||||||
private counterfactualVersion = 0;
|
private counterfactualVersion = 0;
|
||||||
private wasCancelling = false;
|
private wasCancelling = false;
|
||||||
|
private readonly initialAccepted = this.calculations.accepted();
|
||||||
|
private readonly initialExplanation = this.calculations.explanation();
|
||||||
|
private readonly initialExplanationError = this.calculations.explanationError();
|
||||||
private displayDateLocale = '';
|
private displayDateLocale = '';
|
||||||
private displayDateFormatter?: Intl.DateTimeFormat;
|
private displayDateFormatter?: Intl.DateTimeFormat;
|
||||||
|
|
||||||
@@ -314,18 +317,20 @@ export class CalendarYear {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.isLoading()) untracked(() => this.liveMessage.set(this.tr().loading));
|
if (this.calculations.pendingId() !== null)
|
||||||
|
untracked(() => this.liveMessage.set(this.tr().loading));
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
|
const pending = this.calculations.pendingId() !== null;
|
||||||
const cancelling = this.isCancelling();
|
const cancelling = this.isCancelling();
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
if (this.wasCancelling && !cancelling) this.resultsTitle()?.nativeElement.focus();
|
if (this.wasCancelling && !pending) this.resultsTitle()?.nativeElement.focus();
|
||||||
this.wasCancelling = cancelling;
|
this.wasCancelling = cancelling;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const accepted = this.calculations.accepted();
|
const accepted = this.calculations.accepted();
|
||||||
if (!accepted) return;
|
if (!accepted || accepted === this.initialAccepted) return;
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
this.closeStatistics();
|
this.closeStatistics();
|
||||||
this.selectedPlacementIndex.set(0);
|
this.selectedPlacementIndex.set(0);
|
||||||
@@ -351,7 +356,8 @@ export class CalendarYear {
|
|||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const explanation = this.calculations.explanation();
|
const explanation = this.calculations.explanation();
|
||||||
if (explanation?.kind !== 'PlanExplanation') return;
|
if (explanation?.kind !== 'PlanExplanation' || explanation === this.initialExplanation)
|
||||||
|
return;
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
this.selectPlacement(0, false);
|
this.selectPlacement(0, false);
|
||||||
const outcome = this.planningOutcome();
|
const outcome = this.planningOutcome();
|
||||||
@@ -362,7 +368,11 @@ export class CalendarYear {
|
|||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const reason = this.explanationError();
|
const reason = this.explanationError();
|
||||||
if (reason?.code === 'explanation-error')
|
if (
|
||||||
|
reason &&
|
||||||
|
reason !== this.initialExplanationError &&
|
||||||
|
this.calculations.explanationRejected()
|
||||||
|
)
|
||||||
untracked(() =>
|
untracked(() =>
|
||||||
this.liveMessage.set(
|
this.liveMessage.set(
|
||||||
`${this.tr().planning.explanationError} ${reason.message}`,
|
`${this.tr().planning.explanationError} ${reason.message}`,
|
||||||
|
|||||||
@@ -155,6 +155,33 @@ describe('UserInput', () => {
|
|||||||
expect(card.textContent).toBe(explanation);
|
expect(card.textContent).toBe(explanation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => {
|
||||||
|
await calculate({
|
||||||
|
horizon: { start: '2027-05-01', end: '2027-05-10' },
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
id: 'other',
|
||||||
|
type: 'other',
|
||||||
|
quantity: 1,
|
||||||
|
availableFrom: '2027-05-10',
|
||||||
|
expiryDate: '2028-05-10',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(calculations.displayedPlan()).toBeDefined();
|
||||||
|
expect(root.querySelector('.warning-card')).not.toBeNull();
|
||||||
|
|
||||||
|
await show(calculations.accepted()!.calculation!, {
|
||||||
|
kind: 'PlanningConflict',
|
||||||
|
reasons: [],
|
||||||
|
conflictingRuleIds: ['rule'],
|
||||||
|
});
|
||||||
|
expect(calculations.displayedPlan()).toBeUndefined();
|
||||||
|
expect(root.querySelector('.warning-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
|
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
|
||||||
TestBed.inject(TranslationService).setLanguage('fr');
|
TestBed.inject(TranslationService).setLanguage('fr');
|
||||||
await calculate({
|
await calculate({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { UserInputService } from './user-input-service';
|
|||||||
type Horizon = { start: string; end: string };
|
type Horizon = { start: string; end: string };
|
||||||
|
|
||||||
interface PendingCalculation {
|
interface PendingCalculation {
|
||||||
|
id: number;
|
||||||
horizon: Horizon;
|
horizon: Horizon;
|
||||||
progress: number;
|
progress: number;
|
||||||
cancelling: boolean;
|
cancelling: boolean;
|
||||||
@@ -78,6 +79,7 @@ export class PlanningCalculationService {
|
|||||||
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
|
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
|
||||||
private readonly explanationState = signal<PlanningExplanation | null>(null);
|
private readonly explanationState = signal<PlanningExplanation | null>(null);
|
||||||
private readonly explanationErrorState = signal<PlanningReason | null>(null);
|
private readonly explanationErrorState = signal<PlanningReason | null>(null);
|
||||||
|
private readonly explanationRejectedState = signal(false);
|
||||||
|
|
||||||
readonly request = computed(() => {
|
readonly request = computed(() => {
|
||||||
const horizon = this.settings.planningHorizon();
|
const horizon = this.settings.planningHorizon();
|
||||||
@@ -101,6 +103,8 @@ export class PlanningCalculationService {
|
|||||||
readonly accepted = this.acceptedState.asReadonly();
|
readonly accepted = this.acceptedState.asReadonly();
|
||||||
readonly explanation = this.explanationState.asReadonly();
|
readonly explanation = this.explanationState.asReadonly();
|
||||||
readonly explanationError = this.explanationErrorState.asReadonly();
|
readonly explanationError = this.explanationErrorState.asReadonly();
|
||||||
|
readonly explanationRejected = this.explanationRejectedState.asReadonly();
|
||||||
|
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
|
||||||
readonly isCalculating = computed(() => this.pendingState() !== null);
|
readonly isCalculating = computed(() => this.pendingState() !== null);
|
||||||
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
|
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
|
||||||
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
|
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
|
||||||
@@ -124,7 +128,12 @@ export class PlanningCalculationService {
|
|||||||
const requestedAt = performance.now();
|
const requestedAt = performance.now();
|
||||||
const version = ++this.version;
|
const version = ++this.version;
|
||||||
this.userInput.declarationEntrySignal.set(false);
|
this.userInput.declarationEntrySignal.set(false);
|
||||||
this.pendingState.set({ horizon: request.horizon, progress: 0, cancelling: false });
|
this.pendingState.set({
|
||||||
|
id: version,
|
||||||
|
horizon: request.horizon,
|
||||||
|
progress: 0,
|
||||||
|
cancelling: false,
|
||||||
|
});
|
||||||
let calculation: PlanningCalculationContext;
|
let calculation: PlanningCalculationContext;
|
||||||
try {
|
try {
|
||||||
calculation = this.calendar.createPlanningCalculation({
|
calculation = this.calendar.createPlanningCalculation({
|
||||||
@@ -193,6 +202,7 @@ export class PlanningCalculationService {
|
|||||||
const cancelling = this.pendingState()?.cancelling ?? false;
|
const cancelling = this.pendingState()?.cancelling ?? false;
|
||||||
this.explanationState.set(null);
|
this.explanationState.set(null);
|
||||||
this.explanationErrorState.set(null);
|
this.explanationErrorState.set(null);
|
||||||
|
this.explanationRejectedState.set(false);
|
||||||
this.pendingState.set(null);
|
this.pendingState.set(null);
|
||||||
this.acceptedState.set({
|
this.acceptedState.set({
|
||||||
calculation,
|
calculation,
|
||||||
@@ -209,7 +219,9 @@ export class PlanningCalculationService {
|
|||||||
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
||||||
const current = () => this.acceptedState()?.calculation === calculation;
|
const current = () => this.acceptedState()?.calculation === calculation;
|
||||||
const fail = (error: unknown) => {
|
const fail = (error: unknown) => {
|
||||||
if (current()) this.explanationErrorState.set(explanationFailure(error));
|
if (!current()) return;
|
||||||
|
this.explanationRejectedState.set(true);
|
||||||
|
this.explanationErrorState.set(explanationFailure(error));
|
||||||
};
|
};
|
||||||
calculation.explanation
|
calculation.explanation
|
||||||
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
|
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
|
||||||
|
|||||||
Reference in new issue
Block a user