Files
MIKROTIK-RMM/backend/src/utils/__tests__/lteDwell.test.ts
T
2GT-RichandClaude Opus 5 6c5b8ac21a feat(lte): time-on-band and time-on-cell statistics (v0.23.33-beta)
Closes #120.

The Tower and band changes panel listed every handover, newest first, growing
without bound. On a link that hands over several times a day it reads as a wall
of near-identical rows — it answers what happened, but not what the pattern is,
which is the question an operator actually has.

Dwell totals now sit above it: how long the modem has spent on each band and on
each cell, over 24 hours, 7 days or 30 days, ordered by time spent so the answer
is the first row rather than something to be worked out. Visit counts sit
alongside, because six brief stays and one long one are different situations
that share a total.

No new collection. History is already written on every change, so dwell is the
interval between consecutive events — the same reconstruction used for WiFi
roaming sessions.

Three decisions worth recording.

**The state in force when the window opened is included.** Without it, a link
that changed once yesterday would report a few minutes of dwell across seven
days, having silently dropped everything before the first change in range. The
last event *before* the window is fetched specifically to anchor that first
stretch.

**Aggregated carriers each count for the whole stretch.** A period on B1 and B3
together credits both with the full duration, so band shares describe how much of
the window each band was in use rather than dividing it between them. Totals can
exceed 100%, which is the honest description of carrier aggregation rather than a
rounding artefact.

**Absence is reported as absence.** A segment whose cell was never reported is
excluded rather than attributed to "unknown", a device with no history reads as
"no changes recorded" rather than an empty panel, and a window longer than our
records says so instead of implying the quiet period was real.

Logic lives in utils/lteDwell.ts with 17 tests. Verified end to end against
seeded history matching the reporter's own pattern: a 24-hour window covered
exactly 24 hours, B3 correctly showed 19 of them against a five-hour stretch
without it, cell totals summed to the window, and a 30-day request over 9 days of
records reported itself truncated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 09:30:07 -07:00

154 lines
5.5 KiB
TypeScript

