rs2322tcp/internal/client/application_test.go
2026-08-11 12:51:17 +02:00

344 lines
8.4 KiB
Go

/*
* ============================================================================
* 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