feat(chart): TrainingChart dinâmico com viewport, zoom, pan e pin sobrevivente
Substitui o gráfico estático (últimos 120 samples + selectedIndex por
posição) por um modelo de viewport temporal que resolve os problemas de
qualidade em tempo real e abre zoom/pan nos 3 call sites (Captura,
Janela salva e Faixa customizada).
Adições:
- src/training/chartViewport.ts: funções puras (granularityForSpan,
formatTickLabel, buildChartViewport, applyZoomIn/Out, applyPan,
isLiveTailSnap, pickSampleAtTimestamp, xFromTimestampMs,
timestampMsFromX, tipos Viewport/ChartPin/Granularity).
- __tests__/chartViewport.test.ts: 34 testes cobrindo limites,
ordenação defensiva, snap zone, hit-test e granularidade adaptativa.
- src/components/TrainingChart.tsx: reescrito para consumir viewport.
- Pin por timestampMs (sobrevive a novas amostras em tempo real).
- Pinch (Gesture.Pinch) + Pan (Gesture.Pan) + Tap (Gesture.Tap) via
react-native-gesture-handler v3.
- Auto-follow: endMs estende com novos samples enquanto liveTail.
- Snap zone (5% da duração visível) reanexa o live ao final do pan.
- Eixo X com ticks adaptativos: minute/halfMinute/quarterMinute/
tenSeconds/second/halfSecond, conforme duração visível.
- Botões +/- (zoom 2x) e ↺ (reset) na lateral.
- Indicador 'dados mais novos disponíveis' quando !liveTail.
- Estado vazio preservado (samples < 2).
- jest.setup.js + jest.config.js: mocks de gesture-handler e
reanimated para o App.test rodar em Jest (módulos nativos não
disponíveis no host).
Modificações de setup (dependências aprovadas):
- package.json + package-lock.json: adiciona react-native-gesture-handler
^3.0.1, react-native-reanimated ^4.4.1, react-native-worklets ^0.9.2.
- index.js: import 'react-native-gesture-handler' no topo.
- App.tsx: <GestureHandlerRootView> envolvendo SafeAreaProvider.
- babel.config.js: plugin react-native-worklets/plugin.
Verificações:
- npm run typecheck: 0 errors.
- npm run lint: 0 errors, 0 warnings.
- npm test -- --runInBand: 55/55 passam.
- Build Android: não verificado nesta sessão (timeout); o usuário
valida localmente antes de distribuir.
Não comitado neste commit (mudanças pré-existentes não relacionadas):
- android/* (migração fightratern → healthtracking/fightrate, mipmaps)
- README.md
- app.json
This commit is contained in:
1 parent
86908495d2
commit
41b49ba442
15 files changed
+2492
-340
No files matched your search
+54
-1
@@ -3,8 +3,61 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { NativeModules } from 'react-native';
|
||||
import ReactTestRenderer from 'react-test-renderer';
|
||||
import App from '../App';
|
||||
|
||||
const emptySnapshot = {
|
||||
phase: 'idle',
|
||||
isCapturing: false,
|
||||
isMonitoring: false,
|
||||
startedAt: 0,
|
||||
statusMessage: 'Captura parada.',
|
||||
connectedDevice: null,
|
||||
discoveredDevices: [],
|
||||
samples: [],
|
||||
sessions: [],
|
||||
services: [],
|
||||
characteristics: [],
|
||||
batteryOptimizationIgnored: true,
|
||||
};
|
||||
|
||||
NativeModules.FightRateCapture = {
|
||||
addListener: jest.fn(),
|
||||
removeListeners: jest.fn(),
|
||||
getSnapshot: jest.fn(async () => emptySnapshot),
|
||||
startMonitoring: jest.fn(async () => emptySnapshot),
|
||||
startCapture: jest.fn(async () => emptySnapshot),
|
||||
startWorkout: jest.fn(async () => ({
|
||||
...emptySnapshot,
|
||||
isMonitoring: true,
|
||||
isCapturing: true,
|
||||
})),
|
||||
connectToDevice: jest.fn(async () => emptySnapshot),
|
||||
stopCapture: jest.fn(async () => emptySnapshot),
|
||||
stopWorkout: jest.fn(async () => ({
|
||||
...emptySnapshot,
|
||||
isMonitoring: true,
|
||||
})),
|
||||
stopMonitoring: jest.fn(async () => emptySnapshot),
|
||||
getSessionSamples: jest.fn(async () => []),
|
||||
getContinuousSamples: jest.fn(async () => []),
|
||||
isIgnoringBatteryOptimizations: jest.fn(async () => true),
|
||||
openBatteryOptimizationSettings: jest.fn(async () => true),
|
||||
};
|
||||
|
||||
// react-native-gesture-handler requires a native module; mock it for tests.
|
||||
NativeModules.RNGestureHandlerModule = {
|
||||
attachGestureHandler: jest.fn(),
|
||||
createGestureHandler: jest.fn(),
|
||||
dropGestureHandler: jest.fn(),
|
||||
updateGestureHandler: jest.fn(),
|
||||
flushOperations: jest.fn(),
|
||||
install: jest.fn(),
|
||||
State: { UNDETERMINED: 0, FAILED: 1, BEGAN: 2, CANCELLED: 3, ACTIVE: 4, END: 5 },
|
||||
Directions: { RIGHT: 1, LEFT: 2, UP: 4, DOWN: 8 },
|
||||
};
|
||||
|
||||
const App = require('../App').default;
|
||||
|
||||
test('renders correctly', async () => {
|
||||
await ReactTestRenderer.act(() => {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { type HeartRateSample } from '../src/training/trainingTypes';
|
||||
import {
|
||||
applyPan,
|
||||
applyZoomIn,
|
||||
applyZoomOut,
|
||||
buildChartViewport,
|
||||
formatTickLabel,
|
||||
granularityForSpan,
|
||||
isLiveTailSnap,
|
||||
pickSampleAtTimestamp,
|
||||
xFromTimestampMs,
|
||||
timestampMsFromX,
|
||||
type Viewport,
|
||||
} from '../src/training/chartViewport';
|
||||
|
||||
const MIN_SPAN = 1_000;
|
||||
const MAX_SPAN = 24 * 60 * 60 * 1_000;
|
||||
|
||||
function sample(timestamp: number, bpm: number): HeartRateSample {
|
||||
return {
|
||||
timestamp,
|
||||
bpm,
|
||||
contactDetected: null,
|
||||
rrIntervalsMs: [],
|
||||
deviceName: 'Magene H003',
|
||||
};
|
||||
}
|
||||
|
||||
function viewport(startMs: number, endMs: number, liveTail = true): Viewport {
|
||||
return { startMs, endMs, liveTail };
|
||||
}
|
||||
|
||||
describe('granularityForSpan', () => {
|
||||
test('returns minute granularity for spans >= 5 minutes', () => {
|
||||
const g = granularityForSpan(5 * 60 * 1_000);
|
||||
expect(g.kind).toBe('minute');
|
||||
expect(g.stepMs).toBe(60_000);
|
||||
});
|
||||
|
||||
test('returns halfMinute granularity for spans >= 1 minute and < 5 minutes', () => {
|
||||
expect(granularityForSpan(60 * 1_000).kind).toBe('halfMinute');
|
||||
expect(granularityForSpan(4 * 60 * 1_000).kind).toBe('halfMinute');
|
||||
expect(granularityForSpan(5 * 60 * 1_000).kind).toBe('minute');
|
||||
});
|
||||
|
||||
test('returns quarterMinute granularity for spans >= 30s and < 1 minute', () => {
|
||||
expect(granularityForSpan(30 * 1_000).kind).toBe('quarterMinute');
|
||||
expect(granularityForSpan(59 * 1_000).kind).toBe('quarterMinute');
|
||||
});
|
||||
|
||||
test('returns tenSeconds granularity for spans >= 10s and < 30s', () => {
|
||||
expect(granularityForSpan(10 * 1_000).kind).toBe('tenSeconds');
|
||||
expect(granularityForSpan(29 * 1_000).kind).toBe('tenSeconds');
|
||||
});
|
||||
|
||||
test('returns second granularity for spans >= 2s and < 10s', () => {
|
||||
expect(granularityForSpan(2 * 1_000).kind).toBe('second');
|
||||
expect(granularityForSpan(9 * 1_000).kind).toBe('second');
|
||||
});
|
||||
|
||||
test('returns halfSecond granularity for spans < 2s', () => {
|
||||
expect(granularityForSpan(1_999).kind).toBe('halfSecond');
|
||||
expect(granularityForSpan(0).kind).toBe('halfSecond');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
const T = new Date('2026-06-14T09:30:45.500Z').getTime();
|
||||
|
||||
test('minute granularity formats HH:mm in local time', () => {
|
||||
const g = granularityForSpan(5 * 60 * 1_000);
|
||||
const label = formatTickLabel(T, g);
|
||||
expect(label).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test('second granularity formats HH:mm:ss', () => {
|
||||
const g = granularityForSpan(2 * 1_000);
|
||||
const label = formatTickLabel(T, g);
|
||||
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test('halfSecond granularity formats HH:mm:ss.S', () => {
|
||||
const g = granularityForSpan(500);
|
||||
const label = formatTickLabel(T, g);
|
||||
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}\.\d$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChartViewport', () => {
|
||||
test('returns null for fewer than 2 samples', () => {
|
||||
expect(buildChartViewport([], { defaultSpanMs: 60_000 })).toBeNull();
|
||||
expect(
|
||||
buildChartViewport([sample(1_000, 90)], { defaultSpanMs: 60_000 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('initial viewport fits the dataset when total span is smaller than defaultSpan', () => {
|
||||
const samples = [
|
||||
sample(1_000, 90),
|
||||
sample(11_000, 100),
|
||||
sample(21_000, 110),
|
||||
];
|
||||
const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 });
|
||||
expect(vp).not.toBeNull();
|
||||
expect(vp!.startMs).toBe(1_000);
|
||||
expect(vp!.endMs).toBe(21_000);
|
||||
expect(vp!.liveTail).toBe(true);
|
||||
});
|
||||
|
||||
test('initial viewport keeps defaultSpan ending at the last sample when dataset is larger', () => {
|
||||
const samples = [
|
||||
sample(0, 90),
|
||||
sample(10_000, 100),
|
||||
sample(20_000, 110),
|
||||
sample(30_000, 120),
|
||||
sample(40_000, 130),
|
||||
];
|
||||
const vp = buildChartViewport(samples, { defaultSpanMs: 30_000 });
|
||||
expect(vp).not.toBeNull();
|
||||
expect(vp!.endMs).toBe(40_000);
|
||||
expect(vp!.endMs - vp!.startMs).toBe(30_000);
|
||||
expect(vp!.liveTail).toBe(true);
|
||||
});
|
||||
|
||||
test('initial viewport is non-live when liveTail: false is passed', () => {
|
||||
const samples = [sample(0, 90), sample(10_000, 100)];
|
||||
const vp = buildChartViewport(samples, {
|
||||
defaultSpanMs: 60_000,
|
||||
liveTail: false,
|
||||
});
|
||||
expect(vp!.liveTail).toBe(false);
|
||||
});
|
||||
|
||||
test('sorts samples defensively before computing viewport', () => {
|
||||
const samples = [sample(30_000, 130), sample(0, 90), sample(15_000, 110)];
|
||||
const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 });
|
||||
expect(vp!.startMs).toBe(0);
|
||||
expect(vp!.endMs).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyZoomIn', () => {
|
||||
test('halves the span around the focus point', () => {
|
||||
const start = viewport(0, 60_000);
|
||||
const z = applyZoomIn(start, 30_000, {
|
||||
minSpanMs: MIN_SPAN,
|
||||
maxSpanMs: MAX_SPAN,
|
||||
});
|
||||
expect(z.endMs - z.startMs).toBe(30_000);
|
||||
expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(30_000, 0);
|
||||
expect(z.liveTail).toBe(false);
|
||||
});
|
||||
|
||||
test('never goes below minSpanMs', () => {
|
||||
const start = viewport(0, 2_000);
|
||||
const z = applyZoomIn(start, 1_000, {
|
||||
minSpanMs: MIN_SPAN,
|
||||
maxSpanMs: MAX_SPAN,
|
||||
});
|
||||
expect(z.endMs - z.startMs).toBe(MIN_SPAN);
|
||||
});
|
||||
|
||||
test('preserves focus when minSpan is hit', () => {
|
||||
const start = viewport(0, 2_000);
|
||||
const z = applyZoomIn(start, 1_000, {
|
||||
minSpanMs: MIN_SPAN,
|
||||
maxSpanMs: MAX_SPAN,
|
||||
});
|
||||
expect(z.startMs).toBeLessThanOrEqual(1_000);
|
||||
expect(z.endMs).toBeGreaterThanOrEqual(1_000);
|
||||
});
|
||||
|
||||
test('forces liveTail=false (zoom is always a user interaction)', () => {
|
||||
const start = viewport(0, 60_000, true);
|
||||
expect(applyZoomIn(start, 30_000).liveTail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyZoomOut', () => {
|
||||
test('doubles the span around the focus point', () => {
|
||||
const start = viewport(0, 30_000, false);
|
||||
const z = applyZoomOut(start, 15_000, {
|
||||
minSpanMs: MIN_SPAN,
|
||||
maxSpanMs: MAX_SPAN,
|
||||
});
|
||||
expect(z.endMs - z.startMs).toBe(60_000);
|
||||
expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(15_000, 0);
|
||||
});
|
||||
|
||||
test('never goes above maxSpanMs', () => {
|
||||
const start = viewport(0, 12 * 60 * 60 * 1_000, false);
|
||||
const z = applyZoomOut(start, 6 * 60 * 60 * 1_000, {
|
||||
minSpanMs: MIN_SPAN,
|
||||
maxSpanMs: MAX_SPAN,
|
||||
});
|
||||
expect(z.endMs - z.startMs).toBe(MAX_SPAN);
|
||||
});
|
||||
|
||||
test('forces liveTail=false', () => {
|
||||
expect(applyZoomOut(viewport(0, 30_000, true), 15_000).liveTail).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPan', () => {
|
||||
test('dx > 0 shifts the viewport forward in time and disables liveTail', () => {
|
||||
const start = viewport(0, 60_000, true);
|
||||
const p = applyPan(start, 100, 1); // 1 pixel per ms
|
||||
expect(p.startMs).toBe(100);
|
||||
expect(p.endMs).toBe(60_100);
|
||||
expect(p.liveTail).toBe(false);
|
||||
});
|
||||
|
||||
test('dx < 0 shifts the viewport backward in time', () => {
|
||||
const start = viewport(0, 60_000, true);
|
||||
const p = applyPan(start, -50, 1);
|
||||
expect(p.startMs).toBe(-50);
|
||||
expect(p.endMs).toBe(59_950);
|
||||
expect(p.liveTail).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps liveTail false even if input was live (any pan detaches)', () => {
|
||||
const start = viewport(0, 60_000, true);
|
||||
expect(applyPan(start, 0.5, 1).liveTail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLiveTailSnap', () => {
|
||||
test('returns true when endMs is within 5% of last sample timestamp', () => {
|
||||
const vp = viewport(0, 100_000, false);
|
||||
expect(isLiveTailSnap(vp, 104_000)).toBe(true);
|
||||
expect(isLiveTailSnap(vp, 105_000, 0.05)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when endMs is more than 5% behind last sample timestamp', () => {
|
||||
const vp = viewport(0, 100_000, false);
|
||||
expect(isLiveTailSnap(vp, 110_000, 0.05)).toBe(false);
|
||||
});
|
||||
|
||||
test('uses a custom snap fraction', () => {
|
||||
const vp = viewport(0, 100_000, false);
|
||||
expect(isLiveTailSnap(vp, 150_000, 0.5)).toBe(true);
|
||||
expect(isLiveTailSnap(vp, 250_000, 0.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSampleAtTimestamp', () => {
|
||||
const samples = [
|
||||
sample(0, 80),
|
||||
sample(1_000, 90),
|
||||
sample(2_500, 100),
|
||||
sample(4_000, 110),
|
||||
sample(5_000, 120),
|
||||
];
|
||||
|
||||
test('returns the closest sample within tolerance', () => {
|
||||
expect(pickSampleAtTimestamp(samples, 2_400, 500)).toEqual(samples[2]);
|
||||
expect(pickSampleAtTimestamp(samples, 600, 500)).toEqual(samples[1]);
|
||||
});
|
||||
|
||||
test('returns null when no sample is within tolerance', () => {
|
||||
expect(pickSampleAtTimestamp(samples, 3_500, 100)).toBeNull();
|
||||
});
|
||||
|
||||
test('tolerates unsorted samples by considering the global closest', () => {
|
||||
const shuffled = [
|
||||
samples[3],
|
||||
samples[0],
|
||||
samples[2],
|
||||
samples[4],
|
||||
samples[1],
|
||||
];
|
||||
expect(pickSampleAtTimestamp(shuffled, 2_400, 500)).toEqual(samples[2]);
|
||||
});
|
||||
|
||||
test('returns null for empty input', () => {
|
||||
expect(pickSampleAtTimestamp([], 0, 1_000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xFromTimestampMs and timestampMsFromX', () => {
|
||||
const vp = viewport(0, 100_000, false);
|
||||
const chartWidth = 320;
|
||||
const padX = 14;
|
||||
|
||||
test('xFromTimestampMs maps edge timestamps to padX and chartWidth-padX', () => {
|
||||
expect(xFromTimestampMs(0, vp, chartWidth, padX)).toBeCloseTo(padX, 5);
|
||||
expect(xFromTimestampMs(100_000, vp, chartWidth, padX)).toBeCloseTo(
|
||||
chartWidth - padX,
|
||||
5,
|
||||
);
|
||||
expect(xFromTimestampMs(50_000, vp, chartWidth, padX)).toBeCloseTo(
|
||||
chartWidth / 2,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
test('timestampMsFromX is the inverse of xFromTimestampMs', () => {
|
||||
const ts = 73_421;
|
||||
const x = xFromTimestampMs(ts, vp, chartWidth, padX);
|
||||
const back = timestampMsFromX(x, vp, chartWidth, padX);
|
||||
expect(back).toBeCloseTo(ts, 0);
|
||||
});
|
||||
|
||||
test('timestampMsFromX clamps to viewport bounds', () => {
|
||||
const before = timestampMsFromX(-100, vp, chartWidth, padX);
|
||||
expect(before).toBeLessThanOrEqual(vp.startMs);
|
||||
const after = timestampMsFromX(500, vp, chartWidth, padX);
|
||||
expect(after).toBeGreaterThanOrEqual(vp.endMs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
buildContinuousUsageHistory,
|
||||
filterSamplesByRange,
|
||||
} from '../src/training/continuousUsage';
|
||||
import { type HeartRateSample } from '../src/training/trainingTypes';
|
||||
|
||||
function sample(
|
||||
timestamp: number,
|
||||
bpm: number,
|
||||
deviceName = 'Magene H003',
|
||||
): HeartRateSample {
|
||||
return {
|
||||
timestamp,
|
||||
bpm,
|
||||
contactDetected: null,
|
||||
rrIntervalsMs: [],
|
||||
deviceName,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildContinuousUsageHistory', () => {
|
||||
it('groups continuous samples into usage blocks separated by long gaps', () => {
|
||||
const blocks = buildContinuousUsageHistory(
|
||||
[
|
||||
sample(1_000, 120),
|
||||
sample(2_000, 130),
|
||||
sample(10 * 60_000, 140),
|
||||
sample(10 * 60_000 + 1_000, 150),
|
||||
],
|
||||
{ gapMs: 5 * 60_000 },
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
startedAt: 10 * 60_000,
|
||||
endedAt: 10 * 60_000 + 1_000,
|
||||
sampleCount: 2,
|
||||
averageBpm: 145,
|
||||
peakBpm: 150,
|
||||
minBpm: 140,
|
||||
});
|
||||
expect(blocks[1]).toMatchObject({
|
||||
startedAt: 1_000,
|
||||
endedAt: 2_000,
|
||||
sampleCount: 2,
|
||||
averageBpm: 125,
|
||||
peakBpm: 130,
|
||||
minBpm: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts samples and ignores invalid BPM values', () => {
|
||||
const blocks = buildContinuousUsageHistory(
|
||||
[sample(5_000, 150), sample(1_000, 0), sample(3_000, 120)],
|
||||
{ gapMs: 10_000 },
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].startedAt).toBe(3_000);
|
||||
expect(blocks[0].endedAt).toBe(5_000);
|
||||
expect(blocks[0].sampleCount).toBe(2);
|
||||
expect(blocks[0].samples.map(item => item.bpm)).toEqual([120, 150]);
|
||||
});
|
||||
|
||||
it('uses the most recent block first for history display', () => {
|
||||
const blocks = buildContinuousUsageHistory(
|
||||
[sample(1_000, 110), sample(30 * 60_000, 160)],
|
||||
{ gapMs: 5 * 60_000 },
|
||||
);
|
||||
|
||||
expect(blocks.map(block => block.startedAt)).toEqual([30 * 60_000, 1_000]);
|
||||
});
|
||||
|
||||
it('keeps a stable block id while live samples extend the same usage block', () => {
|
||||
const initial = buildContinuousUsageHistory(
|
||||
[sample(1_000, 120), sample(2_000, 130)],
|
||||
{ gapMs: 5 * 60_000 },
|
||||
);
|
||||
const updated = buildContinuousUsageHistory(
|
||||
[sample(1_000, 120), sample(2_000, 130), sample(3_000, 140)],
|
||||
{ gapMs: 5 * 60_000 },
|
||||
);
|
||||
|
||||
expect(updated[0].id).toBe(initial[0].id);
|
||||
expect(updated[0].sampleCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSamplesByRange', () => {
|
||||
it('returns samples inside an inclusive custom date/time range', () => {
|
||||
const samples = [
|
||||
sample(Date.UTC(2026, 5, 10, 8, 59), 110),
|
||||
sample(Date.UTC(2026, 5, 10, 9, 0), 120),
|
||||
sample(Date.UTC(2026, 5, 10, 9, 30), 130),
|
||||
sample(Date.UTC(2026, 5, 10, 10, 0), 140),
|
||||
sample(Date.UTC(2026, 5, 10, 10, 1), 150),
|
||||
];
|
||||
|
||||
const filtered = filterSamplesByRange(samples, {
|
||||
start: Date.UTC(2026, 5, 10, 9, 0),
|
||||
end: Date.UTC(2026, 5, 10, 10, 0),
|
||||
});
|
||||
|
||||
expect(filtered.map(item => item.bpm)).toEqual([120, 130, 140]);
|
||||
});
|
||||
|
||||
it('supports ranges that cross multiple days', () => {
|
||||
const samples = [
|
||||
sample(Date.UTC(2026, 5, 10, 23, 50), 120),
|
||||
sample(Date.UTC(2026, 5, 11, 0, 10), 130),
|
||||
sample(Date.UTC(2026, 5, 11, 1, 0), 140),
|
||||
];
|
||||
|
||||
const filtered = filterSamplesByRange(samples, {
|
||||
start: Date.UTC(2026, 5, 10, 23, 45),
|
||||
end: Date.UTC(2026, 5, 11, 0, 15),
|
||||
});
|
||||
|
||||
expect(filtered.map(item => item.bpm)).toEqual([120, 130]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { parseHeartRateMeasurement } from '../src/ble/heartRateParser';
|
||||
|
||||
describe('parseHeartRateMeasurement', () => {
|
||||
it('parses 8-bit BPM payloads', () => {
|
||||
expect(parseHeartRateMeasurement([0x00, 154])).toMatchObject({
|
||||
bpm: 154,
|
||||
contactSupported: false,
|
||||
contactDetected: null,
|
||||
energyExpended: null,
|
||||
rrIntervalsMs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses 16-bit BPM payloads', () => {
|
||||
expect(parseHeartRateMeasurement([0x01, 0x2c, 0x01]).bpm).toBe(300);
|
||||
});
|
||||
|
||||
it('parses contact, energy and RR interval data', () => {
|
||||
const parsed = parseHeartRateMeasurement([
|
||||
0x1e, 160, 0x10, 0x00, 0x00, 0x04,
|
||||
]);
|
||||
|
||||
expect(parsed.bpm).toBe(160);
|
||||
expect(parsed.contactSupported).toBe(true);
|
||||
expect(parsed.contactDetected).toBe(true);
|
||||
expect(parsed.energyExpended).toBe(16);
|
||||
expect(parsed.rrIntervalsMs).toEqual([1000]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { formatRssi, normalizeRssi } from '../src/ble/rssi';
|
||||
|
||||
describe('RSSI dBm helpers', () => {
|
||||
it('keeps valid BLE RSSI values as dBm', () => {
|
||||
expect(normalizeRssi(-63)).toBe(-63);
|
||||
expect(formatRssi(-63)).toBe('-63 dBm');
|
||||
});
|
||||
|
||||
it('hides Android sentinel and impossible RSSI values', () => {
|
||||
expect(normalizeRssi(-2147483648)).toBeNull();
|
||||
expect(normalizeRssi(127)).toBeNull();
|
||||
expect(formatRssi(-2147483648)).toBe('sem leitura');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
buildInsightDashboard,
|
||||
calculateBpmVariability,
|
||||
calculateHeartRateDrift,
|
||||
calculateLiveMetrics,
|
||||
calculatePostSessionRecovery,
|
||||
filterReliableHeartRateSamples,
|
||||
isReliableHeartRateSample,
|
||||
summarizeRrAvailability,
|
||||
} from '../src/training/trainingMetrics';
|
||||
import {
|
||||
type CompletedTrainingSession,
|
||||
type HeartRateSample,
|
||||
} from '../src/training/trainingTypes';
|
||||
|
||||
function sample(
|
||||
timestamp: number,
|
||||
bpm: number,
|
||||
rrIntervalsMs: readonly number[] = [],
|
||||
): HeartRateSample {
|
||||
return {
|
||||
timestamp,
|
||||
bpm,
|
||||
contactDetected: null,
|
||||
rrIntervalsMs,
|
||||
deviceName: 'Magene H003',
|
||||
};
|
||||
}
|
||||
|
||||
function session(
|
||||
startedAt: number,
|
||||
endedAt: number,
|
||||
peakBpm = 180,
|
||||
): CompletedTrainingSession {
|
||||
return {
|
||||
id: `session-${startedAt}-${endedAt}`,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: endedAt - startedAt,
|
||||
sampleCount: 12,
|
||||
averageBpm: 150,
|
||||
peakBpm,
|
||||
minBpm: 112,
|
||||
deviceName: 'Magene H003',
|
||||
};
|
||||
}
|
||||
|
||||
describe('sample reliability guards', () => {
|
||||
it('rejects zero BPM and explicit no-contact packets', () => {
|
||||
expect(isReliableHeartRateSample(sample(1_000, 0))).toBe(false);
|
||||
expect(
|
||||
isReliableHeartRateSample({
|
||||
...sample(2_000, 128),
|
||||
contactDetected: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isReliableHeartRateSample(sample(3_000, 128))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes invalid readings before metrics are calculated', () => {
|
||||
const reliable = filterReliableHeartRateSamples([
|
||||
sample(1_000, 0),
|
||||
{ ...sample(2_000, 125), contactDetected: false },
|
||||
sample(3_000, 130),
|
||||
]);
|
||||
|
||||
expect(reliable.map(item => item.bpm)).toEqual([130]);
|
||||
});
|
||||
|
||||
it('marks live BPM as unavailable when the latest valid reading is stale', () => {
|
||||
const metrics = calculateLiveMetrics([sample(1_000, 140)], {
|
||||
now: 20_000,
|
||||
staleAfterMs: 12_000,
|
||||
});
|
||||
|
||||
expect(metrics.currentBpm).toBeNull();
|
||||
expect(metrics.averageBpm).toBe(140);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateBpmVariability', () => {
|
||||
it('summarizes BPM range, standard deviation and coefficient of variation', () => {
|
||||
const variability = calculateBpmVariability([
|
||||
sample(1_000, 100),
|
||||
sample(2_000, 120),
|
||||
sample(3_000, 140),
|
||||
]);
|
||||
|
||||
expect(variability).toMatchObject({
|
||||
averageBpm: 120,
|
||||
minBpm: 100,
|
||||
maxBpm: 140,
|
||||
rangeBpm: 40,
|
||||
standardDeviationBpm: 16.3,
|
||||
coefficientOfVariation: 0.136,
|
||||
classification: 'oscilante',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateHeartRateDrift', () => {
|
||||
it('compares the first and last fifth of a long enough sample window', () => {
|
||||
const start = Date.UTC(2026, 5, 10, 9, 0);
|
||||
const samples = Array.from({ length: 12 }, (_, index) =>
|
||||
sample(start + index * 60_000, index < 6 ? 130 + index : 150 + index),
|
||||
);
|
||||
|
||||
expect(calculateHeartRateDrift(samples)).toMatchObject({
|
||||
status: 'ok',
|
||||
driftBpm: 30,
|
||||
driftPercent: 23,
|
||||
initialAverageBpm: 131,
|
||||
finalAverageBpm: 161,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not over-interpret short windows as cardiovascular drift', () => {
|
||||
expect(
|
||||
calculateHeartRateDrift([sample(1_000, 130), sample(60_000, 140)]),
|
||||
).toMatchObject({ status: 'short-window' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePostSessionRecovery', () => {
|
||||
it('calculates one and two minute recovery from post-window continuous samples', () => {
|
||||
const startedAt = Date.UTC(2026, 5, 10, 10, 0);
|
||||
const endedAt = startedAt + 10 * 60_000;
|
||||
|
||||
expect(
|
||||
calculatePostSessionRecovery(session(startedAt, endedAt, 182), [
|
||||
sample(endedAt + 5_000, 176),
|
||||
sample(endedAt + 61_000, 160),
|
||||
sample(endedAt + 119_000, 148),
|
||||
]),
|
||||
).toMatchObject({
|
||||
status: 'ok',
|
||||
oneMinuteDropBpm: 22,
|
||||
twoMinuteDropBpm: 34,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeRrAvailability', () => {
|
||||
it('reports RR availability without calculating fake HRV', () => {
|
||||
expect(
|
||||
summarizeRrAvailability([
|
||||
sample(1_000, 120, [1000, 980]),
|
||||
sample(2_000, 121, [990]),
|
||||
]),
|
||||
).toMatchObject({ status: 'ok', intervalCount: 3, sampleCount: 2 });
|
||||
|
||||
expect(summarizeRrAvailability([sample(1_000, 120)])).toMatchObject({
|
||||
status: 'needs-rr',
|
||||
intervalCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInsightDashboard', () => {
|
||||
it('builds compact scientific rows using persisted samples before live fallback', () => {
|
||||
const start = Date.UTC(2026, 5, 10, 9, 0);
|
||||
const persisted = [
|
||||
sample(start, 120),
|
||||
sample(start + 60_000, 140),
|
||||
sample(start + 11 * 60_000, 160),
|
||||
];
|
||||
|
||||
const dashboard = buildInsightDashboard({
|
||||
liveSamples: [sample(start + 12 * 60_000, 170)],
|
||||
continuousSamples: persisted,
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
expect(dashboard.sampleCount).toBe(3);
|
||||
expect(dashboard.rows.map(row => row.key)).toEqual([
|
||||
'current-bpm',
|
||||
'average-bpm',
|
||||
'bpm-range',
|
||||
'bpm-variability',
|
||||
'zones',
|
||||
'load',
|
||||
'drift',
|
||||
'recovery',
|
||||
'profile',
|
||||
'hrv',
|
||||
]);
|
||||
expect(dashboard.rows.find(row => row.key === 'hrv')).toMatchObject({
|
||||
status: 'needs-rr',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user