Konfig in robot.json

This commit is contained in:
chk
2026-06-11 22:05:45 +02:00
parent 05355facf1
commit 66a8e247b5
18 changed files with 1761 additions and 151 deletions

150
test/RobotConfig.test.js Normal file
View File

@@ -0,0 +1,150 @@
'use strict';
const { load, DEFAULTS } = require('../robot/RobotConfig');
function makeFs(content) {
return {
readFileSync: jest.fn(() => content)
};
}
function makeFailFs() {
return {
readFileSync: jest.fn(() => { throw new Error('ENOENT'); })
};
}
const log = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
const FULL_ROBOT_JSON = {
kinematics: { type: 'arm3segmentlinearx' },
motion: { defaultFeedrate: 2300, speedMode: 'legacy', speedModeOptions: ['legacy', 'correct'] },
controllers: {
base: { ip: 'fluidNcBase.local', port: 2300, protocol: 'telnet', axes: ['x', 'y', 'z'] },
elbow: { ip: 'fluidNcEllbow.local', port: 5000, protocol: 'telnet', axes: ['a', null, null] },
hand: { ip: 'fluidNcHand.local', port: 5000, protocol: 'telnet', axes: ['c', 'e', 'b'] }
},
links: {
Arm1: { skeleton: { from: [0,0,0], to: [0,-250,0] } },
Arm2: { skeleton: { from: [0,0,0], to: [0,-250,0] } },
Ellbow: { skeleton: { from: [0,0,0], to: [90,0,0] } }
}
};
describe('RobotConfig.load — Vollständige robot.json', () => {
let cfg;
beforeEach(() => {
cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), {}, log);
});
test('kinematics.type aus robot.json', () => {
expect(cfg.kinematics.type).toBe('arm3segmentlinearx');
});
test('l1/l2 aus links.Arm1/Arm2.skeleton.to[1] (Betrag)', () => {
expect(cfg.kinematics.l1).toBe(250);
expect(cfg.kinematics.l2).toBe(250);
});
test('l3 aus links.Ellbow.skeleton.to[0]', () => {
expect(cfg.kinematics.l3).toBe(90);
});
test('motion.defaultFeedrate aus robot.json', () => {
expect(cfg.motion.defaultFeedrate).toBe(2300);
});
test('motion.speedMode aus robot.json', () => {
expect(cfg.motion.speedMode).toBe('legacy');
});
test('motion.useSpeedCalc false für legacy', () => {
expect(cfg.motion.useSpeedCalc).toBe(false);
});
test('controllers enthält alle drei Endpunkte', () => {
expect(cfg.controllers.base.ip).toBe('fluidNcBase.local');
expect(cfg.controllers.base.port).toBe(2300);
expect(cfg.controllers.base.protocol).toBe('telnet');
expect(cfg.controllers.elbow.port).toBe(5000);
expect(cfg.controllers.hand.ip).toBe('fluidNcHand.local');
});
test('axesByController gibt korrektes Array zurück', () => {
expect(cfg.axesByController('base')).toEqual(['x', 'y', 'z']);
expect(cfg.axesByController('elbow')).toEqual(['a', null, null]);
expect(cfg.axesByController('hand')).toEqual(['c', 'e', 'b']);
});
test('axesByController gibt [] für unbekannten Key zurück', () => {
expect(cfg.axesByController('unknown')).toEqual([]);
});
});
describe('RobotConfig.load — Fehlerbehandlung', () => {
test('fehlende robot.json → Defaults', () => {
const cfg = load(makeFailFs(), {}, log);
expect(cfg.kinematics.l1).toBe(DEFAULTS.kinematics.l1);
expect(cfg.motion.defaultFeedrate).toBe(DEFAULTS.motion.defaultFeedrate);
expect(cfg.controllers.base.ip).toBe(DEFAULTS.controllers.base.ip);
});
test('ungültiges JSON → Defaults', () => {
const cfg = load(makeFs('{ not valid json }'), {}, log);
expect(cfg.kinematics.type).toBe(DEFAULTS.kinematics.type);
});
test('fehlende links → kinematics-Defaults', () => {
const json = { ...FULL_ROBOT_JSON };
delete json.links;
const cfg = load(makeFs(JSON.stringify(json)), {}, log);
expect(cfg.kinematics.l1).toBe(DEFAULTS.kinematics.l1);
expect(cfg.kinematics.l3).toBe(DEFAULTS.kinematics.l3);
});
});
describe('RobotConfig.load — Env-Override', () => {
test('ROBOT_DEFAULT_FEEDRATE überschreibt robot.json', () => {
const env = { ROBOT_DEFAULT_FEEDRATE: '5000' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.motion.defaultFeedrate).toBe(5000);
});
test('ROBOT_SPEED_MODE=correct setzt useSpeedCalc=true', () => {
const env = { ROBOT_SPEED_MODE: 'correct' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.motion.speedMode).toBe('correct');
expect(cfg.motion.useSpeedCalc).toBe(true);
});
test('GRBL_BASE_IP überschreibt controller.base.ip', () => {
const env = { GRBL_BASE_IP: '192.168.1.10' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.controllers.base.ip).toBe('192.168.1.10');
});
test('GRBL_ELLBOW_IP überschreibt controller.elbow.ip', () => {
const env = { GRBL_ELLBOW_IP: '192.168.1.11' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.controllers.elbow.ip).toBe('192.168.1.11');
});
test('GRBL_HAND_IP überschreibt controller.hand.ip', () => {
const env = { GRBL_HAND_IP: '192.168.1.12' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.controllers.hand.ip).toBe('192.168.1.12');
});
});
describe('RobotConfig.load — speedMode correct', () => {
test('speedMode correct aus robot.json setzt useSpeedCalc=true', () => {
const json = { ...FULL_ROBOT_JSON, motion: { ...FULL_ROBOT_JSON.motion, speedMode: 'correct' } };
const cfg = load(makeFs(JSON.stringify(json)), {}, log);
expect(cfg.motion.useSpeedCalc).toBe(true);
});
test('ROBOT_USE_SPEED_CALC=true setzt useSpeedCalc', () => {
const env = { ROBOT_USE_SPEED_CALC: 'true' };
const cfg = load(makeFs(JSON.stringify(FULL_ROBOT_JSON)), env, log);
expect(cfg.motion.useSpeedCalc).toBe(true);
});
});

