diff --git a/Directory.Build.props b/Directory.Build.props index d8682a2..00d4d39 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,9 @@ - 3.0.11.0 - 3.0.11.0 + 3.0.13.0 + 3.0.13.0 + diff --git a/OSDP-Bench.sln b/OSDP-Bench.sln index 56dcc91..8188b91 100644 --- a/OSDP-Bench.sln +++ b/OSDP-Bench.sln @@ -28,6 +28,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{ED2EC291-3 ProjectSection(SolutionItems) = preProject README.md = README.md docs\CLAUDE.md = docs\CLAUDE.md + docs\ConnectionButtonBehavior.md = docs\ConnectionButtonBehavior.md EndProjectSection EndProject Global diff --git a/README.md b/README.md index cab7b5a..13758ef 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Core functionality is under an open source license to help increase the adoption OSDP Bench is available for purchase on multiple platforms: -[![Microsoft Store](https://get.microsoft.com/images/en-us%20dark.svg)](ms-windows-store://pdp/?productid=9N3W7QR3R5S7&cid=&mode=mini) +[![Microsoft Store](https://get.microsoft.com/images/en-us%20dark.svg)](https://apps.microsoft.com/detail/9n3w7qr3r5s7?hl=en-US&gl=US) [![Google Play](src/Assets/google-play.svg)](https://play.google.com/store/apps/details?id=com.z_bitco.com.osdpbenchmobile) ## Getting Started diff --git a/docs/ConnectionButtonBehavior.md b/docs/ConnectionButtonBehavior.md new file mode 100644 index 0000000..af611a2 --- /dev/null +++ b/docs/ConnectionButtonBehavior.md @@ -0,0 +1,51 @@ +## Implementation Status Summary + +### Discover Mode (ConnectionTypeComboBox Index = 0) + +| State | Discovery Button | Connect Button | Disconnect Button | Cancel Button | ConnectionTypeComboBox | Status | +|-------------------------|------------------|----------------|-------------------|---------------|------------------------|---------------| +| **Disconnected** | Visible | Hidden | Hidden | Hidden | Enabled | ✓ Correct | +| **Discovering** | Hidden | Hidden | Hidden | Visible | Disabled | ✓ Correct | +| **Discovery Cancelled** | Visible | Hidden | Hidden | Hidden | Enabled | ✓ Correct | +| **Discovered** | Hidden | Hidden | Visible | Hidden | Disabled | ✓ Correct | +| **Connecting** | Hidden | Hidden | Visible | Hidden | Disabled | ✓ Correct | +| **Connected** | Hidden | Hidden | Visible | Hidden | Disabled | ✓ Correct | +| **Error** | Hidden | Hidden | Visible | Hidden | Enabled | ✓ Correct | + +### Manual Mode (ConnectionTypeComboBox Index = 1) + +| State | Discovery Button | Connect Button | Disconnect Button | Cancel Button | ConnectionTypeComboBox | Status | +|------------------|------------------|----------------|-------------------|---------------|------------------------|-----------| +| **Disconnected** | Hidden | Visible | Hidden | Hidden | Enabled | ✓ Correct | +| **Connecting** | Hidden | Hidden | Visible | Hidden | Disabled | ✓ Correct | +| **Connected** | Hidden | Hidden | Visible | Hidden | Disabled | ✓ Correct | +| **Error** | Hidden | Hidden | Visible | Hidden | Enabled | ✓ Correct | + +### Fixed Bugs + +**Bug #1: ConnectionTypeComboBox Enable/Disable** ✓ FIXED +- Added `IsConnectionTypeEnabled` property to ConnectViewModel +- ComboBox is now properly disabled during: Discovering, Discovered, Connecting, ConnectingManually, Connected states +- Location: `ConnectViewModel.cs:523-531` + +**Bug #2: Discovery Cancelled State** ✓ FIXED +- Changed cancelled/failed discovery to use `StatusLevel.Disconnected` instead of `StatusLevel.Error` +- Start Discovery button now correctly appears after cancelling discovery +- Location: `ConnectViewModel.cs:335-348` + +**Bug #3: Connecting States Missing from isConnected Check** ✓ FIXED +- Added `StatusLevel.Connecting` and `StatusLevel.ConnectingManually` to button visibility checks +- Buttons now show correct visibility during connection attempts +- Location: `ConnectViewModel.cs:479-545` + +**Bug #4: Discovery Button May Appear During Connection** ✓ FIXED +- Updated StartDiscovery visibility check to account for all connecting states +- Discovery button no longer appears during connection attempts +- Location: `ConnectViewModel.cs:503-515` + +**Bug #5: Disconnect Button Not Visible for Invalid Security Key Error** ✓ FIXED +- Added `StatusLevel.Error` to disconnect button visibility check +- When an invalid security key error occurs, the disconnect button is now shown +- This allows users to properly disconnect and clean up the connection state +- Location: `ConnectViewModel.cs:520-534` +- Test: `ConnectViewModelTests.cs:292-304` \ No newline at end of file diff --git a/docs/Windows-UI-Updates-for-MAUI.md b/docs/Windows-UI-Updates-for-MAUI.md new file mode 100644 index 0000000..f75c75b --- /dev/null +++ b/docs/Windows-UI-Updates-for-MAUI.md @@ -0,0 +1,605 @@ +# Windows UI Updates to Replicate in MAUI + +This document outlines changes made to the Windows WPF UI implementation that need to be replicated in the MAUI mobile application. + +**Date:** November 7, 2025 +**Commits:** `eac362e` through `62a0a6c` (4 commits ahead of origin/develop) + +--- + +## Summary of Changes + +1. Fixed disconnect button visibility for invalid security key error +2. Fixed Monitor page activation during invalid security key error state +3. Allowed Monitor page access after disconnect +4. Added packet statistics to Monitor page + +--- + +## 1. Disconnect Button Visibility Fix + +**Commit:** `eac362e` - "Fix disconnect button not visible for invalid security key error" + +### Core Changes (Already Applied - Shared) +**File:** `src/Core/ViewModels/Pages/ConnectViewModel.cs` + +The `DisconnectButtonVisible` property now returns `true` for error states: + +```csharp +public bool DisconnectButtonVisible => + ConnectionStatus is ConnectionStatus.Connected or ConnectionStatus.InvalidSecurityKey; +``` + +**Previously:** Only visible when `ConnectionStatus.Connected` +**Now:** Visible for both `Connected` and `InvalidSecurityKey` states + +### MAUI Implementation Notes +- This ViewModel change is in the shared Core library, so MAUI automatically gets this fix +- Ensure MAUI Connect page binds disconnect button visibility to `DisconnectButtonVisible` +- Test that disconnect button appears when invalid security key error occurs + +### Updated Tests +**File:** `test/Core.Tests/ViewModels/ConnectViewModelTests.cs` + +Added test case: +```csharp +[Fact] +public void DisconnectButton_ShouldBeVisible_WhenInvalidSecurityKey() +``` + +--- + +## 2. Monitor Page Error State Fix + +**Commit:** `19576bb` - "Fix Monitor page not active during invalid security key error state" + +### Core Changes (Already Applied - Shared) +**File:** `src/Core/ViewModels/Pages/MonitorViewModel.cs` + +Updated `OnDeviceManagementServiceOnConnectionStatusChange` to handle `InvalidSecurityKey`: + +```csharp +private void OnDeviceManagementServiceOnConnectionStatusChange(object? _, ConnectionStatus connectionStatus) +{ + if (connectionStatus == ConnectionStatus.Connected) InitializePollingMetrics(); + + UpdateConnectionInfo(); + + switch (connectionStatus) + { + case ConnectionStatus.Connected: + StatusLevel = StatusLevel.Connected; + break; + case ConnectionStatus.InvalidSecurityKey: + StatusLevel = StatusLevel.Error; // NEW + break; + default: + StatusLevel = StatusLevel.Disconnected; + break; + } +} +``` + +**Key Change:** Invalid security key now sets `StatusLevel.Error` instead of `StatusLevel.Disconnected` + +### MAUI Implementation Notes +- This ViewModel change is in shared Core library, automatically available in MAUI +- Ensure MAUI Monitor page shows content when `StatusLevel` is `Error` (not just `Connected`) +- The page should be active during error states to allow user interaction + +### Windows XAML Reference +**File:** `src/UI/Windows/Views/Pages/MonitorPage.xaml` + +The visibility binding was updated to show content for non-secure-channel cases: +```xaml + +``` + +**MAUI Equivalent:** Bind visibility to `!ViewModel.UsingSecureChannel` + +### Updated Tests +**File:** `test/Core.Tests/ViewModels/MonitorViewModelTests.cs` + +Added test case: +```csharp +[Fact] +public void StatusLevel_ShouldBeError_WhenInvalidSecurityKey() +``` + +--- + +## 3. Allow Monitor Page Access After Disconnect + +**Commit:** `6eb7d43` - "Allow Monitor page access after disconnect" + +### Core Changes +None - this was a UI-only change + +### Windows XAML Changes +**File:** `src/UI/Windows/Views/Pages/MonitorPage.xaml` + +**Removed:** +- "Device not connected" warning message +- Visibility constraint that only showed content when Connected/Error status + +**Before:** +```xaml + +``` + +**After:** +```xaml + +``` + +### MAUI Implementation Notes +- Remove any connection status checks that prevent Monitor page access +- Allow users to view trace history even when disconnected +- Monitor page should be accessible in all navigation states +- The secure channel warning should still be shown when applicable + +### User Experience Benefits +- Users can review captured trace data after disconnecting +- Better workflow for analyzing communication sessions +- Consistent with standard monitoring tool behavior + +--- + +## 4. Add Packet Statistics to Monitor Page + +**Commit:** `62a0a6c` - "Add packet statistics to Monitor page" + +### Core Changes (Already Applied - Shared) +**File:** `src/Core/ViewModels/Pages/MonitorViewModel.cs` + +#### New Properties +```csharp +// Packet Statistics +[ObservableProperty] private int _commandsSent; +[ObservableProperty] private int _repliesReceived; +[ObservableProperty] private int _polls; +[ObservableProperty] private int _naks; +``` + +#### Line Quality Percentage Calculation +```csharp +/// +/// Line quality percentage based on commands sent vs replies received +/// Accounts for 2 in-flight commands to prevent jumping during normal operation +/// +public double LineQualityPercentage +{ + get + { + if (CommandsSent == 0) return 100.0; + + // Allow for 2 commands to be in-flight without penalizing quality + int inFlight = CommandsSent - RepliesReceived; + + // If we have more than 2 commands without a reply, count the excess as failures + int missedReplies = Math.Max(0, inFlight - 2); + int effectiveCommandsSent = CommandsSent - Math.Min(inFlight, 2); + + if (effectiveCommandsSent == 0) return 100.0; + + int successfulCommands = RepliesReceived; + return (successfulCommands / (double)(successfulCommands + missedReplies)) * 100.0; + } +} + +partial void OnCommandsSentChanged(int value) +{ + OnPropertyChanged(nameof(LineQualityPercentage)); +} + +partial void OnRepliesReceivedChanged(int value) +{ + OnPropertyChanged(nameof(LineQualityPercentage)); +} +``` + +**Algorithm Explanation:** +- Allows up to 2 commands to be "in-flight" (sent but not yet replied to) without affecting quality +- Only penalizes quality when more than 2 commands are waiting for replies +- Prevents percentage from jumping on every command send during normal operation +- Formula: `Quality = (RepliesReceived / (RepliesReceived + MissedReplies)) * 100` + +#### Statistics Tracking +Updated `OnDeviceManagementServiceOnTraceEntryReceived`: + +```csharp +// Update statistics +if (traceEntry.Direction == Output) +{ + CommandsSent++; + if (packetTraceEntry.Packet.CommandType == CommandType.Poll) + { + Polls++; + } +} +else if (traceEntry.Direction == Input) +{ + RepliesReceived++; + if (packetTraceEntry.Packet.ReplyType == ReplyType.Nak) + { + Naks++; + } +} +``` + +#### Statistics Reset on Reconnect +Updated `InitializePollingMetrics`: + +```csharp +private void InitializePollingMetrics() +{ + TraceEntriesView.Clear(); + _lastPacketEntry = null; + + // Reset statistics + CommandsSent = 0; + RepliesReceived = 0; + Polls = 0; + Naks = 0; +} +``` + +### Resource Strings Added +**File:** `src/Core/Resources/Resources.resx` + +```xml + + Commands Sent + Label for commands sent statistic + + + Replies Received + Label for replies received statistic + + + Polls + Label for poll packets statistic + + + NAKs + Label for NAK replies statistic + + + Line Quality + Label for line quality percentage statistic + +``` + +### Windows UI Implementation +**File:** `src/UI/Windows/Views/Pages/MonitorPage.xaml` + +#### Statistics Panel Layout + +```xaml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### MAUI Implementation Guidelines + +#### 1. Layout Structure +Create a statistics panel above the trace entries list with: +- **Top Section:** Line quality percentage (large, prominent display) +- **Bottom Section:** 4-column grid with packet statistics + +#### 2. Line Quality Display +- Label: "Line Quality" (centered, normal size) +- Value: Large font size (28-32pt), bold, with decimal formatting (`{0:F1}`) +- Percent symbol: Slightly smaller (20-24pt), bold + +#### 3. Statistics Grid +Use a Grid with 4 equal-width columns: +- **Column 0:** Commands Sent +- **Column 1:** Replies Received +- **Column 2:** Polls +- **Column 3:** NAKs + +Each statistic cell should have: +- Label text (small, wrapped, centered) +- Value (large, bold, centered, margin above) + +#### 4. MAUI XAML Example Structure + +```xaml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +#### 5. Data Bindings Required +All bindings are to the `MonitorViewModel`: +- `LineQualityPercentage` - computed property (read-only) +- `CommandsSent` - observable property +- `RepliesReceived` - observable property +- `Polls` - observable property +- `Naks` - observable property + +#### 6. Responsive Considerations +- On smaller screens, consider: + - Reducing font sizes slightly + - Reducing margins/padding + - Potentially stacking statistics in 2x2 grid instead of 1x4 + - Ensuring labels wrap properly in narrow columns + +#### 7. Testing Checklist +- [ ] Statistics panel displays correctly on Monitor page +- [ ] Line quality shows 100.0% initially +- [ ] Commands sent increments when packets are sent +- [ ] Replies received increments when responses arrive +- [ ] Polls counter tracks poll packets +- [ ] NAKs counter tracks NAK replies +- [ ] Line quality percentage updates correctly +- [ ] Line quality remains stable (doesn't jump) during normal operation +- [ ] Statistics reset to zero on reconnection +- [ ] Layout works on various screen sizes (phone/tablet) +- [ ] All resource strings are localized + +--- + +## Testing Notes + +### Shared Core Tests +All Core ViewModel changes have corresponding unit tests in `test/Core.Tests/ViewModels/`: +- `ConnectViewModelTests.cs` - Disconnect button visibility +- `MonitorViewModelTests.cs` - Error state handling + +### Manual Testing Scenarios + +#### 1. Invalid Security Key Error +1. Configure device with incorrect security key +2. Attempt connection +3. Verify: + - Disconnect button is visible on Connect page + - Monitor page shows content (not blocked) + - StatusLevel is set to Error + +#### 2. Disconnect Access +1. Connect to device +2. Navigate to Monitor page +3. View trace entries +4. Disconnect from device +5. Verify: + - Monitor page remains accessible + - Previous trace history still visible + - Can review captured data + +#### 3. Packet Statistics +1. Connect to device +2. Navigate to Monitor page +3. Send various commands (polls, identify, etc.) +4. Verify: + - Commands sent counter increments + - Replies received counter increments + - Polls counter tracks poll packets + - Line quality shows near 100% during normal operation +5. Trigger NAK response +6. Verify: + - NAKs counter increments +7. Disconnect and reconnect +8. Verify: + - All statistics reset to zero + +#### 4. Line Quality Stability +1. Connect to device with normal polling +2. Observe line quality percentage +3. Verify: + - Percentage doesn't jump/flicker during normal operation + - Stays at or near 100% with good connection + - Only drops when actual communication failures occur (>2 missed replies) + +--- + +## Migration Priority + +**High Priority:** +1. Packet statistics to Monitor page (most significant user-facing feature) +2. Allow Monitor page access after disconnect (UX improvement) + +**Medium Priority:** +3. Monitor page error state fix (edge case handling) +4. Disconnect button visibility fix (edge case handling) + +--- + +## Additional Notes + +### Shared Core Library +Most changes are in the shared `Core` library (`src/Core`), which means MAUI automatically inherits: +- ViewModel property changes +- Statistics tracking logic +- Line quality calculation +- Status level handling +- Resource strings + +### Platform-Specific Implementation +Only the XAML/UI layer needs to be replicated in MAUI: +- Statistics panel layout +- Visibility bindings +- UI styling and formatting + +### Resource Localization +The 5 new resource strings in `Resources.resx` are shared, so they'll be available in MAUI through the same localization system. + +--- + +## Questions or Issues? + +For questions about implementing these changes in MAUI, refer to: +- Windows XAML files as UI reference +- Core ViewModels for binding requirements +- Unit tests for expected behavior +- This document for implementation details \ No newline at end of file diff --git a/src/Core/Resources/Resources.resx b/src/Core/Resources/Resources.resx index cf8218c..0e1b977 100644 --- a/src/Core/Resources/Resources.resx +++ b/src/Core/Resources/Resources.resx @@ -527,6 +527,26 @@ Collapse Button text to collapse row details + + Commands Sent + Label for commands sent statistic + + + Replies Received + Label for replies received statistic + + + Polls + Label for poll packets statistic + + + NAKs + Label for NAK replies statistic + + + Line Quality + Label for line quality percentage statistic + Discover Connection type option for discovery diff --git a/src/Core/Services/DeviceManagementService.cs b/src/Core/Services/DeviceManagementService.cs index 75f9821..c00f614 100644 --- a/src/Core/Services/DeviceManagementService.cs +++ b/src/Core/Services/DeviceManagementService.cs @@ -149,31 +149,34 @@ public async Task DiscoverDevice(IEnumerable c try { results = await DiscoveryRoutines(connections, progress, cancellationToken); - } - finally - { - _isDiscovering = false; - } - if (results == null) - { - throw new Exception("Unable to discover device"); - } + if (results == null) + { + throw new Exception("Unable to discover device"); + } - if (results.Status != DiscoveryStatus.Succeeded) return results; + if (results.Status != DiscoveryStatus.Succeeded) return results; - Address = results.Address; - BaudRate = (uint)results.Connection.BaudRate; - IdentityLookup = new IdentityLookup(results.Id); - CapabilitiesLookup = new CapabilitiesLookup(results.Capabilities); - UsesDefaultSecurityKey = results.UsesDefaultSecurityKey; + Address = results.Address; + BaudRate = (uint)results.Connection.BaudRate; + IdentityLookup = new IdentityLookup(results.Id); + CapabilitiesLookup = new CapabilitiesLookup(results.Capabilities); + UsesDefaultSecurityKey = results.UsesDefaultSecurityKey; + // Don't auto-enable secure channel after discovery - let user choose + IsUsingSecureChannel = false; - RaiseEvent(DeviceLookupsChanged); + RaiseEvent(DeviceLookupsChanged); - _connectionId = _panel.StartConnection(results.Connection, _defaultPollInterval, Tracer); - _panel.AddDevice(_connectionId, Address, CapabilitiesLookup.CRC, false); + _connectionId = _panel.StartConnection(results.Connection, _defaultPollInterval, Tracer); + // Use 5-parameter overload to explicitly disable secure channel after discovery + _panel.AddDevice(_connectionId, Address, CapabilitiesLookup.CRC, false, null); - return results; + return results; + } + finally + { + _isDiscovering = false; + } } private async Task DiscoveryRoutines(IEnumerable connections, @@ -216,15 +219,23 @@ public Task ExecuteDeviceAction(IDeviceAction deviceAction, object? para /// public async Task Shutdown() + { + await Shutdown(waitForOffline: true); + } + + private async Task Shutdown(bool waitForOffline) { IdentityLookup = null; CapabilitiesLookup = null; await _panel.Shutdown(); - await WaitUntilDeviceIsOffline(); + if (waitForOffline) + { + await WaitUntilDeviceIsOffline(); + } } - + private async Task WaitUntilDeviceIsOffline() { // Skip waiting if we never had a valid connection @@ -281,6 +292,20 @@ public async Task Reconnect(IOsdpConnection osdpConnection, byte connectionParam await Connect(osdpConnection, connectionParametersAddress, IsUsingSecureChannel, UsesDefaultSecurityKey, _securityKey); } + /// + public async Task ReconnectAfterCommunicationChange(IOsdpConnection osdpConnection, byte connectionParametersAddress) + { + // Don't wait for device to go offline since it has already switched to new communication settings + await Shutdown(waitForOffline: false); + + Address = connectionParametersAddress; + BaudRate = (uint)osdpConnection.BaudRate; + + _connectionId = _panel.StartConnection(osdpConnection, _defaultPollInterval, Tracer); + _panel.AddDevice(_connectionId, Address, true, IsUsingSecureChannel, + UsesDefaultSecurityKey ? null : _securityKey); + } + /// /// Helper method to raise events with proper synchronization context handling /// diff --git a/src/Core/Services/IDeviceManagementService.cs b/src/Core/Services/IDeviceManagementService.cs index 50578ad..dc38bbe 100644 --- a/src/Core/Services/IDeviceManagementService.cs +++ b/src/Core/Services/IDeviceManagementService.cs @@ -79,6 +79,16 @@ Task Connect(IOsdpConnection connection, byte address, bool useSecureChannel = f /// A task representing the asynchronous reconnect operation. Task Reconnect(IOsdpConnection osdpConnection, byte address); + /// + /// Reestablishes a connection with a device after communication settings have been changed. + /// This method does not wait for the device to go offline on the old connection settings + /// since the device has already switched to new communication parameters. + /// + /// The connection instance to use for communication. + /// The address of the device to reconnect with. + /// A task representing the asynchronous reconnect operation. + Task ReconnectAfterCommunicationChange(IOsdpConnection osdpConnection, byte address); + /// /// Discovers a device asynchronously over the provided connections. /// diff --git a/src/Core/ViewModels/Pages/ConnectViewModel.cs b/src/Core/ViewModels/Pages/ConnectViewModel.cs index 16d3435..b3f36d6 100644 --- a/src/Core/ViewModels/Pages/ConnectViewModel.cs +++ b/src/Core/ViewModels/Pages/ConnectViewModel.cs @@ -55,11 +55,35 @@ public ConnectViewModel(IDialogService dialogService, IDeviceManagementService d _usbDeviceMonitorService.UsbDeviceChanged += OnUsbDeviceChanged; _usbDeviceMonitorService.StartMonitoring(); } - + + // Initialize connection types with localized strings + UpdateConnectionTypes(); + + // Subscribe to culture changes to update localized strings + Resources.Resources.PropertyChanged += OnResourcesPropertyChanged; + // Perform an initial port scan Task.Run(async () => await InitializeSerialPorts()); } + private void OnResourcesPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // When culture changes, update the connection types with new localized strings + UpdateConnectionTypes(); + } + + private void UpdateConnectionTypes() + { + int previousSelectedIndex = SelectedConnectionTypeIndex; + + ConnectionTypes.Clear(); + ConnectionTypes.Add(Resources.Resources.GetString("ConnectionType_Discover")); + ConnectionTypes.Add(Resources.Resources.GetString("ConnectionType_Manual")); + + // Restore selection after update + SelectedConnectionTypeIndex = previousSelectedIndex; + } + private void OnDeviceManagementServiceOnTraceEntryReceived(object? sender, TraceEntry traceEntry) { // Update activity indicators based on a raw trace entry direction (works for encrypted packets too) @@ -104,6 +128,12 @@ private void DeviceManagementServiceOnConnectionStatusChange(object? sender, Con StatusText = Resources.Resources.GetString("Status_Connected"); NakText = string.Empty; StatusLevel = StatusLevel.Connected; + + // Sync communication settings from service to keep UI in sync + ConnectedAddress = _deviceManagementService.Address; + ConnectedBaudRate = (int)_deviceManagementService.BaudRate; + UseSecureChannel = _deviceManagementService.IsUsingSecureChannel; + UseDefaultKey = _deviceManagementService.UsesDefaultSecurityKey; } else if (StatusLevel == StatusLevel.Discovered) { @@ -150,7 +180,7 @@ private void DeviceManagementServiceOnNakReplyReceived(object? sender, string na [ObservableProperty] private int _connectedBaudRate; - [ObservableProperty] private bool _useSecureChannel; + [ObservableProperty] private bool _useSecureChannel = false; [ObservableProperty] private bool _useDefaultKey = true; @@ -162,6 +192,52 @@ private void DeviceManagementServiceOnNakReplyReceived(object? sender, string na [ObservableProperty] private string _usbStatusText = string.Empty; + /// + /// Gets the available connection types (localized). + /// + [ObservableProperty] private ObservableCollection _connectionTypes = []; + + /// + /// Gets or sets the selected connection type index (0 = Discovery, 1 = Manual). + /// + [ObservableProperty] private int _selectedConnectionTypeIndex; + + /// + /// Gets a value indicating whether the Connect button should be visible. + /// + public bool ConnectVisible => CalculateConnectVisibility(); + + /// + /// Gets a value indicating whether the Disconnect button should be visible. + /// + public bool DisconnectVisible => CalculateDisconnectVisibility(); + + /// + /// Gets a value indicating whether the Start Discovery button should be visible. + /// + public bool StartDiscoveryVisible => CalculateStartDiscoveryVisibility(); + + /// + /// Gets a value indicating whether the Cancel Discovery button should be visible. + /// + public bool CancelDiscoveryVisible => CalculateCancelDiscoveryVisibility(); + + /// + /// Gets a value indicating whether the ConnectionTypeComboBox should be enabled. + /// + public bool IsConnectionTypeEnabled => CalculateConnectionTypeEnabled(); + + // Partial methods to notify when properties change + partial void OnStatusLevelChanged(StatusLevel value) + { + NotifyButtonVisibilityChanged(); + } + + partial void OnSelectedConnectionTypeIndexChanged(int value) + { + NotifyButtonVisibilityChanged(); + } + private async Task InitializeSerialPorts() { try @@ -258,16 +334,16 @@ private void UpdateDiscoveryStatus(DiscoveryResult current) case DiscoveryStatus.DeviceNotFound: StatusText = Resources.Resources.GetString("Status_FailedToConnect"); - StatusLevel = StatusLevel.Error; + StatusLevel = StatusLevel.Disconnected; break; - + case DiscoveryStatus.Error: StatusText = Resources.Resources.GetString("Status_ErrorWhileDiscovering"); - StatusLevel = StatusLevel.Error; + StatusLevel = StatusLevel.Disconnected; break; - + case DiscoveryStatus.Cancelled: - StatusLevel = StatusLevel.Error; + StatusLevel = StatusLevel.Disconnected; StatusText = Resources.Resources.GetString("Status_CancelledDiscovery"); break; @@ -427,6 +503,81 @@ public void Dispose() GC.SuppressFinalize(this); } + #region Button Visibility Calculation Methods + + private bool CalculateConnectVisibility() + { + // Show the Connect button when Manual mode is selected and not connected/connecting + // Bug #3 Fix: Include Connecting and ConnectingManually in the connected check + bool isConnected = StatusLevel == StatusLevel.Connected || + StatusLevel == StatusLevel.Error || + StatusLevel == StatusLevel.Connecting || + StatusLevel == StatusLevel.ConnectingManually; + + return SelectedConnectionTypeIndex == 1 && !isConnected; + } + + private bool CalculateDisconnectVisibility() + { + // Show the Disconnect button when connected or connecting + // Bug #2 Fix: Don't show Disconnect for cancelled discovery (Error state when not actually connected) + // Bug #3 Fix: Include Connecting and ConnectingManually states + // Include Discovered state so user can cancel before auto-connection + // Include Error state for invalid security key errors where user needs to disconnect + bool isConnectedOrConnecting = StatusLevel == StatusLevel.Connected || + StatusLevel == StatusLevel.Connecting || + StatusLevel == StatusLevel.ConnectingManually || + StatusLevel == StatusLevel.Discovered || + StatusLevel == StatusLevel.Error; + + return isConnectedOrConnecting; + } + + private bool CalculateStartDiscoveryVisibility() + { + // Show Start Discovery button when in Discovery mode and not discovering, discovered, connected, or connecting + // Bug #4 Fix: Include Connecting state check + bool isConnectedOrConnecting = StatusLevel == StatusLevel.Connected || + StatusLevel == StatusLevel.Error || + StatusLevel == StatusLevel.Connecting || + StatusLevel == StatusLevel.ConnectingManually; + + return SelectedConnectionTypeIndex == 0 && + StatusLevel is not StatusLevel.Discovering and not StatusLevel.Discovered && + !isConnectedOrConnecting; + } + + private bool CalculateCancelDiscoveryVisibility() + { + // Show Cancel button only when actively discovering + return SelectedConnectionTypeIndex == 0 && StatusLevel == StatusLevel.Discovering; + } + + private bool CalculateConnectionTypeEnabled() + { + // Bug #1 Fix: Disable ConnectionTypeComboBox during active operations + return StatusLevel != StatusLevel.Discovering && + StatusLevel != StatusLevel.Discovered && + StatusLevel != StatusLevel.Connecting && + StatusLevel != StatusLevel.ConnectingManually && + StatusLevel != StatusLevel.Connected; + } + + /// + /// Notifies UI that button visibility properties may have changed. + /// Should be called whenever StatusLevel or SelectedConnectionTypeIndex changes. + /// + private void NotifyButtonVisibilityChanged() + { + OnPropertyChanged(nameof(ConnectVisible)); + OnPropertyChanged(nameof(DisconnectVisible)); + OnPropertyChanged(nameof(StartDiscoveryVisible)); + OnPropertyChanged(nameof(CancelDiscoveryVisible)); + OnPropertyChanged(nameof(IsConnectionTypeEnabled)); + } + + #endregion + /// /// Releases unmanaged and - optionally - managed resources. /// @@ -440,13 +591,14 @@ protected virtual void Dispose(bool disposing) _deviceManagementService.ConnectionStatusChange -= DeviceManagementServiceOnConnectionStatusChange; _deviceManagementService.NakReplyReceived -= DeviceManagementServiceOnNakReplyReceived; _deviceManagementService.TraceEntryReceived -= OnDeviceManagementServiceOnTraceEntryReceived; - + Resources.Resources.PropertyChanged -= OnResourcesPropertyChanged; + if (_usbDeviceMonitorService != null) { _usbDeviceMonitorService.UsbDeviceChanged -= OnUsbDeviceChanged; _usbDeviceMonitorService.StopMonitoring(); } - + _usbStatusTimer?.Dispose(); } diff --git a/src/Core/ViewModels/Pages/ManageViewModel.cs b/src/Core/ViewModels/Pages/ManageViewModel.cs index 79b230f..abafac7 100644 --- a/src/Core/ViewModels/Pages/ManageViewModel.cs +++ b/src/Core/ViewModels/Pages/ManageViewModel.cs @@ -62,7 +62,15 @@ await ExceptionHelper.ExecuteSafelyAsync(_dialogService, Resources.Resources.Get var result = await ExecuteSelectedDeviceAction(); if (result != null && SelectedDeviceAction is SetCommunicationAction) { - await HandleSetCommunicationAction(result); + await ExceptionHelper.ExecuteSafelyAsync( + _dialogService, + Resources.Resources.GetString("Dialog_UpdateCommunications_Title"), + async () => + { + await HandleSetCommunicationAction(result); + return true; + }, + false); } }); } @@ -80,7 +88,7 @@ private async Task HandleSetCommunicationAction(object result) { if (result is not CommunicationParameters connectionParameters) return; - bool parametersChanged = + bool parametersChanged = _deviceManagementService.BaudRate != connectionParameters.BaudRate || _deviceManagementService.Address != connectionParameters.Address; @@ -91,14 +99,22 @@ await _dialogService.ShowMessageDialog(Resources.Resources.GetString("Dialog_Upd return; } - await _dialogService.ShowMessageDialog(Resources.Resources.GetString("Dialog_UpdateCommunications_Title"), - Resources.Resources.GetString("Dialog_UpdateCommunications_Success"), MessageIcon.Information); - + // Reconnect with new settings before showing success if (_deviceManagementService.PortName != null) - { await _deviceManagementService.Reconnect(_serialPortConnectionService.GetConnection( - _deviceManagementService.PortName, - (int)connectionParameters.BaudRate), connectionParameters.Address); + { + // Give device time to switch to new settings + await Task.Delay(500); + + // Use ReconnectAfterCommunicationChange to avoid waiting for device to go offline on old connection + await _deviceManagementService.ReconnectAfterCommunicationChange( + _serialPortConnectionService.GetConnection( + _deviceManagementService.PortName, + (int)connectionParameters.BaudRate), + connectionParameters.Address); } + + await _dialogService.ShowMessageDialog(Resources.Resources.GetString("Dialog_UpdateCommunications_Title"), + Resources.Resources.GetString("Dialog_UpdateCommunications_Success"), MessageIcon.Information); } private async Task HandleResetCypressDeviceAction() diff --git a/src/Core/ViewModels/Pages/MonitorViewModel.cs b/src/Core/ViewModels/Pages/MonitorViewModel.cs index 8b08c18..0d50971 100644 --- a/src/Core/ViewModels/Pages/MonitorViewModel.cs +++ b/src/Core/ViewModels/Pages/MonitorViewModel.cs @@ -33,9 +33,21 @@ public MonitorViewModel(IDeviceManagementService deviceManagementService) private void OnDeviceManagementServiceOnConnectionStatusChange(object? _, ConnectionStatus connectionStatus) { if (connectionStatus == ConnectionStatus.Connected) InitializePollingMetrics(); - + UpdateConnectionInfo(); - StatusLevel = connectionStatus == ConnectionStatus.Connected ? StatusLevel.Connected : StatusLevel.Disconnected; + + switch (connectionStatus) + { + case ConnectionStatus.Connected: + StatusLevel = StatusLevel.Connected; + break; + case ConnectionStatus.InvalidSecurityKey: + StatusLevel = StatusLevel.Error; + break; + default: + StatusLevel = StatusLevel.Disconnected; + break; + } } private void UpdateConnectionInfo() @@ -48,6 +60,12 @@ private void InitializePollingMetrics() { TraceEntriesView.Clear(); _lastPacketEntry = null; + + // Reset statistics + CommandsSent = 0; + RepliesReceived = 0; + Polls = 0; + Naks = 0; } private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry traceEntry) @@ -77,6 +95,24 @@ private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry return; } + // Update statistics + if (traceEntry.Direction == Output) + { + CommandsSent++; + if (packetTraceEntry.Packet.CommandType == CommandType.Poll) + { + Polls++; + } + } + else if (traceEntry.Direction == Input) + { + RepliesReceived++; + if (packetTraceEntry.Packet.ReplyType == ReplyType.Nak) + { + Naks++; + } + } + bool notDisplaying = packetTraceEntry.Packet.CommandType == CommandType.Poll || _lastPacketEntry?.Packet.CommandType == CommandType.Poll && packetTraceEntry.Packet.ReplyType == ReplyType.Ack; @@ -105,4 +141,47 @@ private void OnDeviceManagementServiceOnTraceEntryReceived(object? _, TraceEntry [ObservableProperty] private byte _connectedAddress; [ObservableProperty] private uint _connectedBaudRate; + + // Packet Statistics + [ObservableProperty] private int _commandsSent; + + [ObservableProperty] private int _repliesReceived; + + [ObservableProperty] private int _polls; + + [ObservableProperty] private int _naks; + + /// + /// Line quality percentage based on commands sent vs replies received + /// Accounts for 2 in-flight commands to prevent jumping during normal operation + /// + public double LineQualityPercentage + { + get + { + if (CommandsSent == 0) return 100.0; + + // Allow for 2 commands to be in-flight without penalizing quality + int inFlight = CommandsSent - RepliesReceived; + + // If we have more than 2 commands without a reply, count the excess as failures + int missedReplies = Math.Max(0, inFlight - 2); + int effectiveCommandsSent = CommandsSent - Math.Min(inFlight, 2); + + if (effectiveCommandsSent == 0) return 100.0; + + int successfulCommands = RepliesReceived; + return (successfulCommands / (double)(successfulCommands + missedReplies)) * 100.0; + } + } + + partial void OnCommandsSentChanged(int value) + { + OnPropertyChanged(nameof(LineQualityPercentage)); + } + + partial void OnRepliesReceivedChanged(int value) + { + OnPropertyChanged(nameof(LineQualityPercentage)); + } } \ No newline at end of file diff --git a/src/UI/Windows/Services/WindowsLanguageMismatchService.cs b/src/UI/Windows/Services/WindowsLanguageMismatchService.cs index 7bbd450..abaa16d 100644 --- a/src/UI/Windows/Services/WindowsLanguageMismatchService.cs +++ b/src/UI/Windows/Services/WindowsLanguageMismatchService.cs @@ -81,25 +81,24 @@ await _userSettingsService.UpdateSettingsAsync(settings => public async Task<(bool userWantsToSwitch, bool dontAskAgain)> ShowLanguageMismatchDialogAsync(string systemLanguageName) { var tcs = new TaskCompletionSource<(bool, bool)>(); - + await Application.Current.Dispatcher.InvokeAsync(() => { // Create the dialog content var message = _localizationService.GetString("Language_SystemMismatchMessage", systemLanguageName); - + Window? dialogWindow = null; - var window = dialogWindow; var viewModel = new LanguageMismatchDialogViewModel(message, (userChoice, dontAsk) => { - tcs.SetResult((userChoice, dontAsk)); - window?.Close(); + tcs.TrySetResult((userChoice, dontAsk)); + dialogWindow?.Close(); }); - + var dialogContent = new LanguageMismatchDialog { DataContext = viewModel }; - + // Create and show the dialog window dialogWindow = new Window { @@ -111,10 +110,10 @@ await Application.Current.Dispatcher.InvokeAsync(() => Owner = Application.Current.MainWindow, ShowInTaskbar = false }; - + dialogWindow.ShowDialog(); }); - + return await tcs.Task; } diff --git a/src/UI/Windows/Views/Pages/ConnectPage.xaml b/src/UI/Windows/Views/Pages/ConnectPage.xaml index 226814b..3e165b6 100644 --- a/src/UI/Windows/Views/Pages/ConnectPage.xaml +++ b/src/UI/Windows/Views/Pages/ConnectPage.xaml @@ -17,6 +17,7 @@ + @@ -90,30 +91,43 @@ - + Style="{StaticResource Button.Primary}"> + + + + + - + Style="{StaticResource Button.Primary}"> + + + + + - + Style="{StaticResource Button.Secondary}"> + + + + + + Style="{StaticResource Button.Secondary}"> + + + + + ItemsSource="{Binding ViewModel.ConnectionTypes}" + SelectedIndex="{Binding ViewModel.SelectedConnectionTypeIndex, Mode=TwoWay}" + IsEnabled="{Binding ViewModel.IsConnectionTypeEnabled}"/> diff --git a/src/UI/Windows/Views/Pages/ConnectPage.xaml.cs b/src/UI/Windows/Views/Pages/ConnectPage.xaml.cs index 5b73958..bcf763a 100644 --- a/src/UI/Windows/Views/Pages/ConnectPage.xaml.cs +++ b/src/UI/Windows/Views/Pages/ConnectPage.xaml.cs @@ -1,6 +1,5 @@ -using System.Windows.Controls; -using System.ComponentModel; -using System.Windows; +using System.Windows; +using System.Windows.Controls; using OSDPBench.Core.ViewModels.Pages; using Wpf.Ui.Abstractions.Controls; using Wpf.Ui.Controls; @@ -10,23 +9,13 @@ namespace OSDPBench.Windows.Views.Pages; /// /// Interaction logic for ConnectPage.xaml /// -public partial class ConnectPage : INavigableView, INotifyPropertyChanged +public partial class ConnectPage : INavigableView { public ConnectPage(ConnectViewModel viewModel) { ViewModel = viewModel; DataContext = this; - // Initialize connection types - _connectionTypes = new System.Collections.ObjectModel.ObservableCollection(); - UpdateConnectionTypes(); - - // Subscribe to culture changes - Core.Resources.Resources.PropertyChanged += OnResourcesPropertyChanged; - - // Subscribe to StatusLevel changes to update button visibility - ViewModel.PropertyChanged += OnViewModelPropertyChanged; - InitializeComponent(); // Set up loaded event to ensure proper initialization @@ -35,108 +24,12 @@ public ConnectPage(ConnectViewModel viewModel) public ConnectViewModel ViewModel { get; } - private readonly System.Collections.ObjectModel.ObservableCollection _connectionTypes; - public System.Collections.ObjectModel.ObservableCollection ConnectionTypes => _connectionTypes; - - private void UpdateConnectionTypes() - { - int previousSelectedConnectionTypeIndex = SelectedConnectionTypeIndex; - - _connectionTypes.Clear(); - - _connectionTypes.Add(Core.Resources.Resources.GetString("ConnectionType_Discover")); - _connectionTypes.Add(Core.Resources.Resources.GetString("ConnectionType_Manual")); - - SelectedConnectionTypeIndex = previousSelectedConnectionTypeIndex; - } - - private int _selectedConnectionTypeIndex = -1; // Start with -1 to ensure property change fires - - public int SelectedConnectionTypeIndex - { - get => _selectedConnectionTypeIndex; - set - { - if (_selectedConnectionTypeIndex != value) - { - _selectedConnectionTypeIndex = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedConnectionTypeIndex))); - UpdateButtonVisibility(); - } - } - } - - // Button visibility properties - public Visibility ConnectVisibility => CalculateConnectVisibility(); - public Visibility DisconnectVisibility => CalculateDisconnectVisibility(); - public Visibility StartDiscoveryVisibility => CalculateStartDiscoveryVisibility(); - public Visibility CancelDiscoveryVisibility => CalculateCancelDiscoveryVisibility(); - - private Visibility CalculateConnectVisibility() - { - // Show the Connect button when Manual mode is selected and not connected - return SelectedConnectionTypeIndex == 1 && ViewModel.StatusLevel != StatusLevel.Connected - ? Visibility.Visible - : Visibility.Collapsed; - } - - private Visibility CalculateDisconnectVisibility() - { - // Show the Disconnect button when connected - return ViewModel.StatusLevel == StatusLevel.Connected - ? Visibility.Visible - : Visibility.Collapsed; - } - - private Visibility CalculateStartDiscoveryVisibility() - { - return SelectedConnectionTypeIndex == 0 && ViewModel.StatusLevel is not StatusLevel.Discovering - and not StatusLevel.Discovered and not StatusLevel.Connected - ? Visibility.Visible - : Visibility.Collapsed; - } - - private Visibility CalculateCancelDiscoveryVisibility() - { - return SelectedConnectionTypeIndex == 0 && ViewModel.StatusLevel == StatusLevel.Discovering - ? Visibility.Visible - : Visibility.Collapsed; - } - - private void OnResourcesPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - // When culture changes, update the connection types with new localized strings - // Since we're updating in place. The selection should be maintained - UpdateConnectionTypes(); - - // Ensure the UI updates properly - UpdateButtonVisibility(); - } - - private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(ViewModel.StatusLevel)) - { - UpdateButtonVisibility(); - } - } - - private void UpdateButtonVisibility() - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ConnectVisibility))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DisconnectVisibility))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StartDiscoveryVisibility))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CancelDiscoveryVisibility))); - } - - public event PropertyChangedEventHandler? PropertyChanged; - private void OnPageLoaded(object sender, RoutedEventArgs e) { // Ensure a default selection is set when a page is loaded - if (SelectedConnectionTypeIndex == -1) + if (ViewModel.SelectedConnectionTypeIndex == -1) { - SelectedConnectionTypeIndex = 0; + ViewModel.SelectedConnectionTypeIndex = 0; } } diff --git a/src/UI/Windows/Views/Pages/InfoPage.xaml.cs b/src/UI/Windows/Views/Pages/InfoPage.xaml.cs index 2082623..3ef9c56 100644 --- a/src/UI/Windows/Views/Pages/InfoPage.xaml.cs +++ b/src/UI/Windows/Views/Pages/InfoPage.xaml.cs @@ -1,4 +1,5 @@ using OSDPBench.Core.ViewModels.Windows; +using System.ComponentModel; using System.IO; using System.Windows; using Wpf.Ui.Abstractions.Controls; @@ -8,7 +9,7 @@ namespace OSDPBench.Windows.Views.Pages; /// /// Interaction logic for InfoPage.xaml /// -public sealed partial class InfoPage : INavigableView +public sealed partial class InfoPage : INavigableView, INotifyPropertyChanged, IDisposable { const string EplFilePath = "pack://application:,,,/Assets/EPL.txt"; const string ApacheFilePath = "pack://application:,,,/Assets/Apache.txt"; @@ -20,6 +21,9 @@ public InfoPage(MainWindowViewModel viewModel) DataContext = this; InitializeComponent(); + + // Subscribe to theme change events + Wpf.Ui.Appearance.ApplicationThemeManager.Changed += OnThemeChanged; Loaded += (_, _) => { @@ -61,4 +65,21 @@ public string AppVersion public bool IsDarkMode => Wpf.Ui.Appearance.ApplicationThemeManager.GetAppTheme() == Wpf.Ui.Appearance.ApplicationTheme.Dark; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void OnThemeChanged(Wpf.Ui.Appearance.ApplicationTheme currentApplicationTheme, System.Windows.Media.Color systemAccent) + { + OnPropertyChanged(nameof(IsDarkMode)); + } + + public void Dispose() + { + Wpf.Ui.Appearance.ApplicationThemeManager.Changed -= OnThemeChanged; + } } \ No newline at end of file diff --git a/src/UI/Windows/Views/Pages/MonitorPage.xaml b/src/UI/Windows/Views/Pages/MonitorPage.xaml index a7a63a2..2794bc1 100644 --- a/src/UI/Windows/Views/Pages/MonitorPage.xaml +++ b/src/UI/Windows/Views/Pages/MonitorPage.xaml @@ -20,32 +20,12 @@ - + - - - - - - - - + + @@ -68,6 +48,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +/// Unit tests for ConnectViewModel button visibility logic. +/// Based on specifications in docs/ConnectionButtonBehavior.md +/// +[TestFixture(TestOf = typeof(ConnectViewModel))] +public class ConnectViewModelButtonVisibilityTests +{ + private Mock _dialogServiceMock; + private Mock _deviceManagementServiceMock; + private Mock _serialPortConnectionServiceMock; + private ConnectViewModel _viewModel; + + [SetUp] + public void Setup() + { + _dialogServiceMock = new Mock(); + _deviceManagementServiceMock = new Mock(); + _serialPortConnectionServiceMock = new Mock(); + + // Set up a serial port service to return an empty list to avoid initialization issues + _serialPortConnectionServiceMock.Setup(x => x.FindAvailableSerialPorts()) + .ReturnsAsync([]); + + _viewModel = new ConnectViewModel( + _dialogServiceMock.Object, + _deviceManagementServiceMock.Object, + _serialPortConnectionServiceMock.Object); + } + + #region Discover Mode Tests (SelectedConnectionTypeIndex = 0) + + [Test] + public async Task DiscoverMode_Disconnected_ShowsCorrectButtons() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Disconnected; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.True, "Discovery button should be visible"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.False, "Disconnect button should be hidden"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.True, "ConnectionTypeComboBox should be enabled"); + } + + [Test] + public async Task DiscoverMode_Discovering_ShowsCorrectButtons() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Discovering; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.False, "Disconnect button should be hidden"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.True, "Cancel button should be visible"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled"); + } + + [Test] + public async Task DiscoverMode_DiscoveryCancelled_ShowsCorrectButtons() + { + // Arrange - Bug #2 verification + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Disconnected; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.True, "Discovery button should be visible"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.False, "Disconnect button should be hidden (Bug #2 FIXED)"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.True, "ConnectionTypeComboBox should be enabled"); + } + + [Test] + public async Task DiscoverMode_Discovered_ShowsCorrectButtons() + { + // Arrange - After successful discovery, before auto-connect + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Discovered; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.True, "Disconnect button should be visible to allow cancellation"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled"); + } + + [Test] + public async Task DiscoverMode_Connecting_ShowsCorrectButtons() + { + // Arrange - Bug #3 verification + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Connecting; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.True, "Disconnect button should be visible (Bug #3 FIXED)"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled"); + } + + [Test] + public async Task DiscoverMode_Connected_ShowsCorrectButtons() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Connected; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.True, "Disconnect button should be visible"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled"); + } + + #endregion + + #region Manual Mode Tests (SelectedConnectionTypeIndex = 1) + + [Test] + public async Task ManualMode_Disconnected_ShowsCorrectButtons() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 1; + _viewModel.StatusLevel = StatusLevel.Disconnected; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.True, "Connect button should be visible"); + Assert.That(_viewModel.DisconnectVisible, Is.False, "Disconnect button should be hidden"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.True, "ConnectionTypeComboBox should be enabled"); + } + + [Test] + public async Task ManualMode_Connecting_ShowsCorrectButtons() + { + // Arrange - Bug #3 verification + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 1; + _viewModel.StatusLevel = StatusLevel.ConnectingManually; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden (Bug #3 FIXED)"); + Assert.That(_viewModel.DisconnectVisible, Is.True, "Disconnect button should be visible (Bug #3 FIXED)"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled"); + } + + [Test] + public async Task ManualMode_Connected_ShowsCorrectButtons() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 1; + _viewModel.StatusLevel = StatusLevel.Connected; + + // Act & Assert + Assert.That(_viewModel.StartDiscoveryVisible, Is.False, "Discovery button should be hidden"); + Assert.That(_viewModel.ConnectVisible, Is.False, "Connect button should be hidden"); + Assert.That(_viewModel.DisconnectVisible, Is.True, "Disconnect button should be visible"); + Assert.That(_viewModel.CancelDiscoveryVisible, Is.False, "Cancel button should be hidden"); + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled"); + } + + #endregion + + #region ConnectionTypeComboBox Enable/Disable Tests (Bug #1) + + [Test] + public async Task ConnectionTypeComboBox_Disconnected_IsEnabled() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.StatusLevel = StatusLevel.Disconnected; + + // Act & Assert + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.True, "ConnectionTypeComboBox should be enabled when disconnected"); + } + + [Test] + public async Task ConnectionTypeComboBox_Discovering_IsDisabled() + { + // Arrange - Bug #1 verification + await _viewModel.InitializationComplete; + _viewModel.StatusLevel = StatusLevel.Discovering; + + // Act & Assert + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled when discovering (Bug #1 FIXED)"); + } + + [Test] + public async Task ConnectionTypeComboBox_Discovered_IsDisabled() + { + // Arrange - Bug #1 verification + await _viewModel.InitializationComplete; + _viewModel.StatusLevel = StatusLevel.Discovered; + + // Act & Assert + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled when discovered (Bug #1 FIXED)"); + } + + [Test] + public async Task ConnectionTypeComboBox_Connecting_IsDisabled() + { + // Arrange - Bug #1 verification + await _viewModel.InitializationComplete; + _viewModel.StatusLevel = StatusLevel.Connecting; + + // Act & Assert + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled when connecting (Bug #1 FIXED)"); + } + + [Test] + public async Task ConnectionTypeComboBox_ConnectingManually_IsDisabled() + { + // Arrange - Bug #1 verification + await _viewModel.InitializationComplete; + _viewModel.StatusLevel = StatusLevel.ConnectingManually; + + // Act & Assert + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled when connecting manually (Bug #1 FIXED)"); + } + + [Test] + public async Task ConnectionTypeComboBox_Connected_IsDisabled() + { + // Arrange - Bug #1 verification + await _viewModel.InitializationComplete; + _viewModel.StatusLevel = StatusLevel.Connected; + + // Act & Assert + Assert.That(_viewModel.IsConnectionTypeEnabled, Is.False, "ConnectionTypeComboBox should be disabled when connected (Bug #1 FIXED)"); + } + + #endregion + + #region Property Change Notification Tests + + [Test] + public async Task StatusLevelChange_NotifiesButtonVisibilityProperties() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 1; + _viewModel.StatusLevel = StatusLevel.Disconnected; + + bool connectVisibleChanged = false; + bool disconnectVisibleChanged = false; + bool startDiscoveryVisibleChanged = false; + bool cancelDiscoveryVisibleChanged = false; + bool isConnectionTypeEnabledChanged = false; + + _viewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(ConnectViewModel.ConnectVisible)) connectVisibleChanged = true; + if (args.PropertyName == nameof(ConnectViewModel.DisconnectVisible)) disconnectVisibleChanged = true; + if (args.PropertyName == nameof(ConnectViewModel.StartDiscoveryVisible)) startDiscoveryVisibleChanged = true; + if (args.PropertyName == nameof(ConnectViewModel.CancelDiscoveryVisible)) cancelDiscoveryVisibleChanged = true; + if (args.PropertyName == nameof(ConnectViewModel.IsConnectionTypeEnabled)) isConnectionTypeEnabledChanged = true; + }; + + // Act + _viewModel.StatusLevel = StatusLevel.Connected; + + // Assert + Assert.That(connectVisibleChanged, Is.True, "ConnectVisible should notify change"); + Assert.That(disconnectVisibleChanged, Is.True, "DisconnectVisible should notify change"); + Assert.That(startDiscoveryVisibleChanged, Is.True, "StartDiscoveryVisible should notify change"); + Assert.That(cancelDiscoveryVisibleChanged, Is.True, "CancelDiscoveryVisible should notify change"); + Assert.That(isConnectionTypeEnabledChanged, Is.True, "IsConnectionTypeEnabled should notify change"); + } + + [Test] + public async Task SelectedConnectionTypeIndexChange_NotifiesButtonVisibilityProperties() + { + // Arrange + await _viewModel.InitializationComplete; + _viewModel.SelectedConnectionTypeIndex = 0; + _viewModel.StatusLevel = StatusLevel.Disconnected; + + bool connectVisibleChanged = false; + bool startDiscoveryVisibleChanged = false; + + _viewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(ConnectViewModel.ConnectVisible)) connectVisibleChanged = true; + if (args.PropertyName == nameof(ConnectViewModel.StartDiscoveryVisible)) startDiscoveryVisibleChanged = true; + }; + + // Act + _viewModel.SelectedConnectionTypeIndex = 1; // Switch from Discovery to Manual + + // Assert + Assert.That(connectVisibleChanged, Is.True, "ConnectVisible should notify change when mode changes"); + Assert.That(startDiscoveryVisibleChanged, Is.True, "StartDiscoveryVisible should notify change when mode changes"); + } + + #endregion +} diff --git a/test/Core.Tests/ViewModels/ConnectViewModelTests.cs b/test/Core.Tests/ViewModels/ConnectViewModelTests.cs index 93902a8..804379e 100644 --- a/test/Core.Tests/ViewModels/ConnectViewModelTests.cs +++ b/test/Core.Tests/ViewModels/ConnectViewModelTests.cs @@ -279,14 +279,29 @@ public async Task ConnectViewModel_DeviceManagementServiceOnConnectionStatusChan { // Arrange await _viewModel.InitializationComplete; - + // Act RaiseConnectionStatusEvent(ConnectionStatus.InvalidSecurityKey); - + // Assert Assert.That(_viewModel.StatusText, Is.EqualTo("Invalid security key")); Assert.That(_viewModel.StatusLevel, Is.EqualTo(StatusLevel.Error)); } + + [Test] + public async Task ConnectViewModel_DisconnectButtonVisible_WhenInvalidSecurityKeyError() + { + // Arrange + await _viewModel.InitializationComplete; + + // Act + RaiseConnectionStatusEvent(ConnectionStatus.InvalidSecurityKey); + + // Assert - Disconnect button should be visible to allow user to clean up connection state + Assert.That(_viewModel.DisconnectVisible, Is.True); + Assert.That(_viewModel.ConnectVisible, Is.False); + Assert.That(_viewModel.StartDiscoveryVisible, Is.False); + } [Test] public async Task ConnectViewModel_DeviceManagementServiceOnConnectionStatusChange_WhenDiscoveredStatus() diff --git a/test/Core.Tests/ViewModels/ManageViewModelTests.cs b/test/Core.Tests/ViewModels/ManageViewModelTests.cs index 6bdb49c..5af80e5 100644 --- a/test/Core.Tests/ViewModels/ManageViewModelTests.cs +++ b/test/Core.Tests/ViewModels/ManageViewModelTests.cs @@ -155,7 +155,7 @@ public async Task ExecuteDeviceAction_ForSetCommunicationAction_WithChangedParam Times.Once); _deviceManagementServiceMock.Verify( - x => x.Reconnect( + x => x.ReconnectAfterCommunicationChange( It.IsAny(), newAddress), Times.Once); diff --git a/test/Core.Tests/ViewModels/MonitorViewModelTests.cs b/test/Core.Tests/ViewModels/MonitorViewModelTests.cs index d2d9e6f..52fe4b6 100644 --- a/test/Core.Tests/ViewModels/MonitorViewModelTests.cs +++ b/test/Core.Tests/ViewModels/MonitorViewModelTests.cs @@ -50,19 +50,32 @@ public void MonitorViewModel_DeviceManagementServiceOnConnectionStatusChange_Dis { // Arrange - First set status to connected _deviceManagementServiceMock.Raise( - d => d.ConnectionStatusChange += null!, - EventArgs.Empty, + d => d.ConnectionStatusChange += null!, + EventArgs.Empty, ConnectionStatus.Connected); - + // Act _deviceManagementServiceMock.Raise( - d => d.ConnectionStatusChange += null!, - EventArgs.Empty, + d => d.ConnectionStatusChange += null!, + EventArgs.Empty, ConnectionStatus.Disconnected); - + // Assert Assert.That(_viewModel.StatusLevel, Is.EqualTo(StatusLevel.Disconnected)); } + + [Test] + public void MonitorViewModel_DeviceManagementServiceOnConnectionStatusChange_InvalidSecurityKey() + { + // Act + _deviceManagementServiceMock.Raise( + d => d.ConnectionStatusChange += null!, + EventArgs.Empty, + ConnectionStatus.InvalidSecurityKey); + + // Assert + Assert.That(_viewModel.StatusLevel, Is.EqualTo(StatusLevel.Error)); + } [Test] public void MonitorViewModel_ConnectionStatusChanges_UpdatesStatusLevel()