Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions src/architecture/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface PaginatedResponse<T = unknown> {
message?: string;
}

// --- Typings (ws.client.ts ou architecture/api/types.ts) ---
// --- WebSocket client ---

export interface WebSocketClientConfig {
/** Path appended to base WS URL (e.g. 'robot/status'). */
Expand All @@ -47,7 +47,7 @@ export interface WebSocketClientCallbacks<TMessage = unknown> {
onError?: (event: Event) => void;
}

// --- Robot WebSocket Messages ---
// --- Domain data ---

export interface RobotState {
is_connected: boolean;
Expand All @@ -64,13 +64,102 @@ export interface HealthData {
robot_state: RobotState;
}

export interface Waypoint {
id: string;
name: string;
x: number;
y: number;
yaw: number;
map_id: string;
created_at: string;
}

export interface MapMeta {
resolution: number; // mètres par pixel
width: number; // pixels
height: number;
origin_x: number; // coin bas-gauche dans le repère map (m)
origin_y: number;
image_url: string | null;
}

export interface NavProgress {
waypoint: Waypoint | null;
distance_remaining: number;
eta_seconds: number;
navigation_time: number;
recoveries: number;
current_x: number;
current_y: number;
}

export interface CommandAck {
command: string;
linear_x?: number;
linear_y?: number;
angular_z?: number;
}

export interface CommandError {
command: string;
error: string;
}

export interface ActivityEvent {
action: string;
actor?: string;
payload?: Record<string, unknown>;
timestamp?: string;
}

export interface TeleopCommandInput {
linear_x: number;
linear_y: number;
angular_z: number;
}

// --- WebSocket messages (1:1) ---

export type WsIncomingMessage =
| { type: 'health_response'; data: HealthData }
| { type: 'pong' }
| { type: 'initial_state'; data: RobotState }
| { type: 'state_response'; data: RobotState }
| { type: 'robot_state_updated'; data: RobotState }
| { type: 'pong' };
| { type: 'health_response'; data: HealthData }
| { type: 'activity_history'; data: ActivityEvent[] }
| { type: 'activity_event'; data: ActivityEvent }
| { type: 'command_ack'; data: CommandAck }
| { type: 'command_error'; data: CommandError }
| { type: 'waypoint.saved'; data: Waypoint }
| { type: 'waypoint_list'; data: Waypoint[] }
| { type: 'map_response'; data: MapMeta }
| { type: 'nav.started'; data: { waypoint: Waypoint } }
| { type: 'nav.progress'; data: NavProgress }
| { type: 'nav.arrived'; data: { waypoint: Waypoint | null } }
| {
type: 'nav.failed';
data: { waypoint: Waypoint | null; reason?: string };
};

export type WsOutgoingMessage =
| { type: 'get_health' }
| { type: 'ping' }
| { type: 'get_state' };
| { type: 'get_state' }
| { type: 'get_health' }
| { type: 'get_activity'; limit?: number }
| { type: 'teleop.move'; data: TeleopCommandInput }
| { type: 'emergency_stop' }
| {
type: 'waypoint.save';
data: {
name: string;
x: number;
y: number;
yaw?: number;
map_id?: string;
};
}
| { type: 'waypoint.list'; map_id?: string }
| { type: 'waypoint.delete'; data: { id: string } }
| { type: 'nav.goto'; data: { id?: string; name?: string } }
| { type: 'nav.cancel' }
| { type: 'get_map' };
179 changes: 179 additions & 0 deletions src/architecture/gateways/robot.gateway.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { RobotGateway } from './robot.gateway';
import type { WsIncomingMessage } from '../api/types';

type MockWs = {
readyState: number;
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
onopen: (() => void) | null;
onmessage: ((e: MessageEvent) => void) | null;
onclose: ((e: CloseEvent) => void) | null;
onerror: ((e: Event) => void) | null;
};

let WebSocketCtor: ReturnType<typeof vi.fn>;
let mockWs: MockWs;

beforeAll(() => {
WebSocketCtor = vi.fn();
Object.assign(WebSocketCtor, {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
});
vi.stubGlobal('WebSocket', WebSocketCtor);
});

beforeEach(() => {
WebSocketCtor.mockClear();
mockWs = {
readyState: 1, // OPEN
send: vi.fn(),
close: vi.fn(),
onopen: null,
onmessage: null,
onclose: null,
onerror: null,
};
WebSocketCtor.mockImplementation(() => mockWs);
});

/** Gateway with a subscriber (opens the connection), ready to send. */
function openGateway(): RobotGateway {
const gateway = new RobotGateway();
gateway.subscribe(() => {}); // first subscriber opens the connection
return gateway;
}

function lastSent(): unknown {
const { calls } = mockWs.send.mock;
return JSON.parse(calls[calls.length - 1][0] as string);
}

/** Simulate an incoming server message. */
function emitIncoming(msg: WsIncomingMessage): void {
mockWs.onmessage?.({ data: JSON.stringify(msg) } as MessageEvent);
}

describe('RobotGateway — subscriptions', () => {
it('opens the connection on the first subscribe', () => {
const gateway = new RobotGateway();
expect(WebSocketCtor).not.toHaveBeenCalled();
gateway.subscribe(() => {});
expect(WebSocketCtor).toHaveBeenCalledTimes(1);
});

it('delivers incoming messages to the subscriber', () => {
const gateway = new RobotGateway();
const listener = vi.fn();
gateway.subscribe(listener);
emitIncoming({ type: 'pong' });
expect(listener).toHaveBeenCalledWith({ type: 'pong' });
});

it('delivers the same message to multiple subscribers', () => {
const gateway = new RobotGateway();
const a = vi.fn();
const b = vi.fn();
gateway.subscribe(a);
gateway.subscribe(b);
emitIncoming({ type: 'pong' });
expect(a).toHaveBeenCalledWith({ type: 'pong' });
expect(b).toHaveBeenCalledWith({ type: 'pong' });
});

it('unsubscribes without affecting the others', () => {
const gateway = new RobotGateway();
const a = vi.fn();
const b = vi.fn();
const unsubA = gateway.subscribe(a);
gateway.subscribe(b);
unsubA();
emitIncoming({ type: 'pong' });
expect(a).not.toHaveBeenCalled();
expect(b).toHaveBeenCalledWith({ type: 'pong' });
});

it('closes the connection when the last subscriber leaves', () => {
const gateway = new RobotGateway();
const unsub1 = gateway.subscribe(() => {});
const unsub2 = gateway.subscribe(() => {});
unsub1();
expect(mockWs.close).not.toHaveBeenCalled(); // still one subscriber left
unsub2();
expect(mockWs.close).toHaveBeenCalled();
});

it('notifies connection state changes', () => {
const gateway = new RobotGateway();
const onChange = vi.fn();
gateway.onConnectionChange(onChange);
gateway.subscribe(() => {});
mockWs.onopen?.();
expect(onChange).toHaveBeenCalledWith('open');
expect(gateway.connectionState).toBe('open');
mockWs.onclose?.({ wasClean: true } as CloseEvent);
expect(onChange).toHaveBeenCalledWith('closed');
});
});

describe('RobotGateway — WS commands', () => {
it('move() sends teleop.move with the velocities', () => {
openGateway().move({ linear_x: 0.1, linear_y: 0, angular_z: -0.2 });
expect(lastSent()).toEqual({
type: 'teleop.move',
data: { linear_x: 0.1, linear_y: 0, angular_z: -0.2 },
});
});

it('emergencyStop() sends emergency_stop', () => {
openGateway().emergencyStop();
expect(lastSent()).toEqual({ type: 'emergency_stop' });
});

it('navGoto() sends nav.goto by id', () => {
openGateway().navGoto({ id: 'wp-1' });
expect(lastSent()).toEqual({ type: 'nav.goto', data: { id: 'wp-1' } });
});

it('navGoto() sends nav.goto by name', () => {
openGateway().navGoto({ name: 'Chambre B' });
expect(lastSent()).toEqual({
type: 'nav.goto',
data: { name: 'Chambre B' },
});
});

it('navCancel() sends nav.cancel', () => {
openGateway().navCancel();
expect(lastSent()).toEqual({ type: 'nav.cancel' });
});

it('saveWaypoint() sends waypoint.save', () => {
openGateway().saveWaypoint({ name: 'Chambre B', x: 2, y: 1.5 });
expect(lastSent()).toEqual({
type: 'waypoint.save',
data: { name: 'Chambre B', x: 2, y: 1.5 },
});
});

it('listWaypoints() sends waypoint.list with map_id', () => {
openGateway().listWaypoints('default');
expect(lastSent()).toEqual({ type: 'waypoint.list', map_id: 'default' });
});

it('deleteWaypoint() sends waypoint.delete', () => {
openGateway().deleteWaypoint('wp-1');
expect(lastSent()).toEqual({
type: 'waypoint.delete',
data: { id: 'wp-1' },
});
});

it('getMap() sends get_map', () => {
openGateway().getMap();
expect(lastSent()).toEqual({ type: 'get_map' });
});
});
Loading
Loading