Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css, README, application.properties) und deshalb nicht getrennt committet werden können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen: Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon- buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen. Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen fest. Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an und reicht es über Server-Sent Events an die offenen Browser weiter, wo es der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten 50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token) ist vorgesehen, aber nicht voreingestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b2f608055a
commit
01594a1f3d
31 files changed
+2687
-32
No files matched your search
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
COMMANDS,
|
||||
CONTACT_QUERY_MAX_LENGTH,
|
||||
parseAddresses,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseSnapshot,
|
||||
type Contact,
|
||||
type HelloMessage,
|
||||
type ResultMessage,
|
||||
type SnapshotMessage,
|
||||
@@ -20,6 +23,8 @@ interface Listeners {
|
||||
status: (status: ConnectionStatus) => void
|
||||
hello: (hello: HelloMessage) => void
|
||||
snapshot: (snapshot: SnapshotMessage) => void
|
||||
/** Unaufgeforderter Adress-Cache der App; auch eine leere Liste zählt. */
|
||||
addresses: (contacts: Contact[]) => void
|
||||
raw: (message: RawMessage) => void
|
||||
error: (message: string) => void
|
||||
}
|
||||
@@ -50,6 +55,7 @@ export class SwyxTrayClient {
|
||||
status: new Set(),
|
||||
hello: new Set(),
|
||||
snapshot: new Set(),
|
||||
addresses: new Set(),
|
||||
raw: new Set(),
|
||||
error: new Set(),
|
||||
}
|
||||
@@ -179,6 +185,35 @@ export class SwyxTrayClient {
|
||||
return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchsucht die Adressdaten des Swyx-Clients. Gesucht wird als
|
||||
* Teilzeichenkette in Name und Rufnummer; die App liefert höchstens 100
|
||||
* Treffer und kürzt ohne Hinweis. Ein leerer
|
||||
* Suchbegriff wäre ein Fehler der App – er wird hier gar nicht erst gesendet.
|
||||
*/
|
||||
searchContacts(query: string): Promise<ResultMessage> {
|
||||
const wanted = query.trim()
|
||||
if (!wanted) {
|
||||
return Promise.reject(new Error('Bitte einen Suchbegriff angeben.'))
|
||||
}
|
||||
if (wanted.length > CONTACT_QUERY_MAX_LENGTH) {
|
||||
return Promise.reject(
|
||||
new Error(`Der Suchbegriff darf höchstens ${CONTACT_QUERY_MAX_LENGTH} Zeichen lang sein.`),
|
||||
)
|
||||
}
|
||||
return this.send(COMMANDS.contacts, { query: wanted })
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Adress-Cache der App am Stück – ohne Suchbegriff und ohne Deckel.
|
||||
* Ältere App-Versionen kennen das Kommando nicht und antworten mit
|
||||
* `"Unbekanntes Kommando 'addresses'."`; die Startseite weicht dann auf die
|
||||
* Einzelabfragen aus.
|
||||
*/
|
||||
listAddresses(): Promise<ResultMessage> {
|
||||
return this.send(COMMANDS.addresses)
|
||||
}
|
||||
|
||||
ping(): Promise<ResultMessage> {
|
||||
return this.send(COMMANDS.ping)
|
||||
}
|
||||
@@ -270,14 +305,22 @@ export class SwyxTrayClient {
|
||||
return
|
||||
}
|
||||
|
||||
// Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender
|
||||
// Anruf – kommt als vollständiger Snapshot.
|
||||
// Die App schickt keine Anruf-Ereignisse: Jede Änderung – auch ein
|
||||
// eingehender Anruf – kommt als vollständiger Snapshot.
|
||||
const snapshot = parseSnapshot(parsed)
|
||||
if (snapshot) {
|
||||
this.emit('snapshot', snapshot)
|
||||
return
|
||||
}
|
||||
|
||||
// Der Adress-Cache dagegen kommt unaufgefordert – beim Verbinden und wenn
|
||||
// die App ihn erneuert.
|
||||
const addresses = parseAddresses(parsed)
|
||||
if (addresses) {
|
||||
this.emit('addresses', addresses)
|
||||
return
|
||||
}
|
||||
|
||||
const hello = parseHello(parsed)
|
||||
if (hello) {
|
||||
this.emit('hello', hello)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CONTACT_RESULT_LIMIT, type Contact } from './protocol'
|
||||
import {
|
||||
contactKey,
|
||||
filterContacts,
|
||||
initialQueries,
|
||||
loadDirectory,
|
||||
sortContacts,
|
||||
sweepDirectory,
|
||||
type DirectoryProgress,
|
||||
} from './directory'
|
||||
|
||||
/**
|
||||
* Spielt die SwyxTray-App nach – mit genau den Regeln, die gegen die laufende
|
||||
* App sondiert wurden: Teilzeichenkette in **Name und Rufnummer** (nicht in der
|
||||
* Beschreibung), Schreibweise egal, nach Namen sortiert und stillschweigend bei
|
||||
* {@link CONTACT_RESULT_LIMIT} Treffern gekürzt.
|
||||
*/
|
||||
function fakeApp(contacts: Contact[]) {
|
||||
const calls: string[] = []
|
||||
const search = async (query: string): Promise<Contact[]> => {
|
||||
calls.push(query)
|
||||
const needle = query.toLowerCase()
|
||||
return contacts
|
||||
.filter(
|
||||
(c) =>
|
||||
(c.name ?? '').toLowerCase().includes(needle) ||
|
||||
(c.number ?? '').toLowerCase().includes(needle),
|
||||
)
|
||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
.slice(0, CONTACT_RESULT_LIMIT)
|
||||
}
|
||||
return { search, calls }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestand mit vierstelligen Rufnummern – wie in der Anlage. Die Namen bestehen
|
||||
* bewusst nur aus Buchstaben: eine Ziffer im Namen ließe die Ziffernabfragen
|
||||
* auch über den Namen treffen, was es in der Anlage nicht gibt.
|
||||
*/
|
||||
function directoryOf(count: number, step = 1, from = 4000): Contact[] {
|
||||
const letter = (i: number) => String.fromCharCode(97 + (i % 26))
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
name: `Muster${letter(Math.floor(i / 676))}${letter(Math.floor(i / 26))}${letter(i)}, Max`,
|
||||
number: String(from + i * step),
|
||||
description: 'HH-MT',
|
||||
}))
|
||||
}
|
||||
|
||||
describe('initialQueries', () => {
|
||||
it('fragt zehn Ziffern und hundert Ziffernpaare ab', () => {
|
||||
const queries = initialQueries()
|
||||
expect(queries).toHaveLength(110)
|
||||
expect(new Set(queries).size).toBe(110)
|
||||
expect(queries).toContain('0')
|
||||
expect(queries).toContain('00')
|
||||
expect(queries).toContain('99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepDirectory', () => {
|
||||
it('findet den gesamten Bestand', async () => {
|
||||
// 404 Einträge – der Umfang der echten Anlage, hier aber auf einem
|
||||
// lückenlosen Nummernblock: dann hängt "40" am Deckel und der Durchlauf
|
||||
// muss verfeinern. Vollständig wird er trotzdem.
|
||||
const contacts = directoryOf(404)
|
||||
const { search } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(search)
|
||||
|
||||
expect(result.contacts).toHaveLength(404)
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.aborted).toBe(false)
|
||||
expect(new Set(result.contacts.map(contactKey))).toEqual(new Set(contacts.map(contactKey)))
|
||||
})
|
||||
|
||||
it('kommt ohne Verfeinerung aus, solange keine Abfrage gekürzt wird', async () => {
|
||||
// Rufnummern mit Lücken, wie in einer gewachsenen Anlage.
|
||||
const { search, calls } = fakeApp(directoryOf(404, 7))
|
||||
const result = await sweepDirectory(search)
|
||||
// Genau die 110 Abfragen der Grundliste – gemessen ist bei der echten
|
||||
// Anlage keine davon am Deckel (Höchstwert 59 Treffer).
|
||||
expect(calls).toHaveLength(110)
|
||||
expect(result.queries).toBe(110)
|
||||
})
|
||||
|
||||
it('meldet jeden Eintrag nur einmal, auch wenn ihn mehrere Abfragen finden', async () => {
|
||||
// "4123" wird von "41", "12", "23", "1", "2", "3", "4" gefunden.
|
||||
const { search } = fakeApp([{ name: 'Muster, Max', number: '4123' }])
|
||||
const result = await sweepDirectory(search)
|
||||
expect(result.contacts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('findet auch eine einstellige Rufnummer – dafür sind die Ziffernabfragen da', async () => {
|
||||
// "0" steckt in keinem Ziffernpaar; ohne die einstelligen Abfragen fehlte
|
||||
// die Zentrale im Bestand.
|
||||
const contacts = [...directoryOf(20), { name: 'Zentrale', number: '0', description: '' }]
|
||||
const { search } = fakeApp(contacts)
|
||||
const result = await sweepDirectory(search)
|
||||
expect(result.contacts.map((c) => c.number)).toContain('0')
|
||||
})
|
||||
|
||||
it('verfeinert eine gekürzte Abfrage und wird trotzdem vollständig', async () => {
|
||||
// Alle 105 Nummern beginnen mit "12"; diese Abfrage hängt also am Deckel.
|
||||
const contacts = Array.from({ length: 105 }, (_, i) => ({
|
||||
name: `Muster${String.fromCharCode(97 + (i % 26))}${i}, Max`.replace(/\d/g, ''),
|
||||
number: `12${i % 10}${String(i).padStart(3, '0')}`,
|
||||
}))
|
||||
const { search, calls } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(search)
|
||||
|
||||
expect(result.contacts).toHaveLength(105)
|
||||
expect(result.complete).toBe(true)
|
||||
// Verfeinert wurde vorn und hinten – beides gehört dazu, weil eine
|
||||
// Teilzeichenkette an beiden Enden weitergehen kann.
|
||||
expect(calls.length).toBeGreaterThan(110)
|
||||
expect(calls).toContain('120')
|
||||
expect(calls).toContain('012')
|
||||
})
|
||||
|
||||
it('gibt auf, wenn sich eine gekürzte Abfrage nicht mehr verfeinern lässt', async () => {
|
||||
// Über hundert Einträge mit derselben dreistelligen Nummer: die Abfrage
|
||||
// "123" bleibt gekürzt, egal wie oft verfeinert wird.
|
||||
const contacts = directoryOf(130, 1).map((c) => ({ ...c, number: '123' }))
|
||||
const { search } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(search)
|
||||
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.contacts.length).toBeLessThan(130)
|
||||
})
|
||||
|
||||
it('meldet den Fortschritt und zählt bis zum Ende hoch', async () => {
|
||||
const onProgress = vi.fn<(p: DirectoryProgress) => void>()
|
||||
const { search } = fakeApp(directoryOf(30))
|
||||
|
||||
const result = await sweepDirectory(search, { onProgress })
|
||||
|
||||
const reports = onProgress.mock.calls.map(([p]) => p)
|
||||
expect(reports[0]).toEqual({ done: 0, total: 110, found: 0 })
|
||||
expect(reports.at(-1)).toEqual({ done: 110, total: 110, found: 30 })
|
||||
// Monoton – sonst spränge der Balken im Wartedialog zurück.
|
||||
expect(reports.map((p) => p.done)).toEqual([...reports.map((p) => p.done)].sort((a, b) => a - b))
|
||||
expect(result.queries).toBe(110)
|
||||
})
|
||||
|
||||
it('bricht ab, ohne den bisherigen Stand wegzuwerfen', async () => {
|
||||
const contacts = directoryOf(404)
|
||||
const controller = new AbortController()
|
||||
const { search, calls } = fakeApp(contacts)
|
||||
|
||||
const result = await sweepDirectory(
|
||||
async (query) => {
|
||||
// Nach zwanzig Abfragen abbrechen – wie ein Klick auf „Abbrechen".
|
||||
if (calls.length >= 20) controller.abort()
|
||||
return search(query)
|
||||
},
|
||||
{ signal: controller.signal, concurrency: 1 },
|
||||
)
|
||||
|
||||
expect(result.aborted).toBe(true)
|
||||
expect(result.complete).toBe(false)
|
||||
// Kein Fehler, kein leeres Ergebnis: das bereits Geladene bleibt erhalten.
|
||||
expect(result.contacts.length).toBeGreaterThan(0)
|
||||
expect(result.contacts.length).toBeLessThan(404)
|
||||
expect(calls.length).toBeLessThan(110)
|
||||
})
|
||||
|
||||
it('reicht den Fehler einer Abfrage durch – ein Abbruch ist etwas anderes', async () => {
|
||||
await expect(
|
||||
sweepDirectory(async () => {
|
||||
throw new Error('Keine Verbindung zur SwyxTray-App.')
|
||||
}),
|
||||
).rejects.toThrow('Keine Verbindung zur SwyxTray-App.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadDirectory', () => {
|
||||
const cached: Contact[] = [
|
||||
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
|
||||
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
|
||||
]
|
||||
|
||||
it('nimmt den Adress-Cache der App und fragt dann nichts mehr', async () => {
|
||||
const { search, calls } = fakeApp(directoryOf(404, 7))
|
||||
|
||||
const result = await loadDirectory({ listAddresses: async () => cached, search })
|
||||
|
||||
expect(result.source).toBe('cache')
|
||||
expect(result.queries).toBe(1)
|
||||
expect(result.complete).toBe(true)
|
||||
// Eine einzige Nachricht statt 110 – darum geht es beim Cache.
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.contacts.map((c) => c.name)).toEqual(['Abt, Bettina', 'Muster GmbH'])
|
||||
})
|
||||
|
||||
it('meldet jeden Eintrag des Caches nur einmal', async () => {
|
||||
const doppelt = [...cached, { ...cached[0] }]
|
||||
const { search } = fakeApp([])
|
||||
const result = await loadDirectory({ listAddresses: async () => doppelt, search })
|
||||
expect(result.contacts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('weicht auf die Einzelabfragen aus, wenn die App das Kommando nicht kennt', async () => {
|
||||
// Genau der Fall der Anlage am 21.08.2026.
|
||||
const { search, calls } = fakeApp(directoryOf(404, 7))
|
||||
|
||||
const result = await loadDirectory({
|
||||
listAddresses: async () => {
|
||||
throw new Error("Unbekanntes Kommando 'addresses'.")
|
||||
},
|
||||
search,
|
||||
})
|
||||
|
||||
expect(result.source).toBe('sweep')
|
||||
expect(result.contacts).toHaveLength(404)
|
||||
expect(calls).toHaveLength(110)
|
||||
})
|
||||
|
||||
it('weicht auch bei leerem Cache aus – so meldet sich die Anlage zurzeit', async () => {
|
||||
const { search } = fakeApp(directoryOf(40, 7))
|
||||
const result = await loadDirectory({ listAddresses: async () => [], search })
|
||||
expect(result.source).toBe('sweep')
|
||||
expect(result.contacts).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('fragt nach einem Abbruch gar nicht erst', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const listAddresses = vi.fn(async () => cached)
|
||||
const { search, calls } = fakeApp(directoryOf(10))
|
||||
|
||||
const result = await loadDirectory({ listAddresses, search }, { signal: controller.signal })
|
||||
|
||||
expect(listAddresses).not.toHaveBeenCalled()
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortContacts', () => {
|
||||
it('sortiert nach Namen, bei gleichem Namen nach Rufnummer', () => {
|
||||
const sorted = sortContacts([
|
||||
{ name: 'Bandorf, Volker', number: '53119' },
|
||||
{ name: 'Abt, Bettina', number: '7587' },
|
||||
{ name: 'Bandorf, Volker', number: '41295' },
|
||||
])
|
||||
expect(sorted.map((c) => `${c.name} ${c.number}`)).toEqual([
|
||||
'Abt, Bettina 7587',
|
||||
'Bandorf, Volker 41295',
|
||||
'Bandorf, Volker 53119',
|
||||
])
|
||||
})
|
||||
|
||||
it('lässt die Vorlage unangetastet', () => {
|
||||
const input = [{ name: 'Z' }, { name: 'A' }]
|
||||
sortContacts(input)
|
||||
expect(input.map((c) => c.name)).toEqual(['Z', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterContacts', () => {
|
||||
const contacts: Contact[] = [
|
||||
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
|
||||
{ name: 'Muster, Max', number: '4711', description: 'HH-MT' },
|
||||
]
|
||||
|
||||
it('gibt ohne Filter alles zurück', () => {
|
||||
expect(filterContacts(contacts, ' ')).toEqual(contacts)
|
||||
})
|
||||
|
||||
it('sucht in Namen und Rufnummer, Schreibweise egal', () => {
|
||||
expect(filterContacts(contacts, 'bettina')).toHaveLength(1)
|
||||
expect(filterContacts(contacts, '471')).toEqual([contacts[1]])
|
||||
})
|
||||
|
||||
it('sucht auch in der Beschreibung – anders als die App', () => {
|
||||
// Der Bestand liegt vollständig im Browser; das Kürzel ist damit filterbar.
|
||||
expect(filterContacts(contacts, 'hh-mt')).toEqual([contacts[1]])
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
callStateOf,
|
||||
describeCall,
|
||||
describeContact,
|
||||
describeTab,
|
||||
parseAddresses,
|
||||
mergeSnapshot,
|
||||
parseHello,
|
||||
parseResult,
|
||||
parseContacts,
|
||||
parseSnapshot,
|
||||
parseTabs,
|
||||
type CallEvent,
|
||||
@@ -79,6 +82,22 @@ describe('parseHello', () => {
|
||||
?.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', () => {
|
||||
@@ -296,6 +315,56 @@ describe('parseResult', () => {
|
||||
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', () => {
|
||||
@@ -324,6 +393,59 @@ describe('describeTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws`
|
||||
* (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App
|
||||
* `protocol: 6`).
|
||||
* (App-Version 1.0.0.0, `protocol: 1`; mit der Tab-Verwaltung meldete die App
|
||||
* `protocol: 6`, mit der Adresssuche `protocol: 7`, mit dem Adress-Cache `protocol: 8`).
|
||||
*
|
||||
* App → Seite:
|
||||
* { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" }
|
||||
@@ -31,7 +31,30 @@
|
||||
* { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true }
|
||||
* Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`.
|
||||
*
|
||||
* **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein
|
||||
* Protokoll 7 bringt die Adressdaten des Swyx-Clients – das globale Telefonbuch:
|
||||
* { "id":20,"cmd":"contacts","query":"abt" }
|
||||
* → { "type":"result","id":20,"ok":true,
|
||||
* "contacts":[{"name":"Abt, Bettina","number":"7587","description":"S-SB"}, …] }
|
||||
* Gesucht wird als Teilzeichenkette in **Name und Rufnummer**, Schreibweise egal;
|
||||
* die Beschreibung wird nicht durchsucht. Sortiert nach Namen, höchstens
|
||||
* {@link CONTACT_RESULT_LIMIT} Treffer – gekürzt wird stillschweigend. `query` ist
|
||||
* Pflicht, darf nicht leer sein und höchstens {@link CONTACT_QUERY_MAX_LENGTH}
|
||||
* Zeichen lang; sonst antwortet die App mit
|
||||
* `"Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen)."`.
|
||||
*
|
||||
* Protokoll 8 bringt den **Adress-Cache**: die App hält die Adressdaten selbst vor
|
||||
* und gibt sie am Stück heraus – ohne Suchbegriff und ohne Deckel.
|
||||
* { "id":5,"cmd":"addresses" }
|
||||
* → { "type":"result","id":5,"ok":true,
|
||||
* "addresses":[{"name":"Muster GmbH","number":"+493012345",
|
||||
* "description":"Globales Telefonbuch"}] }
|
||||
* Dieselben Daten schickt die App außerdem **unaufgefordert** als eigene Nachricht,
|
||||
* direkt nach dem ersten Snapshot und wenn sich ihr Cache ändert:
|
||||
* { "type":"addresses","addresses":[…] }
|
||||
* Beide tragen dieselben Einträge wie `contacts` – Name, Rufnummer, Beschreibung.
|
||||
* Der Push ist die einzige Nachricht der App **ohne** `id` und ohne `ok`.
|
||||
*
|
||||
* **Sonst kennt die App keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein
|
||||
* eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen.
|
||||
* Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots
|
||||
* (siehe {@link mergeSnapshot}).
|
||||
@@ -66,6 +89,10 @@ export interface ResultMessage {
|
||||
tabs?: BrowserTab[]
|
||||
/** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */
|
||||
tabId?: number
|
||||
/** Nur bei `contacts`: die gefundenen Adressdaten. */
|
||||
contacts?: Contact[]
|
||||
/** Nur bei `addresses`: der gesamte Adress-Cache der App. */
|
||||
addresses?: Contact[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -79,6 +106,26 @@ export interface BrowserTab {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Eintrag aus den Adressdaten des Swyx-Clients, wie ihn die Quittung auf
|
||||
* `contacts` meldet. Leere Felder schickt die App als `""`; hier bleiben sie
|
||||
* `undefined`.
|
||||
*/
|
||||
export interface Contact {
|
||||
/** Anzeigename, meist „Nachname, Vorname". */
|
||||
name?: string
|
||||
/** Rufnummer, meist die interne Durchwahl – so, wie sie an `call` geht. */
|
||||
number?: string
|
||||
/** Zusatz der Telefonanlage, z. B. der Standort („HH-MT"). */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Mehr Treffer meldet die App nicht; sie kürzt ohne Hinweis. */
|
||||
export const CONTACT_RESULT_LIMIT = 100
|
||||
|
||||
/** Längere Suchbegriffe weist die App mit einem Fehler zurück. */
|
||||
export const CONTACT_QUERY_MAX_LENGTH = 128
|
||||
|
||||
/** Begrüßung beim Verbindungsaufbau. */
|
||||
export interface HelloMessage {
|
||||
app?: string
|
||||
@@ -115,6 +162,8 @@ export const COMMANDS = {
|
||||
tabs: 'tabs',
|
||||
openTab: 'opentab',
|
||||
closeTab: 'closetab',
|
||||
contacts: 'contacts',
|
||||
addresses: 'addresses',
|
||||
} as const
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -207,6 +256,8 @@ export function parseResult(raw: unknown): ResultMessage | null {
|
||||
focused: asBoolean(raw.focused),
|
||||
tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined,
|
||||
tabId: asLine(raw.tabId),
|
||||
contacts: Array.isArray(raw.contacts) ? parseContacts(raw.contacts) : undefined,
|
||||
addresses: Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : undefined,
|
||||
error: asString(raw.error),
|
||||
}
|
||||
}
|
||||
@@ -236,6 +287,35 @@ export function describeTab(tab: BrowserTab): string {
|
||||
return tab.title ?? tab.url ?? `Tab ${tab.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Adressdaten aus der Quittung auf `contacts`. Einträge ohne Namen
|
||||
* *und* ohne Rufnummer fallen weg – sie ließen sich weder anzeigen noch wählen.
|
||||
*/
|
||||
export function parseContacts(raw: unknown[]): Contact[] {
|
||||
return raw.flatMap((entry) => {
|
||||
if (!isRecord(entry)) return []
|
||||
const name = asString(entry.name)
|
||||
const number = asString(entry.number)
|
||||
if (name === undefined && number === undefined) return []
|
||||
return [{ name, number, description: asString(entry.description) }]
|
||||
})
|
||||
}
|
||||
|
||||
/** Anzeigename eines Adresseintrags: Name, sonst die Rufnummer. */
|
||||
export function describeContact(contact: Contact): string {
|
||||
return contact.name ?? contact.number ?? 'Ohne Namen'
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die unaufgeforderte Cache-Nachricht `{"type":"addresses","addresses":[…]}`.
|
||||
* Ein leeres Feld ist ein gültiger Zustand – die App meldet damit einen leeren
|
||||
* Cache – und darf deshalb nicht wie „keine Nachricht" behandelt werden.
|
||||
*/
|
||||
export function parseAddresses(raw: unknown): Contact[] | null {
|
||||
if (!isRecord(raw) || raw.type !== 'addresses') return null
|
||||
return Array.isArray(raw.addresses) ? parseContacts(raw.addresses) : []
|
||||
}
|
||||
|
||||
/** Liest die Begrüßung. */
|
||||
export function parseHello(raw: unknown): HelloMessage | null {
|
||||
if (!isRecord(raw) || raw.type !== 'hello') return null
|
||||
|
||||
Reference in new issue
Block a user