Event-driven CommandService dispatches exact case-sensitive #command matches on COMMAND_CHANNEL (default 2) through a single-registration-point registry; first command #ping -> #pong!. Replies ride COMMAND_CHANNEL: directed DM back to the sender when present, else broadcast. Failing handler/send only logs a warn. DI: CommandModule's factory injects MeshService and attaches itself via MeshService.attachCommands() — a constructor-level cycle leaves one side null in NestJS 12's injector (verified), so no forwardRef is used. Tests 106 -> 121; docs synced (AGENTS.md, MESHTASTIC.md, README).
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BROADCAST_ADDR } from '../mesh/codec.js';
|
||||
import {
|
||||
startBridge,
|
||||
textPacketFrame,
|
||||
waitFor,
|
||||
KAMIL_NUM,
|
||||
} from '../mesh/fake-bridge.js';
|
||||
import type { MeshService } from '../mesh/mesh.service.js';
|
||||
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
|
||||
import { CommandService } from './command.service.js';
|
||||
|
||||
function makeService(
|
||||
sendText?: ReturnType<typeof vi.fn>,
|
||||
): [CommandService, MeshService] {
|
||||
const mesh = { sendText: sendText ?? vi.fn() } as unknown as MeshService;
|
||||
return [new CommandService(mesh), mesh];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
|
||||
});
|
||||
|
||||
describe('CommandService.onRx', () => {
|
||||
it('answers #ping on the command channel with a directed DM back to the sender', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping', 42, 7);
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
|
||||
});
|
||||
|
||||
it('broadcasts the reply when there is no sender', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping');
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(
|
||||
BROADCAST_ADDR,
|
||||
'#pong!',
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores the same text on other channels (ch0 and ch3)', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(0, '#ping', 42);
|
||||
await svc.onRx(3, '#ping', 42);
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-command text on the command channel', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, 'hello there', 42);
|
||||
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches command names exactly and case-sensitively', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#Ping', 42);
|
||||
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before matching', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, ' #ping ', 42);
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
|
||||
});
|
||||
|
||||
it('honors the COMMAND_CHANNEL env override', async () => {
|
||||
process.env.COMMAND_CHANNEL = '5';
|
||||
const [svc, mesh] = makeService();
|
||||
await svc.onRx(2, '#ping', 42); // old default no longer triggers
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
await svc.onRx(5, '#ping', 42);
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 5);
|
||||
});
|
||||
|
||||
it('sends nothing when a handler returns void', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('silent', () => undefined);
|
||||
await svc.onRx(2, '#silent', 42);
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is extensible: a second registered command dispatches through the same path', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
|
||||
await svc.onRx(2, '#status', 99, 5);
|
||||
expect(mesh.sendText).toHaveBeenCalledWith(99, 'up from 99', undefined, 2);
|
||||
});
|
||||
|
||||
it('swallows a failing handler (a bad command must not crash the app)', async () => {
|
||||
const [svc, mesh] = makeService();
|
||||
svc.register('boom', () => {
|
||||
throw new Error('kaboom');
|
||||
});
|
||||
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
|
||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a failing send (mesh down) without throwing', async () => {
|
||||
const [svc] = makeService(
|
||||
vi.fn().mockRejectedValue(new Error('no bridge')),
|
||||
);
|
||||
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshService wiring (event-driven trigger)', () => {
|
||||
it('calls onRx for decoded rx text and answers #ping over the wire', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
try {
|
||||
// wire the responder exactly like command.module.ts does in production
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
||||
|
||||
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
||||
await waitFor(() => svc.messages().length === 2);
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
text: '#pong!',
|
||||
channel: 2,
|
||||
});
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not answer #ping arriving on a non-command channel', async () => {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new RealMeshService();
|
||||
try {
|
||||
svc.attachCommands(new CommandService(svc));
|
||||
svc.onModuleInit();
|
||||
await waitFor(
|
||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||
);
|
||||
|
||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3));
|
||||
|
||||
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
|
||||
expect(svc.messages()[0]).toMatchObject({
|
||||
dir: 'rx',
|
||||
text: '#ping',
|
||||
channel: 3,
|
||||
});
|
||||
} finally {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user