import { TestBed } from '@angular/core/testing'; import { SchedulingRun, SchedulingState, SchedulingStatus } from './scheduling-status'; class FakeEventSource { static current: FakeEventSource; readonly close = vi.fn(); onmessage?: (event: { data: string }) => void; onerror?: () => void; constructor(readonly url: string) { FakeEventSource.current = this; } send(runs: SchedulingRun[] | null): void { this.onmessage?.({ data: JSON.stringify({ runs }) }); } } function run(state: SchedulingState, runId = 'run-1'): SchedulingRun { return { runId, schoolId: 1, showId: 2, showName: 'Zomershow', startedAt: '2026-11-03T12:00:00Z', finishedAt: state === 'RUNNING' ? null : '2026-11-03T12:01:02Z', state, progress: { phase: 'VARIETY', feasible: true }, message: 'Afgerond', summary: null, }; } describe('Scheduling status over SSE', () => { let status: SchedulingStatus; beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal('EventSource', FakeEventSource); TestBed.configureTestingModule({}); status = TestBed.inject(SchedulingStatus); status.connect(); }); afterEach(() => { TestBed.resetTestingModule(); vi.unstubAllGlobals(); vi.useRealTimers(); }); it('recovers an existing outcome without replaying old notifications', () => { expect(FakeEventSource.current.url).toBe('/api/scheduling/events'); FakeEventSource.current.send([run('OPTIMAL')]); expect(status.run(1, 2)?.state).toBe('OPTIMAL'); expect(status.notifications()).toEqual([]); expect(status.elapsed(run('OPTIMAL'))).toBe('1m 2s'); expect(status.phase(run('OPTIMAL'))).toBe('Variatie'); }); it('announces a completion missed during disconnection exactly once', () => { FakeEventSource.current.send([run('RUNNING')]); FakeEventSource.current.onerror?.(); expect(status.connected()).toBe(false); const disconnected = FakeEventSource.current; vi.advanceTimersByTime(3000); expect(FakeEventSource.current).not.toBe(disconnected); expect(disconnected.close).toHaveBeenCalledOnce(); expect(status.run(1, 2)?.state).toBe('RUNNING'); FakeEventSource.current.send([run('VALID')]); expect(status.connected()).toBe(true); expect(status.notifications()).toHaveLength(1); status.dismiss(); FakeEventSource.current.send([run('VALID')]); expect(status.notifications()).toEqual([]); }); it('does not treat heartbeats or phase changes as completions', () => { FakeEventSource.current.send(null); FakeEventSource.current.send([run('OPTIMAL')]); expect(status.notifications()).toEqual([]); FakeEventSource.current.send([run('RUNNING', 'run-2')]); FakeEventSource.current.send(null); expect(status.run(1, 2)?.runId).toBe('run-2'); expect(status.notifications()).toEqual([]); FakeEventSource.current.send([run('SOLVER_FAILED', 'run-2')]); expect(status.notifications()).toHaveLength(1); }); it('announces a locally started run completed before the first SSE snapshot', () => { FakeEventSource.current.onerror?.(); status.started(run('RUNNING')); vi.advanceTimersByTime(3000); FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]); expect(status.notifications().map((item) => item.runId)).toEqual(['run-1']); status.dismiss(); FakeEventSource.current.onerror?.(); vi.advanceTimersByTime(3000); FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]); expect(status.notifications()).toEqual([]); }); it('retains a fast completion when the start response arrives after SSE', () => { FakeEventSource.current.send([]); FakeEventSource.current.send([run('OPTIMAL')]); status.started(run('RUNNING')); expect(status.run(1, 2)?.state).toBe('OPTIMAL'); status.connect(); const source = FakeEventSource.current; TestBed.resetTestingModule(); expect(source.close).toHaveBeenCalledOnce(); }); });