Skip to content
markpaper

src/ws/selfHeal.test.ts

v0.3.0 · 3.4 KB

Download file
import { describe, expect, it } from 'vitest';
import { createStalenessTracker } from './selfHeal.js';

const S = 1_000;
const TICK = 30 * S;

describe('createStalenessTracker', () => {
  it('does not resubscribe during the grace period after track()', () => {
    const t = createStalenessTracker();
    t.track('u1', 0);
    expect(t.tick(TICK)).toEqual([]);
    expect(t.tick(3 * TICK)).toEqual([]); // 90 s is not beyond the threshold yet
  });

  it('resubscribes an address silent > 90 s after 2 stale ticks', () => {
    const t = createStalenessTracker();
    t.track('u1', 0);
    expect(t.tick(91 * S)).toEqual([]); // stale tick 1
    expect(t.tick(121 * S)).toEqual(['u1']); // stale tick 2
  });

  it('backs off exponentially: required ticks 2, 4, 8, ... capped at 20', () => {
    const t = createStalenessTracker();
    t.track('u1', 0);
    const resubTicks: number[] = [];
    for (let i = 1; i <= 80; i++) {
      if (t.tick(61 * S + i * TICK).length > 0) resubTicks.push(i); // tick 1 at 91 s is already stale
    }
    const gaps = resubTicks.map((v, i) => v - (resubTicks[i - 1] ?? 0));
    expect(gaps.slice(0, 6)).toEqual([2, 4, 8, 16, 20, 20]);
  });

  it('a fresh message resets both counters; a resubscribe does not refresh lastSeen', () => {
    const t = createStalenessTracker();
    t.track('u1', 0);
    t.tick(91 * S);
    expect(t.tick(121 * S)).toEqual(['u1']); // attempts = 1, next needs 4 ticks
    expect(t.tick(151 * S)).toEqual([]); // still stale: resubscribe did not count as a message
    t.markSeen('u1', 160 * S);
    expect(t.tick(181 * S)).toEqual([]);
    // silent again: back to 2 ticks because attempts were reset
    expect(t.tick(251 * S)).toEqual([]);
    expect(t.tick(281 * S)).toEqual(['u1']);
  });

  it('uses 15 min for confirmed-empty accounts and 90 s for accounts with positions', () => {
    const t = createStalenessTracker();
    t.track('empty', 0);
    t.track('pos', 0);
    t.markSeen('empty', 0, false);
    t.markSeen('pos', 0, true);
    expect(t.tick(100 * S)).toEqual([]);
    expect(t.tick(130 * S)).toEqual(['pos']);
    expect(t.tick(14 * 60 * S)).toEqual([]);
    expect(t.tick(15 * 60 * S + 1)).toEqual([]);
    expect(t.tick(15 * 60 * S + TICK)).toEqual(['empty']);
  });

  it('caps resubscribes per tick at 5', () => {
    const t = createStalenessTracker();
    for (let i = 0; i < 8; i++) t.track(`u${i}`, 0);
    t.tick(91 * S);
    expect(t.tick(121 * S)).toHaveLength(5);
    expect(t.tick(151 * S)).toEqual(['u5', 'u6', 'u7']);
  });

  it('skips ineligible keys (unhealthy connection)', () => {
    const t = createStalenessTracker({ resubAfterStaleTicks: 1 });
    t.track('u1', 0);
    expect(t.tick(91 * S, () => false)).toEqual([]);
    expect(t.tick(92 * S, () => true)).toEqual(['u1']);
  });

  it('untrack clears everything; markSeen on unknown keys is ignored', () => {
    const t = createStalenessTracker({ resubAfterStaleTicks: 1 });
    t.track('u1', 0);
    t.untrack('u1');
    t.markSeen('u1', 5);
    expect(t.tick(200 * S)).toEqual([]);
    expect(t.stats(200 * S).tracked).toBe(0);
  });

  it('freshness stats count only real messages', () => {
    const t = createStalenessTracker();
    t.track('a', 0);
    t.track('b', 0);
    t.track('c', 0);
    t.markSeen('a', 10 * S, true);
    t.markSeen('b', 0, false);
    expect(t.stats(10 * S)).toEqual({ tracked: 3, fresh90s: 2, fresh15m: 2, withPositions: 1 });
    expect(t.stats(200 * S)).toEqual({ tracked: 3, fresh90s: 0, fresh15m: 2, withPositions: 1 });
  });
});
All files