Config Page
This commit is contained in:
49
test/configMerge.test.js
Normal file
49
test/configMerge.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const { mergeConfig } = require('../src/configService');
|
||||
|
||||
function base() {
|
||||
return {
|
||||
cameras: [
|
||||
{ id: 'cam0', device: '/dev/video0', name: 'Kamera 0', position: 'front', stream: true, hires: true, note: 'x', liveSize: '640x480', hiresSize: '1280x960' },
|
||||
{ id: 'cam1', device: '/dev/video2', name: 'Kamera 1', position: 'left', stream: true, hires: true, note: 'y', hiresSize: '1280x960' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeConfig', () => {
|
||||
test('patcht liveSize, erhält ALLE übrigen Felder', () => {
|
||||
const out = mergeConfig(base(), [{ id: 'cam0', liveSize: '320x240', stream: true }]);
|
||||
const c0 = out.cameras.find((c) => c.id === 'cam0');
|
||||
expect(c0.liveSize).toBe('320x240');
|
||||
expect(c0.device).toBe('/dev/video0');
|
||||
expect(c0.name).toBe('Kamera 0');
|
||||
expect(c0.position).toBe('front');
|
||||
expect(c0.hiresSize).toBe('1280x960');
|
||||
expect(c0.note).toBe('x');
|
||||
});
|
||||
|
||||
test('lässt nicht genannte Kameras unverändert', () => {
|
||||
const b = base();
|
||||
const out = mergeConfig(b, [{ id: 'cam0', liveSize: '320x240', stream: true }]);
|
||||
expect(out.cameras.find((c) => c.id === 'cam1')).toEqual(b.cameras[1]);
|
||||
});
|
||||
|
||||
test('setzt stream:false', () => {
|
||||
const out = mergeConfig(base(), [{ id: 'cam1', stream: false }]);
|
||||
expect(out.cameras.find((c) => c.id === 'cam1').stream).toBe(false);
|
||||
});
|
||||
|
||||
test('mutiert das Original nicht', () => {
|
||||
const b = base();
|
||||
const snapshot = JSON.stringify(b);
|
||||
mergeConfig(b, [{ id: 'cam0', liveSize: '160x120', stream: true }]);
|
||||
expect(JSON.stringify(b)).toBe(snapshot);
|
||||
});
|
||||
|
||||
test('erhält Top-Level-Felder neben "cameras"', () => {
|
||||
const b = { version: 2, cameras: base().cameras };
|
||||
const out = mergeConfig(b, [{ id: 'cam0', liveSize: '800x600', stream: true }]);
|
||||
expect(out.version).toBe(2);
|
||||
});
|
||||
});
|
||||
54
test/configValidate.test.js
Normal file
54
test/configValidate.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
const { validateConfig } = require('../src/configService');
|
||||
const { LIVE_SIZES } = require('../src/liveSizes');
|
||||
|
||||
const IDS = ['cam0', 'cam1', 'cam2'];
|
||||
|
||||
describe('validateConfig', () => {
|
||||
test('akzeptiert gültige Auflösung', () => {
|
||||
const r = validateConfig([{ id: 'cam0', liveSize: '320x240', stream: true }], IDS);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('alle erlaubten Auflösungen sind gültig', () => {
|
||||
for (const s of LIVE_SIZES) {
|
||||
expect(validateConfig([{ id: 'cam0', liveSize: s, stream: true }], IDS).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('lehnt unbekannte id ab', () => {
|
||||
expect(validateConfig([{ id: 'camX', liveSize: '320x240', stream: true }], IDS).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('lehnt ungültige Auflösung ab', () => {
|
||||
expect(validateConfig([{ id: 'cam0', liveSize: '999x999', stream: true }], IDS).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('"Aus" (stream:false ohne liveSize) ist gültig', () => {
|
||||
expect(validateConfig([{ id: 'cam2', stream: false }], IDS).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('stream muss boolean sein', () => {
|
||||
expect(validateConfig([{ id: 'cam0', liveSize: '320x240', stream: 'ja' }], IDS).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('ein Fehler kippt das ganze Ergebnis (kein Teil-Apply)', () => {
|
||||
const r = validateConfig([
|
||||
{ id: 'cam0', liveSize: '320x240', stream: true },
|
||||
{ id: 'cam1', liveSize: 'bad', stream: true },
|
||||
], IDS);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Nicht-Array → Fehler', () => {
|
||||
expect(validateConfig(undefined, IDS).ok).toBe(false);
|
||||
expect(validateConfig(null, IDS).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('Eintrag ohne id → Fehler', () => {
|
||||
expect(validateConfig([{ liveSize: '320x240', stream: true }], IDS).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
59
test/mpjpegParser.test.js
Normal file
59
test/mpjpegParser.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
const { MpjpegParser } = require('../src/cameraSwitch');
|
||||
|
||||
// Baut ein FFmpeg-`-f mpjpeg`-Paket: Boundary + Header (Content-Length) + Body + CRLF.
|
||||
function packet(jpeg) {
|
||||
return Buffer.concat([
|
||||
Buffer.from(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`, 'latin1'),
|
||||
jpeg,
|
||||
Buffer.from('\r\n', 'latin1'),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('MpjpegParser', () => {
|
||||
test('ein Frame', () => {
|
||||
const frames = [];
|
||||
const p = new MpjpegParser((f) => frames.push(f));
|
||||
const jpeg = Buffer.from([0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9]);
|
||||
p.push(packet(jpeg));
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0].equals(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
test('mehrere Frames in einem Chunk', () => {
|
||||
const frames = [];
|
||||
const p = new MpjpegParser((f) => frames.push(f));
|
||||
const a = Buffer.from([0xFF, 0xD8, 1]);
|
||||
const b = Buffer.from([0xFF, 0xD8, 2, 3, 4]);
|
||||
p.push(Buffer.concat([packet(a), packet(b)]));
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[0].equals(a)).toBe(true);
|
||||
expect(frames[1].equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
test('über Chunk-Grenze gesplittetes Frame', () => {
|
||||
const frames = [];
|
||||
const p = new MpjpegParser((f) => frames.push(f));
|
||||
const jpeg = Buffer.from([10, 20, 30, 40, 50]);
|
||||
const pkt = packet(jpeg);
|
||||
p.push(pkt.subarray(0, 6)); // mitten im Header
|
||||
expect(frames).toHaveLength(0);
|
||||
p.push(pkt.subarray(6)); // Rest
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0].equals(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
test('Body über mehrere Chunks', () => {
|
||||
const frames = [];
|
||||
const p = new MpjpegParser((f) => frames.push(f));
|
||||
const jpeg = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const pkt = packet(jpeg);
|
||||
const cut = pkt.length - 5; // mitten im Body trennen
|
||||
p.push(pkt.subarray(0, cut));
|
||||
expect(frames).toHaveLength(0);
|
||||
p.push(pkt.subarray(cut));
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0].equals(jpeg)).toBe(true);
|
||||
});
|
||||
});
|
||||
32
test/readJpegWidth.test.js
Normal file
32
test/readJpegWidth.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
const { readJpegWidth } = require('../src/cameraSwitch');
|
||||
|
||||
// Minimaler JPEG-Kopf: SOI (FFD8) + SOF-Marker mit Höhe/Breite.
|
||||
// SOF-Layout: FF Cx | len(2) | precision(1) | height(2) | width(2)
|
||||
function jpegWithWidth(width, height = 480, marker = 0xC0) {
|
||||
const b = Buffer.alloc(20, 0);
|
||||
b[0] = 0xFF; b[1] = 0xD8; // SOI
|
||||
b[2] = 0xFF; b[3] = marker; // SOF0 / SOF2
|
||||
b.writeUInt16BE(17, 4); // Segment-Länge
|
||||
b[6] = 8; // precision
|
||||
b.writeUInt16BE(height, 7); // Höhe
|
||||
b.writeUInt16BE(width, 9); // Breite
|
||||
return b;
|
||||
}
|
||||
|
||||
describe('readJpegWidth', () => {
|
||||
test('liest Breite aus SOF0 (baseline)', () => {
|
||||
expect(readJpegWidth(jpegWithWidth(320))).toBe(320);
|
||||
expect(readJpegWidth(jpegWithWidth(1920))).toBe(1920);
|
||||
});
|
||||
|
||||
test('liest Breite aus SOF2 (progressive)', () => {
|
||||
expect(readJpegWidth(jpegWithWidth(1280, 720, 0xC2))).toBe(1280);
|
||||
});
|
||||
|
||||
test('kein SOF-Marker → null', () => {
|
||||
const b = Buffer.from([0xFF, 0xD8, 0xFF, 0xD9, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(readJpegWidth(b)).toBeNull();
|
||||
});
|
||||
});
|
||||
75
test/reconfigure.test.js
Normal file
75
test/reconfigure.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const { CameraSwitch } = require('../src/cameraSwitch');
|
||||
|
||||
// Mockt die FFmpeg-berührenden Methoden – getestet wird NUR die
|
||||
// Entscheidungslogik von reconfigure() (kill/spawn/no-op), keine Hardware.
|
||||
function makeSwitch(opts = {}) {
|
||||
const sw = new CameraSwitch({ id: 'camT', device: '/dev/null', liveSize: '640x480', ...opts });
|
||||
sw._killCurrentAndWait = jest.fn(() => { sw.proc = null; sw.state = 'stopped'; return Promise.resolve(); });
|
||||
sw._spawnLive = jest.fn(() => { sw.proc = {}; sw.state = 'live'; });
|
||||
return sw;
|
||||
}
|
||||
|
||||
describe('CameraSwitch.reconfigure', () => {
|
||||
test('geänderte liveSize → genau 1× kill + 1× spawn', async () => {
|
||||
const sw = makeSwitch({ onDemand: false });
|
||||
sw.proc = {}; sw.state = 'live';
|
||||
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||
expect(sw.liveSize).toBe('320x240');
|
||||
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(sw._spawnLive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('gleiche liveSize → no-op (kein kill, kein spawn)', async () => {
|
||||
const sw = makeSwitch({ onDemand: false });
|
||||
sw.proc = {}; sw.state = 'live';
|
||||
await sw.reconfigure({ liveSize: '640x480', stream: true });
|
||||
expect(sw._killCurrentAndWait).not.toHaveBeenCalled();
|
||||
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('lock aktiv (HD-Grab) → nur Feld setzen, kein kill/spawn', async () => {
|
||||
const sw = makeSwitch({ onDemand: false });
|
||||
sw.lock = true; sw.proc = {}; sw.state = 'grabbing';
|
||||
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||
expect(sw.liveSize).toBe('320x240');
|
||||
expect(sw._killCurrentAndWait).not.toHaveBeenCalled();
|
||||
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('stream:false → kill, KEIN respawn, streamEnabled=false', async () => {
|
||||
const sw = makeSwitch({ onDemand: false });
|
||||
sw.proc = {}; sw.state = 'live';
|
||||
await sw.reconfigure({ stream: false });
|
||||
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||
expect(sw.streamEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('On-Demand ohne Verbraucher → kill bei Resize, aber kein spawn', async () => {
|
||||
const sw = makeSwitch({ onDemand: true });
|
||||
sw.subscribers = 0;
|
||||
sw.proc = {}; sw.state = 'live';
|
||||
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(sw._spawnLive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('On-Demand mit Verbraucher → kill + spawn (nahtloser Resize)', async () => {
|
||||
const sw = makeSwitch({ onDemand: true });
|
||||
sw.subscribers = 1;
|
||||
sw.proc = {}; sw.state = 'live';
|
||||
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||
expect(sw._killCurrentAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(sw._spawnLive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Wiedereinschalten (stream:true) startet bei vorhandenem Verbraucher', async () => {
|
||||
const sw = makeSwitch({ onDemand: true, stream: false });
|
||||
sw.subscribers = 1; sw.state = 'stopped'; sw.proc = null;
|
||||
await sw.reconfigure({ liveSize: '320x240', stream: true });
|
||||
expect(sw.streamEnabled).toBe(true);
|
||||
expect(sw._spawnLive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user