first commit

SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:03:34 +02:00
co-authored by Claude Opus 5
commit ae397e1ea3
44 changed files with 6640 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
import { describe, expect, it } from 'vitest'
import {
callStateOf,
describeCall,
describeTab,
mergeSnapshot,
parseHello,
parseResult,
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<string, unknown> = {}) {
return {
line: 1,
state: 'LSInactive',
stateCode: 0,
stateText: 'frei',
peer: 'unbekannt',
peerNumber: '',
peerName: '',
busy: false,
selected: false,
...overrides,
}
}
function snapshot(...entries: Record<string, unknown>[]) {
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)
})
})
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()
})
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('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')
})
})