This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
/**
* WS reconnect state machine + frame contract tests.
*
* The ramp under test is the one the service team must expect from the client:
* 1s → 2s → 4s → 8s → 16s → 30s (cap), jittered ±25%, reset on a successful
* open.
*/
import {
AlarmSocket,
DEFAULT_RECONNECT_POLICY,
buildAck,
buildPing,
parseFrame,
ReconnectMachine,
type AlarmFrame,
} from '../src/services/alarmSocket';
// A deterministic "random" that always returns the midpoint, so jitter is 1.0×
// and the ramp is exactly the nominal one.
const midpoint = () => 0.5;
describe('ReconnectMachine ramp', () => {
it('follows 1s→2s→4s→8s→16s→30s and saturates at the cap', () => {
const m = new ReconnectMachine({}, midpoint);
expect(m.nominalDelayFor(1)).toBe(1000);
expect(m.nominalDelayFor(2)).toBe(2000);
expect(m.nominalDelayFor(3)).toBe(4000);
expect(m.nominalDelayFor(4)).toBe(8000);
expect(m.nominalDelayFor(5)).toBe(16000);
expect(m.nominalDelayFor(6)).toBe(30000);
expect(m.nominalDelayFor(7)).toBe(30000);
expect(m.nominalDelayFor(50)).toBe(30000);
});
it('applies the configured jitter band', () => {
const low = new ReconnectMachine({}, () => 0).nominalDelayFor(3);
const high = new ReconnectMachine({}, () => 1).nominalDelayFor(3);
const lo = new ReconnectMachine({}, () => 0).delayFor(3);
const hi = new ReconnectMachine({}, () => 1).delayFor(3);
expect(lo).toBe(Math.round(low * (1 - DEFAULT_RECONNECT_POLICY.jitter)));
expect(hi).toBe(Math.round(high * (1 + DEFAULT_RECONNECT_POLICY.jitter)));
});
it('reports the same delay through onClosed as delayFor(attempt)', () => {
const m = new ReconnectMachine({}, midpoint);
expect(m.onClosed()).toBe(1000);
expect(m.onClosed()).toBe(2000);
expect(m.onClosed()).toBe(4000);
});
it('resets the ramp after a successful open', () => {
const m = new ReconnectMachine({}, midpoint);
m.onClosed();
m.onClosed();
m.onClosed();
expect(m.getAttempts()).toBe(3);
m.onOpened();
expect(m.getAttempts()).toBe(0);
expect(m.getState()).toBe('open');
expect(m.onClosed()).toBe(1000);
});
it('walks the state machine idle → connecting → open → reconnecting', () => {
const m = new ReconnectMachine({}, midpoint);
expect(m.getState()).toBe('idle');
m.onConnecting();
expect(m.getState()).toBe('connecting');
m.onOpened();
expect(m.getState()).toBe('open');
m.onClosed();
expect(m.getState()).toBe('reconnecting');
m.onClosedPermanently();
expect(m.getState()).toBe('closed');
});
it('never returns a negative delay', () => {
const m = new ReconnectMachine({ baseMs: 0 }, midpoint);
expect(m.delayFor(1)).toBeGreaterThanOrEqual(0);
});
});
describe('frame contract', () => {
it('parses a trigger frame', () => {
const raw = JSON.stringify({
type: 'alarm',
alertId: 'alr_1',
eventId: 'evt_9',
action: 'trigger',
message: 'Fire drill',
serverTime: '2026-10-18T17:00:00Z',
autoStopSeconds: 60,
});
expect(parseFrame(raw)).toEqual({
type: 'alarm',
alertId: 'alr_1',
eventId: 'evt_9',
action: 'trigger',
message: 'Fire drill',
serverTime: '2026-10-18T17:00:00Z',
autoStopSeconds: 60,
});
});
it('parses a disarm frame', () => {
const frame = parseFrame(
JSON.stringify({ type: 'alarm', alertId: 'a', eventId: 'e', action: 'disarm' }),
);
expect(frame.type).toBe('alarm');
expect(frame.type === 'alarm' && frame.action).toBe('disarm');
});
it('parses ack and ping frames', () => {
expect(parseFrame('{"type":"ack","eventId":"e1"}')).toEqual({ type: 'ack', eventId: 'e1' });
expect(parseFrame('{"type":"ping"}')).toEqual({ type: 'ping' });
});
it('degrades malformed / unknown frames to unknown instead of throwing', () => {
for (const junk of [
'',
'not json',
'[]',
'null',
'{"type":"alarm"}',
'{"type":"alarm","alertId":"a","eventId":"e","action":"explode"}',
'{"type":"ack"}',
'{"type":"something-else"}',
]) {
expect(parseFrame(junk)).toEqual({ type: 'unknown' });
}
});
it('builds the client→server frames exactly', () => {
expect(buildAck('evt_9')).toBe('{"type":"ack","eventId":"evt_9"}');
expect(buildPing()).toBe('{"type":"ping"}');
});
});
// ── A minimal fake WebSocket so the wrapper is exercised without a network ───
class FakeSocket {
static last: FakeSocket | null = null;
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
sent: string[] = [];
closed = false;
constructor(public url: string) {
FakeSocket.last = this;
}
send(data: string) {
this.sent.push(data);
}
close() {
this.closed = true;
this.onclose?.();
}
// test helpers
open() {
this.onopen?.();
}
message(data: unknown) {
this.onmessage?.({ data });
}
drop() {
this.onclose?.();
}
}
describe('AlarmSocket wrapper', () => {
const url = 'ws://127.0.0.1:8088/ws/alarm?alertId=alr_1&token=sub_x';
it('acks a received alarm frame automatically', () => {
const alarms: AlarmFrame[] = [];
const socket = new AlarmSocket({
url,
onAlarm: f => alarms.push(f),
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
random: midpoint,
pingIntervalMs: 0,
});
socket.connect();
const ws = FakeSocket.last!;
ws.open();
ws.message(JSON.stringify({ type: 'alarm', alertId: 'alr_1', eventId: 'evt_1', action: 'trigger' }));
expect(alarms).toHaveLength(1);
expect(ws.sent).toEqual(['{"type":"ack","eventId":"evt_1"}']);
expect(socket.getState()).toBe('open');
});
it('schedules a reconnect with the ramp delay after a drop', () => {
const delays: number[] = [];
const socket = new AlarmSocket({
url,
onAlarm: () => {},
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
random: midpoint,
pingIntervalMs: 0,
setTimer: (fn, ms) => {
delays.push(ms);
return 0 as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: () => {},
});
socket.connect();
FakeSocket.last!.open();
FakeSocket.last!.drop();
FakeSocket.last!.drop();
expect(delays).toEqual([1000, 2000]);
expect(socket.getState()).toBe('reconnecting');
});
it('stops reconnecting once closed by the user', () => {
let timers = 0;
const socket = new AlarmSocket({
url,
onAlarm: () => {},
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
random: midpoint,
pingIntervalMs: 0,
setTimer: () => {
timers += 1;
return 0 as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: () => {},
});
socket.connect();
FakeSocket.last!.open();
socket.close();
expect(socket.getState()).toBe('closed');
expect(timers).toBe(0);
});
it('does not send when the socket is not open', () => {
const socket = new AlarmSocket({
url,
onAlarm: () => {},
socketFactory: u => new FakeSocket(u) as unknown as WebSocket,
random: midpoint,
pingIntervalMs: 0,
});
socket.connect();
expect(socket.send('{"type":"ping"}')).toBe(false);
FakeSocket.last!.open();
expect(socket.send('{"type":"ping"}')).toBe(true);
expect(FakeSocket.last!.sent).toEqual(['{"type":"ping"}']);
});
});