import { describe, expect, it } from 'vitest' import { callStateOf, describeCall, describeContact, describeTab, parseAddresses, mergeSnapshot, parseHello, parseResult, parseContacts, parseSnapshot, parseTabs, type CallEvent, } from './protocol' /** * Die Beispielnachrichten sind gegen die laufende SwyxTray-App 1.0.0.0 * mitgeschnitten (ws://192.168.180.135:17654/ws). */ const HELLO = '{"app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello"}' /** Wörtlich mitgeschnitten, während ein Anruf auf Leitung 1 einging. */ const RINGING_SNAPSHOT = '{"connected":true,"serverUp":true,"overall":"Ringing","statusText":"Eingehender Ruf",' + '"user":"Muster, M.","server":"127.0.0.1","lines":[' + '{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt","peer":"001602107449",' + '"peerNumber":"001602107449","peerName":"","busy":true,"selected":true},' + '{"line":2,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' + '"peerNumber":"","peerName":"","busy":false,"selected":false},' + '{"line":3,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' + '"peerNumber":"","peerName":"","busy":false,"selected":false},' + '{"line":4,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' + '"peerNumber":"","peerName":"","busy":false,"selected":false}],"type":"snapshot"}' /** Leitungseintrag im Snapshot; Vorgabe ist eine freie Leitung. */ function line(overrides: Record = {}) { return { line: 1, state: 'LSInactive', stateCode: 0, stateText: 'frei', peer: 'unbekannt', peerNumber: '', peerName: '', busy: false, selected: false, ...overrides, } } function snapshot(...entries: Record[]) { return { connected: true, serverUp: true, overall: 'Idle', statusText: 'Bereit', user: 'Muster, M.', server: '127.0.0.1', lines: entries, type: 'snapshot', } } describe('parseHello', () => { it('liest die Begrüßung der App', () => { expect(parseHello(JSON.parse(HELLO))).toEqual({ app: 'SwyxTray', version: '1.0.0.0', protocol: 1, session: 7, }) }) it('hält andere Nachrichten für keine Begrüßung', () => { expect(parseHello(snapshot())).toBeNull() }) it('liest die Protokollversion der Tab-Verwaltung', () => { expect( parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":6,"session":1,"type":"hello"}')) ?.protocol, ).toBe(6) }) it('liest die Protokollversion des Adress-Caches', () => { // Wörtlich mitgeschnitten am 21.08.2026 gegen die laufende Anlage. expect( parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":8,"session":3,"type":"hello"}')) ?.protocol, ).toBe(8) }) it('liest die Protokollversion der Adressdaten', () => { // Wörtlich mitgeschnitten, nachdem die App `contacts` gelernt hatte. expect( parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":7,"session":3,"type":"hello"}')) ?.protocol, ).toBe(7) }) }) describe('callStateOf', () => { it('hält freie Leitungen für frei', () => { expect(callStateOf('LSInactive', 'frei')).toBeNull() expect(callStateOf('LSTerminate', 'beendet')).toBeNull() }) it('erkennt einen eingehenden Anruf am Zustand', () => { expect(callStateOf('LSRinging', 'klingelt')).toBe('incoming') }) it('erkennt ihn auch nur am Klartext der App', () => { expect(callStateOf('LSIrgendwas', 'klingelt')).toBe('incoming') }) it('erkennt den Wählvorgang', () => { expect(callStateOf('LSDialing', 'wählt')).toBe('outgoing') expect(callStateOf('LSAlerting', 'Verbindungsaufbau')).toBe('outgoing') }) it('hält alles Unbekannte für belegt statt für frei', () => { // Sonst verschwände eine Leitung stillschweigend – der Fehler, wegen dem // eingehende Anrufe gar nicht angezeigt wurden. expect(callStateOf('LSActive', 'verbunden')).toBe('connected') expect(callStateOf('LSVölligNeu', 'was auch immer')).toBe('connected') }) }) describe('parseSnapshot', () => { it('liest den Zustand der App', () => { expect(parseSnapshot(snapshot(line()))).toMatchObject({ connected: true, serverUp: true, overall: 'Idle', statusText: 'Bereit', user: 'Muster, M.', server: '127.0.0.1', }) }) it('verwirft freie Leitungen – die App schickt immer alle vier', () => { const parsed = parseSnapshot( snapshot(line({ line: 1 }), line({ line: 2 }), line({ line: 3 }), line({ line: 4 })), ) expect(parsed?.lines).toEqual([]) }) it('liest die klingelnde Leitung mit Gegenstelle', () => { const parsed = parseSnapshot( snapshot( line({ line: 1 }), line({ line: 2, state: 'LSRinging', stateCode: 4, stateText: 'klingelt', peer: 'Muster GmbH (+493012345)', peerNumber: '+493012345', peerName: 'Muster GmbH', busy: true, }), ), ) expect(parsed?.lines).toEqual([ { line: 2, event: 'incoming', direction: 'incoming', peer: 'Muster GmbH (+493012345)', peerNumber: '+493012345', peerName: 'Muster GmbH', stateText: 'klingelt', state: 'LSRinging', }, ]) }) it('lässt den Platzhalter "unbekannt" weg', () => { const parsed = parseSnapshot(snapshot(line({ state: 'LSActive', stateText: 'verbunden' }))) expect(parsed?.lines[0]).toMatchObject({ line: 1, event: 'connected', peer: undefined }) }) it('hält andere Nachrichten für keinen Snapshot', () => { expect(parseSnapshot(JSON.parse(HELLO))).toBeNull() expect(parseSnapshot('hallo')).toBeNull() }) }) describe('echter eingehender Anruf', () => { it('meldet den mitgeschnittenen Anruf als klingelnde Leitung', () => { const parsed = parseSnapshot(JSON.parse(RINGING_SNAPSHOT)) expect(parsed?.statusText).toBe('Eingehender Ruf') expect(parsed?.lines).toEqual([ { line: 1, event: 'incoming', direction: 'incoming', peer: '001602107449', peerNumber: '001602107449', peerName: undefined, stateText: 'klingelt', state: 'LSRinging', }, ]) // Aus dem Vergleich mit dem vorherigen Stand entsteht die Anrufmeldung. const { events } = mergeSnapshot({}, parsed!.lines) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ line: 1, event: 'incoming' }) }) it('räumt die Leitung wieder ab, wenn der Anrufer auflegt', () => { const ringing = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))! const { lines } = mergeSnapshot({}, ringing.lines) const idle = parseSnapshot(snapshot(line({ line: 1 }), line({ line: 2 })))! const { lines: next, events } = mergeSnapshot(lines, idle.lines) expect(next).toEqual({}) expect(events[0]).toMatchObject({ line: 1, event: 'ended', peerNumber: '001602107449' }) }) }) describe('mergeSnapshot', () => { const ringing: CallEvent = { line: 2, event: 'incoming', direction: 'incoming', peerNumber: '+493012345', peerName: 'Muster GmbH', } it('meldet eine neu klingelnde Leitung', () => { const { lines, events } = mergeSnapshot({}, [ringing]) expect(events).toEqual([ringing]) expect(lines[2]).toEqual(ringing) }) it('meldet nichts, solange sich nichts ändert', () => { const { lines } = mergeSnapshot({}, [ringing]) expect(mergeSnapshot(lines, [ringing]).events).toEqual([]) }) it('führt die Richtung über das Annehmen hinweg mit', () => { const { lines } = mergeSnapshot({}, [ringing]) const connected = { ...ringing, event: 'connected' as const, direction: undefined } const { events } = mergeSnapshot(lines, [connected]) expect(events).toEqual([{ ...connected, direction: 'incoming' }]) }) it('meldet das Ende, wenn die Leitung im Snapshot fehlt', () => { const { lines } = mergeSnapshot({}, [ringing]) const { lines: next, events } = mergeSnapshot(lines, []) expect(events).toEqual([{ ...ringing, event: 'ended', state: undefined, stateText: undefined }]) expect(next).toEqual({}) }) it('erkennt einen zweiten Anruf auf derselben Leitung', () => { const { lines } = mergeSnapshot({}, [ringing]) const other = { ...ringing, peerNumber: '+499998888', peerName: 'Andere GmbH' } expect(mergeSnapshot(lines, [other]).events).toEqual([other]) }) }) describe('parseResult', () => { it('liest die Erfolgsquittung mit Leitungsnummer', () => { expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))).toEqual({ id: 11, ok: true, line: 4, error: undefined, }) }) it('liest die Fehlerquittung', () => { expect( parseResult(JSON.parse('{"id":8,"ok":false,"error":"Unbekanntes Kommando","type":"result"}')), ).toMatchObject({ id: 8, ok: false, error: 'Unbekanntes Kommando' }) }) it('liest die beiden Quittungen auf focus', () => { // Fenster war da und ist jetzt vorn. expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":true}'))).toMatchObject( { id: 9, ok: true, focused: true }, ) // Kein Fenster gefunden – die App hat die URL im Browser geöffnet. expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":false}'))).toMatchObject( { id: 9, ok: true, focused: false }, ) }) it('lässt focused bei allen anderen Quittungen offen', () => { expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))?.focused).toBeUndefined() }) it('liest die Quittung auf tabs', () => { const raw = '{"type":"result","id":12,"ok":true,"tabs":[' + '{"id":43,"title":"Kundenakte","url":"https://crm.example.local/kunden/4711","active":true},' + '{"id":44,"title":"SwyxWeb","url":"http://localhost:5173/","active":false}]}' expect(parseResult(JSON.parse(raw))?.tabs).toEqual([ { id: 43, title: 'Kundenakte', url: 'https://crm.example.local/kunden/4711', active: true }, { id: 44, title: 'SwyxWeb', url: 'http://localhost:5173/', active: false }, ]) }) it('liest die Kennung aus der Quittung auf opentab', () => { expect(parseResult(JSON.parse('{"type":"result","id":13,"ok":true,"tabId":43}'))).toMatchObject({ id: 13, ok: true, tabId: 43, }) }) it('lässt tabs und tabId bei allen anderen Quittungen offen', () => { const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}')) expect(result?.tabs).toBeUndefined() expect(result?.tabId).toBeUndefined() expect(result?.contacts).toBeUndefined() }) it('liest die Quittung auf contacts', () => { // Wörtlich mitgeschnitten – Suchbegriff "abt". const raw = '{"id":20,"ok":true,"contacts":[' + '{"name":"Abt, Bettina","number":"7587","description":"S-SB"},' + '{"name":"Abdul, Rokhsareh","number":"5215","description":""}],"type":"result"}' expect(parseResult(JSON.parse(raw))?.contacts).toEqual([ { name: 'Abt, Bettina', number: '7587', description: 'S-SB' }, // Die leere Beschreibung der App wird zu undefined. { name: 'Abdul, Rokhsareh', number: '5215', description: undefined }, ]) }) it('liest die leere Trefferliste als leere Liste, nicht als "nicht gefragt"', () => { expect(parseResult(JSON.parse('{"id":21,"ok":true,"contacts":[],"type":"result"}'))?.contacts).toEqual( [], ) }) it('liest die Fehlerquittung auf einen leeren Suchbegriff', () => { const raw = '{"id":22,"ok":false,' + '"error":"Feld \'query\' fehlt oder ist zu lang (max. 128 Zeichen).","type":"result"}' expect(parseResult(JSON.parse(raw))).toMatchObject({ id: 22, ok: false, error: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).", }) }) it('liest die Quittung auf addresses', () => { const raw = '{"type":"result","id":5,"ok":true,"addresses":[' + '{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}' expect(parseResult(JSON.parse(raw))?.addresses).toEqual([ { name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' }, ]) }) it('liest die Fehlerquittung älterer App-Versionen auf addresses', () => { // Genau das antwortet die Anlage am 21.08.2026 noch. const raw = '{"id":5,"ok":false,"error":"Unbekanntes Kommando \'addresses\'.","type":"result"}' expect(parseResult(JSON.parse(raw))).toMatchObject({ id: 5, ok: false, error: "Unbekanntes Kommando 'addresses'.", }) }) it('hält einen Snapshot für keine Quittung', () => { expect(parseResult(snapshot())).toBeNull() }) }) describe('parseTabs', () => { it('nimmt fehlende Felder hin – nur die Kennung ist Pflicht', () => { expect(parseTabs([{ id: 7 }])).toEqual([{ id: 7, title: undefined, url: undefined, active: false }]) }) it('verwirft Einträge ohne brauchbare Kennung', () => { // Ohne Kennung ließe sich der Tab ohnehin nicht schließen. expect(parseTabs([{ title: 'Ohne Kennung' }, 'Unfug', null, { id: 9 }])).toEqual([ { id: 9, title: undefined, url: undefined, active: false }, ]) }) }) describe('describeTab', () => { it('nimmt den Titel, sonst die URL, sonst die Kennung', () => { expect(describeTab({ id: 1, title: 'Kundenakte', url: 'https://x/', active: true })).toBe('Kundenakte') expect(describeTab({ id: 2, url: 'https://x/', active: false })).toBe('https://x/') expect(describeTab({ id: 3, active: false })).toBe('Tab 3') }) }) describe('parseAddresses', () => { it('liest die unaufgeforderte Cache-Nachricht', () => { const raw = '{"type":"addresses","addresses":[' + '{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}' expect(parseAddresses(JSON.parse(raw))).toEqual([ { name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' }, ]) }) it('hält den leeren Cache für eine Antwort, nicht für ein Versehen', () => { // Wörtlich mitgeschnitten: genau das schickt die Anlage zurzeit. expect(parseAddresses(JSON.parse('{"addresses":[],"type":"addresses"}'))).toEqual([]) }) it('hält andere Nachrichten für keine Cache-Nachricht', () => { expect(parseAddresses(JSON.parse(HELLO))).toBeNull() expect(parseAddresses(snapshot())).toBeNull() }) it('ist keine Quittung – sie trägt weder id noch ok', () => { expect(parseResult(JSON.parse('{"addresses":[],"type":"addresses"}'))).toBeNull() }) }) describe('parseContacts', () => { it('nimmt einen Eintrag ohne Namen hin – die Rufnummer genügt', () => { expect(parseContacts([{ number: '4711' }])).toEqual([ { name: undefined, number: '4711', description: undefined }, ]) }) it('verwirft Einträge ohne Namen und ohne Rufnummer', () => { // Anzeigen ließe sich so ein Eintrag nicht, wählen erst recht nicht. expect(parseContacts([{ description: 'HH-MT' }, 'Unfug', null, { name: 'Muster, Max' }])).toEqual([ { name: 'Muster, Max', number: undefined, description: undefined }, ]) }) it('hält die Reihenfolge der App ein – sie sortiert bereits nach Namen', () => { const entries = [{ name: 'Abt, Bettina', number: '7587' }, { name: 'Zentrale', number: '0' }] expect(parseContacts(entries).map((c) => c.name)).toEqual(['Abt, Bettina', 'Zentrale']) }) }) describe('describeContact', () => { it('nimmt den Namen, sonst die Rufnummer', () => { expect(describeContact({ name: 'Muster, Max', number: '4711' })).toBe('Muster, Max') expect(describeContact({ number: '4711' })).toBe('4711') expect(describeContact({})).toBe('Ohne Namen') }) }) describe('describeCall', () => { it('bevorzugt die fertige Anzeigeform der App', () => { expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe( 'Muster GmbH (+49301)', ) expect(describeCall({ line: 3, event: 'connected' })).toBe('Leitung 3') }) })