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:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user