mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user