init
This commit is contained in:
@@ -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"}']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user