/* * ============================================================================ * Projekt.....: rs2322tcp * Datei.......: internal/client/application_test.go * Copyright (C) 2026 Dieter Lang * * SPDX-License-Identifier: GPL-3.0-or-later * * Beschreibung: * Tests für die Anwendungsschicht des rs2322tcp-Clients. * * 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 * ihre lokale Konfiguration bereits kennt, obwohl noch keine Verbindung * zum Server besteht. * ============================================================================ */ package client 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 /////////////////////////////////////////////////////////////////////////////// // TestNewApplicationRejectsEmptyConfigFile verifies that an empty // configuration-file path is rejected. func TestNewApplicationRejectsEmptyConfigFile(t *testing.T) { t.Helper() application, err := NewApplication("") if err == nil { t.Fatal("NewApplication with empty config file returned no error") } if application != nil { t.Fatal("NewApplication with empty config file returned an application") } } // TestNewApplication verifies that a valid configuration-file path creates // an application and loads the local configuration without starting the // network connection. func TestNewApplication(t *testing.T) { t.Helper() configFile := writeTestConfig(t) application, err := NewApplication(configFile) if err != nil { t.Fatalf("NewApplication: %v", err) } if application == nil { t.Fatal("NewApplication returned nil application") } if application.Running() { t.Fatal("new application reports running") } 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, 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 and that the local configuration // remains available. func TestApplicationCloseBeforeStart(t *testing.T) { t.Helper() configFile := writeTestConfig(t) application, err := NewApplication(configFile) if err != nil { t.Fatalf("NewApplication: %v", err) } if err := application.Close(); err != nil { t.Fatalf("Close before Start: %v", err) } 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 and without losing the local // configuration. func TestApplicationCloseIsIdempotent(t *testing.T) { t.Helper() configFile := writeTestConfig(t) application, err := NewApplication(configFile) if err != nil { t.Fatalf("NewApplication: %v", err) } if err := application.Close(); err != nil { t.Fatalf("first Close: %v", err) } if err := application.Close(); err != nil { t.Fatalf("second Close: %v", err) } 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", ) } } /////////////////////////////////////////////////////////////////////////////// // Nil receiver /////////////////////////////////////////////////////////////////////////////// // TestNilApplication verifies that the public lifecycle methods behave // safely when called on a nil Application receiver. // // This is deliberately a small defensive test. The GUI should normally // never operate on a nil Application. func TestNilApplication(t *testing.T) { t.Helper() var application *Application if err := application.Start(); err == nil { t.Fatal("nil Application Start returned no error") } if err := application.Reconnect(); err == nil { t.Fatal("nil Application Reconnect returned no error") } if err := application.Close(); err != nil { t.Fatalf("nil Application Close: %v", err) } if application.Running() { t.Fatal("nil Application reports running") } if application.Config() != nil { t.Fatal("nil Application returned a configuration") } if devices := application.Devices(); devices != nil { 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