AI Implementation feature(905): Challenges Page and Challenge Solve Modal 1.01 (#47)

This commit was merged in pull request #47.
This commit is contained in:
2026-07-23 01:20:00 +00:00
parent 1bd4a5a198
commit cbfa9c35ca
9 changed files with 437 additions and 115 deletions
+20
View File
@@ -183,6 +183,26 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
expect(handler).toHaveBeenCalledTimes(2);
});
it('repeating the same zero-countdown SSE frame does not disarm the auto-reload latch (Job 905)', () => {
const handler = jest.fn();
eventStore.reloadAtCountdownZero(handler);
const frame = {
state: 'countdown' as const,
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 0,
secondsToEnd: null,
};
eventStore.applyServerStatus(frame);
eventStore.applyServerStatus(frame);
eventStore.applyServerStatus(frame);
(eventStore as any).checkZero();
expect(handler).toHaveBeenCalledTimes(1);
});
it('isRunning flips true only when state is running', async () => {
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
await challenges.load();
@@ -0,0 +1,193 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { EventStatusStore } from '../../frontend/src/app/core/services/event-status.store';
import { EventStatePayload } from '../../frontend/src/app/core/services/event-status.pure';
describe('EventStatusStore auto-reload watcher (Job 905)', () => {
let store: EventStatusStore;
function payload(over: Partial<EventStatePayload>): EventStatePayload {
const start = new Date(Date.now() + 60_000).toISOString();
const end = new Date(Date.now() + 7_200_000).toISOString();
return {
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: start,
eventEndUtc: end,
secondsToStart: 60,
secondsToEnd: null,
...over,
};
}
beforeEach(() => {
jest.useFakeTimers();
const injector = Injector.create({ providers: [] });
store = runInInjectionContext(injector, () => new EventStatusStore());
});
afterEach(() => {
if (store) store.stop();
jest.useRealTimers();
});
it('fires the reload handler exactly once when secondsToStart crosses zero via the real setInterval path', () => {
const handler = jest.fn();
store.reloadAtCountdownZero(handler);
store.applyServerStatus(
payload({ state: 'countdown', secondsToStart: 2, eventStartUtc: new Date(Date.now() + 2000).toISOString() }),
);
jest.advanceTimersByTime(3000);
expect(handler).toHaveBeenCalledTimes(1);
});
it('re-arms the latch on a state transition so a later end-of-event zero reloads again', () => {
const handler = jest.fn();
store.reloadAtCountdownZero(handler);
store.applyServerStatus(
payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(handler).toHaveBeenCalledTimes(1);
store.applyServerStatus(
payload({
state: 'running',
secondsToStart: null,
secondsToEnd: 9001,
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
eventEndUtc: new Date(Date.now() + 9001_000).toISOString(),
}),
);
jest.advanceTimersByTime(1000);
expect(handler).toHaveBeenCalledTimes(1);
store.applyServerStatus(
payload({
state: 'running',
secondsToStart: null,
secondsToEnd: 0,
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
eventEndUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(handler).toHaveBeenCalledTimes(2);
});
it('registering a new handler replaces the previous one without double-firing', () => {
const first = jest.fn();
const second = jest.fn();
store.reloadAtCountdownZero(first);
store.reloadAtCountdownZero(second);
store.applyServerStatus(
payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it('stop() resets the latch so a fresh handler can fire on the next zero', () => {
const first = jest.fn();
store.reloadAtCountdownZero(first);
store.applyServerStatus(
payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(first).toHaveBeenCalledTimes(1);
store.stop();
const second = jest.fn();
store.reloadAtCountdownZero(second);
store.applyServerStatus(
payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(second).toHaveBeenCalledTimes(1);
});
it('does not disarm the latch when the same zero-countdown payload is delivered repeatedly', () => {
const handler = jest.fn();
store.reloadAtCountdownZero(handler);
const frame = payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
});
store.applyServerStatus(frame);
store.applyServerStatus(frame);
store.applyServerStatus(frame);
jest.advanceTimersByTime(2000);
expect(handler).toHaveBeenCalledTimes(1);
});
it('subscribeReloadAtCountdownZero unregister prevents the handler from firing', () => {
const handler = jest.fn();
const unsubscribe = store.subscribeReloadAtCountdownZero(handler);
unsubscribe();
store.applyServerStatus(
payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(handler).not.toHaveBeenCalled();
});
it('start() preserves a previously registered reload handler (Job 905 handler ownership)', () => {
const handler = jest.fn();
store.subscribeReloadAtCountdownZero(handler);
const fakeSource = {
addEventListener: jest.fn(),
close: jest.fn(),
};
store.start(() => fakeSource as any);
expect((store as any).reloadOnZero).toBe(handler);
store.applyServerStatus(
payload({
state: 'countdown',
secondsToStart: 0,
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
}),
);
jest.advanceTimersByTime(2000);
expect(handler).toHaveBeenCalledTimes(1);
});
});