import {
buildSegments, summarise, summariseBands, expandBands, formatDuration,
type CellEvent,
} from '../lteDwell';
const T = (iso: string) => new Date(iso);
const ev = (at: string, cellId: string | null, bands: string | null, enbId: string | null = 'E1'): CellEvent =>
({ at: T(at), cellId, enbId, bands });
const NOW = T('2026-09-06T12:00:00Z');
describe('buildSegments', () => {
it('runs each state until the next change', () => {
const segs = buildSegments([
ev('2026-09-06T09:00:00Z', 'A', '1'),
ev('2026-09-06T10:00:00Z', 'B', '1,3'),
], NOW);
expect(segs).toHaveLength(2);
expect(segs[0]).toMatchObject({ cellId: 'A', seconds: 3600, open: false });
expect(segs[1]).toMatchObject({ cellId: 'B', seconds: 7200, open: true });
});
it('leaves the final segment open, since it is still accruing', () => {
const segs = buildSegments([ev('2026-09-06T11:30:00Z', 'A', '1')], NOW);
expect(segs[0].open).toBe(true);
expect(segs[0].seconds).toBe(1800);
});
it('credits the window before the first change to the state then in force', () => {
// Without this, a link that changed once at 11:00 would report only one hour
// of dwell across a twelve-hour window — most of it silently dropped.
const segs = buildSegments(
[ev('2026-09-06T11:00:00Z', 'B', '3')],
NOW,
T('2026-09-06T00:00:00Z'),
ev('2026-09-05T22:00:00Z', 'A', '1'),
);
expect(segs).toHaveLength(2);
expect(segs[0]).toMatchObject({ cellId: 'A', seconds: 39600 });
expect(segs[1]).toMatchObject({ cellId: 'B', seconds: 3600 });
});
it('sorts events that arrive out of order', () => {
const segs = buildSegments([
ev('2026-09-06T10:00:00Z', 'B', '1'),
ev('2026-09-06T09:00:00Z', 'A', '1'),
], NOW);
expect(segs.map((s) => s.cellId)).toEqual(['A', 'B']);
});
it('discards a state that never actually held', () => {
// Two changes in the same second describe a transient, not a stretch of
// time; counting it would inflate the visit count for that cell.
const segs = buildSegments([
ev('2026-09-06T09:00:00Z', 'A', '1'),
ev('2026-09-06T09:00:00Z', 'B', '1'),
ev('2026-09-06T10:00:00Z', 'C', '1'),
], NOW);
expect(segs.map((s) => s.cellId)).toEqual(['B', 'C']);
});
it('returns nothing when there is no history at all', () => {
expect(buildSegments([], NOW)).toEqual([]);
});
});
describe('summarise', () => {
const segs = buildSegments([
ev('2026-09-06T06:00:00Z', 'A', '1'),
ev('2026-09-06T08:00:00Z', 'B', '1'),
ev('2026-09-06T09:00:00Z', 'A', '1'),
], NOW);
it('adds up separate visits to the same cell', () => {
const totals = summarise(segs, (s) => s.cellId);
const a = totals.find((t) => t.key === 'A')!;
expect(a.seconds).toBe(7200 + 10800);
expect(a.visits).toBe(2);
});
it('orders by time spent, not by recency', () => {
expect(summarise(segs, (s) => s.cellId).map((t) => t.key)).toEqual(['A', 'B']);
});
it('reports each share of the measured time', () => {
const totals = summarise(segs, (s) => s.cellId);
expect(totals.reduce((n, t) => n + t.pct, 0)).toBeCloseTo(100, 0);
});
it('ignores segments where the attribute was never reported', () => {
// Attributing an unreported cell to "unknown" would invent a state the
// modem never described.
const withGap = buildSegments([
ev('2026-09-06T09:00:00Z', null, '1'),
ev('2026-09-06T10:00:00Z', 'A', '1'),
], NOW);
expect(summarise(withGap, (s) => s.cellId).map((t) => t.key)).toEqual(['A']);
});
it('tracks when each was last in use', () => {
const a = summarise(segs, (s) => s.cellId).find((t) => t.key === 'A')!;
expect(a.lastSeen).toEqual(NOW);
});
});
describe('summariseBands', () => {
it('credits both bands of an aggregated pair for the whole stretch', () => {
// "1,3" means B1 and B3 were in use *simultaneously*, so each gets the full
// duration — percentages describe usage, not a division of the window.
const segs = buildSegments([ev('2026-09-06T11:00:00Z', 'A', '1,3')], NOW);
const bands = summariseBands(segs, 3600);
expect(bands.map((b) => b.label).sort()).toEqual(['B1', 'B3']);
expect(bands.every((b) => b.seconds === 3600)).toBe(true);
expect(bands.every((b) => b.pct === 100)).toBe(true);
});
it('adds a band up across the stretches it appeared in', () => {
const segs = buildSegments([
ev('2026-09-06T10:00:00Z', 'A', '1,3'),
ev('2026-09-06T11:00:00Z', 'A', '1'),
], NOW);
const b1 = summariseBands(segs, 7200).find((b) => b.label === 'B1')!;
const b3 = summariseBands(segs, 7200).find((b) => b.label === 'B3')!;
expect(b1.seconds).toBe(7200);
expect(b3.seconds).toBe(3600);
});
it('handles a segment with no band recorded', () => {
const segs = buildSegments([ev('2026-09-06T11:00:00Z', 'A', null)], NOW);
expect(summariseBands(segs, 3600)).toEqual([]);
});
});
describe('expandBands', () => {
it('splits and trims', () => {
expect(expandBands('1, 3 ,7')).toEqual(['1', '3', '7']);
});
it('copes with nothing', () => {
expect(expandBands(null)).toEqual([]);
expect(expandBands('')).toEqual([]);
});
});
describe('formatDuration', () => {
it('reads the way a person would say it', () => {
expect(formatDuration(45)).toBe('45s');
expect(formatDuration(600)).toBe('10m');
expect(formatDuration(3600)).toBe('1h');
expect(formatDuration(5400)).toBe('1h 30m');
expect(formatDuration(86400)).toBe('1d');
expect(formatDuration(187200)).toBe('2d 4h');
});
});