View File

@@ -0,0 +1,272 @@
const fs = require('fs');
const https = require('https');
const path = require('path');
const express = require('express');
const service = require('../server/RobotConfigService');
const DATA_DIR = path.join(__dirname, '..', 'data', 'robot');
const ROBOT_JSON = path.join(DATA_DIR, 'robot.json');
const TEST_KEY = 'test-api-key-12345';
const SAMPLE_ROBOT = {
links: {
Arm1: { size: [70, 250, 70] },
Arm2: { size: [70, 250, 70] },
Ellbow: { size: [90, 0, 0] }
}
};
// ── HTTPS-Test-Server ─────────────────────────────────────────────────────────
function makeServer(apiKey) {
const app = express();
service.register(app, { apiKey });
const key = fs.readFileSync('https/localhost.key');
const cert = fs.readFileSync('https/localhost.pem');
return https.createServer({ key, cert, passphrase: 'abcd' }, app);
}
function listen(server) {
return new Promise((resolve, reject) => {
server.listen(0, () => {
const addr = server.address();
addr ? resolve(addr.port) : reject(new Error('no port'));
});
server.on('error', reject);
});
}
function closeServer(server) {
return new Promise(resolve => { server.close(resolve); });
}
function request(method, url, { body, token } = {}) {
return new Promise((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
const payload = body ? JSON.stringify(body) : undefined;
const headers = {
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
const req = https.request(url, { method, agent, headers }, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
// ── Setup / Teardown ──────────────────────────────────────────────────────────
function cleanupTestFiles() {
for (const f of fs.readdirSync(DATA_DIR)) {
if (f === '.gitkeep' || f === '.apikey') continue;
fs.unlinkSync(path.join(DATA_DIR, f));
}
}
beforeEach(() => {
fs.mkdirSync(DATA_DIR, { recursive: true });
cleanupTestFiles();
});
afterEach(() => {
cleanupTestFiles();
});
// ── Einzel-Funktionen ─────────────────────────────────────────────────────────
describe('readRobotJson / writeRobotJson', () => {
test('writeRobotJson schreibt Datei und legt Snapshot an', async () => {
fs.writeFileSync(ROBOT_JSON, JSON.stringify({ version: 1 }), 'utf8');
const { snapshotFile } = await service.writeRobotJson({ version: 2 });
expect(snapshotFile).toMatch(/^robot_\d{8}_\d{6}\.json$/);
expect(fs.existsSync(path.join(DATA_DIR, snapshotFile))).toBe(true);
const written = JSON.parse(fs.readFileSync(ROBOT_JSON, 'utf8'));
expect(written).toEqual({ version: 2 });
const snapshot = JSON.parse(fs.readFileSync(path.join(DATA_DIR, snapshotFile), 'utf8'));
expect(snapshot).toEqual({ version: 1 });
});
test('kein Snapshot wenn robot.json noch nicht existiert', async () => {
const { snapshotFile } = await service.writeRobotJson({ fresh: true });
expect(fs.existsSync(path.join(DATA_DIR, snapshotFile))).toBe(false);
});
test('readRobotJson liest die aktuelle Datei', async () => {
fs.writeFileSync(ROBOT_JSON, JSON.stringify(SAMPLE_ROBOT), 'utf8');
const data = await service.readRobotJson();
expect(data).toEqual(SAMPLE_ROBOT);
});
test('readRobotJson wirft, wenn robot.json fehlt', async () => {
await expect(service.readRobotJson()).rejects.toThrow();
});
});
describe('listHistory / readSnapshot', () => {
test('listHistory gibt leere Liste zurück wenn kein Snapshot', async () => {
const list = await service.listHistory();
expect(list).toEqual([]);
});
test('listHistory listet vorhandene Snapshots, neueste zuerst', async () => {
fs.writeFileSync(path.join(DATA_DIR, 'robot_20260101_120000.json'), '{}', 'utf8');
fs.writeFileSync(path.join(DATA_DIR, 'robot_20260102_083000.json'), '{}', 'utf8');
const list = await service.listHistory();
expect(list.map(e => e.filename)).toEqual([
'robot_20260102_083000.json',
'robot_20260101_120000.json'
]);
expect(list[0]).toEqual({ filename: 'robot_20260102_083000.json', day: '20260102', timestamp: '083000' });
});
test('readSnapshot liest einen bestimmten Snapshot', async () => {
const ts = '20260101_120000';
fs.writeFileSync(path.join(DATA_DIR, `robot_${ts}.json`), JSON.stringify({ x: 42 }), 'utf8');
const data = await service.readSnapshot(ts);
expect(data).toEqual({ x: 42 });
});
test('readSnapshot wirft bei unbekanntem Timestamp', async () => {
await expect(service.readSnapshot('99999999_999999')).rejects.toThrow();
});
});
describe('Pruning', () => {
test('≤ 100 Snapshots pro Tag bleiben alle erhalten', async () => {
for (let i = 1; i <= 5; i++) {
fs.writeFileSync(path.join(DATA_DIR, `robot_2026061${i}_120000.json`), '{}', 'utf8');
}
fs.writeFileSync(ROBOT_JSON, '{}', 'utf8');
await service.writeRobotJson({ pruned: true });
const list = await service.listHistory();
// 5 bestehende (verschiedene Tage) + 1 neuer Snapshot = 6
expect(list.length).toBe(6);
});
test('> 100 Snapshots eines Tages: nur neuester bleibt', async () => {
const day = '20260101';
for (let i = 0; i < 102; i++) {
const hh = String(Math.floor(i / 3600)).padStart(2, '0');
const mm = String(Math.floor((i % 3600) / 60)).padStart(2, '0');
const ss = String(i % 60).padStart(2, '0');
fs.writeFileSync(path.join(DATA_DIR, `robot_${day}_${hh}${mm}${ss}.json`), `{"i":${i}}`, 'utf8');
}
// Neuester (lexikographisch letzter) vorher anlegen
const newest = `robot_${day}_235959.json`;
fs.writeFileSync(path.join(DATA_DIR, newest), '{"newest":true}', 'utf8');
fs.writeFileSync(ROBOT_JSON, '{}', 'utf8');
await service.writeRobotJson({ trigger: true });
const list = await service.listHistory();
const daySnapshots = list.filter(e => e.day === day);
expect(daySnapshots.length).toBe(1);
expect(daySnapshots[0].filename).toBe(newest);
});
});
// ── HTTP-Routen ───────────────────────────────────────────────────────────────
describe('HTTP GET /api/robot', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('404 wenn robot.json nicht existiert', async () => {
const { statusCode } = await request('GET', `https://127.0.0.1:${port}/api/robot`);
expect(statusCode).toBe(404);
});
test('200 mit robot.json-Inhalt', async () => {
fs.writeFileSync(ROBOT_JSON, JSON.stringify(SAMPLE_ROBOT), 'utf8');
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot`);
expect(statusCode).toBe(200);
expect(JSON.parse(body)).toEqual(SAMPLE_ROBOT);
});
});
describe('HTTP PUT /api/robot', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('401 ohne Authorization-Header', async () => {
const { statusCode } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, { body: SAMPLE_ROBOT });
expect(statusCode).toBe(401);
});
test('401 mit falschem Key', async () => {
const { statusCode } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, {
body: SAMPLE_ROBOT, token: 'wrong-key'
});
expect(statusCode).toBe(401);
});
test('200 mit korrektem Key — schreibt Datei', async () => {
const { statusCode, body } = await request('PUT', `https://127.0.0.1:${port}/api/robot`, {
body: SAMPLE_ROBOT, token: TEST_KEY
});
expect(statusCode).toBe(200);
const result = JSON.parse(body);
expect(result.ok).toBe(true);
expect(result.snapshotFile).toMatch(/^robot_\d{8}_\d{6}\.json$/);
const written = JSON.parse(fs.readFileSync(ROBOT_JSON, 'utf8'));
expect(written).toEqual(SAMPLE_ROBOT);
});
});
describe('HTTP GET /api/robot/history', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('200 mit leerer History', async () => {
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history`);
expect(statusCode).toBe(200);
expect(JSON.parse(body)).toEqual({ history: [] });
});
test('200 listet vorhandene Snapshots', async () => {
fs.writeFileSync(path.join(DATA_DIR, 'robot_20260611_100000.json'), '{}', 'utf8');
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history`);
expect(statusCode).toBe(200);
const { history } = JSON.parse(body);
expect(history).toHaveLength(1);
expect(history[0].filename).toBe('robot_20260611_100000.json');
});
});
describe('HTTP GET /api/robot/history/:ts', () => {
let server, port;
beforeEach(async () => { server = makeServer(TEST_KEY); port = await listen(server); });
afterEach(async () => { await closeServer(server); });
test('200 für vorhandenen Snapshot', async () => {
const ts = '20260611_100000';
fs.writeFileSync(path.join(DATA_DIR, `robot_${ts}.json`), JSON.stringify({ snap: true }), 'utf8');
const { statusCode, body } = await request('GET', `https://127.0.0.1:${port}/api/robot/history/${ts}`);
expect(statusCode).toBe(200);
expect(JSON.parse(body)).toEqual({ snap: true });
});
test('404 für unbekannten Snapshot', async () => {
const { statusCode } = await request('GET', `https://127.0.0.1:${port}/api/robot/history/99991231_235959`);
expect(statusCode).toBe(404);
});
});

View File

@@ -43,7 +43,7 @@ describe('startRobot orchestrator', () => {
consoleObj: { log: jest.fn(), warn: jest.fn(), error: jest.fn() }
});
expect(readFileSync).toHaveBeenCalledTimes(2);
expect(readFileSync).toHaveBeenCalledTimes(3); // key, cert, data/robot/robot.json
expect(httpsModuleMock.createServer).toHaveBeenCalledWith({
enable: true,
key: 'fake-key',
@@ -61,7 +61,8 @@ describe('startRobot orchestrator', () => {
expect.objectContaining({ name: 'Base', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Elbow', instance: expect.any(Object) }),
expect.objectContaining({ name: 'Hand', instance: expect.any(Object) })
])
]),
expect.objectContaining({}) // options: { apiKey }
);
expect(httpsServerMock.listen).toHaveBeenCalledWith(2095);