diff --git a/internal/client/application.go b/internal/client/application.go index ace78a0..2d75751 100644 --- a/internal/client/application.go +++ b/internal/client/application.go @@ -70,15 +70,23 @@ type Application struct { // NewApplication creates a new client Application. // -// No configuration is loaded and no network connection is established. -// Start must be called before the application can be used. +// The client configuration is loaded immediately. No network connection +// is established. Start must be called before the application connects to +// the server or starts the Runtime. + func NewApplication(configFile string) (*Application, error) { if configFile == "" { 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{ configFile: configFile, + config: cfg, }, nil } @@ -203,7 +211,6 @@ func (a *Application) Close() error { a.runtime = nil a.client = nil - a.config = nil a.devices = nil a.runtimeDone = nil @@ -224,13 +231,10 @@ func (a *Application) Close() error { return firstErr } -/////////////////////////////////////////////////////////////////////////////// -// 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 { if a == nil { return nil diff --git a/internal/client/application_test.go b/internal/client/application_test.go index c58dfb1..3a9bc34 100644 --- a/internal/client/application_test.go +++ b/internal/client/application_test.go @@ -9,18 +9,68 @@ * Beschreibung: * Tests für die Anwendungsschicht des rs2322tcp-Clients. * - * Die Tests prüfen zunächst den Lebenszyklus der Application, ohne dafür - * eine echte Serververbindung aufbauen zu müssen. + * Die Tests prüfen den Lebenszyklus der Application und die lokale + * Konfigurationsverwaltung, ohne dafür eine echte Serververbindung + * aufbauen zu müssen. * * Insbesondere wird sichergestellt, dass eine neu erzeugte Application - * zunächst keinen laufenden Client und keine laufende Runtime besitzt und - * dass Close auch vor einem Start sicher verwendet werden kann. + * ihre lokale Konfiguration bereits kennt, obwohl noch keine Verbindung + * zum Server besteht. * ============================================================================ */ 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 @@ -42,11 +92,14 @@ func TestNewApplicationRejectsEmptyConfigFile(t *testing.T) { } // 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) { t.Helper() - application, err := NewApplication("client.json") + configFile := writeTestConfig(t) + + application, err := NewApplication(configFile) if err != nil { t.Fatalf("NewApplication: %v", err) } @@ -59,28 +112,115 @@ func TestNewApplication(t *testing.T) { t.Fatal("new application reports running") } - if application.Config() != nil { - t.Fatal("new application returned a configuration") + cfg := application.Config() + 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 { t.Fatalf( - "new application returned %d devices", + "new application returned %d devices, want 0", 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 /////////////////////////////////////////////////////////////////////////////// // 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) { t.Helper() - application, err := NewApplication("client.json") + configFile := writeTestConfig(t) + + application, err := NewApplication(configFile) if err != nil { t.Fatalf("NewApplication: %v", err) } @@ -92,14 +232,21 @@ func TestApplicationCloseBeforeStart(t *testing.T) { if application.Running() { 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 -// than once without producing an error. +// than once without producing an error and without losing the local +// configuration. func TestApplicationCloseIsIdempotent(t *testing.T) { t.Helper() - application, err := NewApplication("client.json") + configFile := writeTestConfig(t) + + application, err := NewApplication(configFile) if err != nil { t.Fatalf("NewApplication: %v", err) } @@ -115,6 +262,42 @@ func TestApplicationCloseIsIdempotent(t *testing.T) { if application.Running() { 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") } } + +// Keep config imported explicitly so the test documents that the temporary +// file represents the normal ClientConfig structure. +var _ config.ClientConfig diff --git a/internal/gui/app.go b/internal/gui/app.go index fe559cf..1c8f7d2 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -27,6 +27,10 @@ * Dabei wird die bestehende technische Client-Anwendung beendet und * anschließend vollständig neu gestartet. Dadurch wird die aktuelle * 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 @@ -52,9 +56,10 @@ type App struct { clientApplication *client.Application - statusLabel *widget.Label - errorLabel *widget.Label - reconnectButton *widget.Button + statusLabel *widget.Label + errorLabel *widget.Label + reconnectButton *widget.Button + assignmentButton *widget.Button } // NewApp creates the graphical rs2322tcp client application. @@ -90,6 +95,11 @@ func NewApp( app.reconnect, ) + app.assignmentButton = widget.NewButton( + "Gerätezuordnung", + app.showAssignments, + ) + app.buildContent() return app, nil @@ -106,6 +116,7 @@ func (a *App) buildContent() { title, a.statusLabel, a.errorLabel, + a.assignmentButton, 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. // // The Runtime and the server connection are closed before the GUI window diff --git a/internal/gui/assignment.go b/internal/gui/assignment.go new file mode 100644 index 0000000..f54f43a --- /dev/null +++ b/internal/gui/assignment.go @@ -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 +} diff --git a/internal/gui/assignment_test.go b/internal/gui/assignment_test.go new file mode 100644 index 0000000..6a99e5d --- /dev/null +++ b/internal/gui/assignment_test.go @@ -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", + ) + } +}