fix: complete sensing server API, WebSocket connectivity, and mobile tests (#125)

The web UI had persistent 404 errors on model, recording, and training
endpoints, and the sensing WebSocket never connected on Dashboard/Live
Demo tabs because sensingService.start() was only called lazily on
Sensing tab visit.

Server (main.rs):
- Add 14 fully-functional Axum handlers: model CRUD (7), recording
  lifecycle (4), training control (3)
- Scan data/models/ and data/recordings/ at startup
- Recording writes CSI frames to .jsonl via tokio background task
- Model load/unload lifecycle with state tracking

Web UI (app.js):
- Import and start sensingService early in initializeServices() so
  Dashboard and Live Demo tabs connect to /ws/sensing immediately

Mobile (ws.service.ts):
- Fix WebSocket URL builder to use same-origin port instead of
  hardcoded port 3001

Mobile (jest.config.js):
- Fix testPathIgnorePatterns that was ignoring the entire test directory

Mobile (25 test files):
- Replace all it.todo() placeholder tests with real implementations
  covering components, services, stores, hooks, screens, and utils

ADR-043 documents all changes.
This commit is contained in:
rUv
2026-03-03 13:27:03 -05:00
committed by GitHub
parent 977da0f28e
commit d4fb7d30d3
34 changed files with 2975 additions and 87 deletions
@@ -1,5 +1,185 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import axios from 'axios';
jest.mock('axios', () => {
const mockAxiosInstance = {
request: jest.fn(),
};
const mockAxios = {
create: jest.fn(() => mockAxiosInstance),
isAxiosError: jest.fn(),
__mockInstance: mockAxiosInstance,
};
return {
__esModule: true,
default: mockAxios,
...mockAxios,
};
});
// Import after mocking so the mock takes effect
const { apiService } = require('@/services/api.service');
const mockAxios = axios as jest.Mocked<typeof axios> & { __mockInstance: { request: jest.Mock } };
describe('ApiService', () => {
const mockRequest = mockAxios.__mockInstance.request;
beforeEach(() => {
jest.clearAllMocks();
apiService.setBaseUrl('');
});
describe('setBaseUrl', () => {
it('stores the base URL', () => {
apiService.setBaseUrl('http://10.0.0.1:3000');
mockRequest.mockResolvedValueOnce({ data: { ok: true } });
apiService.get('/test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://10.0.0.1:3000/test' }),
);
});
it('handles null by falling back to empty string', () => {
apiService.setBaseUrl(null as unknown as string);
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/api/status');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: '/api/status' }),
);
});
});
describe('buildUrl (via get)', () => {
it('concatenates baseUrl and path', () => {
apiService.setBaseUrl('http://example.com');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/api/v1/status');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://example.com/api/v1/status' }),
);
});
it('removes trailing slash from baseUrl', () => {
apiService.setBaseUrl('http://example.com/');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://example.com/test' }),
);
});
it('uses path as-is when baseUrl is empty', () => {
apiService.setBaseUrl('');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/standalone');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: '/standalone' }),
);
});
it('uses the full URL path if path starts with http', () => {
apiService.setBaseUrl('http://base.com');
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('https://other.com/endpoint');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://other.com/endpoint' }),
);
});
});
describe('get', () => {
it('returns response data on success', async () => {
apiService.setBaseUrl('http://localhost:3000');
mockRequest.mockResolvedValueOnce({ data: { status: 'ok' } });
const result = await apiService.get('/api/v1/pose/status');
expect(result).toEqual({ status: 'ok' });
});
it('uses GET method', () => {
mockRequest.mockResolvedValueOnce({ data: {} });
apiService.get('/test');
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET' }),
);
});
});
describe('post', () => {
it('sends body data', () => {
apiService.setBaseUrl('http://localhost:3000');
mockRequest.mockResolvedValueOnce({ data: { id: 1 } });
apiService.post('/api/events', { name: 'test' });
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
data: { name: 'test' },
}),
);
});
});
describe('error normalization', () => {
it('normalizes axios error with response data message', async () => {
const axiosError = {
message: 'Request failed with status code 400',
response: {
status: 400,
data: { message: 'Bad request body' },
},
code: 'ERR_BAD_REQUEST',
isAxiosError: true,
};
mockRequest.mockRejectedValue(axiosError);
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(true);
await expect(apiService.get('/test')).rejects.toEqual(
expect.objectContaining({
message: 'Bad request body',
status: 400,
code: 'ERR_BAD_REQUEST',
}),
);
});
it('normalizes generic Error', async () => {
mockRequest.mockRejectedValue(new Error('network timeout'));
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
await expect(apiService.get('/test')).rejects.toEqual(
expect.objectContaining({ message: 'network timeout' }),
);
});
it('normalizes unknown error', async () => {
mockRequest.mockRejectedValue('string error');
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
await expect(apiService.get('/test')).rejects.toEqual(
expect.objectContaining({ message: 'Unknown error' }),
);
});
});
describe('retry logic', () => {
it('retries up to 2 times on failure then throws', async () => {
const error = new Error('fail');
mockRequest.mockRejectedValue(error);
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
await expect(apiService.get('/flaky')).rejects.toEqual(
expect.objectContaining({ message: 'fail' }),
);
// 1 initial + 2 retries = 3 total calls
expect(mockRequest).toHaveBeenCalledTimes(3);
});
it('succeeds on second attempt without throwing', async () => {
mockRequest
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce({ data: { recovered: true } });
const result = await apiService.get('/flaky');
expect(result).toEqual({ recovered: true });
expect(mockRequest).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,5 +1,96 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
// In the Jest environment (jsdom/node), Platform.OS defaults to a value that
// causes rssi.service.ts to load the web implementation. We test the web
// version which provides synthetic data.
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
return {
...RN,
Platform: { ...RN.Platform, OS: 'web' },
};
});
describe('RssiService (web)', () => {
let rssiService: any;
beforeEach(() => {
jest.useFakeTimers();
jest.isolateModules(() => {
rssiService = require('@/services/rssi.service').rssiService;
});
});
afterEach(() => {
rssiService?.stopScanning();
jest.useRealTimers();
});
describe('subscribe / unsubscribe', () => {
it('subscribe returns an unsubscribe function', () => {
const listener = jest.fn();
const unsub = rssiService.subscribe(listener);
expect(typeof unsub).toBe('function');
unsub();
});
it('listener is not called without scanning', () => {
const listener = jest.fn();
rssiService.subscribe(listener);
jest.advanceTimersByTime(5000);
// Without startScanning, the listener should not be called
// (unless the service sends an initial broadcast, which web does on start)
expect(listener).not.toHaveBeenCalled();
});
});
describe('startScanning / stopScanning', () => {
it('startScanning delivers network data to subscribers', () => {
const listener = jest.fn();
rssiService.subscribe(listener);
rssiService.startScanning(1000);
// The web service immediately broadcasts once and sets up interval
expect(listener).toHaveBeenCalled();
const networks = listener.mock.calls[0][0];
expect(Array.isArray(networks)).toBe(true);
expect(networks.length).toBeGreaterThan(0);
expect(networks[0]).toHaveProperty('ssid');
expect(networks[0]).toHaveProperty('level');
});
it('stopScanning stops delivering data', () => {
const listener = jest.fn();
rssiService.subscribe(listener);
rssiService.startScanning(1000);
const callCount = listener.mock.calls.length;
rssiService.stopScanning();
jest.advanceTimersByTime(5000);
// No new calls after stopping
expect(listener.mock.calls.length).toBe(callCount);
});
it('unsubscribed listener does not receive scan results', () => {
const listener = jest.fn();
const unsub = rssiService.subscribe(listener);
unsub();
rssiService.startScanning(1000);
jest.advanceTimersByTime(3000);
expect(listener).not.toHaveBeenCalled();
});
});
describe('getLatestScan equivalent behavior', () => {
it('returns empty networks initially when no scan has run', () => {
// The web rssi service does not have a getLatestScan method,
// but we verify that without scanning no data is emitted.
const listener = jest.fn();
rssiService.subscribe(listener);
// No startScanning called
expect(listener).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,88 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
import { generateSimulatedData } from '@/services/simulation.service';
describe('generateSimulatedData', () => {
it('returns a valid SensingFrame shape', () => {
const frame = generateSimulatedData();
expect(frame).toHaveProperty('type', 'sensing_update');
expect(frame).toHaveProperty('timestamp');
expect(frame).toHaveProperty('source', 'simulated');
expect(typeof frame.tick).toBe('number');
});
it('has a nodes array with at least one node', () => {
const frame = generateSimulatedData();
expect(Array.isArray(frame.nodes)).toBe(true);
expect(frame.nodes.length).toBeGreaterThanOrEqual(1);
const node = frame.nodes[0];
expect(typeof node.node_id).toBe('number');
expect(typeof node.rssi_dbm).toBe('number');
expect(Array.isArray(node.position)).toBe(true);
expect(node.position).toHaveLength(3);
});
it('has features object with expected numeric fields', () => {
const frame = generateSimulatedData();
const { features } = frame;
expect(typeof features.mean_rssi).toBe('number');
expect(typeof features.variance).toBe('number');
expect(typeof features.motion_band_power).toBe('number');
expect(typeof features.breathing_band_power).toBe('number');
expect(typeof features.spectral_entropy).toBe('number');
expect(typeof features.std).toBe('number');
expect(typeof features.dominant_freq_hz).toBe('number');
});
it('has classification with valid motion_level', () => {
const frame = generateSimulatedData();
const { classification } = frame;
expect(['absent', 'present_still', 'active']).toContain(classification.motion_level);
expect(typeof classification.presence).toBe('boolean');
expect(typeof classification.confidence).toBe('number');
expect(classification.confidence).toBeGreaterThanOrEqual(0);
expect(classification.confidence).toBeLessThanOrEqual(1);
});
it('has signal_field with correct grid_size', () => {
const frame = generateSimulatedData();
const { signal_field } = frame;
expect(signal_field.grid_size).toEqual([20, 1, 20]);
expect(Array.isArray(signal_field.values)).toBe(true);
expect(signal_field.values.length).toBe(20 * 20);
});
it('has signal_field values clamped between 0 and 1', () => {
const frame = generateSimulatedData();
for (const v of frame.signal_field.values) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
it('has vital_signs present', () => {
const frame = generateSimulatedData();
expect(frame.vital_signs).toBeDefined();
expect(typeof frame.vital_signs!.breathing_bpm).toBe('number');
expect(typeof frame.vital_signs!.hr_proxy_bpm).toBe('number');
expect(typeof frame.vital_signs!.confidence).toBe('number');
});
it('has estimated_persons field', () => {
const frame = generateSimulatedData();
expect(typeof frame.estimated_persons).toBe('number');
expect(frame.estimated_persons).toBeGreaterThanOrEqual(0);
});
it('produces different data for different timestamps', () => {
const frame1 = generateSimulatedData(1000);
const frame2 = generateSimulatedData(5000);
// The RSSI values should differ since the simulation is time-based
expect(frame1.features.mean_rssi).not.toBe(frame2.features.mean_rssi);
});
it('accepts a custom timeMs parameter', () => {
const t = 1700000000000;
const frame = generateSimulatedData(t);
expect(frame.timestamp).toBe(t);
});
});
@@ -1,5 +1,169 @@
describe('placeholder', () => {
it('passes', () => {
expect(true).toBe(true);
// We test the WsService class by importing a fresh instance.
// We need to mock the poseStore to prevent side effects.
jest.mock('@/stores/poseStore', () => ({
usePoseStore: {
getState: jest.fn(() => ({
setConnectionStatus: jest.fn(),
})),
},
}));
jest.mock('@/services/simulation.service', () => ({
generateSimulatedData: jest.fn(() => ({
type: 'sensing_update',
timestamp: Date.now(),
source: 'simulated',
nodes: [],
features: { mean_rssi: -45, variance: 1 },
classification: { motion_level: 'absent', presence: false, confidence: 0.5 },
signal_field: { grid_size: [20, 1, 20], values: [] },
})),
}));
// Create a fresh WsService for each test to avoid shared state
function createWsService() {
// Use jest.isolateModules to get a fresh module instance
let service: any;
jest.isolateModules(() => {
service = require('@/services/ws.service').wsService;
});
return service;
}
describe('WsService', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
describe('buildWsUrl', () => {
it('uses the same port as the HTTP URL, not a hardcoded port', () => {
// This is the critical bug-fix verification.
// buildWsUrl is private, so we test it indirectly via connect().
// We mock WebSocket to capture the URL it is called with.
const capturedUrls: string[] = [];
const OrigWebSocket = globalThis.WebSocket;
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
readyState = 0;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
onmessage: (() => void) | null = null;
close() {}
constructor(url: string) {
capturedUrls.push(url);
}
}
globalThis.WebSocket = MockWebSocket as any;
try {
const ws = createWsService();
// Test with port 3000
ws.connect('http://192.168.1.10:3000');
expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/ws/sensing');
// Clean up, create another service
ws.disconnect();
const ws2 = createWsService();
// Test with port 8080
ws2.connect('http://myserver.local:8080');
expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/ws/sensing');
ws2.disconnect();
// Test HTTPS -> WSS upgrade (port 443 is default for HTTPS so host drops it)
const ws3 = createWsService();
ws3.connect('https://secure.example.com:443');
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
ws3.disconnect();
// Test WSS input
const ws4 = createWsService();
ws4.connect('wss://secure.example.com');
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
ws4.disconnect();
// Verify port 3001 is NOT hardcoded anywhere
for (const url of capturedUrls) {
expect(url).not.toContain(':3001');
}
} finally {
globalThis.WebSocket = OrigWebSocket;
}
});
});
describe('connect with empty URL', () => {
it('falls back to simulation mode when URL is empty', () => {
const ws = createWsService();
ws.connect('');
expect(ws.getStatus()).toBe('simulated');
ws.disconnect();
});
});
describe('subscribe and unsubscribe', () => {
it('adds a listener and returns an unsubscribe function', () => {
const ws = createWsService();
const listener = jest.fn();
const unsub = ws.subscribe(listener);
expect(typeof unsub).toBe('function');
unsub();
ws.disconnect();
});
it('listener receives simulated frames', () => {
const ws = createWsService();
const listener = jest.fn();
ws.subscribe(listener);
ws.connect('');
// Advance timer to trigger simulation
jest.advanceTimersByTime(600);
expect(listener).toHaveBeenCalled();
const frame = listener.mock.calls[0][0];
expect(frame).toHaveProperty('type', 'sensing_update');
ws.disconnect();
});
it('unsubscribed listener does not receive frames', () => {
const ws = createWsService();
const listener = jest.fn();
const unsub = ws.subscribe(listener);
unsub();
ws.connect('');
jest.advanceTimersByTime(600);
expect(listener).not.toHaveBeenCalled();
ws.disconnect();
});
});
describe('disconnect', () => {
it('clears state and sets status to disconnected', () => {
const ws = createWsService();
ws.connect('');
expect(ws.getStatus()).toBe('simulated');
ws.disconnect();
expect(ws.getStatus()).toBe('disconnected');
});
});
describe('getStatus', () => {
it('returns disconnected initially', () => {
const ws = createWsService();
expect(ws.getStatus()).toBe('disconnected');
});
});
});