Gerätezuordnungen im Client anzeigen
This commit is contained in:
parent
80e4196e16
commit
084ca66c7b
5 changed files with 860 additions and 25 deletions
|
|
@ -70,15 +70,23 @@ type Application struct {
|
||||||
|
|
||||||
// NewApplication creates a new client Application.
|
// NewApplication creates a new client Application.
|
||||||
//
|
//
|
||||||
// No configuration is loaded and no network connection is established.
|
// The client configuration is loaded immediately. No network connection
|
||||||
// Start must be called before the application can be used.
|
// is established. Start must be called before the application connects to
|
||||||
|
// the server or starts the Runtime.
|
||||||
|
|
||||||
func NewApplication(configFile string) (*Application, error) {
|
func NewApplication(configFile string) (*Application, error) {
|
||||||
if configFile == "" {
|
if configFile == "" {
|
||||||
return nil, fmt.Errorf("configuration file is empty")
|
return nil, fmt.Errorf("configuration file is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadClient(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load client configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &Application{
|
return &Application{
|
||||||
configFile: configFile,
|
configFile: configFile,
|
||||||
|
config: cfg,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +211,6 @@ func (a *Application) Close() error {
|
||||||
|
|
||||||
a.runtime = nil
|
a.runtime = nil
|
||||||
a.client = nil
|
a.client = nil
|
||||||
a.config = nil
|
|
||||||
a.devices = nil
|
a.devices = nil
|
||||||
a.runtimeDone = nil
|
a.runtimeDone = nil
|
||||||
|
|
||||||
|
|
@ -224,13 +231,10 @@ func (a *Application) Close() error {
|
||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Configuration
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// Config returns a copy of the currently loaded client configuration.
|
// Config returns a copy of the currently loaded client configuration.
|
||||||
//
|
//
|
||||||
// It returns nil when the application has not been started.
|
// The configuration is available after NewApplication, even when the
|
||||||
|
// server connection has not yet been established.
|
||||||
func (a *Application) Config() *config.ClientConfig {
|
func (a *Application) Config() *config.ClientConfig {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,68 @@
|
||||||
* Beschreibung:
|
* Beschreibung:
|
||||||
* Tests für die Anwendungsschicht des rs2322tcp-Clients.
|
* Tests für die Anwendungsschicht des rs2322tcp-Clients.
|
||||||
*
|
*
|
||||||
* Die Tests prüfen zunächst den Lebenszyklus der Application, ohne dafür
|
* Die Tests prüfen den Lebenszyklus der Application und die lokale
|
||||||
* eine echte Serververbindung aufbauen zu müssen.
|
* Konfigurationsverwaltung, ohne dafür eine echte Serververbindung
|
||||||
|
* aufbauen zu müssen.
|
||||||
*
|
*
|
||||||
* Insbesondere wird sichergestellt, dass eine neu erzeugte Application
|
* Insbesondere wird sichergestellt, dass eine neu erzeugte Application
|
||||||
* zunächst keinen laufenden Client und keine laufende Runtime besitzt und
|
* ihre lokale Konfiguration bereits kennt, obwohl noch keine Verbindung
|
||||||
* dass Close auch vor einem Start sicher verwendet werden kann.
|
* zum Server besteht.
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test helpers
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// writeTestConfig creates a temporary client configuration file.
|
||||||
|
func writeTestConfig(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "client.json")
|
||||||
|
|
||||||
|
content := `{
|
||||||
|
"server": {
|
||||||
|
"address": "127.0.0.1",
|
||||||
|
"port": 5000
|
||||||
|
},
|
||||||
|
"virtual_port_range": {
|
||||||
|
"first": 100,
|
||||||
|
"last": 199
|
||||||
|
},
|
||||||
|
"virtual_ports": [
|
||||||
|
{
|
||||||
|
"port": "/dev/ttyUSB100",
|
||||||
|
"remote_device": "radio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": "/dev/ttyUSB101",
|
||||||
|
"remote_device": "rotor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filename,
|
||||||
|
[]byte(content),
|
||||||
|
0600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write test configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// NewApplication
|
// NewApplication
|
||||||
|
|
@ -42,11 +92,14 @@ func TestNewApplicationRejectsEmptyConfigFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewApplication verifies that a valid configuration-file path creates
|
// TestNewApplication verifies that a valid configuration-file path creates
|
||||||
// an application without starting it.
|
// an application and loads the local configuration without starting the
|
||||||
|
// network connection.
|
||||||
func TestNewApplication(t *testing.T) {
|
func TestNewApplication(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
application, err := NewApplication("client.json")
|
configFile := writeTestConfig(t)
|
||||||
|
|
||||||
|
application, err := NewApplication(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewApplication: %v", err)
|
t.Fatalf("NewApplication: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -59,28 +112,115 @@ func TestNewApplication(t *testing.T) {
|
||||||
t.Fatal("new application reports running")
|
t.Fatal("new application reports running")
|
||||||
}
|
}
|
||||||
|
|
||||||
if application.Config() != nil {
|
cfg := application.Config()
|
||||||
t.Fatal("new application returned a configuration")
|
if cfg == nil {
|
||||||
|
t.Fatal("new application returned no configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VirtualPorts) != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"new application returned %d virtual ports, want 2",
|
||||||
|
len(cfg.VirtualPorts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VirtualPorts[0].Port != "/dev/ttyUSB100" {
|
||||||
|
t.Errorf(
|
||||||
|
"virtual port 0 = %q, want %q",
|
||||||
|
cfg.VirtualPorts[0].Port,
|
||||||
|
"/dev/ttyUSB100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VirtualPorts[0].RemoteDevice != "radio" {
|
||||||
|
t.Errorf(
|
||||||
|
"remote device 0 = %q, want %q",
|
||||||
|
cfg.VirtualPorts[0].RemoteDevice,
|
||||||
|
"radio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VirtualPorts[1].Port != "/dev/ttyUSB101" {
|
||||||
|
t.Errorf(
|
||||||
|
"virtual port 1 = %q, want %q",
|
||||||
|
cfg.VirtualPorts[1].Port,
|
||||||
|
"/dev/ttyUSB101",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VirtualPorts[1].RemoteDevice != "rotor" {
|
||||||
|
t.Errorf(
|
||||||
|
"remote device 1 = %q, want %q",
|
||||||
|
cfg.VirtualPorts[1].RemoteDevice,
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if devices := application.Devices(); len(devices) != 0 {
|
if devices := application.Devices(); len(devices) != 0 {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"new application returned %d devices",
|
"new application returned %d devices, want 0",
|
||||||
len(devices),
|
len(devices),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Configuration copy
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// TestApplicationConfigReturnsCopy verifies that modifying the returned
|
||||||
|
// configuration does not modify the Application's internal configuration.
|
||||||
|
func TestApplicationConfigReturnsCopy(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configFile := writeTestConfig(t)
|
||||||
|
|
||||||
|
application, err := NewApplication(configFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewApplication: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := application.Config()
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("Config returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.VirtualPorts[0].Port = "/dev/ttyUSB999"
|
||||||
|
cfg.VirtualPorts[0].RemoteDevice = "changed"
|
||||||
|
|
||||||
|
current := application.Config()
|
||||||
|
if current == nil {
|
||||||
|
t.Fatal("second Config returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.VirtualPorts[0].Port != "/dev/ttyUSB100" {
|
||||||
|
t.Errorf(
|
||||||
|
"internal port changed to %q",
|
||||||
|
current.VirtualPorts[0].Port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.VirtualPorts[0].RemoteDevice != "radio" {
|
||||||
|
t.Errorf(
|
||||||
|
"internal remote device changed to %q",
|
||||||
|
current.VirtualPorts[0].RemoteDevice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Close
|
// Close
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// TestApplicationCloseBeforeStart verifies that Close can safely be called
|
// TestApplicationCloseBeforeStart verifies that Close can safely be called
|
||||||
// before the application has been started.
|
// before the application has been started and that the local configuration
|
||||||
|
// remains available.
|
||||||
func TestApplicationCloseBeforeStart(t *testing.T) {
|
func TestApplicationCloseBeforeStart(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
application, err := NewApplication("client.json")
|
configFile := writeTestConfig(t)
|
||||||
|
|
||||||
|
application, err := NewApplication(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewApplication: %v", err)
|
t.Fatalf("NewApplication: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -92,14 +232,21 @@ func TestApplicationCloseBeforeStart(t *testing.T) {
|
||||||
if application.Running() {
|
if application.Running() {
|
||||||
t.Fatal("application reports running after Close")
|
t.Fatal("application reports running after Close")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg := application.Config(); cfg == nil {
|
||||||
|
t.Fatal("configuration was lost after Close")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestApplicationCloseIsIdempotent verifies that Close can be called more
|
// TestApplicationCloseIsIdempotent verifies that Close can be called more
|
||||||
// than once without producing an error.
|
// than once without producing an error and without losing the local
|
||||||
|
// configuration.
|
||||||
func TestApplicationCloseIsIdempotent(t *testing.T) {
|
func TestApplicationCloseIsIdempotent(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
application, err := NewApplication("client.json")
|
configFile := writeTestConfig(t)
|
||||||
|
|
||||||
|
application, err := NewApplication(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewApplication: %v", err)
|
t.Fatalf("NewApplication: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +262,42 @@ func TestApplicationCloseIsIdempotent(t *testing.T) {
|
||||||
if application.Running() {
|
if application.Running() {
|
||||||
t.Fatal("application reports running after repeated Close")
|
t.Fatal("application reports running after repeated Close")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg := application.Config(); cfg == nil {
|
||||||
|
t.Fatal("configuration was lost after repeated Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Invalid configuration
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// TestNewApplicationRejectsInvalidConfiguration verifies that an invalid
|
||||||
|
// configuration file is rejected immediately.
|
||||||
|
func TestNewApplicationRejectsInvalidConfiguration(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "invalid.json")
|
||||||
|
|
||||||
|
if err := os.WriteFile(
|
||||||
|
filename,
|
||||||
|
[]byte(`{"invalid": true}`),
|
||||||
|
0600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write invalid configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
application, err := NewApplication(filename)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewApplication with invalid configuration returned no error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if application != nil {
|
||||||
|
t.Fatal(
|
||||||
|
"NewApplication with invalid configuration returned an application",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -155,3 +338,7 @@ func TestNilApplication(t *testing.T) {
|
||||||
t.Fatal("nil Application returned devices")
|
t.Fatal("nil Application returned devices")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep config imported explicitly so the test documents that the temporary
|
||||||
|
// file represents the normal ClientConfig structure.
|
||||||
|
var _ config.ClientConfig
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@
|
||||||
* Dabei wird die bestehende technische Client-Anwendung beendet und
|
* Dabei wird die bestehende technische Client-Anwendung beendet und
|
||||||
* anschließend vollständig neu gestartet. Dadurch wird die aktuelle
|
* anschließend vollständig neu gestartet. Dadurch wird die aktuelle
|
||||||
* client.json erneut geladen.
|
* client.json erneut geladen.
|
||||||
|
*
|
||||||
|
* Die Gerätezuordnung wird zunächst ausschließlich angezeigt. Änderungen
|
||||||
|
* an der Zuordnung und deren Speicherung werden in einem späteren
|
||||||
|
* Entwicklungsschritt ergänzt.
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
package gui
|
package gui
|
||||||
|
|
@ -52,9 +56,10 @@ type App struct {
|
||||||
|
|
||||||
clientApplication *client.Application
|
clientApplication *client.Application
|
||||||
|
|
||||||
statusLabel *widget.Label
|
statusLabel *widget.Label
|
||||||
errorLabel *widget.Label
|
errorLabel *widget.Label
|
||||||
reconnectButton *widget.Button
|
reconnectButton *widget.Button
|
||||||
|
assignmentButton *widget.Button
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates the graphical rs2322tcp client application.
|
// NewApp creates the graphical rs2322tcp client application.
|
||||||
|
|
@ -90,6 +95,11 @@ func NewApp(
|
||||||
app.reconnect,
|
app.reconnect,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.assignmentButton = widget.NewButton(
|
||||||
|
"Gerätezuordnung",
|
||||||
|
app.showAssignments,
|
||||||
|
)
|
||||||
|
|
||||||
app.buildContent()
|
app.buildContent()
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
|
|
@ -106,6 +116,7 @@ func (a *App) buildContent() {
|
||||||
title,
|
title,
|
||||||
a.statusLabel,
|
a.statusLabel,
|
||||||
a.errorLabel,
|
a.errorLabel,
|
||||||
|
a.assignmentButton,
|
||||||
a.reconnectButton,
|
a.reconnectButton,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -209,6 +220,98 @@ func (a *App) reconnect() {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showAssignments displays the current local-to-remote device assignments.
|
||||||
|
//
|
||||||
|
// This first implementation is deliberately read-only. It obtains the
|
||||||
|
// configuration and the current server device list from client.Application
|
||||||
|
// and converts them through the GUI-independent assignment functions.
|
||||||
|
//
|
||||||
|
// No configuration is changed or written by this function.
|
||||||
|
func (a *App) showAssignments() {
|
||||||
|
if a == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := a.clientApplication.Config()
|
||||||
|
devices := a.clientApplication.Devices()
|
||||||
|
|
||||||
|
assignments := BuildAssignments(cfg, devices)
|
||||||
|
unassigned := UnassignedDevices(cfg, devices)
|
||||||
|
|
||||||
|
content := container.NewVBox(
|
||||||
|
widget.NewLabel("Lokale Zuordnungen"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(assignments) == 0 {
|
||||||
|
content.Add(
|
||||||
|
widget.NewLabel("Keine lokalen Zuordnungen vorhanden."),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
for _, assignment := range assignments {
|
||||||
|
status := "nicht verfügbar"
|
||||||
|
|
||||||
|
if assignment.Available {
|
||||||
|
status = "verfügbar"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Add(
|
||||||
|
widget.NewLabel(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%s -> %s (%s)",
|
||||||
|
assignment.LocalPort,
|
||||||
|
assignment.RemoteDevice,
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Add(widget.NewSeparator())
|
||||||
|
content.Add(
|
||||||
|
widget.NewLabel("Server-Geräte ohne lokale Zuordnung"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(unassigned) == 0 {
|
||||||
|
content.Add(
|
||||||
|
widget.NewLabel(
|
||||||
|
"Keine nicht verwendeten Server-Geräte.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
for _, device := range unassigned {
|
||||||
|
content.Add(
|
||||||
|
widget.NewLabel(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%s – noch nicht verwendet",
|
||||||
|
device.ID,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assignmentWindow := a.fyneApp.NewWindow(
|
||||||
|
"Gerätezuordnung",
|
||||||
|
)
|
||||||
|
assignmentWindow.Resize(fyne.NewSize(650, 450))
|
||||||
|
assignmentWindow.SetContent(
|
||||||
|
container.NewBorder(
|
||||||
|
nil,
|
||||||
|
widget.NewButton(
|
||||||
|
"Schließen",
|
||||||
|
func() {
|
||||||
|
assignmentWindow.Close()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
container.NewVScroll(content),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assignmentWindow.Show()
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the technical client application.
|
// Close closes the technical client application.
|
||||||
//
|
//
|
||||||
// The Runtime and the server connection are closed before the GUI window
|
// The Runtime and the server connection are closed before the GUI window
|
||||||
|
|
|
||||||
167
internal/gui/assignment.go
Normal file
167
internal/gui/assignment.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/gui/assignment.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* GUI-unabhängige Aufbereitung der Zuordnung zwischen lokalen virtuellen
|
||||||
|
* seriellen Schnittstellen und den vom rs2322tcp-Server angebotenen Geräten.
|
||||||
|
*
|
||||||
|
* Die Datei enthält bewusst keine Fyne-Abhängigkeiten.
|
||||||
|
*
|
||||||
|
* Die lokale Client-Konfiguration beschreibt ausschließlich die tatsächlich
|
||||||
|
* gewünschten Verbindungen. Physisch vorhandene /dev/ttyUSB-Schnittstellen
|
||||||
|
* außerhalb des von rs2322tcp verwalteten virtuellen Bereichs werden hier
|
||||||
|
* nicht betrachtet.
|
||||||
|
*
|
||||||
|
* Ein Server-Gerät kann daher in drei Zuständen erscheinen:
|
||||||
|
*
|
||||||
|
* 1. lokal zugeordnet und aktuell vom Server angeboten
|
||||||
|
* -> verfügbar / verbunden
|
||||||
|
*
|
||||||
|
* 2. lokal zugeordnet, aber momentan nicht vom Server angeboten
|
||||||
|
* -> nicht verfügbar
|
||||||
|
*
|
||||||
|
* 3. vom Server angeboten, aber keiner lokalen Schnittstelle zugeordnet
|
||||||
|
* -> noch nicht verwendet
|
||||||
|
*
|
||||||
|
* Nicht verwendete Server-Geräte werden nicht Bestandteil der
|
||||||
|
* Client-Konfiguration.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Device assignment
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// DeviceAssignment beschreibt eine bereits in der Client-Konfiguration
|
||||||
|
// vorhandene Zuordnung zwischen einer lokalen virtuellen Schnittstelle
|
||||||
|
// und einem entfernten Gerät.
|
||||||
|
//
|
||||||
|
// Available gibt an, ob das konfigurierte Remote-Gerät momentan in der
|
||||||
|
// vom Server gelieferten Geräteliste vorhanden ist.
|
||||||
|
//
|
||||||
|
// Available == false bedeutet nicht, dass die Konfiguration ungültig ist.
|
||||||
|
// Das Gerät kann lediglich momentan nicht vom Server angeboten werden.
|
||||||
|
type DeviceAssignment struct {
|
||||||
|
LocalPort string
|
||||||
|
RemoteDevice string
|
||||||
|
Available bool
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Build assignments
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// BuildAssignments erzeugt die Zuordnungsansicht aus der bestehenden
|
||||||
|
// Client-Konfiguration und der aktuell vom Server gelieferten Geräteliste.
|
||||||
|
//
|
||||||
|
// Es werden ausschließlich die in der Client-Konfiguration vorhandenen
|
||||||
|
// lokalen virtuellen Ports berücksichtigt.
|
||||||
|
//
|
||||||
|
// Physisch vorhandene serielle Schnittstellen des lokalen Rechners werden
|
||||||
|
// hier nicht gesucht und nicht bewertet.
|
||||||
|
func BuildAssignments(
|
||||||
|
cfg *config.ClientConfig,
|
||||||
|
devices []transport.RemoteDeviceInfo,
|
||||||
|
) []DeviceAssignment {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments := make([]DeviceAssignment, 0, len(cfg.VirtualPorts))
|
||||||
|
|
||||||
|
for _, virtualPort := range cfg.VirtualPorts {
|
||||||
|
assignments = append(assignments, DeviceAssignment{
|
||||||
|
LocalPort: virtualPort.Port,
|
||||||
|
RemoteDevice: virtualPort.RemoteDevice,
|
||||||
|
Available: remoteDeviceExists(
|
||||||
|
virtualPort.RemoteDevice,
|
||||||
|
devices,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Unassigned devices
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// UnassignedDevices liefert die vom Server angebotenen Geräte, die aktuell
|
||||||
|
// keiner lokalen virtuellen Schnittstelle zugeordnet sind.
|
||||||
|
//
|
||||||
|
// Diese Geräte werden bewusst nicht in die Client-Konfiguration übernommen.
|
||||||
|
// Erst wenn der Anwender im GUI eine Zuordnung vornimmt, entsteht daraus
|
||||||
|
// ein Eintrag in ClientConfig.VirtualPorts.
|
||||||
|
func UnassignedDevices(
|
||||||
|
cfg *config.ClientConfig,
|
||||||
|
devices []transport.RemoteDeviceInfo,
|
||||||
|
) []transport.RemoteDeviceInfo {
|
||||||
|
assigned := assignedRemoteDevices(cfg)
|
||||||
|
|
||||||
|
unassigned := make([]transport.RemoteDeviceInfo, 0)
|
||||||
|
|
||||||
|
for _, device := range devices {
|
||||||
|
if assigned[device.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unassigned = append(unassigned, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
return unassigned
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Internal helpers
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// assignedRemoteDevices erzeugt eine Menge der Remote-Geräte, die bereits
|
||||||
|
// einer lokalen virtuellen Schnittstelle zugeordnet sind.
|
||||||
|
//
|
||||||
|
// Die Remote-Device-ID ist der stabile Schlüssel der Zuordnung.
|
||||||
|
func assignedRemoteDevices(
|
||||||
|
cfg *config.ClientConfig,
|
||||||
|
) map[string]bool {
|
||||||
|
assigned := make(map[string]bool)
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
return assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, virtualPort := range cfg.VirtualPorts {
|
||||||
|
if virtualPort.RemoteDevice == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
assigned[virtualPort.RemoteDevice] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteDeviceExists prüft, ob ein Remote-Gerät mit der angegebenen ID in
|
||||||
|
// der aktuellen Geräteliste des Servers vorhanden ist.
|
||||||
|
func remoteDeviceExists(
|
||||||
|
id string,
|
||||||
|
devices []transport.RemoteDeviceInfo,
|
||||||
|
) bool {
|
||||||
|
for _, device := range devices {
|
||||||
|
if device.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
374
internal/gui/assignment_test.go
Normal file
374
internal/gui/assignment_test.go
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/gui/assignment_test.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Tests für die GUI-unabhängige Aufbereitung der Zuordnung zwischen lokalen
|
||||||
|
* virtuellen seriellen Schnittstellen und den vom rs2322tcp-Server
|
||||||
|
* angebotenen Geräten.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test helpers
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// testClientConfig creates a client configuration containing the supplied
|
||||||
|
// local-to-remote assignments.
|
||||||
|
func testClientConfig(
|
||||||
|
assignments ...config.VirtualPortConfig,
|
||||||
|
) *config.ClientConfig {
|
||||||
|
return &config.ClientConfig{
|
||||||
|
VirtualPorts: assignments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRemoteDevices creates a list of remote devices from their IDs.
|
||||||
|
func testRemoteDevices(ids ...string) []transport.RemoteDeviceInfo {
|
||||||
|
devices := make([]transport.RemoteDeviceInfo, 0, len(ids))
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
devices = append(devices, transport.RemoteDeviceInfo{
|
||||||
|
ID: id,
|
||||||
|
Name: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// BuildAssignments tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestBuildAssignments(t *testing.T) {
|
||||||
|
cfg := testClientConfig(
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB101",
|
||||||
|
RemoteDevice: "rotor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
"gps",
|
||||||
|
)
|
||||||
|
|
||||||
|
assignments := BuildAssignments(cfg, devices)
|
||||||
|
|
||||||
|
if len(assignments) != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"BuildAssignments() returned %d assignments, want 2",
|
||||||
|
len(assignments),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[0].LocalPort != "/dev/ttyUSB100" {
|
||||||
|
t.Errorf(
|
||||||
|
"assignments[0].LocalPort = %q, want %q",
|
||||||
|
assignments[0].LocalPort,
|
||||||
|
"/dev/ttyUSB100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[0].RemoteDevice != "radio" {
|
||||||
|
t.Errorf(
|
||||||
|
"assignments[0].RemoteDevice = %q, want %q",
|
||||||
|
assignments[0].RemoteDevice,
|
||||||
|
"radio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !assignments[0].Available {
|
||||||
|
t.Error("assignments[0].Available = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[1].LocalPort != "/dev/ttyUSB101" {
|
||||||
|
t.Errorf(
|
||||||
|
"assignments[1].LocalPort = %q, want %q",
|
||||||
|
assignments[1].LocalPort,
|
||||||
|
"/dev/ttyUSB101",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[1].RemoteDevice != "rotor" {
|
||||||
|
t.Errorf(
|
||||||
|
"assignments[1].RemoteDevice = %q, want %q",
|
||||||
|
assignments[1].RemoteDevice,
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !assignments[1].Available {
|
||||||
|
t.Error("assignments[1].Available = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAssignmentsUnavailableDevice(t *testing.T) {
|
||||||
|
cfg := testClientConfig(
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB101",
|
||||||
|
RemoteDevice: "rotor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
devices := testRemoteDevices("rotor")
|
||||||
|
|
||||||
|
assignments := BuildAssignments(cfg, devices)
|
||||||
|
|
||||||
|
if len(assignments) != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"BuildAssignments() returned %d assignments, want 2",
|
||||||
|
len(assignments),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[0].RemoteDevice != "radio" {
|
||||||
|
t.Errorf(
|
||||||
|
"assignments[0].RemoteDevice = %q, want %q",
|
||||||
|
assignments[0].RemoteDevice,
|
||||||
|
"radio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[0].Available {
|
||||||
|
t.Error("radio assignment marked available, want unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignments[1].RemoteDevice != "rotor" {
|
||||||
|
t.Errorf(
|
||||||
|
"assignments[1].RemoteDevice = %q, want %q",
|
||||||
|
assignments[1].RemoteDevice,
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !assignments[1].Available {
|
||||||
|
t.Error("rotor assignment marked unavailable, want available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAssignmentsNilConfig(t *testing.T) {
|
||||||
|
devices := testRemoteDevices("radio")
|
||||||
|
|
||||||
|
assignments := BuildAssignments(nil, devices)
|
||||||
|
|
||||||
|
if assignments != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"BuildAssignments(nil, ...) = %#v, want nil",
|
||||||
|
assignments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAssignmentsEmptyConfig(t *testing.T) {
|
||||||
|
cfg := testClientConfig()
|
||||||
|
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
|
|
||||||
|
assignments := BuildAssignments(cfg, devices)
|
||||||
|
|
||||||
|
if len(assignments) != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"BuildAssignments() returned %d assignments, want 0",
|
||||||
|
len(assignments),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// UnassignedDevices tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestUnassignedDevices(t *testing.T) {
|
||||||
|
cfg := testClientConfig(
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB101",
|
||||||
|
RemoteDevice: "rotor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
"gps",
|
||||||
|
"tnc",
|
||||||
|
)
|
||||||
|
|
||||||
|
unassigned := UnassignedDevices(cfg, devices)
|
||||||
|
|
||||||
|
if len(unassigned) != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"UnassignedDevices() returned %d devices, want 2",
|
||||||
|
len(unassigned),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unassigned[0].ID != "gps" {
|
||||||
|
t.Errorf(
|
||||||
|
"unassigned[0].ID = %q, want %q",
|
||||||
|
unassigned[0].ID,
|
||||||
|
"gps",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unassigned[1].ID != "tnc" {
|
||||||
|
t.Errorf(
|
||||||
|
"unassigned[1].ID = %q, want %q",
|
||||||
|
unassigned[1].ID,
|
||||||
|
"tnc",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassignedDevicesAllAssigned(t *testing.T) {
|
||||||
|
cfg := testClientConfig(
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB101",
|
||||||
|
RemoteDevice: "rotor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
|
|
||||||
|
unassigned := UnassignedDevices(cfg, devices)
|
||||||
|
|
||||||
|
if len(unassigned) != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"UnassignedDevices() returned %d devices, want 0",
|
||||||
|
len(unassigned),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassignedDevicesEmptyConfig(t *testing.T) {
|
||||||
|
cfg := testClientConfig()
|
||||||
|
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
|
|
||||||
|
unassigned := UnassignedDevices(cfg, devices)
|
||||||
|
|
||||||
|
if len(unassigned) != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"UnassignedDevices() returned %d devices, want 2",
|
||||||
|
len(unassigned),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassignedDevicesNilConfig(t *testing.T) {
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
|
|
||||||
|
unassigned := UnassignedDevices(nil, devices)
|
||||||
|
|
||||||
|
if len(unassigned) != 2 {
|
||||||
|
t.Fatalf(
|
||||||
|
"UnassignedDevices(nil, ...) returned %d devices, want 2",
|
||||||
|
len(unassigned),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassignedDevicesEmptyServerList(t *testing.T) {
|
||||||
|
cfg := testClientConfig(
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
unassigned := UnassignedDevices(cfg, nil)
|
||||||
|
|
||||||
|
if len(unassigned) != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"UnassignedDevices() returned %d devices, want 0",
|
||||||
|
len(unassigned),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Existing configuration does not change
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestAssignmentFunctionsDoNotModifyConfig(t *testing.T) {
|
||||||
|
cfg := testClientConfig(
|
||||||
|
config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
devices := testRemoteDevices(
|
||||||
|
"radio",
|
||||||
|
"rotor",
|
||||||
|
"gps",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = BuildAssignments(cfg, devices)
|
||||||
|
_ = UnassignedDevices(cfg, devices)
|
||||||
|
|
||||||
|
if len(cfg.VirtualPorts) != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"configuration contains %d virtual ports, want 1",
|
||||||
|
len(cfg.VirtualPorts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VirtualPorts[0].Port != "/dev/ttyUSB100" {
|
||||||
|
t.Errorf(
|
||||||
|
"configuration port = %q, want %q",
|
||||||
|
cfg.VirtualPorts[0].Port,
|
||||||
|
"/dev/ttyUSB100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VirtualPorts[0].RemoteDevice != "radio" {
|
||||||
|
t.Errorf(
|
||||||
|
"configuration remote device = %q, want %q",
|
||||||
|
cfg.VirtualPorts[0].RemoteDevice,
|
||||||
|
"radio",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue