From 98754037da54c34197bb751bf934cacc03a262d1 Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 09:25:47 +0200 Subject: [PATCH 01/10] feat(workspace): per-wall material editing and guided-walk improvements Repurpose the Select tool to click-select walls (multi-select, highlighted) for a batch material/thickness apply, and click a test point to recapture its reading. Guided walk gains a Skip action, renders remaining queued points on the canvas, and now persists its pending queue on the floor so it survives an app restart instead of being lost mid-walk. --- src/Pulsemap.App.Core/Models/Floor.cs | 9 + .../Controls/FloorPlanCanvas.xaml.cs | 52 +++- .../Services/LocalizationService.cs | 10 + src/Pulsemap.App/Strings/en-US/Resources.resw | 12 + src/Pulsemap.App/Strings/pl-PL/Resources.resw | 12 + .../ViewModels/WorkspaceViewModel.cs | 246 +++++++++++++++--- src/Pulsemap.App/Views/WorkspacePage.xaml | 18 ++ src/Pulsemap.App/Views/WorkspacePage.xaml.cs | 54 +++- .../ViewModels/WorkspaceViewModelTests.cs | 155 +++++++++++ 9 files changed, 522 insertions(+), 46 deletions(-) diff --git a/src/Pulsemap.App.Core/Models/Floor.cs b/src/Pulsemap.App.Core/Models/Floor.cs index 9d44607..f4a6d67 100644 --- a/src/Pulsemap.App.Core/Models/Floor.cs +++ b/src/Pulsemap.App.Core/Models/Floor.cs @@ -29,4 +29,13 @@ public sealed class Floor public List TestPoints { get; init; } = []; public List AccessPoints { get; init; } = []; + + /// Points not yet captured in an in-progress guided measurement walk, so closing and + /// reopening the app mid-walk resumes instead of losing the remaining queue. Empty when no + /// walk is in progress on this floor. + public List PendingGuidedWalkPoints { get; init; } = []; + + /// The band the pending walk above was suggested for — needed to restore + /// SelectedBand on resume, since the suggester's candidate order depends on it. + public Band? PendingGuidedWalkBand { get; set; } } diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs index 231ecac..9845a85 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs @@ -54,6 +54,8 @@ public FloorPlanCanvas() public event EventHandler? DeleteRequested; + public event EventHandler? WallSelectRequested; + /// /// Renders walls/points/heatmap immediately, plus (for an image-style floor plan) the /// background image/PDF once it's decoded — decoding only happens the first time a given @@ -61,7 +63,13 @@ public FloorPlanCanvas() /// newer call starts while this one is still decoding, this call /// abandons itself rather than overwriting the newer render's result. /// - public async Task RenderAsync(Floor floor, IReadOnlyList heatmap, Point2D? walkTarget = null, CancellationToken cancellationToken = default) + public async Task RenderAsync( + Floor floor, + IReadOnlyList heatmap, + Point2D? walkTarget = null, + IReadOnlyCollection? selectedWalls = null, + IReadOnlyList? remainingWalkPoints = null, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(floor); ArgumentNullException.ThrowIfNull(heatmap); @@ -104,7 +112,7 @@ public async Task RenderAsync(Floor floor, IReadOnlyList heatmap _backgroundHeightMeters = 0; } - RenderCore(floor, heatmap, walkTarget); + RenderCore(floor, heatmap, walkTarget, selectedWalls, remainingWalkPoints); } private static Task<(int PixelWidth, int PixelHeight)> LoadAndMeasureAsync(BitmapImage bitmap, string filePath) @@ -132,7 +140,12 @@ void OnFailed(object sender, ExceptionRoutedEventArgs e) return completion.Task; } - private void RenderCore(Floor floor, IReadOnlyList heatmap, Point2D? walkTarget) + private void RenderCore( + Floor floor, + IReadOnlyList heatmap, + Point2D? walkTarget, + IReadOnlyCollection? selectedWalls, + IReadOnlyList? remainingWalkPoints) { _bounds = ComputeBounds(floor); RootGrid.Width = _bounds.WidthMeters * PixelsPerMeter; @@ -156,7 +169,7 @@ private void RenderCore(Floor floor, IReadOnlyList heatmap, Poin foreach (var wall in floor.Walls) { - WallsLayer.Children.Add(BuildWallLine(wall)); + WallsLayer.Children.Add(BuildWallLine(wall, selectedWalls?.Contains(wall) == true)); } foreach (var testPoint in floor.TestPoints) @@ -169,6 +182,11 @@ private void RenderCore(Floor floor, IReadOnlyList heatmap, Poin MarkersLayer.Children.Add(BuildAccessPointMarker(accessPoint)); } + foreach (var upcoming in remainingWalkPoints ?? []) + { + MarkersLayer.Children.Add(BuildRemainingWalkPointMarker(upcoming)); + } + if (walkTarget is { } target) { MarkersLayer.Children.Add(BuildWalkTargetMarker(target)); @@ -191,7 +209,7 @@ private Rectangle BuildHeatmapCell(CoverageSample sample) return cell; } - private Line BuildWallLine(Wall wall) + private Line BuildWallLine(Wall wall, bool isSelected) { var (x1, y1) = ToPixels(wall.Start); var (x2, y2) = ToPixels(wall.End); @@ -201,8 +219,8 @@ private Line BuildWallLine(Wall wall) Y1 = y1, X2 = x2, Y2 = y2, - Stroke = new SolidColorBrush(Colors.DimGray), - StrokeThickness = WallStrokeThicknessPx, + Stroke = new SolidColorBrush(isSelected ? Colors.DodgerBlue : Colors.DimGray), + StrokeThickness = isSelected ? WallStrokeThicknessPx + 2 : WallStrokeThicknessPx, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, }; @@ -259,6 +277,23 @@ private Ellipse BuildWalkTargetMarker(Point2D position) return marker; } + private Ellipse BuildRemainingWalkPointMarker(Point2D position) + { + var (px, py) = ToPixels(position); + double diameter = WalkTargetRadiusPx; + var marker = new Ellipse + { + Width = diameter, + Height = diameter, + Stroke = new SolidColorBrush(Colors.MediumPurple), + StrokeThickness = 1.5, + Opacity = 0.5, + }; + Canvas.SetLeft(marker, px - (diameter / 2)); + Canvas.SetTop(marker, py - (diameter / 2)); + return marker; + } + private static Color HeatmapColor(double signalDbm) => signalDbm switch { >= -50 => Colors.Green, @@ -297,6 +332,9 @@ private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e) break; case WorkspaceTool.Select: + WallSelectRequested?.Invoke(this, meters); + break; + default: break; } diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 575e380..5e01719 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -110,6 +110,11 @@ public string GetString(string key) ["WorkspaceSaveSnapshotDialogLabelHeader"] = "Label", ["WorkspaceSaveSnapshotDialogPrimaryButton"] = "Save", ["WorkspaceSaveSnapshotDialogCloseButton"] = "Cancel", + ["WorkspaceWallSelectionCountFormat"] = "Selected walls: {0}", + ["WorkspaceRecaptureDialogTitle"] = "Recapture this point?", + ["WorkspaceRecaptureDialogContent"] = "This replaces its existing reading with a new scan from here.", + ["WorkspaceRecaptureDialogPrimaryButton"] = "Recapture", + ["WorkspaceRecaptureDialogCloseButton"] = "Cancel", }, ["pl-PL"] = new Dictionary { @@ -178,6 +183,11 @@ public string GetString(string key) ["WorkspaceSaveSnapshotDialogLabelHeader"] = "Etykieta", ["WorkspaceSaveSnapshotDialogPrimaryButton"] = "Zapisz", ["WorkspaceSaveSnapshotDialogCloseButton"] = "Anuluj", + ["WorkspaceWallSelectionCountFormat"] = "Zaznaczone ściany: {0}", + ["WorkspaceRecaptureDialogTitle"] = "Zarejestrować ten punkt ponownie?", + ["WorkspaceRecaptureDialogContent"] = "Zastąpi to jego dotychczasowy odczyt nowym skanem z tego miejsca.", + ["WorkspaceRecaptureDialogPrimaryButton"] = "Zarejestruj ponownie", + ["WorkspaceRecaptureDialogCloseButton"] = "Anuluj", }, }; } diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw index 07f29ff..892e1bc 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -347,9 +347,21 @@ Confirm Arrival & Capture + + Skip + Cancel Walk + + Unspecified + + + Apply material + + + Clear selection + diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw index 2ee29de..f1124a1 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -347,9 +347,21 @@ Potwierdź przybycie i zarejestruj + + Pomiń + Anuluj pomiar + + Nieokreślony + + + Zastosuj materiał + + + Wyczyść zaznaczenie + diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index 10cd7e5..0ba50d5 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -176,6 +176,7 @@ public string AccessPointSummaryDisplay [NotifyCanExecuteChangedFor(nameof(StartGuidedWalkCommand))] [NotifyCanExecuteChangedFor(nameof(ConfirmWalkPointCommand))] [NotifyCanExecuteChangedFor(nameof(CancelGuidedWalkCommand))] + [NotifyCanExecuteChangedFor(nameof(SkipWalkPointCommand))] public partial bool IsGuidedWalkActive { get; set; } public bool IsGuidedWalkIdle => !IsGuidedWalkActive; @@ -187,6 +188,7 @@ public string AccessPointSummaryDisplay [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmWalkPointCommand))] + [NotifyCanExecuteChangedFor(nameof(SkipWalkPointCommand))] public partial bool IsCapturingWalkPoint { get; set; } [ObservableProperty] @@ -205,6 +207,26 @@ public string AccessPointSummaryDisplay point.Y) : _localizationService.GetString("WorkspaceGuidedWalkNotWalking"); + // Points still queued (after the current one) in an active guided walk, for the canvas to + // show as upcoming-point markers. + public IReadOnlyList RemainingWalkPoints => IsGuidedWalkActive ? [.. _guidedWalkQueue.Skip(1)] : []; + + // Wall material editing: the Select tool toggles walls in and out of this set by clicking + // them, then a batch action applies one material/thickness to everything selected. + private readonly List _selectedWalls = []; + + public IReadOnlyList SelectedWalls => _selectedWalls; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasWallSelection))] + [NotifyPropertyChangedFor(nameof(WallSelectionCountDisplay))] + public partial int SelectedWallCount { get; set; } + + public bool HasWallSelection => SelectedWallCount > 0; + + public string WallSelectionCountDisplay => + string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceWallSelectionCountFormat"), SelectedWallCount); + public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default) { _filePath = filePath; @@ -308,7 +330,7 @@ private static async Task OpenLocationSettingsAsync() => private bool CanStartGuidedWalk() => !IsGuidedWalkActive && SelectedAdapter is not null && Survey is not null && SelectedFloor is not null; [RelayCommand(CanExecute = nameof(CanStartGuidedWalk))] - private void StartGuidedWalk() + private async Task StartGuidedWalkAsync() { if (Survey is null || SelectedFloor is null) { @@ -326,6 +348,10 @@ private void StartGuidedWalk() _guidedWalkTotalPoints = points.Count; GuidedWalkStatusMessage = null; IsGuidedWalkActive = true; + SelectedFloor.PendingGuidedWalkPoints.Clear(); + SelectedFloor.PendingGuidedWalkPoints.AddRange(points); + SelectedFloor.PendingGuidedWalkBand = SelectedBand; + await SaveAndRefreshAsync(); AdvanceGuidedWalk(); } @@ -352,9 +378,11 @@ private async Task ConfirmWalkPointAsync() var testPoint = TestPointCapture.BuildTestPoint(position, scanResult, Survey, SelectedAdapter.Name, DateTimeOffset.Now); SelectedFloor.TestPoints.Add(testPoint); + _guidedWalkQueue.Dequeue(); + SelectedFloor.PendingGuidedWalkPoints.Clear(); + SelectedFloor.PendingGuidedWalkPoints.AddRange(_guidedWalkQueue); await SaveAndRefreshAsync(); - _guidedWalkQueue.Dequeue(); AdvanceGuidedWalk(); } finally @@ -366,15 +394,39 @@ private async Task ConfirmWalkPointAsync() private bool CanCancelGuidedWalk() => IsGuidedWalkActive; [RelayCommand(CanExecute = nameof(CanCancelGuidedWalk))] - private void CancelGuidedWalk() + private async Task CancelGuidedWalkAsync() { _guidedWalkQueue.Clear(); IsGuidedWalkActive = false; CurrentWalkPoint = null; GuidedWalkStatusMessage = _localizationService.GetString("WorkspaceGuidedWalkCanceled"); + if (SelectedFloor is not null) + { + SelectedFloor.PendingGuidedWalkPoints.Clear(); + await SaveAndRefreshAsync(); + } + + OnPropertyChanged(nameof(RemainingWalkPoints)); FloorChanged?.Invoke(this, EventArgs.Empty); } + private bool CanSkipWalkPoint() => IsGuidedWalkActive && !IsCapturingWalkPoint; + + [RelayCommand(CanExecute = nameof(CanSkipWalkPoint))] + private async Task SkipWalkPointAsync() + { + if (SelectedFloor is null || _guidedWalkQueue.Count == 0) + { + return; + } + + _guidedWalkQueue.Dequeue(); + SelectedFloor.PendingGuidedWalkPoints.Clear(); + SelectedFloor.PendingGuidedWalkPoints.AddRange(_guidedWalkQueue); + await SaveAndRefreshAsync(); + AdvanceGuidedWalk(); + } + private void AdvanceGuidedWalk() { if (_guidedWalkQueue.Count == 0) @@ -388,6 +440,7 @@ private void AdvanceGuidedWalk() CurrentWalkPoint = _guidedWalkQueue.Peek(); } + OnPropertyChanged(nameof(RemainingWalkPoints)); FloorChanged?.Invoke(this, EventArgs.Empty); } @@ -421,6 +474,89 @@ public async Task DeleteNearestElementAsync(Point2D at) } remove(); + _selectedWalls.RemoveAll(wall => !SelectedFloor.Walls.Contains(wall)); + SelectedWallCount = _selectedWalls.Count; + await SaveAndRefreshAsync(); + } + + /// Nearest wall or test point to a Select-tool click, within the same hit tolerance + /// the Delete tool uses — a wall is offered for material editing, a test point for recapture. + /// Access points aren't selectable here; neither has a per-element edit affordance today. + public object? FindNearestSelectable(Point2D at) + { + if (SelectedFloor is not { } floor) + { + return null; + } + + var (testPoint, testPointDistance) = NearestTestPoint(floor, at); + var (wall, wallDistance) = NearestWall(floor, at); + double best = Math.Min(testPointDistance, wallDistance); + if (best > DeleteHitToleranceMeters) + { + return null; + } + + return best == testPointDistance ? testPoint : wall; + } + + public void ToggleWallSelection(Wall wall) + { + if (!_selectedWalls.Remove(wall)) + { + _selectedWalls.Add(wall); + } + + SelectedWallCount = _selectedWalls.Count; + FloorChanged?.Invoke(this, EventArgs.Empty); + } + + public void ClearWallSelection() + { + if (_selectedWalls.Count == 0) + { + return; + } + + _selectedWalls.Clear(); + SelectedWallCount = 0; + FloorChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task ApplyMaterialToSelectedWallsAsync(WallMaterial? material, double? thicknessMeters) + { + if (_selectedWalls.Count == 0) + { + return; + } + + foreach (var wall in _selectedWalls) + { + wall.Material = material; + wall.ThicknessMeters = thicknessMeters; + } + + ClearWallSelection(); + await SaveAndRefreshAsync(); + } + + public async Task RecaptureTestPointAsync(TestPoint testPoint) + { + if (Survey is null || SelectedFloor is null || SelectedAdapter is null) + { + return; + } + + var scanResult = await _wlanAdapterService.ScanAsync(SelectedAdapter.Id); + if (scanResult.Status != WlanScanStatus.Success) + { + ErrorMessage = DescribeScanStatus(scanResult.Status); + return; + } + + var rebuilt = TestPointCapture.BuildTestPoint(testPoint.Position, scanResult, Survey, SelectedAdapter.Name, DateTimeOffset.Now); + SelectedFloor.TestPoints.Remove(testPoint); + SelectedFloor.TestPoints.Add(rebuilt); await SaveAndRefreshAsync(); } @@ -567,6 +703,21 @@ partial void OnSelectedBandChanged(Band value) partial void OnSelectedFloorChanged(Floor? value) { + ClearWallSelection(); + + _guidedWalkQueue.Clear(); + IsGuidedWalkActive = false; + CurrentWalkPoint = null; + if (value is { PendingGuidedWalkPoints.Count: > 0 } floor) + { + _guidedWalkQueue = new Queue(floor.PendingGuidedWalkPoints); + _guidedWalkTotalPoints = _guidedWalkQueue.Count; + SelectedBand = floor.PendingGuidedWalkBand ?? SelectedBand; + IsGuidedWalkActive = true; + CurrentWalkPoint = _guidedWalkQueue.Peek(); + } + + OnPropertyChanged(nameof(RemainingWalkPoints)); Recompute(); FloorChanged?.Invoke(this, EventArgs.Empty); } @@ -601,41 +752,9 @@ private void Recompute() private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove) { - TestPoint? nearestTestPoint = null; - double nearestTestPointDist = double.MaxValue; - foreach (var testPoint in floor.TestPoints) - { - double distance = testPoint.Position.DistanceTo(at); - if (distance < nearestTestPointDist) - { - nearestTestPointDist = distance; - nearestTestPoint = testPoint; - } - } - - AccessPoint? nearestAccessPoint = null; - double nearestAccessPointDist = double.MaxValue; - foreach (var accessPoint in floor.AccessPoints) - { - double distance = accessPoint.Position.DistanceTo(at); - if (distance < nearestAccessPointDist) - { - nearestAccessPointDist = distance; - nearestAccessPoint = accessPoint; - } - } - - Wall? nearestWall = null; - double nearestWallDist = double.MaxValue; - foreach (var wall in floor.Walls) - { - double distance = DistanceToSegment(at, wall.Start, wall.End); - if (distance < nearestWallDist) - { - nearestWallDist = distance; - nearestWall = wall; - } - } + var (nearestTestPoint, nearestTestPointDist) = NearestTestPoint(floor, at); + var (nearestAccessPoint, nearestAccessPointDist) = NearestAccessPoint(floor, at); + var (nearestWall, nearestWallDist) = NearestWall(floor, at); double best = Math.Min(nearestTestPointDist, Math.Min(nearestAccessPointDist, nearestWallDist)); if (best > DeleteHitToleranceMeters) @@ -665,6 +784,57 @@ private static bool TryFindNearestElement(Floor floor, Point2D at, out Action re return true; } + private static (TestPoint? Item, double Distance) NearestTestPoint(Floor floor, Point2D at) + { + TestPoint? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var testPoint in floor.TestPoints) + { + double distance = testPoint.Position.DistanceTo(at); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = testPoint; + } + } + + return (nearest, nearestDistance); + } + + private static (AccessPoint? Item, double Distance) NearestAccessPoint(Floor floor, Point2D at) + { + AccessPoint? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var accessPoint in floor.AccessPoints) + { + double distance = accessPoint.Position.DistanceTo(at); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = accessPoint; + } + } + + return (nearest, nearestDistance); + } + + private static (Wall? Item, double Distance) NearestWall(Floor floor, Point2D at) + { + Wall? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var wall in floor.Walls) + { + double distance = DistanceToSegment(at, wall.Start, wall.End); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = wall; + } + } + + return (nearest, nearestDistance); + } + private static double DistanceToSegment(Point2D point, Point2D segmentStart, Point2D segmentEnd) { double dx = segmentEnd.X - segmentStart.X; diff --git a/src/Pulsemap.App/Views/WorkspacePage.xaml b/src/Pulsemap.App/Views/WorkspacePage.xaml index 43ade19..b84d300 100644 --- a/src/Pulsemap.App/Views/WorkspacePage.xaml +++ b/src/Pulsemap.App/Views/WorkspacePage.xaml @@ -65,6 +65,23 @@ + - + - + + @@ -46,5 +47,17 @@ Command="{x:Bind ViewModel.OpenLogsFolderCommand}" /> + + + + + + + + diff --git a/src/Pulsemap.App/Views/SettingsPage.xaml.cs b/src/Pulsemap.App/Views/SettingsPage.xaml.cs index aa1d1ab..e7bb2ab 100644 --- a/src/Pulsemap.App/Views/SettingsPage.xaml.cs +++ b/src/Pulsemap.App/Views/SettingsPage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; using Pulsemap.App.ViewModels; namespace Pulsemap.App.Views; @@ -13,4 +14,10 @@ public SettingsPage() ViewModel = App.Services.GetRequiredService(); InitializeComponent(); } + + protected override async void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + await ViewModel.LoadCommand.ExecuteAsync(null); + } } diff --git a/src/Pulsemap.App/packages.lock.json b/src/Pulsemap.App/packages.lock.json index d3f6bfb..71bf37e 100644 --- a/src/Pulsemap.App/packages.lock.json +++ b/src/Pulsemap.App/packages.lock.json @@ -17,6 +17,20 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "r+mSvm/Ryc/iYcc9zcUG5VP9EBB8PL1rgVU6macEaYk45vmGRk9PntM3aynFKN6s3Q4WW36kedTycIctctpTUQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, "Microsoft.Windows.SDK.BuildTools": { "type": "Direct", "requested": "[10.0.28000.2705, )", @@ -82,19 +96,102 @@ "resolved": "5.0.0", "contentHash": "pg1W2VwaEQMAiTpGK840hZgzavnqjlCMTVSbtVCXVyT+7AX4mc1o89SPv4TBlAjhgCOo9c1Y+jZ5m3ti2YgGgA==" }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "10.0.11", "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "System.Diagnostics.DiagnosticSource": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.3", - "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "System.Diagnostics.DiagnosticSource": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" } }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, "Microsoft.Web.WebView2": { "type": "Transitive", "resolved": "1.0.3719.77", @@ -235,6 +332,11 @@ "resolved": "4.150.1", "contentHash": "qrLSL8OonbkMJdSH8heK0Jl39Y2xxZGdd7Ru4cyBXk2ITdx+Fu1sCFLmvCKmnMaYs//mlN/YSpSpOurEXErcQw==" }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "0KdBK+h7G13PuOSC2R/DalAoFMvdYMznvGRuICtkdcUMXgl/gYXsG6z4yUvTxHSMACorWgHCU1Faq0KUHU6yAQ==" + }, "System.Numerics.Tensors": { "type": "Transitive", "resolved": "9.0.0", diff --git a/tests/Pulsemap.App.Core.Tests/Settings/FileAppSettingsServiceTests.cs b/tests/Pulsemap.App.Core.Tests/Settings/FileAppSettingsServiceTests.cs new file mode 100644 index 0000000..18773d2 --- /dev/null +++ b/tests/Pulsemap.App.Core.Tests/Settings/FileAppSettingsServiceTests.cs @@ -0,0 +1,52 @@ +using Pulsemap.App.Core.Settings; + +namespace Pulsemap.App.Core.Tests.Settings; + +public sealed class FileAppSettingsServiceTests : IDisposable +{ + private readonly string _filePath = Path.Combine(Path.GetTempPath(), $"pulsemap-settings-tests-{Guid.NewGuid()}", "settings.json"); + private readonly FileAppSettingsService _sut; + + public FileAppSettingsServiceTests() + { + _sut = new FileAppSettingsService(_filePath); + } + + public void Dispose() + { + string? directory = Path.GetDirectoryName(_filePath); + if (directory is not null && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_NoFileYet_ReturnsDefaults() + { + var settings = await _sut.LoadAsync(); + + Assert.True(settings.CheckForUpdatesEnabled); + } + + [Fact] + public async Task SaveAsync_ThenLoadAsync_RoundTripsTheValue() + { + await _sut.SaveAsync(new AppSettings { CheckForUpdatesEnabled = false }); + + var settings = await _sut.LoadAsync(); + + Assert.False(settings.CheckForUpdatesEnabled); + } + + [Fact] + public async Task LoadAsync_CorruptFile_ReturnsDefaultsRatherThanThrowing() + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + await File.WriteAllTextAsync(_filePath, "{ not valid json"); + + var settings = await _sut.LoadAsync(); + + Assert.True(settings.CheckForUpdatesEnabled); + } +} diff --git a/tests/Pulsemap.App.Core.Tests/Updates/SemanticVersionComparerTests.cs b/tests/Pulsemap.App.Core.Tests/Updates/SemanticVersionComparerTests.cs new file mode 100644 index 0000000..31f7f18 --- /dev/null +++ b/tests/Pulsemap.App.Core.Tests/Updates/SemanticVersionComparerTests.cs @@ -0,0 +1,41 @@ +using Pulsemap.App.Core.Updates; + +namespace Pulsemap.App.Core.Tests.Updates; + +public sealed class SemanticVersionComparerTests +{ + [Theory] + [InlineData("1.2.3", "v1.3.0")] + [InlineData("1.2.3", "v2.0.0")] + [InlineData("v1.2.3", "V1.2.4")] + public void IsNewer_CandidateAhead_ReturnsTrue(string current, string candidate) + { + Assert.True(SemanticVersionComparer.IsNewer(current, candidate)); + } + + [Theory] + [InlineData("1.2.3", "v1.2.3")] + [InlineData("1.2.3", "v1.2.2")] + [InlineData("2.0.0", "v1.9.9")] + public void IsNewer_CandidateNotAhead_ReturnsFalse(string current, string candidate) + { + Assert.False(SemanticVersionComparer.IsNewer(current, candidate)); + } + + [Fact] + public void IsNewer_FourPartAssemblyVersionAgainstThreePartTag_ExactMatchIsNotNewer() + { + // MSBuild always pads a 3-part to a 4-part AssemblyVersion (X.Y.Z.0) — the same + // release should never appear "newer than itself" just because of that padding. + Assert.False(SemanticVersionComparer.IsNewer("1.2.3.0", "v1.2.3")); + } + + [Theory] + [InlineData("not-a-version", "v1.0.0")] + [InlineData("1.0.0", "not-a-version")] + [InlineData("", "")] + public void IsNewer_UnparseableInput_ReturnsFalse(string current, string candidate) + { + Assert.False(SemanticVersionComparer.IsNewer(current, candidate)); + } +} diff --git a/tests/Pulsemap.App.Tests/Fakes/FakeAppSettingsService.cs b/tests/Pulsemap.App.Tests/Fakes/FakeAppSettingsService.cs new file mode 100644 index 0000000..b0eaf5c --- /dev/null +++ b/tests/Pulsemap.App.Tests/Fakes/FakeAppSettingsService.cs @@ -0,0 +1,19 @@ +using Pulsemap.App.Core.Settings; + +namespace Pulsemap.App.Tests.Fakes; + +internal sealed class FakeAppSettingsService : IAppSettingsService +{ + public AppSettings SettingsToReturn { get; set; } = new(); + + public AppSettings? LastSaved { get; private set; } + + public Task LoadAsync(CancellationToken cancellationToken = default) => + Task.FromResult(SettingsToReturn); + + public Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default) + { + LastSaved = settings; + return Task.CompletedTask; + } +} diff --git a/tests/Pulsemap.App.Tests/Fakes/FakeUpdateCheckService.cs b/tests/Pulsemap.App.Tests/Fakes/FakeUpdateCheckService.cs new file mode 100644 index 0000000..38c056f --- /dev/null +++ b/tests/Pulsemap.App.Tests/Fakes/FakeUpdateCheckService.cs @@ -0,0 +1,11 @@ +using Pulsemap.App.Core.Abstractions; + +namespace Pulsemap.App.Tests.Fakes; + +internal sealed class FakeUpdateCheckService : IUpdateCheckService +{ + public UpdateCheckResult ResultToReturn { get; set; } = UpdateCheckResult.NoUpdate; + + public Task CheckForUpdateAsync(CancellationToken cancellationToken = default) => + Task.FromResult(ResultToReturn); +} diff --git a/tests/Pulsemap.App.Tests/Fakes/StubHttpMessageHandler.cs b/tests/Pulsemap.App.Tests/Fakes/StubHttpMessageHandler.cs new file mode 100644 index 0000000..e5e1e30 --- /dev/null +++ b/tests/Pulsemap.App.Tests/Fakes/StubHttpMessageHandler.cs @@ -0,0 +1,14 @@ +using System.Net.Http; + +namespace Pulsemap.App.Tests.Fakes; + +internal sealed class StubHttpMessageHandler(Func responder) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(responder(request)); +} + +internal sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => new(handler); +} diff --git a/tests/Pulsemap.App.Tests/Services/GitHubUpdateCheckServiceTests.cs b/tests/Pulsemap.App.Tests/Services/GitHubUpdateCheckServiceTests.cs new file mode 100644 index 0000000..a99c38e --- /dev/null +++ b/tests/Pulsemap.App.Tests/Services/GitHubUpdateCheckServiceTests.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using Pulsemap.App.Services; +using Pulsemap.App.Tests.Fakes; + +namespace Pulsemap.App.Tests.Services; + +public sealed class GitHubUpdateCheckServiceTests +{ + private readonly FakeAppLogger _logger = new(); + + [Fact] + public async Task CheckForUpdateAsync_NewerReleasePublished_ReturnsUpdateAvailable() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"tag_name":"v99.0.0"}""", Encoding.UTF8, "application/json"), + }); + var sut = new GitHubUpdateCheckService(new FakeHttpClientFactory(handler), _logger); + + var result = await sut.CheckForUpdateAsync(); + + Assert.True(result.IsUpdateAvailable); + Assert.Equal("v99.0.0", result.LatestVersion); + Assert.NotNull(result.ReleaseUrl); + } + + [Fact] + public async Task CheckForUpdateAsync_RequestThrows_ReturnsNoUpdate() + { + var handler = new StubHttpMessageHandler(_ => throw new HttpRequestException("offline")); + var sut = new GitHubUpdateCheckService(new FakeHttpClientFactory(handler), _logger); + + var result = await sut.CheckForUpdateAsync(); + + Assert.False(result.IsUpdateAvailable); + Assert.Null(result.LatestVersion); + } + + [Fact] + public async Task CheckForUpdateAsync_ServerErrorStatus_ReturnsNoUpdate() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + var sut = new GitHubUpdateCheckService(new FakeHttpClientFactory(handler), _logger); + + var result = await sut.CheckForUpdateAsync(); + + Assert.False(result.IsUpdateAvailable); + } + + [Fact] + public async Task CheckForUpdateAsync_MalformedJson_ReturnsNoUpdate() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("not json", Encoding.UTF8, "application/json"), + }); + var sut = new GitHubUpdateCheckService(new FakeHttpClientFactory(handler), _logger); + + var result = await sut.CheckForUpdateAsync(); + + Assert.False(result.IsUpdateAvailable); + } +} diff --git a/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs index 4e0a163..8752534 100644 --- a/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs +++ b/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs @@ -1,3 +1,5 @@ +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; using Pulsemap.App.Tests.Fakes; using Pulsemap.App.ViewModels; @@ -6,14 +8,18 @@ namespace Pulsemap.App.Tests.ViewModels; public sealed class HomeViewModelTests { + private readonly FakeSurveyLibraryService _libraryService = new(); + private readonly FakeUpdateCheckService _updateCheckService = new(); + private readonly FakeAppSettingsService _appSettingsService = new(); + private readonly FakeLocalizationService _localizationService = new(); + + private HomeViewModel CreateSut() => new(_libraryService, _updateCheckService, _appSettingsService, _localizationService); + [Fact] public async Task LoadCommand_PopulatesSurveysFromLibrary() { - var libraryService = new FakeSurveyLibraryService - { - SurveysToReturn = [new SurveySummary("C:\\Surveys\\A.pulsemap", "Survey A", null, DateTimeOffset.Now)], - }; - var sut = new HomeViewModel(libraryService); + _libraryService.SurveysToReturn = [new SurveySummary("C:\\Surveys\\A.pulsemap", "Survey A", null, DateTimeOffset.Now)]; + var sut = CreateSut(); await sut.LoadCommand.ExecuteAsync(null); @@ -25,14 +31,11 @@ public async Task LoadCommand_PopulatesSurveysFromLibrary() [Fact] public async Task LoadCommand_ClearsPreviousResultsBeforeReloading() { - var libraryService = new FakeSurveyLibraryService - { - SurveysToReturn = [new SurveySummary("C:\\Surveys\\A.pulsemap", "Survey A", null, DateTimeOffset.Now)], - }; - var sut = new HomeViewModel(libraryService); + _libraryService.SurveysToReturn = [new SurveySummary("C:\\Surveys\\A.pulsemap", "Survey A", null, DateTimeOffset.Now)]; + var sut = CreateSut(); await sut.LoadCommand.ExecuteAsync(null); - libraryService.SurveysToReturn = []; + _libraryService.SurveysToReturn = []; await sut.LoadCommand.ExecuteAsync(null); Assert.Empty(sut.Surveys); @@ -41,23 +44,58 @@ public async Task LoadCommand_ClearsPreviousResultsBeforeReloading() [Fact] public async Task LoadCommand_CapsToTheThreeMostRecentSurveys() { - var libraryService = new FakeSurveyLibraryService - { + _libraryService.SurveysToReturn = + [ // Home is a dashboard, not the library — ListSurveysAsync already returns // newest-first, so the cap should keep exactly the first three as given. - SurveysToReturn = - [ - new SurveySummary("C:\\Surveys\\A.pulsemap", "Survey A", null, DateTimeOffset.Now), - new SurveySummary("C:\\Surveys\\B.pulsemap", "Survey B", null, DateTimeOffset.Now), - new SurveySummary("C:\\Surveys\\C.pulsemap", "Survey C", null, DateTimeOffset.Now), - new SurveySummary("C:\\Surveys\\D.pulsemap", "Survey D", null, DateTimeOffset.Now), - ], - }; - var sut = new HomeViewModel(libraryService); + new SurveySummary("C:\\Surveys\\A.pulsemap", "Survey A", null, DateTimeOffset.Now), + new SurveySummary("C:\\Surveys\\B.pulsemap", "Survey B", null, DateTimeOffset.Now), + new SurveySummary("C:\\Surveys\\C.pulsemap", "Survey C", null, DateTimeOffset.Now), + new SurveySummary("C:\\Surveys\\D.pulsemap", "Survey D", null, DateTimeOffset.Now), + ]; + var sut = CreateSut(); await sut.LoadCommand.ExecuteAsync(null); Assert.Equal(3, sut.Surveys.Count); Assert.Equal(["Survey A", "Survey B", "Survey C"], sut.Surveys.Select(s => s.Name)); } + + [Fact] + public async Task LoadCommand_UpdateAvailableAndChecksEnabled_ShowsBanner() + { + _appSettingsService.SettingsToReturn = new AppSettings { CheckForUpdatesEnabled = true }; + _updateCheckService.ResultToReturn = new UpdateCheckResult(true, "v2.0.0", "https://github.com/DotifyBIZ/pulsemap/releases/latest"); + var sut = CreateSut(); + + await sut.LoadCommand.ExecuteAsync(null); + + Assert.True(sut.HasUpdateAvailable); + Assert.NotNull(sut.UpdateBannerMessage); + } + + [Fact] + public async Task LoadCommand_ChecksDisabled_NeverCallsUpdateService() + { + _appSettingsService.SettingsToReturn = new AppSettings { CheckForUpdatesEnabled = false }; + _updateCheckService.ResultToReturn = new UpdateCheckResult(true, "v2.0.0", "https://example.test"); + var sut = CreateSut(); + + await sut.LoadCommand.ExecuteAsync(null); + + Assert.False(sut.HasUpdateAvailable); + } + + [Fact] + public async Task LoadCommand_NoUpdateAvailable_DoesNotShowBanner() + { + _appSettingsService.SettingsToReturn = new AppSettings { CheckForUpdatesEnabled = true }; + _updateCheckService.ResultToReturn = UpdateCheckResult.NoUpdate; + var sut = CreateSut(); + + await sut.LoadCommand.ExecuteAsync(null); + + Assert.False(sut.HasUpdateAvailable); + Assert.Null(sut.UpdateBannerMessage); + } } diff --git a/tests/Pulsemap.App.Tests/ViewModels/SettingsViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/SettingsViewModelTests.cs new file mode 100644 index 0000000..07c47a7 --- /dev/null +++ b/tests/Pulsemap.App.Tests/ViewModels/SettingsViewModelTests.cs @@ -0,0 +1,48 @@ +using Pulsemap.App.Core.Settings; +using Pulsemap.App.Tests.Fakes; +using Pulsemap.App.ViewModels; + +namespace Pulsemap.App.Tests.ViewModels; + +public sealed class SettingsViewModelTests +{ + private readonly FakeLocalizationService _localizationService = new(); + private readonly FakeAppLogger _logger = new(); + private readonly FakeAppSettingsService _appSettingsService = new(); + + private SettingsViewModel CreateSut() => new(_localizationService, _logger, _appSettingsService); + + [Fact] + public async Task LoadCommand_ReadsCheckForUpdatesEnabledFromSettings() + { + _appSettingsService.SettingsToReturn = new AppSettings { CheckForUpdatesEnabled = false }; + var sut = CreateSut(); + + await sut.LoadCommand.ExecuteAsync(null); + + Assert.False(sut.CheckForUpdatesEnabled); + } + + [Fact] + public async Task LoadCommand_DoesNotPersistTheValueItJustLoaded() + { + _appSettingsService.SettingsToReturn = new AppSettings { CheckForUpdatesEnabled = false }; + var sut = CreateSut(); + + await sut.LoadCommand.ExecuteAsync(null); + + Assert.Null(_appSettingsService.LastSaved); + } + + [Fact] + public async Task TogglingCheckForUpdatesEnabled_PersistsTheNewValue() + { + var sut = CreateSut(); + await sut.LoadCommand.ExecuteAsync(null); + + sut.CheckForUpdatesEnabled = false; + + Assert.NotNull(_appSettingsService.LastSaved); + Assert.False(_appSettingsService.LastSaved.CheckForUpdatesEnabled); + } +} diff --git a/tests/Pulsemap.App.Tests/packages.lock.json b/tests/Pulsemap.App.Tests/packages.lock.json index b9e01df..2a1cfeb 100644 --- a/tests/Pulsemap.App.Tests/packages.lock.json +++ b/tests/Pulsemap.App.Tests/packages.lock.json @@ -65,6 +65,32 @@ "resolved": "17.12.0", "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", "resolved": "10.0.11", @@ -78,14 +104,84 @@ "resolved": "10.0.11", "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "System.Diagnostics.DiagnosticSource": "10.0.0" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "r+mSvm/Ryc/iYcc9zcUG5VP9EBB8PL1rgVU6macEaYk45vmGRk9PntM3aynFKN6s3Q4WW36kedTycIctctpTUQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.3", - "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "System.Diagnostics.DiagnosticSource": "10.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" } }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.12.0", @@ -289,6 +385,11 @@ "resolved": "4.150.1", "contentHash": "qrLSL8OonbkMJdSH8heK0Jl39Y2xxZGdd7Ru4cyBXk2ITdx+Fu1sCFLmvCKmnMaYs//mlN/YSpSpOurEXErcQw==" }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "0KdBK+h7G13PuOSC2R/DalAoFMvdYMznvGRuICtkdcUMXgl/gYXsG6z4yUvTxHSMACorWgHCU1Faq0KUHU6yAQ==" + }, "System.Numerics.Tensors": { "type": "Transitive", "resolved": "9.0.0", @@ -349,6 +450,7 @@ "dependencies": { "CommunityToolkit.Mvvm": "[8.4.2, )", "Microsoft.Extensions.DependencyInjection": "[10.0.11, )", + "Microsoft.Extensions.Http": "[10.0.0, )", "Microsoft.Windows.SDK.BuildTools": "[10.0.28000.2705, )", "Microsoft.Windows.SDK.BuildTools.WinApp": "[0.6.1, )", "Microsoft.WindowsAppSDK": "[2.4.0, )", From 0f93258623c09346d9bc176e4a4e6070cbc32431 Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 10:39:30 +0200 Subject: [PATCH 04/10] feat(core): coordinate AP channel assignment across floors Channel ranking for a floor's suggested APs now also weighs channels already used by APs on adjacent floors, discounted the same way CoverageGridCalculator already discounts cross-floor signal (skipping same-level-different-floor and outdoor pairs). Placement itself stays per-floor, only channel assignment gained the cross-floor awareness. --- CLAUDE.md | 5 +- .../GreedyCoverageApPlacementOptimizer.cs | 76 +++++++++++---- .../Placement/IApPlacementOptimizer.cs | 5 +- .../ViewModels/WorkspaceViewModel.cs | 2 +- ...GreedyCoverageApPlacementOptimizerTests.cs | 92 ++++++++++++++++--- .../Fakes/FakeApPlacementOptimizer.cs | 2 +- 6 files changed, 145 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5141a8f..6b93b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,5 +54,6 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle - **Surveys library page (added 2026-09-01, DONE):** the `NavSurveys` ("Badania") nav tab was a dead stub (`// Not built yet`) — clicking it did nothing. Home previously showed every survey with no cap, so there was nothing left for a dedicated Surveys page to add; per a product decision, Home is now a dashboard (greeting + New Survey CTA + the 3 most recent surveys only, `HomeViewModel.RecentSurveysLimit`), and the new `SurveysPage`/`SurveysViewModel` owns the full, unlimited list plus rename and delete (each behind a `ContentDialog` confirm — this app's first use of `ContentDialog`). Rename changes `Survey.Name` in the persisted file in place via the existing `ISurveyFileService.SaveAsync` — it does not touch the filename on disk, which was never derived from the display name after creation anyway. `ISurveyLibraryService` gained `DeleteAsync`/`RenameAsync`. Per-card Rename/Delete buttons use `Tag="{x:Bind}"` + a code-behind `Click` handler rather than binding `Command` directly inside the `GridView`'s `DataTemplate`, per the documented nested-`DataTemplate`-binding XAML compiler quirk above. - **Multi-floor, outdoor areas, historical comparison, adaptive Kriging suggestion (added 2026-09-02, DONE):** pulled forward from README's Phase 2 at the user's request (Linux/winget stayed deferred). `Survey.Floor` (singular) is now `Survey.Floors` (`List`) — `Floor` gained `Id`/`Name`/`IsOutdoor`/`Level`/`OutdoorBoundsMin`/`OutdoorBoundsMax`, none `required`, all self-defaulting, so a v1 `"Floor": {...}` object deserializes straight into the current `Floor` type with zero migration DTO needed. `ZipSurveyFileService.LoadAsync` pre-parses `SchemaVersion` via `JsonDocument` and branches — v1 files get their singular floor lifted into `Floors: [floor]`; no exception-driven control flow. **Outdoor areas** (confirmed with the user: same local meter grid as indoor, no real GPS/map) reuse the existing engine almost entirely: `FloorGrid.BuildPoints` falls back to an explicit `OutdoorBoundsMin`/`Max` rectangle when `Walls` is empty and `IsOutdoor`, and since `IPropagationModel.PredictSignalDbm` already took a bare wall list (not a `Floor`), an outdoor call with `Walls` empty already produces pure free-space loss with no propagation-formula changes at all. **Inter-floor propagation** is a deliberate simplification, not full 3D: floors are assumed to stack at the same local X/Y origin, and `CoverageGridCalculator.ComputeGrid` (now takes the full `Floors` list alongside the target floor) applies a flat `InterFloorAttenuationDbPerLevel` (25dB) penalty per level difference for another floor's APs, skipping outdoor areas and same-level-different-floor pairs entirely — AP *placement* stays per-floor-only this pass (no joint cross-floor optimizer), matching how the earlier SINR-interference feature scoped its own harder joint-design piece out. **Adaptive Kriging suggestion**: `IKrigingInterpolator` gained `InterpolateVariance` (ordinary kriging's own estimation-variance side-product, reusing the existing LU factorization — `IKrigingInterpolator` had zero production callers before this, so the extension was purely additive); `MeasurementPointSuggester.SuggestPoints` gained a `(Floor, Band, IKrigingInterpolator)` overload that reorders (doesn't replace) the same candidate list by descending uncertainty once ≥2 same-band measurements exist, falling straight back to the old grid-scan order otherwise. **Snapshots**: `SurveySnapshot` freezes a deep copy of `Floors`' walls/test points/access points (not plan images — those stay live, `[JsonIgnore]`d either way) inside the same `survey.json`; heatmaps for a snapshot are always recomputed live via the same `CoverageGridCalculator`, never cached. New Workspace UI: a floor/zone switcher `ComboBox` in the toolbar row (deliberately not a 4th Pivot tab — the header-clipping bug above is confirmed still live, exactly 3 tabs in a fixed 440px column), "+ Add Floor" (name + outdoor checkbox, one `ContentDialog`), "Save Snapshot" (label, another `ContentDialog`), and "Compare" opening a new `SnapshotComparisonPage` (two independently-pickable "Current or a saved snapshot" sides, a floor picker, two `FloorPlanCanvas` instances side-by-side — not a from-scratch delta-heatmap renderer). The wizard itself is untouched — still creates exactly one `Floor`; adding more floors/areas is a Workspace-only action. 135 tests now (69 Core/66 App), Core branch coverage 87.94%. **Not yet verified live**: the user's real existing survey file loading correctly after the schema migration — attempted via the running app but the session locked mid-check; needs a manual pass. - **Live manual QA pass (2026-09-01):** the app's own developer did a real click-through (not automation) and found two real bugs, both fixed same-day: (1) the dead Surveys tab above; (2) Settings' "Open Logs Folder" button silently did nothing on a fresh install, because `FileAppLogger` only creates its log directory lazily on first write, and `Launcher.LaunchFolderPathAsync` fails on a path that doesn't exist yet — fixed at the call site (`SettingsViewModel.OpenLogsFolderAsync` now `Directory.CreateDirectory`s before launching) rather than inside `FileAppLogger` itself, since creating the directory eagerly in `FileAppLogger`'s constructor would have broken an existing `FileAppLoggerTests` case that deliberately blocks the log path with a plain file to prove the logger degrades gracefully when its directory can't be created. The floor-plan image/PDF rendering and the 4 Export buttons weren't specifically called out as broken or working in this pass — still not explicitly confirmed either way, unlike the two bugs above. -- **Per-wall material editing and guided-walk improvements (added 2026-09-02, on branch `feature/wall-material-and-guided-walk`, not yet merged to staging):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). -- **In-app update check (added 2026-09-02, on branch `feature/update-check-notify`, not yet merged to staging — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. +- **Per-wall material editing and guided-walk improvements (added 2026-09-02, DONE, merged to staging via PR #8):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). +- **In-app update check (added 2026-09-02, DONE, merged to staging via PR #7 — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. +- **Cross-floor channel coordination (added 2026-09-02, on branch `feature/cross-floor-channel-coordination`, not yet merged to staging):** `IApPlacementOptimizer.SuggestPlacements` gained an `allFloors` parameter (mirroring `CoverageGridCalculator.ComputeGrid`'s own `floor`+`allFloors` shape) so `GreedyCoverageApPlacementOptimizer`'s channel ranking can see channels already assigned to APs on other floors, not just this floor's own measured interference. Placement itself stays per-floor-only, per explicit scope decision — this is channel-assignment coordination, not a joint cross-floor optimizer. `CrossFloorChannelUsageScore` reuses `CoverageGridCalculator`'s same skip rules (same-level-different-floor and outdoor floors don't participate) and the same 25dB/level attenuation figure (duplicated as its own constant, not shared across layers — matches how `WorkspaceViewModel` already duplicates `ReliableCoverageThresholdDbm`), scored in the same linear-power units as the existing same-floor `MeasuredInterferenceScore` so the two combine meaningfully instead of one dominating on scale. Known, accepted limitation: since AP suggestion is still a per-floor, user-triggered action, a floor suggested *before* its neighbor won't retroactively pick up the neighbor's channels — re-running suggestions on the earlier floor afterward does. diff --git a/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs b/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs index 8dd4e45..83be552 100644 --- a/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs +++ b/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs @@ -39,9 +39,15 @@ public sealed class GreedyCoverageApPlacementOptimizer : IApPlacementOptimizer private const double TargetCoverageFraction = 0.95; private const int MaxAccessPoints = 8; - public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList bands, IPropagationModel propagationModel) + // Matches CoverageGridCalculator's own inter-floor attenuation figure — kept as a separate + // constant rather than shared across layers, same as WorkspaceViewModel's own duplicate of + // ReliableCoverageThresholdDbm elsewhere in this codebase. + private const double InterFloorAttenuationDbPerLevel = 25; + + public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList allFloors, IReadOnlyList bands, IPropagationModel propagationModel) { ArgumentNullException.ThrowIfNull(floor); + ArgumentNullException.ThrowIfNull(allFloors); ArgumentNullException.ThrowIfNull(bands); ArgumentNullException.ThrowIfNull(propagationModel); @@ -84,7 +90,7 @@ public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList BuildAccessPoint(position, index, bands, floor.TestPoints)).ToList(); + return placements.Select((position, index) => BuildAccessPoint(position, index, bands, floor, allFloors)).ToList(); } private static (Point2D? Candidate, bool[]? CoverageMask) FindBestCandidate( @@ -154,7 +160,7 @@ private static double EffectiveReliabilityThresholdDbm(Point2D point, Band band, return Math.Max(ReliableCoverageThresholdDbm, strongestNearbyInterferenceDbm + SinrMarginDb); } - private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOnlyList bands, IReadOnlyList testPoints) + private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOnlyList bands, Floor floor, IReadOnlyList allFloors) { var accessPoint = new AccessPoint { @@ -164,7 +170,7 @@ private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOn foreach (var band in bands) { - var channels = RankChannelsByMeasuredInterference(ChannelPlan.ChannelsFor(band), band, testPoints); + var channels = RankChannelsByInterference(ChannelPlan.ChannelsFor(band), band, floor, allFloors); accessPoint.Radios[band] = new BandRadioSettings { TransmitPowerDbm = ChannelPlan.DefaultTransmitPowerDbm(band), @@ -175,23 +181,24 @@ private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOn return accessPoint; } - // Orders a band's candidate channels least-congested first, using every guided-walk - // interference reading captured on the floor. Round-robin assignment across APs still walks - // this reordered list, so APs keep getting distinct channels — just biased away from whichever - // channels neighboring networks are already using. With no measurements yet (predictive-only - // survey), every channel ties at zero and LINQ's stable OrderBy preserves the original order — - // identical round-robin behavior to before any walk existed. - private static IReadOnlyList RankChannelsByMeasuredInterference(IReadOnlyList channels, Band band, IReadOnlyList testPoints) - { - if (testPoints.Count == 0) - { - return channels; - } - - return channels - .OrderBy(channel => MeasuredInterferenceScore(channel, band, testPoints)) + // Orders a band's candidate channels least-congested first, weighing both this floor's own + // guided-walk interference readings and channels already assigned to APs on nearby floors + // (weighted down by distance the same way CoverageGridCalculator discounts cross-floor signal). + // Round-robin assignment across APs still walks this reordered list, so APs keep getting + // distinct channels — just biased away from whichever channels are already congested, on this + // floor or the ones around it. With no measurements and no other floors' APs yet, every channel + // ties at zero and LINQ's stable OrderBy preserves the original order — identical round-robin + // behavior to before either signal existed. + // + // This only sees APs already placed on other floors at the time this floor's own placement + // runs — since AP suggestion stays a per-floor, user-triggered action (no joint cross-floor + // optimizer), a floor suggested before its neighbor won't retroactively account for channels + // the neighbor picks later. Re-running suggestions on an earlier floor after its neighbors are + // done picks up their channels same as any other re-run. + private static List RankChannelsByInterference(IReadOnlyList channels, Band band, Floor floor, IReadOnlyList allFloors) => + channels + .OrderBy(channel => MeasuredInterferenceScore(channel, band, floor.TestPoints) + CrossFloorChannelUsageScore(channel, band, floor, allFloors)) .ToList(); - } // Linear-power sum (not a dBm average) so a single strong nearby network dominates the score // the way it would dominate real co-channel interference. @@ -201,6 +208,35 @@ private static double MeasuredInterferenceScore(int channel, Band band, IReadOnl .Where(r => r.Band == band && r.Channel == channel) .Sum(r => Math.Pow(10, r.SignalDbm / 10.0)); + // Same skip rules as CoverageGridCalculator (same-level-different-floor and outdoor areas + // don't participate in the flat "stacked at the same origin" cross-floor model), scored in the + // same linear-power units as MeasuredInterferenceScore so the two combine meaningfully. + private static double CrossFloorChannelUsageScore(int channel, Band band, Floor floor, IReadOnlyList allFloors) + { + double score = 0; + + foreach (var otherFloor in allFloors) + { + bool sameFloor = otherFloor.Id == floor.Id; + int levelDifference = Math.Abs(floor.Level - otherFloor.Level); + if (sameFloor || levelDifference == 0 || floor.IsOutdoor || otherFloor.IsOutdoor) + { + continue; + } + + foreach (var accessPoint in otherFloor.AccessPoints) + { + if (accessPoint.Radios.TryGetValue(band, out var radio) && radio.Channel == channel) + { + double leakedSignalDbm = radio.TransmitPowerDbm - (InterFloorAttenuationDbPerLevel * levelDifference); + score += Math.Pow(10, leakedSignalDbm / 10.0); + } + } + } + + return score; + } + private static double CoverageFraction(bool[] covered) => covered.Length == 0 ? 1.0 : covered.Count(c => c) / (double)covered.Length; } diff --git a/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs b/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs index f6cd597..f48cdd7 100644 --- a/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs +++ b/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs @@ -5,6 +5,7 @@ namespace Pulsemap.App.Core.Placement; public interface IApPlacementOptimizer { - /// Suggests AP count and placement for a floor. Always a starting point — every result is user-overridable, never authoritative. - IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList bands, IPropagationModel propagationModel); + /// Suggests AP count and placement for a floor. Always a starting point — every result is user-overridable, never authoritative. + /// lets channel selection avoid reusing a channel a nearby floor's AP already has — placement itself stays per-floor-only (see GreedyCoverageApPlacementOptimizer's remarks). + IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList allFloors, IReadOnlyList bands, IPropagationModel propagationModel); } diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index 0ba50d5..40e78d3 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -569,7 +569,7 @@ private async Task SuggestPlacementsAsync() } SelectedFloor.AccessPoints.RemoveAll(ap => !ap.IsUserOverride); - var suggestions = _placementOptimizer.SuggestPlacements(SelectedFloor, Survey.TargetBands, _propagationModel); + var suggestions = _placementOptimizer.SuggestPlacements(SelectedFloor, Survey.Floors, Survey.TargetBands, _propagationModel); SelectedFloor.AccessPoints.AddRange(suggestions); await SaveAndRefreshAsync(); } diff --git a/tests/Pulsemap.App.Core.Tests/Placement/GreedyCoverageApPlacementOptimizerTests.cs b/tests/Pulsemap.App.Core.Tests/Placement/GreedyCoverageApPlacementOptimizerTests.cs index aa21ccc..c767177 100644 --- a/tests/Pulsemap.App.Core.Tests/Placement/GreedyCoverageApPlacementOptimizerTests.cs +++ b/tests/Pulsemap.App.Core.Tests/Placement/GreedyCoverageApPlacementOptimizerTests.cs @@ -15,7 +15,7 @@ public void SuggestPlacements_SingleSmallRoom_SuggestsExactlyOneAp() { var floor = SquareRoomFloor(sizeMeters: 10); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Single(placements); } @@ -23,7 +23,9 @@ public void SuggestPlacements_SingleSmallRoom_SuggestsExactlyOneAp() [Fact] public void SuggestPlacements_ThreeConcreteWalledRoomsAtSixGhz_SuggestsMoreThanOneAp() { - var placements = _sut.SuggestPlacements(ThreeConcreteWalledRoomsFloor(), [Band.SixGhz], _propagationModel); + var floor = ThreeConcreteWalledRoomsFloor(); + + var placements = _sut.SuggestPlacements(floor, [floor], [Band.SixGhz], _propagationModel); Assert.True(placements.Count > 1, $"Expected more than one AP across three concrete-divided rooms at 6GHz, got {placements.Count}."); } @@ -34,7 +36,7 @@ public void SuggestPlacements_MultipleBandsRequested_EveryPlacementHasRadiosForE var floor = SquareRoomFloor(sizeMeters: 10); Band[] bands = [Band.TwoPointFourGhz, Band.FiveGhz, Band.SixGhz]; - var placements = _sut.SuggestPlacements(floor, bands, _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], bands, _propagationModel); Assert.NotEmpty(placements); foreach (var ap in placements) @@ -49,7 +51,9 @@ public void SuggestPlacements_MultipleBandsRequested_EveryPlacementHasRadiosForE [Fact] public void SuggestPlacements_MultipleAps_AssignsDistinctChannelsPerBand() { - var placements = _sut.SuggestPlacements(ThreeConcreteWalledRoomsFloor(), [Band.SixGhz], _propagationModel); + var floor = ThreeConcreteWalledRoomsFloor(); + + var placements = _sut.SuggestPlacements(floor, [floor], [Band.SixGhz], _propagationModel); Assert.True(placements.Count > 1, "Test requires more than one AP to be meaningful."); var channels = placements.Select(ap => ap.Radios[Band.SixGhz].Channel).ToList(); @@ -61,7 +65,7 @@ public void SuggestPlacements_NoWalls_ReturnsEmpty() { var floor = new Floor { PlanSource = new RoomListSource() }; - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Empty(placements); } @@ -82,7 +86,7 @@ public void SuggestPlacements_ChannelWithMeasuredInterference_IsDeprioritized() ], }); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Single(placements); Assert.NotEqual(1, placements[0].Radios[Band.TwoPointFourGhz].Channel); @@ -93,7 +97,7 @@ public void SuggestPlacements_NoMeasurements_KeepsDefaultChannelOrder() { var floor = SquareRoomFloor(sizeMeters: 10); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Single(placements); Assert.Equal(1, placements[0].Radios[Band.TwoPointFourGhz].Channel); @@ -104,7 +108,7 @@ public void SuggestPlacements_NoInterference_LargeRoomStillCoveredByOneAp() { var floor = SquareRoomFloor(sizeMeters: 20); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Single(placements); } @@ -122,7 +126,7 @@ public void SuggestPlacements_StrongMeasuredInterferenceInFarCorner_RequiresMore ], }); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.True(placements.Count > 1, $"Expected the SINR-adjusted threshold near strong interference to force a second AP, got {placements.Count}."); } @@ -140,7 +144,7 @@ public void SuggestPlacements_MeasurementFarFromCandidateArea_DoesNotAffectPlace ], }); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Single(placements); } @@ -158,9 +162,75 @@ public void SuggestPlacements_NearbyMeasurementOnDifferentBand_DoesNotAffectPlac ], }); - var placements = _sut.SuggestPlacements(floor, [Band.TwoPointFourGhz], _propagationModel); + var placements = _sut.SuggestPlacements(floor, [floor], [Band.TwoPointFourGhz], _propagationModel); + + Assert.Single(placements); + } + + [Fact] + public void SuggestPlacements_ChannelUsedByAdjacentFloorAp_IsDeprioritized() + { + var floorBelow = SquareRoomFloor(sizeMeters: 10); + floorBelow.Level = 0; + floorBelow.AccessPoints.Add(new AccessPoint + { + Position = new Point2D(5, 5), + Label = "Existing AP", + Radios = { [Band.TwoPointFourGhz] = new BandRadioSettings { Channel = 1, TransmitPowerDbm = 17 } }, + }); + var floorAbove = SquareRoomFloor(sizeMeters: 10); + floorAbove.Level = 1; + + var placements = _sut.SuggestPlacements(floorAbove, [floorBelow, floorAbove], [Band.TwoPointFourGhz], _propagationModel); + + Assert.Single(placements); + Assert.NotEqual(1, placements[0].Radios[Band.TwoPointFourGhz].Channel); + } + + [Fact] + public void SuggestPlacements_SameLevelDifferentFloor_DoesNotAffectChannelChoice() + { + var otherFloor = SquareRoomFloor(sizeMeters: 10); + otherFloor.Level = 0; + otherFloor.AccessPoints.Add(new AccessPoint + { + Position = new Point2D(5, 5), + Label = "Existing AP", + Radios = { [Band.TwoPointFourGhz] = new BandRadioSettings { Channel = 1, TransmitPowerDbm = 17 } }, + }); + var floor = SquareRoomFloor(sizeMeters: 10); + floor.Level = 0; + + var placements = _sut.SuggestPlacements(floor, [otherFloor, floor], [Band.TwoPointFourGhz], _propagationModel); Assert.Single(placements); + Assert.Equal(1, placements[0].Radios[Band.TwoPointFourGhz].Channel); + } + + [Fact] + public void SuggestPlacements_AdjacentFloorIsOutdoor_DoesNotAffectChannelChoice() + { + var outdoorFloor = new Floor + { + PlanSource = new RoomListSource(), + IsOutdoor = true, + Level = 0, + OutdoorBoundsMin = new Point2D(0, 0), + OutdoorBoundsMax = new Point2D(10, 10), + AccessPoints = { new AccessPoint + { + Position = new Point2D(5, 5), + Label = "Existing AP", + Radios = { [Band.TwoPointFourGhz] = new BandRadioSettings { Channel = 1, TransmitPowerDbm = 17 } }, + } }, + }; + var floor = SquareRoomFloor(sizeMeters: 10); + floor.Level = 1; + + var placements = _sut.SuggestPlacements(floor, [outdoorFloor, floor], [Band.TwoPointFourGhz], _propagationModel); + + Assert.Single(placements); + Assert.Equal(1, placements[0].Radios[Band.TwoPointFourGhz].Channel); } // Three 10x10m rooms in a row, separated by concrete dividing walls — enough attenuation at diff --git a/tests/Pulsemap.App.Tests/Fakes/FakeApPlacementOptimizer.cs b/tests/Pulsemap.App.Tests/Fakes/FakeApPlacementOptimizer.cs index 41023ce..e579c07 100644 --- a/tests/Pulsemap.App.Tests/Fakes/FakeApPlacementOptimizer.cs +++ b/tests/Pulsemap.App.Tests/Fakes/FakeApPlacementOptimizer.cs @@ -8,6 +8,6 @@ internal sealed class FakeApPlacementOptimizer : IApPlacementOptimizer { public IReadOnlyList PlacementsToReturn { get; set; } = []; - public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList bands, IPropagationModel propagationModel) => + public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList allFloors, IReadOnlyList bands, IPropagationModel propagationModel) => PlacementsToReturn; } From 9cfcc65bdc61521d1da432035d40ede001c37fdb Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 10:51:33 +0200 Subject: [PATCH 05/10] feat(workspace): drag-to-resize/reposition outdoor area bounds Outdoor areas previously locked in at their initial 40x40m default with no way to adjust from the UI. The Select tool now shows a resize handle at the outdoor rectangle's far corner and lets you drag its body to reposition, without disturbing normal test-point and access-point selection inside the area. --- CLAUDE.md | 1 + .../Controls/FloorPlanCanvas.xaml | 8 +- .../Controls/FloorPlanCanvas.xaml.cs | 173 +++++++++++++++++- .../ViewModels/WorkspaceViewModel.cs | 17 +- src/Pulsemap.App/Views/WorkspacePage.xaml.cs | 1 + .../ViewModels/WorkspaceViewModelTests.cs | 25 +++ 6 files changed, 219 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5141a8f..88cad46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,5 +54,6 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle - **Surveys library page (added 2026-09-01, DONE):** the `NavSurveys` ("Badania") nav tab was a dead stub (`// Not built yet`) — clicking it did nothing. Home previously showed every survey with no cap, so there was nothing left for a dedicated Surveys page to add; per a product decision, Home is now a dashboard (greeting + New Survey CTA + the 3 most recent surveys only, `HomeViewModel.RecentSurveysLimit`), and the new `SurveysPage`/`SurveysViewModel` owns the full, unlimited list plus rename and delete (each behind a `ContentDialog` confirm — this app's first use of `ContentDialog`). Rename changes `Survey.Name` in the persisted file in place via the existing `ISurveyFileService.SaveAsync` — it does not touch the filename on disk, which was never derived from the display name after creation anyway. `ISurveyLibraryService` gained `DeleteAsync`/`RenameAsync`. Per-card Rename/Delete buttons use `Tag="{x:Bind}"` + a code-behind `Click` handler rather than binding `Command` directly inside the `GridView`'s `DataTemplate`, per the documented nested-`DataTemplate`-binding XAML compiler quirk above. - **Multi-floor, outdoor areas, historical comparison, adaptive Kriging suggestion (added 2026-09-02, DONE):** pulled forward from README's Phase 2 at the user's request (Linux/winget stayed deferred). `Survey.Floor` (singular) is now `Survey.Floors` (`List`) — `Floor` gained `Id`/`Name`/`IsOutdoor`/`Level`/`OutdoorBoundsMin`/`OutdoorBoundsMax`, none `required`, all self-defaulting, so a v1 `"Floor": {...}` object deserializes straight into the current `Floor` type with zero migration DTO needed. `ZipSurveyFileService.LoadAsync` pre-parses `SchemaVersion` via `JsonDocument` and branches — v1 files get their singular floor lifted into `Floors: [floor]`; no exception-driven control flow. **Outdoor areas** (confirmed with the user: same local meter grid as indoor, no real GPS/map) reuse the existing engine almost entirely: `FloorGrid.BuildPoints` falls back to an explicit `OutdoorBoundsMin`/`Max` rectangle when `Walls` is empty and `IsOutdoor`, and since `IPropagationModel.PredictSignalDbm` already took a bare wall list (not a `Floor`), an outdoor call with `Walls` empty already produces pure free-space loss with no propagation-formula changes at all. **Inter-floor propagation** is a deliberate simplification, not full 3D: floors are assumed to stack at the same local X/Y origin, and `CoverageGridCalculator.ComputeGrid` (now takes the full `Floors` list alongside the target floor) applies a flat `InterFloorAttenuationDbPerLevel` (25dB) penalty per level difference for another floor's APs, skipping outdoor areas and same-level-different-floor pairs entirely — AP *placement* stays per-floor-only this pass (no joint cross-floor optimizer), matching how the earlier SINR-interference feature scoped its own harder joint-design piece out. **Adaptive Kriging suggestion**: `IKrigingInterpolator` gained `InterpolateVariance` (ordinary kriging's own estimation-variance side-product, reusing the existing LU factorization — `IKrigingInterpolator` had zero production callers before this, so the extension was purely additive); `MeasurementPointSuggester.SuggestPoints` gained a `(Floor, Band, IKrigingInterpolator)` overload that reorders (doesn't replace) the same candidate list by descending uncertainty once ≥2 same-band measurements exist, falling straight back to the old grid-scan order otherwise. **Snapshots**: `SurveySnapshot` freezes a deep copy of `Floors`' walls/test points/access points (not plan images — those stay live, `[JsonIgnore]`d either way) inside the same `survey.json`; heatmaps for a snapshot are always recomputed live via the same `CoverageGridCalculator`, never cached. New Workspace UI: a floor/zone switcher `ComboBox` in the toolbar row (deliberately not a 4th Pivot tab — the header-clipping bug above is confirmed still live, exactly 3 tabs in a fixed 440px column), "+ Add Floor" (name + outdoor checkbox, one `ContentDialog`), "Save Snapshot" (label, another `ContentDialog`), and "Compare" opening a new `SnapshotComparisonPage` (two independently-pickable "Current or a saved snapshot" sides, a floor picker, two `FloorPlanCanvas` instances side-by-side — not a from-scratch delta-heatmap renderer). The wizard itself is untouched — still creates exactly one `Floor`; adding more floors/areas is a Workspace-only action. 135 tests now (69 Core/66 App), Core branch coverage 87.94%. **Not yet verified live**: the user's real existing survey file loading correctly after the schema migration — attempted via the running app but the session locked mid-check; needs a manual pass. - **Live manual QA pass (2026-09-01):** the app's own developer did a real click-through (not automation) and found two real bugs, both fixed same-day: (1) the dead Surveys tab above; (2) Settings' "Open Logs Folder" button silently did nothing on a fresh install, because `FileAppLogger` only creates its log directory lazily on first write, and `Launcher.LaunchFolderPathAsync` fails on a path that doesn't exist yet — fixed at the call site (`SettingsViewModel.OpenLogsFolderAsync` now `Directory.CreateDirectory`s before launching) rather than inside `FileAppLogger` itself, since creating the directory eagerly in `FileAppLogger`'s constructor would have broken an existing `FileAppLoggerTests` case that deliberately blocks the log path with a plain file to prove the logger degrades gracefully when its directory can't be created. The floor-plan image/PDF rendering and the 4 Export buttons weren't specifically called out as broken or working in this pass — still not explicitly confirmed either way, unlike the two bugs above. +- **Outdoor-area bounds editor (added 2026-09-02, on branch `feature/outdoor-bounds-editor`, not yet merged to staging):** outdoor areas previously had no way to resize/reposition their extent from the UI — a new floor always got a fixed 40x40m default with no follow-up editor. `FloorPlanCanvas` now draws an outdoor floor's bounds as a dashed rectangle with one resize handle at its far corner (deliberately one anchored corner + one dragged corner, not four independent handles — the smallest interaction that still covers both resize and reposition); dragging inside the rectangle moves the whole area instead. A click first checks for an existing test point/AP within the usual 0.5m tolerance so precise clicks on markers placed inside the area still work — only a click that misses every marker starts a bounds drag. Drag state and a live preview stay canvas-local (mirrors the "canvas owns rendering/gesture recognition only" rule already documented on `FloorPlanCanvas`); only on release does a new `OutdoorBoundsChanged` event fire, which `WorkspaceViewModel.UpdateOutdoorBoundsAsync` persists to `Floor.OutdoorBoundsMin`/`Max`. - **Per-wall material editing and guided-walk improvements (added 2026-09-02, on branch `feature/wall-material-and-guided-walk`, not yet merged to staging):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). - **In-app update check (added 2026-09-02, on branch `feature/update-check-notify`, not yet merged to staging — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml index 06da252..bf536b2 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml @@ -8,7 +8,13 @@ mc:Ignorable="d"> - + diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs index 9845a85..9fbbfec 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs @@ -30,6 +30,9 @@ public sealed partial class FloorPlanCanvas : UserControl private const double WalkTargetRadiusPx = 14; private const double WallStrokeThicknessPx = 4; private const double HeatmapOpacity = 0.55; + private const double OutdoorResizeHandleRadiusPx = 8; + private const double OutdoorHitToleranceMeters = 0.5; // matches WorkspaceViewModel's own DeleteHitToleranceMeters + private const double MinOutdoorSizeMeters = 2; private readonly FloorPlanImageCache _imageCache; @@ -40,6 +43,28 @@ public sealed partial class FloorPlanCanvas : UserControl private double _backgroundHeightMeters; private int _renderVersion; + // Outdoor-bounds drag state — resize/move only ever needs one anchored corner (Min) and one + // dragged corner (Max) rather than four independent handles, the smallest interaction that + // still covers "resize" and "reposition" without a full multi-handle editor. + private OutdoorDragMode _outdoorDragMode = OutdoorDragMode.None; + private Point2D _dragStartPointerMeters; + private Point2D _dragStartMin; + private Point2D _dragStartMax; + private Point2D? _previewBoundsMin; + private Point2D? _previewBoundsMax; + private Floor? _lastFloor; + private IReadOnlyList _lastHeatmap = []; + private Point2D? _lastWalkTarget; + private IReadOnlyCollection? _lastSelectedWalls; + private IReadOnlyList? _lastRemainingWalkPoints; + + private enum OutdoorDragMode + { + None, + Move, + Resize, + } + public FloorPlanCanvas() { InitializeComponent(); @@ -56,6 +81,8 @@ public FloorPlanCanvas() public event EventHandler? WallSelectRequested; + public event EventHandler<(Point2D Min, Point2D Max)>? OutdoorBoundsChanged; + /// /// Renders walls/points/heatmap immediately, plus (for an image-style floor plan) the /// background image/PDF once it's decoded — decoding only happens the first time a given @@ -147,6 +174,12 @@ private void RenderCore( IReadOnlyCollection? selectedWalls, IReadOnlyList? remainingWalkPoints) { + _lastFloor = floor; + _lastHeatmap = heatmap; + _lastWalkTarget = walkTarget; + _lastSelectedWalls = selectedWalls; + _lastRemainingWalkPoints = remainingWalkPoints; + _bounds = ComputeBounds(floor); RootGrid.Width = _bounds.WidthMeters * PixelsPerMeter; RootGrid.Height = _bounds.HeightMeters * PixelsPerMeter; @@ -172,6 +205,14 @@ private void RenderCore( WallsLayer.Children.Add(BuildWallLine(wall, selectedWalls?.Contains(wall) == true)); } + if (floor.IsOutdoor + && (_previewBoundsMin ?? floor.OutdoorBoundsMin) is { } boundsMin + && (_previewBoundsMax ?? floor.OutdoorBoundsMax) is { } boundsMax) + { + MarkersLayer.Children.Add(BuildOutdoorBoundsRectangle(boundsMin, boundsMax)); + MarkersLayer.Children.Add(BuildOutdoorResizeHandle(boundsMax)); + } + foreach (var testPoint in floor.TestPoints) { MarkersLayer.Children.Add(BuildTestPointMarker(testPoint)); @@ -294,6 +335,41 @@ private Ellipse BuildRemainingWalkPointMarker(Point2D position) return marker; } + private Rectangle BuildOutdoorBoundsRectangle(Point2D min, Point2D max) + { + var (x1, y1) = ToPixels(min); + var (x2, y2) = ToPixels(max); + var rectangle = new Rectangle + { + Width = Math.Abs(x2 - x1), + Height = Math.Abs(y2 - y1), + Stroke = new SolidColorBrush(Colors.SeaGreen), + StrokeThickness = 2, + StrokeDashArray = [6, 3], + Fill = new SolidColorBrush(Color.FromArgb(24, 46, 139, 87)), + }; + Canvas.SetLeft(rectangle, Math.Min(x1, x2)); + Canvas.SetTop(rectangle, Math.Min(y1, y2)); + return rectangle; + } + + private Ellipse BuildOutdoorResizeHandle(Point2D max) + { + var (px, py) = ToPixels(max); + double diameter = OutdoorResizeHandleRadiusPx * 2; + var handle = new Ellipse + { + Width = diameter, + Height = diameter, + Fill = new SolidColorBrush(Colors.SeaGreen), + Stroke = new SolidColorBrush(Colors.White), + StrokeThickness = 2, + }; + Canvas.SetLeft(handle, px - OutdoorResizeHandleRadiusPx); + Canvas.SetTop(handle, py - OutdoorResizeHandleRadiusPx); + return handle; + } + private static Color HeatmapColor(double signalDbm) => signalDbm switch { >= -50 => Colors.Green, @@ -332,6 +408,12 @@ private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e) break; case WorkspaceTool.Select: + if (TryStartOutdoorBoundsDrag(position, meters)) + { + RootGrid.CapturePointer(e.Pointer); + break; + } + WallSelectRequested?.Invoke(this, meters); break; @@ -340,6 +422,90 @@ private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e) } } + // A click on the resize handle or inside the rectangle body starts a drag instead of firing + // WallSelectRequested — but only when nothing selectable (test point/AP) is under the click, + // so precise clicks on markers placed inside an outdoor area still work as before. + private bool TryStartOutdoorBoundsDrag(Windows.Foundation.Point position, Point2D meters) + { + if (_lastFloor is not { IsOutdoor: true } floor + || (_previewBoundsMin ?? floor.OutdoorBoundsMin) is not { } min + || (_previewBoundsMax ?? floor.OutdoorBoundsMax) is not { } max + || IsNearExistingMarker(floor, meters)) + { + return false; + } + + var (maxPx, maxPy) = ToPixels(max); + double handleDx = position.X - maxPx; + double handleDy = position.Y - maxPy; + bool onHandle = Math.Sqrt((handleDx * handleDx) + (handleDy * handleDy)) <= OutdoorResizeHandleRadiusPx; + bool insideBody = meters.X >= min.X && meters.X <= max.X && meters.Y >= min.Y && meters.Y <= max.Y; + + if (!onHandle && !insideBody) + { + return false; + } + + _outdoorDragMode = onHandle ? OutdoorDragMode.Resize : OutdoorDragMode.Move; + _dragStartPointerMeters = meters; + _dragStartMin = min; + _dragStartMax = max; + return true; + } + + private static bool IsNearExistingMarker(Floor floor, Point2D at) => + floor.TestPoints.Any(tp => tp.Position.DistanceTo(at) <= OutdoorHitToleranceMeters) || + floor.AccessPoints.Any(ap => ap.Position.DistanceTo(at) <= OutdoorHitToleranceMeters); + + private void RootGrid_PointerMoved(object sender, PointerRoutedEventArgs e) + { + if (_outdoorDragMode == OutdoorDragMode.None || _lastFloor is null) + { + return; + } + + var meters = ToMeters(e.GetCurrentPoint(RootGrid).Position); + double deltaX = meters.X - _dragStartPointerMeters.X; + double deltaY = meters.Y - _dragStartPointerMeters.Y; + + if (_outdoorDragMode == OutdoorDragMode.Move) + { + _previewBoundsMin = new Point2D(_dragStartMin.X + deltaX, _dragStartMin.Y + deltaY); + _previewBoundsMax = new Point2D(_dragStartMax.X + deltaX, _dragStartMax.Y + deltaY); + } + else + { + _previewBoundsMin = _dragStartMin; + _previewBoundsMax = new Point2D( + Math.Max(_dragStartMin.X + MinOutdoorSizeMeters, _dragStartMax.X + deltaX), + Math.Max(_dragStartMin.Y + MinOutdoorSizeMeters, _dragStartMax.Y + deltaY)); + } + + RenderCore(_lastFloor, _lastHeatmap, _lastWalkTarget, _lastSelectedWalls, _lastRemainingWalkPoints); + } + + private void RootGrid_PointerReleased(object sender, PointerRoutedEventArgs e) => EndOutdoorBoundsDrag(raiseChanged: true); + + private void RootGrid_PointerCaptureLost(object sender, PointerRoutedEventArgs e) => EndOutdoorBoundsDrag(raiseChanged: false); + + private void EndOutdoorBoundsDrag(bool raiseChanged) + { + if (_outdoorDragMode == OutdoorDragMode.None) + { + return; + } + + _outdoorDragMode = OutdoorDragMode.None; + + if (raiseChanged && _previewBoundsMin is { } min && _previewBoundsMax is { } max) + { + OutdoorBoundsChanged?.Invoke(this, (min, max)); + } + + _previewBoundsMin = null; + _previewBoundsMax = null; + } + private (double X, double Y) ToPixels(Point2D meters) => ((meters.X - _bounds.MinX) * PixelsPerMeter, (meters.Y - _bounds.MinY) * PixelsPerMeter); @@ -382,8 +548,11 @@ private Bounds ComputeBounds(Floor floor) } // An outdoor area has no walls to size the canvas from — same explicit-bounds fallback - // FloorGrid uses for its candidate grid. - if (floor.IsOutdoor && floor.OutdoorBoundsMin is { } outdoorMin && floor.OutdoorBoundsMax is { } outdoorMax) + // FloorGrid uses for its candidate grid. Prefers the live drag preview (if any) so the + // canvas doesn't clip a rectangle being resized/moved past its last-committed extent. + if (floor.IsOutdoor + && (_previewBoundsMin ?? floor.OutdoorBoundsMin) is { } outdoorMin + && (_previewBoundsMax ?? floor.OutdoorBoundsMax) is { } outdoorMax) { xs.Add(outdoorMin.X); xs.Add(outdoorMax.X); diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index 0ba50d5..268ad6f 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -28,9 +28,8 @@ public partial class WorkspaceViewModel : ObservableObject private const double HeatmapGridSpacingMeters = 0.5; private const double DeleteHitToleranceMeters = 0.5; - // A new outdoor area's extent, since there's no dedicated bounds-editing UI yet — the user can - // redraw/relocate what it covers by editing OutdoorBoundsMin/Max later if this default doesn't - // fit (e.g. via re-export/re-import), a known limitation of this first pass at outdoor areas. + // A new outdoor area's starting extent — resizable/movable afterward via the canvas's drag + // handle (see FloorPlanCanvas/UpdateOutdoorBoundsAsync), so this is just a reasonable default. private const double DefaultOutdoorBoundsSizeMeters = 40; private readonly ISurveyFileService _surveyFileService; @@ -466,6 +465,18 @@ public async Task AddWallAsync(Point2D start, Point2D end) await SaveAndRefreshAsync(); } + public async Task UpdateOutdoorBoundsAsync(Point2D min, Point2D max) + { + if (SelectedFloor is not { IsOutdoor: true } floor) + { + return; + } + + floor.OutdoorBoundsMin = min; + floor.OutdoorBoundsMax = max; + await SaveAndRefreshAsync(); + } + public async Task DeleteNearestElementAsync(Point2D at) { if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove)) diff --git a/src/Pulsemap.App/Views/WorkspacePage.xaml.cs b/src/Pulsemap.App/Views/WorkspacePage.xaml.cs index 25f854e..b0a10dd 100644 --- a/src/Pulsemap.App/Views/WorkspacePage.xaml.cs +++ b/src/Pulsemap.App/Views/WorkspacePage.xaml.cs @@ -27,6 +27,7 @@ public WorkspacePage() PlanCanvas.WallRequested += async (_, span) => await ViewModel.AddWallAsync(span.Start, span.End); PlanCanvas.DeleteRequested += async (_, position) => await ViewModel.DeleteNearestElementAsync(position); PlanCanvas.WallSelectRequested += async (_, position) => await OnSelectClickAsync(position); + PlanCanvas.OutdoorBoundsChanged += async (_, bounds) => await ViewModel.UpdateOutdoorBoundsAsync(bounds.Min, bounds.Max); SelectToolButton.IsChecked = true; } diff --git a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs index 99781d9..3020f66 100644 --- a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs +++ b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs @@ -101,6 +101,31 @@ public async Task AddWallAsync_AddsWallAndSavesSurvey() Assert.Single(_surveyFileService.SaveCalls); } + [Fact] + public async Task UpdateOutdoorBoundsAsync_OutdoorFloor_UpdatesBoundsAndSaves() + { + var floor = new Floor { PlanSource = new RoomListSource(), IsOutdoor = true, OutdoorBoundsMin = new Point2D(0, 0), OutdoorBoundsMax = new Point2D(40, 40) }; + var sut = await LoadedViewModelAsync(floor); + + await sut.UpdateOutdoorBoundsAsync(new Point2D(5, 5), new Point2D(25, 30)); + + Assert.Equal(new Point2D(5, 5), sut.SelectedFloor!.OutdoorBoundsMin); + Assert.Equal(new Point2D(25, 30), sut.SelectedFloor.OutdoorBoundsMax); + Assert.Single(_surveyFileService.SaveCalls); + } + + [Fact] + public async Task UpdateOutdoorBoundsAsync_IndoorFloor_DoesNothing() + { + var floor = SquareRoomFloor(10); + var sut = await LoadedViewModelAsync(floor); + + await sut.UpdateOutdoorBoundsAsync(new Point2D(5, 5), new Point2D(25, 30)); + + Assert.Null(sut.SelectedFloor!.OutdoorBoundsMin); + Assert.Empty(_surveyFileService.SaveCalls); + } + [Fact] public async Task DeleteNearestElementAsync_WithinTolerance_RemovesNearestTestPoint() { From 2fc361f753829e5532303fa020ab983cfd45dc72 Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 11:13:58 +0200 Subject: [PATCH 06/10] fix(workspace): replace Pivot with TabView to fix tab-header clipping Pivot silently clips overflowing headers into an unreachable scroll strip once its host column narrows even slightly, making a tab unclickable with no visible sign why. TabView doesn't have that failure mode. Same three tabs, same headers, no ViewModel changes. --- CLAUDE.md | 5 ++++- src/Pulsemap.App/Views/WorkspacePage.xaml | 20 ++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcd3c0c..2217495 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Pulsemap is developed by Dotify. It follows Dotify's internal engineering standa - **XAML compiler quirks (WindowsAppSDK.WinUI 2.3.6, observed 2026-09-01):** A standalone `.xaml` file whose root element is `ResourceDictionary` may fail with a cryptic `WMC9999 "cannot find Polish/neutral culture resources"` error — this is the XAML compiler's own error-reporting path breaking on a non-English OS locale, not a real problem with your markup, and it can mask a genuinely different underlying error. If you hit `WMC9999`, don't trust it as the real problem: temporarily move suspect files fully outside the project directory (renaming within the tree isn't enough — the SDK's XAML globbing is recursive and folder-name-independent) to bisect down to the actual cause. Concretely already found: (1) an extra `ResourceDictionary`-rooted file caused it once a `Page`-rooted file was *also* present and broken — inlining resources into `App.xaml` (which is `Application`-rooted and always compiled first) sidesteps it; (2) `{x:Bind Some.Method('literalArg')}` — the function-call-with-string-literal-argument form of x:Bind — is not supported by this compiler version and triggers the same masked error; use a computed property on the bound type instead; (3) `{x:Bind PageRoot.ViewModel.SomeCommand}` inside a nested `DataTemplate` — binding to an outer named element's property path from within a `DataTemplate`'s own `x:DataType` scope — also isn't supported here; fall back to a plain `Tag="{x:Bind}"` (binds the template's own DataContext, which *is* supported) plus a code-behind `Click` handler that calls the command directly. - **Related but distinct — a runtime (not compile-time) XAML crash:** declaring `IsChecked="True"` on a `ToggleButton` in markup throws `Microsoft.UI.Xaml.Markup.XamlParseException: Failed to assign to property 'Microsoft.UI.Xaml.Controls.Primitives.ToggleButton.IsChecked'` the moment that page is constructed (`LoadComponent`), silently killing the app since it happens inside `async void`/fire-and-forget navigation code with nothing to catch it. Same locale-masked-error family (the exception's primary message is unreadable Polish mojibake; only a secondary fallback line names the real property), but this one fires at `InitializeComponent()` time, not build time — so it won't show up until the page is actually navigated to. Fix: don't set `IsChecked` declaratively; set it in code-behind after `InitializeComponent()` instead (e.g. `SomeToggleButton.IsChecked = true;`). If a page like this crashes the app with no visible error, temporarily wire `Application.UnhandledException` to write `e.Exception.ToString()` to a file — the default unhandled-exception behavior here is silent process termination with nothing in the UI. - **`x:Bind TwoWay` on `TextBox.Text` defaults to `UpdateSourceTrigger=LostFocus`, not `PropertyChanged`.** This bit us for real: a wizard step's "Next" button stayed disabled after typing a value and clicking straight to "Next" without tabbing away first — the ViewModel property was still empty because the TextBox never lost focus to flush the binding. Any TwoWay-bound `TextBox.Text` that gates a `CanExecute` (or is read immediately after by other logic) needs `UpdateSourceTrigger=PropertyChanged` explicitly: `Text="{x:Bind ViewModel.Foo, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"`. `CheckBox.IsChecked`, `ComboBox.SelectedItem`, and `NumberBox.Value` all commit immediately on their own interaction and don't need this. -- **`Pivot` silently clips overflowing headers into a scroll strip** (small `‹›` chevrons) once header text is too wide for the host column — a clipped-away `PivotItem` is not click-reachable at its "logical" screen position even though `FindFirst`-by-name still returns a seemingly valid `BoundingRectangle` for it (UI Automation doesn't know it's visually clipped). If a Pivot has 3+ items in a narrow side panel, size the column generously and keep header text short, or the last tab(s) become effectively unreachable by mouse. +- **`Pivot` silently clips overflowing headers into a scroll strip** (small `‹›` chevrons) once header text is too wide for the host column — a clipped-away `PivotItem` is not click-reachable at its "logical" screen position even though `FindFirst`-by-name still returns a seemingly valid `BoundingRectangle` for it (UI Automation doesn't know it's visually clipped). If a Pivot has 3+ items in a narrow side panel, size the column generously and keep header text short, or the last tab(s) become effectively unreachable by mouse. **This app no longer uses `Pivot` anywhere** (replaced by `TabView` in the Workspace side panel, 2026-09-02, precisely to remove this failure mode) — kept here as a general WinUI note in case a future control choice reintroduces it. ## Non-negotiable, every change @@ -56,6 +56,9 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle - **Live manual QA pass (2026-09-01):** the app's own developer did a real click-through (not automation) and found two real bugs, both fixed same-day: (1) the dead Surveys tab above; (2) Settings' "Open Logs Folder" button silently did nothing on a fresh install, because `FileAppLogger` only creates its log directory lazily on first write, and `Launcher.LaunchFolderPathAsync` fails on a path that doesn't exist yet — fixed at the call site (`SettingsViewModel.OpenLogsFolderAsync` now `Directory.CreateDirectory`s before launching) rather than inside `FileAppLogger` itself, since creating the directory eagerly in `FileAppLogger`'s constructor would have broken an existing `FileAppLoggerTests` case that deliberately blocks the log path with a plain file to prove the logger degrades gracefully when its directory can't be created. The floor-plan image/PDF rendering and the 4 Export buttons weren't specifically called out as broken or working in this pass — still not explicitly confirmed either way, unlike the two bugs above. - **Per-wall material editing and guided-walk improvements (added 2026-09-02, DONE, merged to staging via PR #8):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). - **In-app update check (added 2026-09-02, DONE, merged to staging via PR #7 — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. + +**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch: +- **Pivot → TabView migration (added 2026-09-02, on branch `feature/workspace-tabview-migration`, not yet merged to staging):** replaces `Pivot` with `TabView` (`IsAddTabButtonVisible="False"`, `IsClosable="False"` per tab) in the Workspace side panel — same 3 tabs (Coverage/Suggestions/Adapter), same `x:Uid`-driven `Header`s, no ViewModel changes, purely a control swap to eliminate the documented header-clipping failure mode for good rather than keep managing around it. Also removes the "deliberately not a 4th Pivot tab" constraint noted on the CSV/JSON/PDF export and multi-floor entries above — that specific limitation no longer applies, though nothing currently plans to add a 4th tab. - **Cross-floor channel coordination (added 2026-09-02, on branch `feature/cross-floor-channel-coordination`, not yet merged to staging):** `IApPlacementOptimizer.SuggestPlacements` gained an `allFloors` parameter (mirroring `CoverageGridCalculator.ComputeGrid`'s own `floor`+`allFloors` shape) so `GreedyCoverageApPlacementOptimizer`'s channel ranking can see channels already assigned to APs on other floors, not just this floor's own measured interference. Placement itself stays per-floor-only, per explicit scope decision — this is channel-assignment coordination, not a joint cross-floor optimizer. `CrossFloorChannelUsageScore` reuses `CoverageGridCalculator`'s same skip rules (same-level-different-floor and outdoor floors don't participate) and the same 25dB/level attenuation figure (duplicated as its own constant, not shared across layers — matches how `WorkspaceViewModel` already duplicates `ReliableCoverageThresholdDbm`), scored in the same linear-power units as the existing same-floor `MeasuredInterferenceScore` so the two combine meaningfully instead of one dominating on scale. Known, accepted limitation: since AP suggestion is still a per-floor, user-triggered action, a floor suggested *before* its neighbor won't retroactively pick up the neighbor's channels — re-running suggestions on the earlier floor afterward does. - **Outdoor-area bounds editor (added 2026-09-02, on branch `feature/outdoor-bounds-editor`, not yet merged to staging):** outdoor areas previously had no way to resize/reposition their extent from the UI — a new floor always got a fixed 40x40m default with no follow-up editor. `FloorPlanCanvas` now draws an outdoor floor's bounds as a dashed rectangle with one resize handle at its far corner (deliberately one anchored corner + one dragged corner, not four independent handles — the smallest interaction that still covers both resize and reposition); dragging inside the rectangle moves the whole area instead. A click first checks for an existing test point/AP within the usual 0.5m tolerance so precise clicks on markers placed inside the area still work — only a click that misses every marker starts a bounds drag. Drag state and a live preview stay canvas-local (mirrors the "canvas owns rendering/gesture recognition only" rule already documented on `FloorPlanCanvas`); only on release does a new `OutdoorBoundsChanged` event fire, which `WorkspaceViewModel.UpdateOutdoorBoundsAsync` persists to `Floor.OutdoorBoundsMin`/`Max`. - **Per-wall material editing and guided-walk improvements (added 2026-09-02, on branch `feature/wall-material-and-guided-walk`, not yet merged to staging):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). diff --git a/src/Pulsemap.App/Views/WorkspacePage.xaml b/src/Pulsemap.App/Views/WorkspacePage.xaml index b84d300..af3073e 100644 --- a/src/Pulsemap.App/Views/WorkspacePage.xaml +++ b/src/Pulsemap.App/Views/WorkspacePage.xaml @@ -95,8 +95,12 @@ - - + + + @@ -127,9 +131,9 @@ FontSize="{StaticResource FontSizeXs}" Foreground="{StaticResource Gray400Brush}" /> - + - + public bool CheckForUpdatesEnabled { get; set; } = true; + + /// Whether the Workspace first-run coach-mark tour has been shown or skipped — + /// suppresses it on every later visit regardless of which survey is open. + public bool HasSeenWorkspaceOnboarding { get; set; } } diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 7645e93..01f2b9d 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -117,6 +117,14 @@ public string GetString(string key) ["WorkspaceRecaptureDialogCloseButton"] = "Cancel", ["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.", + ["HomeGreetingMorning"] = "Good morning", + ["HomeGreetingAfternoon"] = "Good afternoon", + ["HomeGreetingEvening"] = "Good evening", + ["HomeGreetingNight"] = "Hello", + ["HomeWellbeingMessage1"] = "Hope you're doing well today.", + ["HomeWellbeingMessage2"] = "Take a moment for yourself if you need one.", + ["HomeWellbeingMessage3"] = "However today's going, we hope it gets a little easier.", + ["HomeWellbeingMessage4"] = "Glad you're here. Take care of yourself out there.", }, ["pl-PL"] = new Dictionary { @@ -192,6 +200,14 @@ public string GetString(string key) ["WorkspaceRecaptureDialogCloseButton"] = "Anuluj", ["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.", + ["HomeGreetingMorning"] = "Dzień dobry", + ["HomeGreetingAfternoon"] = "Dzień dobry", + ["HomeGreetingEvening"] = "Dobry wieczór", + ["HomeGreetingNight"] = "Witaj", + ["HomeWellbeingMessage1"] = "Mamy nadzieję, że wszystko u Ciebie dobrze.", + ["HomeWellbeingMessage2"] = "Jeśli potrzebujesz chwili dla siebie, zrób sobie przerwę.", + ["HomeWellbeingMessage3"] = "Niezależnie od tego, jak mija dzisiejszy dzień, mamy nadzieję, że będzie choć trochę lżej.", + ["HomeWellbeingMessage4"] = "Miło, że tu jesteś. Zadbaj o siebie.", }, }; } diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw index 86cd2c0..cd43180 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -25,9 +25,6 @@ - - Home - Your surveys, all in one place. @@ -278,6 +275,84 @@ Delete + + Tool Help + + + What each tool does + + + Select + + + Click a wall to add it to a multi-select for batch material/thickness editing. Click a test point to rescan and replace its reading. On an outdoor area, drag its edge to resize or drag inside it to move the whole area. + + + Add Test Point + + + Click anywhere on the canvas to place a manual test point without doing a full guided walk. + + + Draw Wall + + + Click one endpoint, then click the other to draw a wall between them. + + + Delete + + + Click near a wall, test point, or access point to remove it. An Undo option appears right after, until your next delete. + + + Welcome to the Workspace + + + Select does different things depending on what you click: a wall, a test point, or an outdoor area's edge. The Tool Help button always explains this if you forget. + + + Next + + + Skip tour + + + Add Test Point + + + Click anywhere to drop a manual test point — or use the Adapter tab's guided walk to be led through suggested points instead. + + + Next + + + Skip tour + + + Draw Wall + + + Click one end, then the other, to draw a wall. Give it a material afterward with Select for a more accurate coverage prediction. + + + Next + + + Skip tour + + + Delete + + + Click near anything to remove it — an Undo option appears right after, so a misclick isn't permanent. + + + Got it + + + Skip tour + Back diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw index 5baa230..8b4b4f8 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -25,9 +25,6 @@ - - Strona główna - Wszystkie Twoje badania w jednym miejscu. @@ -278,6 +275,84 @@ Usuń + + Pomoc dot. narzędzi + + + Co robi każde narzędzie + + + Zaznacz + + + Kliknij ścianę, aby dodać ją do zaznaczenia wielokrotnego w celu zbiorczej edycji materiału/grubości. Kliknij punkt pomiarowy, aby ponownie zeskanować i zastąpić jego odczyt. W obszarze zewnętrznym przeciągnij krawędź, aby zmienić rozmiar, lub przeciągnij wnętrze, aby przesunąć cały obszar. + + + Dodaj punkt pomiarowy + + + Kliknij dowolne miejsce na płótnie, aby ręcznie umieścić punkt pomiarowy bez pełnego pomiaru terenowego. + + + Rysuj ścianę + + + Kliknij jeden koniec, a następnie drugi, aby narysować między nimi ścianę. + + + Usuń + + + Kliknij w pobliżu ściany, punktu pomiarowego lub punktu dostępowego, aby go usunąć. Zaraz potem pojawi się opcja cofnięcia, ważna do kolejnego usunięcia. + + + Witaj w Obszarze roboczym + + + Narzędzie Zaznacz robi różne rzeczy w zależności od tego, co klikniesz: ścianę, punkt pomiarowy lub krawędź obszaru zewnętrznego. Przycisk Pomoc dot. narzędzi zawsze to wyjaśni, gdybyś zapomniał. + + + Dalej + + + Pomiń + + + Dodaj punkt pomiarowy + + + Kliknij dowolne miejsce, aby umieścić ręczny punkt pomiarowy — albo skorzystaj z pomiaru terenowego w zakładce Adapter, aby zostać poprowadzonym przez sugerowane punkty. + + + Dalej + + + Pomiń + + + Rysuj ścianę + + + Kliknij jeden koniec, a potem drugi, aby narysować ścianę. Nadaj jej materiał później za pomocą Zaznacz, aby uzyskać dokładniejszą prognozę pokrycia. + + + Dalej + + + Pomiń + + + Usuń + + + Kliknij w pobliżu dowolnego elementu, aby go usunąć — zaraz potem pojawi się opcja cofnięcia, więc pomyłka nie jest trwała. + + + Rozumiem + + + Pomiń + Wstecz diff --git a/src/Pulsemap.App/ViewModels/HomeViewModel.cs b/src/Pulsemap.App/ViewModels/HomeViewModel.cs index 7d27ede..6bf3cdd 100644 --- a/src/Pulsemap.App/ViewModels/HomeViewModel.cs +++ b/src/Pulsemap.App/ViewModels/HomeViewModel.cs @@ -18,10 +18,31 @@ public partial class HomeViewModel( // Home is a dashboard, not the library — the Surveys nav tab shows the full, unlimited list. private const int RecentSurveysLimit = 3; + // A quiet, non-interactive touch — no reply expected or possible, just a small sign the app + // isn't purely transactional. Picked once per Home visit (HomeViewModel is transient, so a + // fresh navigation gets a fresh pick) rather than rotated on a timer or tracked across visits. + private static readonly string[] WellbeingMessageKeys = + [ + "HomeWellbeingMessage1", + "HomeWellbeingMessage2", + "HomeWellbeingMessage3", + "HomeWellbeingMessage4", + ]; + private string? _releaseUrl; public ObservableCollection Surveys { get; } = []; + public string GreetingDisplay => DateTime.Now.Hour switch + { + >= 5 and < 12 => localizationService.GetString("HomeGreetingMorning"), + >= 12 and < 17 => localizationService.GetString("HomeGreetingAfternoon"), + >= 17 and < 22 => localizationService.GetString("HomeGreetingEvening"), + _ => localizationService.GetString("HomeGreetingNight"), + }; + + public string WellbeingMessageDisplay { get; } = localizationService.GetString(WellbeingMessageKeys[Random.Shared.Next(WellbeingMessageKeys.Length)]); + [ObservableProperty] public partial bool IsLoading { get; set; } diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index fde20c0..9e222e6 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -11,6 +11,7 @@ using Pulsemap.App.Core.Persistence; using Pulsemap.App.Core.Placement; using Pulsemap.App.Core.Propagation; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; using Windows.System; @@ -42,6 +43,7 @@ public partial class WorkspaceViewModel : ObservableObject private readonly IReportExporter _reportExporter; private readonly ISurveyExportFilePickerService _exportFilePickerService; private readonly IKrigingInterpolator _krigingInterpolator; + private readonly IAppSettingsService _appSettingsService; private string? _filePath; @@ -55,7 +57,8 @@ public WorkspaceViewModel( ISurveyDataExporter surveyDataExporter, IReportExporter reportExporter, ISurveyExportFilePickerService exportFilePickerService, - IKrigingInterpolator krigingInterpolator) + IKrigingInterpolator krigingInterpolator, + IAppSettingsService appSettingsService) { _surveyFileService = surveyFileService; _propagationModel = propagationModel; @@ -67,6 +70,7 @@ public WorkspaceViewModel( _reportExporter = reportExporter; _exportFilePickerService = exportFilePickerService; _krigingInterpolator = krigingInterpolator; + _appSettingsService = appSettingsService; } public event EventHandler? FloorChanged; @@ -226,6 +230,19 @@ public string AccessPointSummaryDisplay public string WallSelectionCountDisplay => string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceWallSelectionCountFormat"), SelectedWallCount); + public async Task ShouldShowOnboardingAsync() + { + var settings = await _appSettingsService.LoadAsync(); + return !settings.HasSeenWorkspaceOnboarding; + } + + public async Task MarkOnboardingSeenAsync() + { + var settings = await _appSettingsService.LoadAsync(); + settings.HasSeenWorkspaceOnboarding = true; + await _appSettingsService.SaveAsync(settings); + } + public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default) { _filePath = filePath; diff --git a/src/Pulsemap.App/Views/HomePage.xaml b/src/Pulsemap.App/Views/HomePage.xaml index 882686b..fb92493 100644 --- a/src/Pulsemap.App/Views/HomePage.xaml +++ b/src/Pulsemap.App/Views/HomePage.xaml @@ -35,8 +35,9 @@ - + + + + + + + + + diff --git a/src/Pulsemap.App/Views/WorkspacePage.xaml.cs b/src/Pulsemap.App/Views/WorkspacePage.xaml.cs index b0a10dd..e6c6dc2 100644 --- a/src/Pulsemap.App/Views/WorkspacePage.xaml.cs +++ b/src/Pulsemap.App/Views/WorkspacePage.xaml.cs @@ -40,7 +40,42 @@ protected override async void OnNavigatedTo(NavigationEventArgs e) await ViewModel.LoadAsync(filePath); PopulateBandSelector(); await RenderCanvasAsync(); + + if (await ViewModel.ShouldShowOnboardingAsync()) + { + OnboardingStep1.IsOpen = true; + } + } + } + + private void OnboardingNext_Click(TeachingTip sender, object args) + { + sender.IsOpen = false; + + if (ReferenceEquals(sender, OnboardingStep1)) + { + OnboardingStep2.IsOpen = true; } + else if (ReferenceEquals(sender, OnboardingStep2)) + { + OnboardingStep3.IsOpen = true; + } + else if (ReferenceEquals(sender, OnboardingStep3)) + { + OnboardingStep4.IsOpen = true; + } + } + + private async void OnboardingFinish_Click(TeachingTip sender, object args) + { + sender.IsOpen = false; + await ViewModel.MarkOnboardingSeenAsync(); + } + + private async void OnboardingSkip_Click(TeachingTip sender, object args) + { + sender.IsOpen = false; + await ViewModel.MarkOnboardingSeenAsync(); } private async Task RenderCanvasAsync() diff --git a/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs index 8752534..7b2fd5d 100644 --- a/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs +++ b/tests/Pulsemap.App.Tests/ViewModels/HomeViewModelTests.cs @@ -98,4 +98,22 @@ public async Task LoadCommand_NoUpdateAvailable_DoesNotShowBanner() Assert.False(sut.HasUpdateAvailable); Assert.Null(sut.UpdateBannerMessage); } + + [Fact] + public void GreetingDisplay_IsNeverEmpty() + { + // Depends on the real current hour, so this asserts the switch always resolves to + // something rather than pinning an exact greeting string. + var sut = CreateSut(); + + Assert.False(string.IsNullOrWhiteSpace(sut.GreetingDisplay)); + } + + [Fact] + public void WellbeingMessageDisplay_PicksOneOfTheKnownMessages() + { + var sut = CreateSut(); + + Assert.StartsWith("HomeWellbeingMessage", sut.WellbeingMessageDisplay); + } } diff --git a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs index 3020f66..701bfe7 100644 --- a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs +++ b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs @@ -20,6 +20,7 @@ public sealed class WorkspaceViewModelTests private readonly FakeSurveyDataExporter _surveyDataExporter = new(); private readonly FakeReportExporter _reportExporter = new(); private readonly FakeSurveyExportFilePickerService _exportFilePickerService = new(); + private readonly FakeAppSettingsService _appSettingsService = new(); // Real (not faked) — pure/deterministic, no I/O, and every test here loads a floor with zero // existing TestPoints, so StartGuidedWalk's adaptive path never actually calls into this (falls @@ -37,7 +38,8 @@ public sealed class WorkspaceViewModelTests _surveyDataExporter, _reportExporter, _exportFilePickerService, - _krigingInterpolator); + _krigingInterpolator, + _appSettingsService); [Fact] public async Task LoadAsync_ValidSurvey_PopulatesSurveyAndBands() @@ -53,6 +55,35 @@ public async Task LoadAsync_ValidSurvey_PopulatesSurveyAndBands() Assert.Contains(Band.TwoPointFourGhz, sut.AvailableBands); } + [Fact] + public async Task ShouldShowOnboardingAsync_NotSeenBefore_ReturnsTrue() + { + _appSettingsService.SettingsToReturn = new Core.Settings.AppSettings { HasSeenWorkspaceOnboarding = false }; + var sut = CreateSut(); + + Assert.True(await sut.ShouldShowOnboardingAsync()); + } + + [Fact] + public async Task ShouldShowOnboardingAsync_AlreadySeen_ReturnsFalse() + { + _appSettingsService.SettingsToReturn = new Core.Settings.AppSettings { HasSeenWorkspaceOnboarding = true }; + var sut = CreateSut(); + + Assert.False(await sut.ShouldShowOnboardingAsync()); + } + + [Fact] + public async Task MarkOnboardingSeenAsync_PersistsFlagAndFutureCheckReturnsFalse() + { + var sut = CreateSut(); + + await sut.MarkOnboardingSeenAsync(); + + Assert.True(_appSettingsService.LastSaved?.HasSeenWorkspaceOnboarding); + Assert.False(await sut.ShouldShowOnboardingAsync()); + } + [Fact] public async Task LoadAsync_LoadThrowsIOException_SetsErrorMessage() { From 8a1bf0096242350c2a9316ab8c77fa63abcae260 Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 12:12:31 +0200 Subject: [PATCH 09/10] feat: WLAN link diagnostics, warmer Home messages, and a warmer Home Adds a standalone Diagnose page (live link state via a new WlanQueryInterface P/Invoke, local gateway/DNS health, and plain-language findings) plus a Workspace-integrated predicted-vs-actual comparison tool, so the app can help answer "why is WiFi slow" without connecting to any network device. Also expands Home's rotating wellbeing messages from 4 to 8. --- CLAUDE.md | 6 + .../Abstractions/ILinkDiagnosticsService.cs | 8 + .../Abstractions/INetworkHealthService.cs | 9 + .../Abstractions/LinkDiagnosticsSnapshot.cs | 20 ++ .../Abstractions/NetworkHealthSnapshot.cs | 10 + .../Diagnostics/DiagnosticFinding.cs | 7 + .../Diagnostics/DiagnosticSeverity.cs | 8 + .../Diagnostics/LinkDiagnosticsAnalyzer.cs | 118 ++++++++++ .../Propagation/CoverageGridCalculator.cs | 78 ++++--- src/Pulsemap.App/App.xaml.cs | 3 + .../Controls/FloorPlanCanvas.xaml.cs | 15 ++ src/Pulsemap.App/Controls/WorkspaceTool.cs | 1 + .../DiagnosticSeverityToBrushConverter.cs | 20 ++ src/Pulsemap.App/Interop/NativeWlan.cs | 118 +++++++++- src/Pulsemap.App/MainPage.xaml | 5 + src/Pulsemap.App/MainPage.xaml.cs | 3 + .../Services/LocalizationService.cs | 60 +++++ .../Services/NetworkHealthService.cs | 72 ++++++ .../Services/WlanLinkDiagnosticsService.cs | 139 +++++++++++ src/Pulsemap.App/Strings/en-US/Resources.resw | 33 +++ src/Pulsemap.App/Strings/pl-PL/Resources.resw | 33 +++ .../ViewModels/DiagnosticFindingDisplay.cs | 10 + .../ViewModels/DiagnosticsViewModel.cs | 219 ++++++++++++++++++ src/Pulsemap.App/ViewModels/HomeViewModel.cs | 4 + .../ViewModels/MonitoringSampleDisplay.cs | 6 + .../ViewModels/WorkspaceViewModel.cs | 45 +++- src/Pulsemap.App/Views/DiagnosticsPage.xaml | 89 +++++++ .../Views/DiagnosticsPage.xaml.cs | 29 +++ src/Pulsemap.App/Views/WorkspacePage.xaml | 5 + src/Pulsemap.App/Views/WorkspacePage.xaml.cs | 46 +++- .../LinkDiagnosticsAnalyzerTests.cs | 142 ++++++++++++ .../CoverageGridCalculatorTests.cs | 22 ++ .../Fakes/FakeLinkDiagnosticsService.cs | 16 ++ .../Fakes/FakeNetworkHealthService.cs | 11 + .../ViewModels/DiagnosticsViewModelTests.cs | 89 +++++++ .../ViewModels/WorkspaceViewModelTests.cs | 45 +++- 36 files changed, 1501 insertions(+), 43 deletions(-) create mode 100644 src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs create mode 100644 src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs create mode 100644 src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs create mode 100644 src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs create mode 100644 src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs create mode 100644 src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs create mode 100644 src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs create mode 100644 src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs create mode 100644 src/Pulsemap.App/Services/NetworkHealthService.cs create mode 100644 src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs create mode 100644 src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs create mode 100644 src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs create mode 100644 src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs create mode 100644 src/Pulsemap.App/Views/DiagnosticsPage.xaml create mode 100644 src/Pulsemap.App/Views/DiagnosticsPage.xaml.cs create mode 100644 tests/Pulsemap.App.Core.Tests/Diagnostics/LinkDiagnosticsAnalyzerTests.cs create mode 100644 tests/Pulsemap.App.Tests/Fakes/FakeLinkDiagnosticsService.cs create mode 100644 tests/Pulsemap.App.Tests/Fakes/FakeNetworkHealthService.cs create mode 100644 tests/Pulsemap.App.Tests/ViewModels/DiagnosticsViewModelTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 4708dd0..4ea1385 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,3 +67,9 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle **UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch: - **Workspace onboarding + tool legend, and a warmer Home (added 2026-09-02, on branch `feature/workspace-onboarding`, not yet merged to staging):** a "Tool Help" button (flyout, always available) now explains all four canvas tools including Select's three click-target-dependent behaviors. A skippable first-run coach-mark tour (`TeachingTip`, one per tool button, Next/Skip actions) walks a new user through them once — tracked via a new `AppSettings.HasSeenWorkspaceOnboarding` (same `IAppSettingsService` store the update-check feature added), `WorkspaceViewModel.ShouldShowOnboardingAsync`/`MarkOnboardingSeenAsync`. Separately, at the user's request, Home's static "Home" title is now a time-of-day greeting (`HomeViewModel.GreetingDisplay`, morning/afternoon/evening/late-night buckets on `DateTime.Now.Hour`) plus a small, non-interactive `WellbeingMessageDisplay` line picked at random from 4 options each visit — no reply affordance, just a quiet touch. The now-unused static "HomeTitle" resource was removed from both languages since this change made it dead. - **Undo last delete (added 2026-09-02, on branch `feature/undo-last-delete`, not yet merged to staging):** the Delete tool removed a wall/test point/AP permanently with zero confirmation or recovery — the single highest-risk unconfirmed action in the app. Deliberately not a full undo/redo stack (that would touch nearly every mutation in `WorkspaceViewModel`) — just single-level: `TryFindNearestElement` now also reports *what* it's about to remove, `DeleteNearestElementAsync` stashes `(Floor, object Item)` before removing, and a new `UndoDeleteCommand` re-adds it. An `InfoBar` with an Undo action button appears after a delete (`CanUndoDelete`/`LastDeletedItemDisplay`), overwritten by the next delete and cleared on floor switch so it never fires somewhere other than where the deletion happened. 5 new tests covering restore-after-delete for both a wall and a test point, overwrite-on-second-delete, and floor-switch clearing. + +**WLAN Link Diagnostics — "Swiss army knife" troubleshooting (added 2026-09-02, on branch `feature/diagnostics-and-polish`, not yet merged to staging):** per the user's own framing — "why on cable I have 100-150mbps, on wlan just a few KBPS" — a generic troubleshooting tool that never connects to or configures any network device, only reads this machine's own adapter state and does local checks (gateway ping, DNS timing), the same category of operation the existing WLAN scan already performs. Landed as one branch covering both halves the user asked for: +- **Core:** `LinkDiagnosticsSnapshot`/`NetworkHealthSnapshot` (Abstractions), `ILinkDiagnosticsService`/`INetworkHealthService` interfaces, and `Diagnostics/LinkDiagnosticsAnalyzer` — pure heuristics (weak signal by the same -67dBm-equivalent threshold `GreedyCoverageApPlacementOptimizer` already uses expressed on `wlanSignalQuality`'s percent scale, a legacy-PHY-rate fallback on 5/6GHz, high gateway ping, DNS failure/slowness, and — only when a predicted dBm is supplied — a predicted-vs-actual mismatch) fully unit tested with zero I/O. `CoverageGridCalculator`'s per-point inner loop was extracted (behavior-preserving) into a public `StrongestSignalDbm(point, floor, allFloors, band, propagationModel)` so both the coverage grid and Workspace's new single-point diagnostics comparison share the exact same cross-floor/outdoor skip rules and never disagree. +- **App, Phase 1 (standalone):** `NativeWlan` gained `WlanQueryInterface` plus the real `WLAN_CONNECTION_ATTRIBUTES`/`WLAN_ASSOCIATION_ATTRIBUTES`/`WLAN_SECURITY_ATTRIBUTES` structs and three small enums, struct layout and opcode values (`wlan_intf_opcode_current_connection` = 7, `wlan_intf_opcode_channel_number` = 8) verified live against Microsoft Learn's wlanapi.h reference rather than assumed, per this file's own documented discipline for this interop file; enum members are named without their native `wlan_..._` prefix (CA1712) with the native name kept in a same-line comment. `ulRxRate`/`ulTxRate`'s undocumented unit was confirmed as plain Kbps (not the 100Kbps unit some other wlanapi rate fields use) against the community `ManagedNativeWifi` wrapper's real-world-validated usage, since Microsoft's own docs state no unit at all. Band classification from a live connection is a documented best-effort: VHT/OFDM PHY types are unambiguous (5GHz-only in practice), other PHY types fall back to channel-number ranges that are only reliable outside 6GHz's numeric overlap with 2.4GHz's 1-14 range (real disambiguation would need a center frequency, which this opcode doesn't expose). New `WlanLinkDiagnosticsService`/`NetworkHealthService` (the latter pure `System.Net.NetworkInformation`, no native surface), `DiagnosticsViewModel` (adapter picker reusing `IWlanAdapterService`, a one-shot "Run diagnostics" plus a `PeriodicTimer`-based continuous-monitoring toggle, 5s interval), new `DiagnosticsPage` and a "Diagnose" nav item. Severity color-coding intentionally lives in a small `DiagnosticSeverityToBrushConverter` (resolving to App.xaml's `DangerBrush`/`WarningBrush`/`SuccessBrush` design tokens) rather than a computed `Brush` property on the ViewModel-layer display record — the first attempt did the latter and broke instantly in a plain xunit test with a null `Application.Current`, since nothing else in this app's ViewModel layer had ever touched a WinUI type directly. +- **App, Phase 2 (Workspace integration):** a 5th toolbar tool, `WorkspaceTool.Diagnose` (not folded into the already-overloaded Select tool, per the UX review's own finding about Select), fires `FloorPlanCanvas.DiagnosePositionRequested` on click; `WorkspaceViewModel.DiagnoseAtPointAsync` combines a live link+health read with `CoverageGridCalculator.StrongestSignalDbm` at that point and runs both through the same `LinkDiagnosticsAnalyzer`, surfaced as a `Flyout` anchored at the actual click position (`FloorPlanCanvas.ToScreenPoint`, a new small public wrapper around its existing private meters-to-pixels math) rather than a new docked panel, keeping the existing 3-tab side panel untouched. Tool Help legend gained a 5th entry. +- 104 App tests / 100 Core tests total on this branch, including the `StrongestSignalDbm` extraction's own regression test and full `LinkDiagnosticsAnalyzerTests` coverage. diff --git a/src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs b/src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs new file mode 100644 index 0000000..0c34a97 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs @@ -0,0 +1,8 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// Platform-specific live WLAN link state, implemented in Pulsemap.App via native +/// wlanapi.dll P/Invoke (WlanQueryInterface) — same split as . +public interface ILinkDiagnosticsService +{ + Task GetCurrentLinkAsync(Guid adapterId, CancellationToken cancellationToken = default); +} diff --git a/src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs b/src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs new file mode 100644 index 0000000..162ae1f --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs @@ -0,0 +1,9 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// Local network health checks (gateway ping, DNS timing) for the given adapter. Pure BCL +/// networking (System.Net.NetworkInformation) — no native P/Invoke needed, unlike the WLAN-specific +/// abstractions in this folder. +public interface INetworkHealthService +{ + Task CheckHealthAsync(Guid adapterId, CancellationToken cancellationToken = default); +} diff --git a/src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs b/src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs new file mode 100644 index 0000000..967aed1 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs @@ -0,0 +1,20 @@ +using Pulsemap.App.Core.Models; + +namespace Pulsemap.App.Core.Abstractions; + +/// A point-in-time reading of this machine's current WLAN association — live link state, +/// not a scan of nearby networks (that's ). / +/// are the negotiated PHY rate, not throughput actually achieved. +public sealed record LinkDiagnosticsSnapshot( + bool IsConnected, + string? Ssid, + string? Bssid, + Band? Band, + int Channel, + int SignalPercent, + string? PhyType, + double RxLinkSpeedMbps, + double TxLinkSpeedMbps) +{ + public static LinkDiagnosticsSnapshot Disconnected { get; } = new(false, null, null, null, 0, 0, null, 0, 0); +} diff --git a/src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs b/src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs new file mode 100644 index 0000000..bc826e2 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs @@ -0,0 +1,10 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// Local network health independent of the WLAN link itself — isolates "it's not your WiFi, +/// it's upstream" from link-quality problems. Null timing fields mean the corresponding check +/// couldn't run at all (no gateway found, no network path), distinct from a check that ran and +/// found a problem ( false, or a very high ping). +public sealed record NetworkHealthSnapshot(string? GatewayAddress, double? GatewayPingMs, double? DnsResolutionMs, bool DnsSucceeded) +{ + public static NetworkHealthSnapshot Unavailable { get; } = new(null, null, null, false); +} diff --git a/src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs b/src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs new file mode 100644 index 0000000..3ac06ce --- /dev/null +++ b/src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs @@ -0,0 +1,7 @@ +namespace Pulsemap.App.Core.Diagnostics; + +/// One troubleshooting observation. is a localization key, +/// not display text — are passed to the App layer's +/// string.Format/ILocalizationService call so this stays free of any UI/culture +/// concerns, matching how the rest of Core hands display strings back as keys. +public sealed record DiagnosticFinding(DiagnosticSeverity Severity, string MessageKey, IReadOnlyList? FormatArgs = null); diff --git a/src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs b/src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs new file mode 100644 index 0000000..75f265b --- /dev/null +++ b/src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs @@ -0,0 +1,8 @@ +namespace Pulsemap.App.Core.Diagnostics; + +public enum DiagnosticSeverity +{ + Info, + Warning, + Error, +} diff --git a/src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs b/src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs new file mode 100644 index 0000000..cb594fa --- /dev/null +++ b/src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs @@ -0,0 +1,118 @@ +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Models; + +namespace Pulsemap.App.Core.Diagnostics; + +/// Pure heuristics turning a link/network snapshot into plain-language troubleshooting +/// findings — no I/O, so this is where the "why is WiFi slow" reasoning actually lives and can be +/// fully unit tested, independent of the native WLAN calls that produce its inputs. +public static class LinkDiagnosticsAnalyzer +{ + // Same -67dBm "reliable data" convention as GreedyCoverageApPlacementOptimizer, expressed in + // WLAN_ASSOCIATION_ATTRIBUTES.wlanSignalQuality's percent scale via its own documented linear + // mapping (0% = -100dBm, 100% = -50dBm): percent = (dBm + 100) / 0.5. + private const int WeakSignalPercentThreshold = 66; + private const int VeryWeakSignalPercentThreshold = 33; + + // A link stuck at or below this negotiated rate on 5GHz/6GHz strongly implies a fallback to a + // legacy/compatibility PHY rate rather than a real 802.11n/ac/ax/be link — a classic cause of + // "fast WAN, painfully slow WiFi" that signal strength alone wouldn't explain. + private const double LowPhyRateMbpsOn5Or6Ghz = 11; + + private const double HighGatewayPingMs = 100; + private const double SlowDnsResolutionMs = 150; + + // How far below the predicted signal an actual reading has to fall before it's flagged as a + // real mismatch rather than ordinary measurement/model noise (Phase 2 only). + private const double PredictedVsActualMismatchDb = 15; + + public static IReadOnlyList Analyze(LinkDiagnosticsSnapshot link, NetworkHealthSnapshot? health, double? predictedSignalDbm = null) + { + ArgumentNullException.ThrowIfNull(link); + + if (!link.IsConnected) + { + return [new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticNotConnected")]; + } + + var findings = new List(); + + AnalyzeSignal(link, findings); + AnalyzePhyRate(link, findings); + AnalyzeNetworkHealth(health, findings); + AnalyzePredictedVsActual(link, predictedSignalDbm, findings); + + if (findings.Count == 0) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Info, "DiagnosticNoIssuesFound")); + } + + return findings; + } + + private static void AnalyzeSignal(LinkDiagnosticsSnapshot link, List findings) + { + if (link.SignalPercent <= VeryWeakSignalPercentThreshold) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticVeryWeakSignal", [link.SignalPercent])); + } + else if (link.SignalPercent <= WeakSignalPercentThreshold) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticWeakSignal", [link.SignalPercent])); + } + } + + private static void AnalyzePhyRate(LinkDiagnosticsSnapshot link, List findings) + { + if (link.Band is Band.FiveGhz or Band.SixGhz && link.RxLinkSpeedMbps > 0 && link.RxLinkSpeedMbps <= LowPhyRateMbpsOn5Or6Ghz) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticLegacyPhyRate", [link.RxLinkSpeedMbps])); + } + } + + private static void AnalyzeNetworkHealth(NetworkHealthSnapshot? health, List findings) + { + if (health is null) + { + return; + } + + if (!health.DnsSucceeded) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticDnsFailed")); + } + else if (health.DnsResolutionMs is { } dnsMs && dnsMs >= SlowDnsResolutionMs) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticSlowDns", [dnsMs])); + } + + if (health.GatewayPingMs is { } pingMs) + { + if (pingMs >= HighGatewayPingMs) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticHighGatewayPing", [pingMs])); + } + } + else if (health.GatewayAddress is not null) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticGatewayUnreachable")); + } + } + + private static void AnalyzePredictedVsActual(LinkDiagnosticsSnapshot link, double? predictedSignalDbm, List findings) + { + if (predictedSignalDbm is not { } predicted) + { + return; + } + + double actualDbm = PercentToDbm(link.SignalPercent); + double gap = predicted - actualDbm; + if (gap >= PredictedVsActualMismatchDb) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticPredictedVsActualMismatch", [Math.Round(predicted, 1), Math.Round(actualDbm, 1)])); + } + } + + private static double PercentToDbm(int signalPercent) => -100 + (signalPercent * 0.5); +} diff --git a/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs b/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs index ba9d441..4ea19b1 100644 --- a/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs +++ b/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs @@ -36,50 +36,60 @@ public static IReadOnlyList ComputeGrid(Floor floor, IReadOnlyLi foreach (var point in gridPoints) { - double? strongest = null; + if (StrongestSignalDbm(point, floor, allFloors, band, propagationModel) is { } value) + { + samples.Add(new CoverageSample(point, value)); + } + } - foreach (var otherFloor in allFloors) + return samples; + } + + /// The strongest predicted signal at a single point, across every access point on + /// and (subject to the same skip rules uses) + /// every other floor in . Shared by the coverage grid (one call per + /// grid point) and by Workspace's single-point diagnostics comparison, so the two never diverge + /// on cross-floor/outdoor skip rules or the per-level attenuation figure. + public static double? StrongestSignalDbm(Point2D point, Floor floor, IReadOnlyList allFloors, Band band, IPropagationModel propagationModel) + { + double? strongest = null; + + foreach (var otherFloor in allFloors) + { + bool sameFloor = otherFloor.Id == floor.Id; + int levelDifference = Math.Abs(floor.Level - otherFloor.Level); + if (!sameFloor && (levelDifference == 0 || floor.IsOutdoor || otherFloor.IsOutdoor)) { - bool sameFloor = otherFloor.Id == floor.Id; - int levelDifference = Math.Abs(floor.Level - otherFloor.Level); - if (!sameFloor && (levelDifference == 0 || floor.IsOutdoor || otherFloor.IsOutdoor)) + // Skip cross-floor contribution when: a different floor shares this one's + // Level (Level is the only signal we have for floor-above/below relationships, + // so an unrelated same-level area shouldn't silently leak in), or either side is + // outdoor — the flat "stacked at the same origin" model this constant represents + // doesn't meaningfully describe an open-air area's relationship to an indoor + // floor, so outdoor areas' coverage stays isolated to their own access points. + continue; + } + + foreach (var accessPoint in otherFloor.AccessPoints) + { + if (!accessPoint.Radios.TryGetValue(band, out var radio)) { - // Skip cross-floor contribution when: a different floor shares this one's - // Level (Level is the only signal we have for floor-above/below relationships, - // so an unrelated same-level area shouldn't silently leak in), or either side is - // outdoor — the flat "stacked at the same origin" model this constant represents - // doesn't meaningfully describe an open-air area's relationship to an indoor - // floor, so outdoor areas' coverage stays isolated to their own access points. continue; } - foreach (var accessPoint in otherFloor.AccessPoints) - { - if (!accessPoint.Radios.TryGetValue(band, out var radio)) - { - continue; - } + // Cross-floor: the other floor's walls aren't in this floor's horizontal path, + // so free-space-only plus a flat per-level penalty stands in for the ceiling/ + // floor slab loss a real building would add. + double signal = sameFloor + ? propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, floor.Walls) + : propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, NoWalls) - (InterFloorAttenuationDbPerLevel * levelDifference); - // Cross-floor: the other floor's walls aren't in this floor's horizontal path, - // so free-space-only plus a flat per-level penalty stands in for the ceiling/ - // floor slab loss a real building would add. - double signal = sameFloor - ? propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, floor.Walls) - : propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, NoWalls) - (InterFloorAttenuationDbPerLevel * levelDifference); - - if (strongest is null || signal > strongest) - { - strongest = signal; - } + if (strongest is null || signal > strongest) + { + strongest = signal; } } - - if (strongest is { } value) - { - samples.Add(new CoverageSample(point, value)); - } } - return samples; + return strongest; } } diff --git a/src/Pulsemap.App/App.xaml.cs b/src/Pulsemap.App/App.xaml.cs index afc539e..f99f297 100644 --- a/src/Pulsemap.App/App.xaml.cs +++ b/src/Pulsemap.App/App.xaml.cs @@ -106,6 +106,8 @@ private static ServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddHttpClient(); @@ -118,6 +120,7 @@ private static ServiceProvider ConfigureServices() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services.BuildServiceProvider(); } diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs index 9fbbfec..535b633 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs @@ -81,6 +81,8 @@ public FloorPlanCanvas() public event EventHandler? WallSelectRequested; + public event EventHandler? DiagnosePositionRequested; + public event EventHandler<(Point2D Min, Point2D Max)>? OutdoorBoundsChanged; /// @@ -417,6 +419,10 @@ private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e) WallSelectRequested?.Invoke(this, meters); break; + case WorkspaceTool.Diagnose: + DiagnosePositionRequested?.Invoke(this, meters); + break; + default: break; } @@ -506,6 +512,15 @@ private void EndOutdoorBoundsDrag(bool raiseChanged) _previewBoundsMax = null; } + /// Converts a floor-plan point to this control's own pixel space — used by + /// WorkspacePage to anchor the diagnostics flyout at the point the user actually clicked, + /// rather than at the control's default position. + public Windows.Foundation.Point ToScreenPoint(Point2D meters) + { + var (x, y) = ToPixels(meters); + return new Windows.Foundation.Point(x, y); + } + private (double X, double Y) ToPixels(Point2D meters) => ((meters.X - _bounds.MinX) * PixelsPerMeter, (meters.Y - _bounds.MinY) * PixelsPerMeter); diff --git a/src/Pulsemap.App/Controls/WorkspaceTool.cs b/src/Pulsemap.App/Controls/WorkspaceTool.cs index 84722da..b1299e2 100644 --- a/src/Pulsemap.App/Controls/WorkspaceTool.cs +++ b/src/Pulsemap.App/Controls/WorkspaceTool.cs @@ -7,4 +7,5 @@ public enum WorkspaceTool AddTestPoint, DrawWall, DeleteElement, + Diagnose, } diff --git a/src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs b/src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs new file mode 100644 index 0000000..b77ee9c --- /dev/null +++ b/src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Pulsemap.App.Core.Diagnostics; + +namespace Pulsemap.App.Converters; + +/// Maps a to one of App.xaml's design-token brushes — +/// kept as a converter (rather than a computed property on the ViewModel-layer display record) so +/// that record stays free of any WinUI dependency and constructible in a plain unit test. +public sealed class DiagnosticSeverityToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + string resourceKey = value is DiagnosticSeverity.Error ? "DangerBrush" : value is DiagnosticSeverity.Warning ? "WarningBrush" : "SuccessBrush"; + return (Brush)Application.Current.Resources[resourceKey]; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); +} diff --git a/src/Pulsemap.App/Interop/NativeWlan.cs b/src/Pulsemap.App/Interop/NativeWlan.cs index 1039661..70003b4 100644 --- a/src/Pulsemap.App/Interop/NativeWlan.cs +++ b/src/Pulsemap.App/Interop/NativeWlan.cs @@ -5,12 +5,16 @@ namespace Pulsemap.App.Interop; /// /// Raw wlanapi.dll P/Invoke declarations and structs. Struct layouts verified against Microsoft /// Learn's wlanapi.h reference (WLAN_INTERFACE_INFO_LIST, WLAN_INTERFACE_INFO, WLAN_BSS_LIST, -/// WLAN_BSS_ENTRY, DOT11_SSID, WLAN_RATE_SET) rather than assumed — a wrong field order/size here -/// silently corrupts memory instead of failing loudly. WLAN_INTERFACE_INFO_LIST and WLAN_BSS_LIST -/// each end in a variable-length array of their entry struct; rather than modeling that array in -/// the struct itself, callers read the fixed header, then walk entries by pointer arithmetic using -/// of the entry type — the standard, safe pattern for this kind -/// of native "count + inline array" structure. +/// WLAN_BSS_ENTRY, DOT11_SSID, WLAN_RATE_SET, WLAN_CONNECTION_ATTRIBUTES, WLAN_ASSOCIATION_ATTRIBUTES) +/// rather than assumed — a wrong field order/size here silently corrupts memory instead of failing +/// loudly. WLAN_INTERFACE_INFO_LIST and WLAN_BSS_LIST each end in a variable-length array of their +/// entry struct; rather than modeling that array in the struct itself, callers read the fixed +/// header, then walk entries by pointer arithmetic using of the +/// entry type — the standard, safe pattern for this kind of native "count + inline array" structure. +/// WLAN_ASSOCIATION_ATTRIBUTES.ulRxRate/ulTxRate are documented by Microsoft's own sample only as +/// "the receiving/transmission rate" with no stated unit; confirmed against the community-maintained +/// ManagedNativeWifi wrapper (which real users have validated produces correct Mbps figures) that +/// the unit is plain Kbps, not the 100Kbps unit some other wlanapi rate fields use. /// internal static class NativeWlan { @@ -21,6 +25,10 @@ internal static class NativeWlan internal const uint ErrorSuccess = 0; internal const uint ErrorAccessDenied = 5; + // WLAN_INTF_OPCODE enum members used with WlanQueryInterface. + internal const uint OpcodeCurrentConnection = 7; + internal const uint OpcodeChannelNumber = 8; + [DllImport(WlanApiDll)] internal static extern uint WlanOpenHandle(uint dwClientVersion, IntPtr pReserved, out uint pdwNegotiatedVersion, out IntPtr phClientHandle); @@ -33,6 +41,16 @@ internal static class NativeWlan [DllImport(WlanApiDll)] internal static extern uint WlanScan(IntPtr hClientHandle, ref Guid pInterfaceGuid, IntPtr pDot11Ssid, IntPtr pIeData, IntPtr pReserved); + [DllImport(WlanApiDll)] + internal static extern uint WlanQueryInterface( + IntPtr hClientHandle, + ref Guid pInterfaceGuid, + uint opCode, + IntPtr pReserved, + out uint pdwDataSize, + out IntPtr ppData, + IntPtr pWlanOpcodeValueType); + [DllImport(WlanApiDll)] internal static extern uint WlanGetNetworkBssList( IntPtr hClientHandle, @@ -112,4 +130,92 @@ internal struct WLAN_BSS_LIST_HEADER public uint dwTotalSize; public uint dwNumberOfItems; } + + // Member names drop the wlanapi.h wlan_interface_state_* prefix (CA1712) — see the mapping in + // each comment for the native name. + internal enum WLAN_INTERFACE_STATE : uint + { + NotReady = 0, // wlan_interface_state_not_ready + Connected = 1, // wlan_interface_state_connected + AdHocNetworkFormed = 2, // wlan_interface_state_ad_hoc_network_formed + Disconnecting = 3, // wlan_interface_state_disconnecting + Disconnected = 4, // wlan_interface_state_disconnected + Associating = 5, // wlan_interface_state_associating + Discovering = 6, // wlan_interface_state_discovering + Authenticating = 7, // wlan_interface_state_authenticating + } + + internal enum WLAN_CONNECTION_MODE : uint + { + Profile = 0, // wlan_connection_mode_profile + TemporaryProfile = 1, // wlan_connection_mode_temporary_profile + DiscoverySecure = 2, // wlan_connection_mode_discovery_secure + DiscoveryUnsecure = 3, // wlan_connection_mode_discovery_unsecure + Auto = 4, // wlan_connection_mode_auto + Invalid = 5, // wlan_connection_mode_invalid + } + + internal enum DOT11_PHY_TYPE : uint + { + Unknown = 0, // dot11_phy_type_unknown + Fhss = 1, // dot11_phy_type_fhss + Dsss = 2, // dot11_phy_type_dsss + IrBaseband = 3, // dot11_phy_type_irbaseband + Ofdm = 4, // dot11_phy_type_ofdm + HrDsss = 5, // dot11_phy_type_hrdsss + Erp = 6, // dot11_phy_type_erp + Ht = 7, // dot11_phy_type_ht + Vht = 8, // dot11_phy_type_vht + Dmg = 9, // dot11_phy_type_dmg + He = 10, // dot11_phy_type_he + Eht = 11, // dot11_phy_type_eht + } + + [StructLayout(LayoutKind.Sequential)] + internal struct WLAN_ASSOCIATION_ATTRIBUTES + { + public DOT11_SSID dot11Ssid; + public uint dot11BssType; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)] + public byte[] dot11Bssid; + + public DOT11_PHY_TYPE dot11PhyType; + public uint uDot11PhyIndex; + public uint wlanSignalQuality; + public uint ulRxRate; + public uint ulTxRate; + } + + // WLAN_SECURITY_ATTRIBUTES follows wlanAssociationAttributes in WLAN_CONNECTION_ATTRIBUTES but + // none of its fields (security enabled/802.1X/auth+cipher algorithm) are needed here — modeled + // only so Marshal.PtrToStructure reads the right total size. + [StructLayout(LayoutKind.Sequential)] + internal struct WLAN_SECURITY_ATTRIBUTES + { + [MarshalAs(UnmanagedType.Bool)] + public bool bSecurityEnabled; + + [MarshalAs(UnmanagedType.Bool)] + public bool bOneXEnabled; + + public uint dot11AuthAlgorithm; + public uint dot11CipherAlgorithm; + } + + // WLAN_MAX_NAME_LENGTH, per wlanapi.h. + private const int WlanMaxNameLength = 256; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct WLAN_CONNECTION_ATTRIBUTES + { + public WLAN_INTERFACE_STATE isState; + public WLAN_CONNECTION_MODE wlanConnectionMode; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = WlanMaxNameLength)] + public string strProfileName; + + public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes; + public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes; + } } diff --git a/src/Pulsemap.App/MainPage.xaml b/src/Pulsemap.App/MainPage.xaml index 5942439..f594b4d 100644 --- a/src/Pulsemap.App/MainPage.xaml +++ b/src/Pulsemap.App/MainPage.xaml @@ -26,6 +26,11 @@ + + + + + diff --git a/src/Pulsemap.App/MainPage.xaml.cs b/src/Pulsemap.App/MainPage.xaml.cs index b505450..18730d7 100644 --- a/src/Pulsemap.App/MainPage.xaml.cs +++ b/src/Pulsemap.App/MainPage.xaml.cs @@ -30,6 +30,9 @@ private void Nav_SelectionChanged(NavigationView sender, NavigationViewSelection case "Surveys": ContentFrame.Navigate(typeof(SurveysPage)); break; + case "Diagnose": + ContentFrame.Navigate(typeof(DiagnosticsPage)); + break; } } } diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 981ecea..82b0693 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -118,6 +118,8 @@ public string GetString(string key) ["WorkspaceUndoDeleteWallMessage"] = "Wall deleted.", ["WorkspaceUndoDeleteTestPointMessage"] = "Test point deleted.", ["WorkspaceUndoDeleteAccessPointMessage"] = "Access point deleted.", + ["WorkspaceDiagnosePredictedFormat"] = "Survey predicted {0:0.0} dBm here.", + ["WorkspaceDiagnoseNoPredictionDisplay"] = "No access points on this band reach this point in the survey model.", ["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.", ["HomeGreetingMorning"] = "Good morning", @@ -128,6 +130,34 @@ public string GetString(string key) ["HomeWellbeingMessage2"] = "Take a moment for yourself if you need one.", ["HomeWellbeingMessage3"] = "However today's going, we hope it gets a little easier.", ["HomeWellbeingMessage4"] = "Glad you're here. Take care of yourself out there.", + ["HomeWellbeingMessage5"] = "Remember to drink some water and stretch your legs.", + ["HomeWellbeingMessage6"] = "No rush today. One thing at a time.", + ["HomeWellbeingMessage7"] = "Hope something good happens for you today.", + ["HomeWellbeingMessage8"] = "You're doing better than you think.", + + ["DiagnosticNotConnected"] = "This adapter isn't connected to a network.", + ["DiagnosticNoIssuesFound"] = "No issues found — link and network health both look healthy.", + ["DiagnosticWeakSignal"] = "Signal is weak ({0}%) — expect reduced range and occasional slowdowns.", + ["DiagnosticVeryWeakSignal"] = "Signal is very weak ({0}%) — this is likely limiting your speed significantly.", + ["DiagnosticLegacyPhyRate"] = "Connected at only {0:0} Mbps on 5/6GHz — this looks like a fallback to a legacy rate rather than a real 802.11n/ac/ax/be link.", + ["DiagnosticDnsFailed"] = "DNS lookups are failing — this can look like \"no internet\" even with a strong WiFi link.", + ["DiagnosticSlowDns"] = "DNS lookups are slow ({0:0} ms) — pages may feel slow to start loading even with fast WiFi.", + ["DiagnosticHighGatewayPing"] = "Ping to your router is high ({0:0} ms) — this points to a problem on the local network, not necessarily your internet connection.", + ["DiagnosticGatewayUnreachable"] = "Your router didn't respond to a ping — it may be busy, blocking ping, or there's a local network problem.", + ["DiagnosticPredictedVsActualMismatch"] = "The actual signal here ({1:0.0} dBm) is notably weaker than the survey predicted ({0:0.0} dBm) — the environment may have changed since the survey.", + + ["DiagnosticsStartMonitoringButton"] = "Start monitoring", + ["DiagnosticsStopMonitoringButton"] = "Stop monitoring", + ["DiagnosticsNotConnectedDisplay"] = "Not connected", + ["DiagnosticsBandAndChannelFormat"] = "{0}, channel {1}", + ["DiagnosticsUnknownBandDisplay"] = "unknown band", + ["DiagnosticsLinkSummaryFormat"] = "{0}% signal · {1} · Rx {2:0} Mbps · Tx {3:0} Mbps", + ["DiagnosticsNetworkHealthSummaryFormat"] = "Gateway {0} · {1} · DNS {2}", + ["DiagnosticsPingMsFormat"] = "{0:0} ms ping", + ["DiagnosticsNoPingReplyDisplay"] = "no ping reply", + ["DiagnosticsDnsMsFormat"] = "{0:0} ms", + ["DiagnosticsDnsFailedDisplay"] = "failed", + ["DiagnosticsMbpsFormat"] = "{0:0} Mbps", }, ["pl-PL"] = new Dictionary { @@ -204,6 +234,8 @@ public string GetString(string key) ["WorkspaceUndoDeleteWallMessage"] = "Usunięto ścianę.", ["WorkspaceUndoDeleteTestPointMessage"] = "Usunięto punkt pomiarowy.", ["WorkspaceUndoDeleteAccessPointMessage"] = "Usunięto punkt dostępowy.", + ["WorkspaceDiagnosePredictedFormat"] = "Pomiar przewidywał tutaj {0:0.0} dBm.", + ["WorkspaceDiagnoseNoPredictionDisplay"] = "Żaden punkt dostępowy na tym paśmie nie dociera do tego miejsca w modelu pomiaru.", ["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.", ["HomeGreetingMorning"] = "Dzień dobry", @@ -214,6 +246,34 @@ public string GetString(string key) ["HomeWellbeingMessage2"] = "Jeśli potrzebujesz chwili dla siebie, zrób sobie przerwę.", ["HomeWellbeingMessage3"] = "Niezależnie od tego, jak mija dzisiejszy dzień, mamy nadzieję, że będzie choć trochę lżej.", ["HomeWellbeingMessage4"] = "Miło, że tu jesteś. Zadbaj o siebie.", + ["HomeWellbeingMessage5"] = "Pamiętaj, żeby napić się wody i trochę się rozciągnąć.", + ["HomeWellbeingMessage6"] = "Bez pośpiechu. Jedna rzecz na raz.", + ["HomeWellbeingMessage7"] = "Mamy nadzieję, że dzisiaj wydarzy się coś dobrego.", + ["HomeWellbeingMessage8"] = "Radzisz sobie lepiej, niż Ci się wydaje.", + + ["DiagnosticNotConnected"] = "Ten adapter nie jest połączony z żadną siecią.", + ["DiagnosticNoIssuesFound"] = "Nie znaleziono problemów — łącze i stan sieci wyglądają dobrze.", + ["DiagnosticWeakSignal"] = "Słaby sygnał ({0}%) — spodziewaj się mniejszego zasięgu i okazjonalnych spowolnień.", + ["DiagnosticVeryWeakSignal"] = "Bardzo słaby sygnał ({0}%) — to prawdopodobnie znacząco ogranicza prędkość.", + ["DiagnosticLegacyPhyRate"] = "Połączenie z prędkością tylko {0:0} Mb/s na 5/6GHz — wygląda na przejście na starszy tryb transmisji zamiast prawdziwego łącza 802.11n/ac/ax/be.", + ["DiagnosticDnsFailed"] = "Zapytania DNS kończą się niepowodzeniem — może to wyglądać jak „brak internetu”, mimo silnego sygnału WiFi.", + ["DiagnosticSlowDns"] = "Zapytania DNS są wolne ({0:0} ms) — strony mogą wydawać się wolno wczytywać nawet przy szybkim WiFi.", + ["DiagnosticHighGatewayPing"] = "Wysoki ping do routera ({0:0} ms) — wskazuje to na problem w sieci lokalnej, niekoniecznie z połączeniem internetowym.", + ["DiagnosticGatewayUnreachable"] = "Router nie odpowiedział na ping — może być zajęty, blokować ping, albo wystąpił problem w sieci lokalnej.", + ["DiagnosticPredictedVsActualMismatch"] = "Rzeczywisty sygnał w tym miejscu ({1:0.0} dBm) jest zauważalnie słabszy niż przewidywał pomiar ({0:0.0} dBm) — otoczenie mogło się zmienić od czasu pomiaru.", + + ["DiagnosticsStartMonitoringButton"] = "Rozpocznij monitorowanie", + ["DiagnosticsStopMonitoringButton"] = "Zatrzymaj monitorowanie", + ["DiagnosticsNotConnectedDisplay"] = "Niepołączono", + ["DiagnosticsBandAndChannelFormat"] = "{0}, kanał {1}", + ["DiagnosticsUnknownBandDisplay"] = "nieznane pasmo", + ["DiagnosticsLinkSummaryFormat"] = "Sygnał {0}% · {1} · Rx {2:0} Mb/s · Tx {3:0} Mb/s", + ["DiagnosticsNetworkHealthSummaryFormat"] = "Router {0} · {1} · DNS {2}", + ["DiagnosticsPingMsFormat"] = "ping {0:0} ms", + ["DiagnosticsNoPingReplyDisplay"] = "brak odpowiedzi", + ["DiagnosticsDnsMsFormat"] = "{0:0} ms", + ["DiagnosticsDnsFailedDisplay"] = "niepowodzenie", + ["DiagnosticsMbpsFormat"] = "{0:0} Mb/s", }, }; } diff --git a/src/Pulsemap.App/Services/NetworkHealthService.cs b/src/Pulsemap.App/Services/NetworkHealthService.cs new file mode 100644 index 0000000..9d38683 --- /dev/null +++ b/src/Pulsemap.App/Services/NetworkHealthService.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.NetworkInformation; +using Pulsemap.App.Core.Abstractions; + +namespace Pulsemap.App.Services; + +/// +/// Local network health (gateway ping, DNS resolution timing) via pure BCL networking — no native +/// P/Invoke needed, unlike the WLAN-specific services in this folder. Every check here is local or +/// to the gateway/DNS the adapter is already configured to use; nothing here is a new outbound call +/// beyond what the OS's own network stack already does for the same adapter. +/// +public sealed class NetworkHealthService : INetworkHealthService +{ + private const int PingTimeoutMs = 2000; + private const string DnsProbeHostName = "www.msftconnecttest.com"; + + public async Task CheckHealthAsync(Guid adapterId, CancellationToken cancellationToken = default) + { + string? gatewayAddress = FindGatewayAddress(adapterId); + + double? pingMs = gatewayAddress is null ? null : await PingGatewayAsync(gatewayAddress, cancellationToken).ConfigureAwait(false); + var (dnsMs, dnsSucceeded) = await ResolveDnsAsync(cancellationToken).ConfigureAwait(false); + + return new NetworkHealthSnapshot(gatewayAddress, pingMs, dnsMs, dnsSucceeded); + } + + private static string? FindGatewayAddress(Guid adapterId) + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (!Guid.TryParse(nic.Id, out var nicId) || nicId != adapterId) + { + continue; + } + + var gateway = nic.GetIPProperties().GatewayAddresses.FirstOrDefault(g => g.Address is not null); + return gateway?.Address.ToString(); + } + + return null; + } + + private static async Task PingGatewayAsync(string gatewayAddress, CancellationToken cancellationToken) + { + try + { + using var ping = new Ping(); + var reply = await ping.SendPingAsync(gatewayAddress, PingTimeoutMs).WaitAsync(cancellationToken).ConfigureAwait(false); + return reply.Status == IPStatus.Success ? reply.RoundtripTime : null; + } + catch (PingException) + { + return null; + } + } + + private static async Task<(double? Ms, bool Succeeded)> ResolveDnsAsync(CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + _ = await System.Net.Dns.GetHostEntryAsync(DnsProbeHostName, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + return (stopwatch.Elapsed.TotalMilliseconds, true); + } + catch (System.Net.Sockets.SocketException) + { + return (null, false); + } + } +} diff --git a/src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs b/src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs new file mode 100644 index 0000000..0c73afb --- /dev/null +++ b/src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs @@ -0,0 +1,139 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Models; +using Pulsemap.App.Interop; + +namespace Pulsemap.App.Services; + +/// +/// Native wlanapi.dll-backed live link state (WlanQueryInterface, wlan_intf_opcode_current_connection). +/// Opens and closes a client handle per call, matching 's style — +/// diagnostics reads are user-triggered/infrequent, not hot-path. +/// +public sealed class WlanLinkDiagnosticsService : ILinkDiagnosticsService +{ + public Task GetCurrentLinkAsync(Guid adapterId, CancellationToken cancellationToken = default) => + Task.Run(() => GetCurrentLink(adapterId), cancellationToken); + + private static LinkDiagnosticsSnapshot GetCurrentLink(Guid adapterId) + { + uint openResult = NativeWlan.WlanOpenHandle(NativeWlan.ClientVersion, IntPtr.Zero, out _, out var clientHandle); + if (openResult != NativeWlan.ErrorSuccess) + { + return LinkDiagnosticsSnapshot.Disconnected; + } + + try + { + var interfaceGuid = adapterId; + uint result = NativeWlan.WlanQueryInterface( + clientHandle, ref interfaceGuid, NativeWlan.OpcodeCurrentConnection, IntPtr.Zero, out _, out var dataPtr, IntPtr.Zero); + + if (result != NativeWlan.ErrorSuccess || dataPtr == IntPtr.Zero) + { + // ERROR_INVALID_STATE (and any other failure) means "not connected" for this + // opcode, per WlanQueryInterface's own documented behavior — not a real error. + return LinkDiagnosticsSnapshot.Disconnected; + } + + try + { + var attributes = Marshal.PtrToStructure(dataPtr); + if (attributes.isState != NativeWlan.WLAN_INTERFACE_STATE.Connected) + { + return LinkDiagnosticsSnapshot.Disconnected; + } + + int channel = QueryChannelNumber(clientHandle, adapterId); + return ToSnapshot(attributes, channel); + } + finally + { + NativeWlan.WlanFreeMemory(dataPtr); + } + } + finally + { + _ = NativeWlan.WlanCloseHandle(clientHandle, IntPtr.Zero); + } + } + + // wlan_intf_opcode_channel_number returns a plain ULONG — WLAN_ASSOCIATION_ATTRIBUTES itself has + // no channel field (only uDot11PhyIndex, an index into the driver's internal rate table). + private static int QueryChannelNumber(IntPtr clientHandle, Guid adapterId) + { + var interfaceGuid = adapterId; + uint result = NativeWlan.WlanQueryInterface( + clientHandle, ref interfaceGuid, NativeWlan.OpcodeChannelNumber, IntPtr.Zero, out _, out var dataPtr, IntPtr.Zero); + + if (result != NativeWlan.ErrorSuccess || dataPtr == IntPtr.Zero) + { + return 0; + } + + try + { + return Marshal.ReadInt32(dataPtr); + } + finally + { + NativeWlan.WlanFreeMemory(dataPtr); + } + } + + private static LinkDiagnosticsSnapshot ToSnapshot(NativeWlan.WLAN_CONNECTION_ATTRIBUTES attributes, int channel) + { + var association = attributes.wlanAssociationAttributes; + int ssidLength = Math.Min((int)association.dot11Ssid.uSSIDLength, association.dot11Ssid.ucSSID.Length); + string ssid = Encoding.UTF8.GetString(association.dot11Ssid.ucSSID, 0, ssidLength); + string bssid = string.Join(":", association.dot11Bssid.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); + + return new LinkDiagnosticsSnapshot( + IsConnected: true, + Ssid: ssid, + Bssid: bssid, + Band: ClassifyBand(channel, association.dot11PhyType), + Channel: channel, + SignalPercent: (int)Math.Min(association.wlanSignalQuality, 100), + PhyType: DisplayPhyType(association.dot11PhyType), + RxLinkSpeedMbps: association.ulRxRate / 1000.0, + TxLinkSpeedMbps: association.ulTxRate / 1000.0); + } + + // Channel numbering alone is ambiguous for 6GHz (whose channels 1/5/9/13/... numerically overlap + // 2.4GHz's 1-14 range) — real disambiguation needs the center frequency, which this opcode + // doesn't expose (unlike WlanGetNetworkBssList's WLAN_BSS_ENTRY, used for the interference scan + // elsewhere in this app). VHT/OFDM PHY types only ever run on 5GHz, so those are unambiguous + // regardless of channel number; HE/EHT (WiFi 6/7, which do span all three bands) fall back to + // channel-range heuristics that are only reliable outside the numeric overlap. + private static Band? ClassifyBand(int channel, NativeWlan.DOT11_PHY_TYPE phyType) => phyType switch + { + NativeWlan.DOT11_PHY_TYPE.Ofdm or NativeWlan.DOT11_PHY_TYPE.Vht => Band.FiveGhz, + NativeWlan.DOT11_PHY_TYPE.Dsss or NativeWlan.DOT11_PHY_TYPE.HrDsss or NativeWlan.DOT11_PHY_TYPE.Erp => Band.TwoPointFourGhz, + _ => channel switch + { + >= 1 and <= 14 => Band.TwoPointFourGhz, + >= 36 and <= 177 => Band.FiveGhz, + > 177 => Band.SixGhz, + _ => null, + }, + }; + + private static string DisplayPhyType(NativeWlan.DOT11_PHY_TYPE phyType) => phyType switch + { + NativeWlan.DOT11_PHY_TYPE.Fhss => "FHSS", + NativeWlan.DOT11_PHY_TYPE.Dsss => "DSSS (802.11)", + NativeWlan.DOT11_PHY_TYPE.IrBaseband => "IR baseband", + NativeWlan.DOT11_PHY_TYPE.Ofdm => "OFDM (802.11a)", + NativeWlan.DOT11_PHY_TYPE.HrDsss => "HRDSSS (802.11b)", + NativeWlan.DOT11_PHY_TYPE.Erp => "ERP (802.11g)", + NativeWlan.DOT11_PHY_TYPE.Ht => "HT (802.11n)", + NativeWlan.DOT11_PHY_TYPE.Vht => "VHT (802.11ac)", + NativeWlan.DOT11_PHY_TYPE.Dmg => "DMG (802.11ad)", + NativeWlan.DOT11_PHY_TYPE.He => "HE (802.11ax / WiFi 6)", + NativeWlan.DOT11_PHY_TYPE.Eht => "EHT (802.11be / WiFi 7)", + _ => "Unknown", + }; +} diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw index 497316e..e285092 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -20,6 +20,9 @@ Surveys + + Diagnose + Settings @@ -275,6 +278,9 @@ Delete + + Diagnose + Tool Help @@ -305,6 +311,12 @@ Click near a wall, test point, or access point to remove it. An Undo option appears right after, until your next delete. + + Diagnose + + + Click any point on the plan to compare this machine's live WiFi signal there against what the survey predicted. + Welcome to the Workspace @@ -525,4 +537,25 @@ {0} — {1} + + Diagnose + + + Check this machine's live WiFi link and local network health — no survey needed. + + + Adapter + + + Run diagnostics + + + Link + + + Findings + + + Monitoring + diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw index db3438d..76e0cc3 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -20,6 +20,9 @@ Badania + + Diagnostyka + Ustawienia @@ -275,6 +278,9 @@ Usuń + + Diagnostyka + Pomoc dot. narzędzi @@ -305,6 +311,12 @@ Kliknij w pobliżu ściany, punktu pomiarowego lub punktu dostępowego, aby go usunąć. Zaraz potem pojawi się opcja cofnięcia, ważna do kolejnego usunięcia. + + Diagnostyka + + + Kliknij dowolny punkt na planie, aby porównać aktualny sygnał WiFi tego urządzenia z tym, co przewidział pomiar. + Witaj w Obszarze roboczym @@ -525,4 +537,25 @@ {0} — {1} + + Diagnostyka + + + Sprawdź aktualne połączenie WiFi tego urządzenia i stan sieci lokalnej — bez potrzeby pomiaru. + + + Adapter + + + Uruchom diagnostykę + + + Połączenie + + + Wyniki + + + Monitorowanie + diff --git a/src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs b/src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs new file mode 100644 index 0000000..564df35 --- /dev/null +++ b/src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs @@ -0,0 +1,10 @@ +using Pulsemap.App.Core.Diagnostics; + +namespace Pulsemap.App.ViewModels; + +/// A with its message key already resolved to display text +/// — is the only place that has both the localization service and +/// the finding's format args at hand. Deliberately has no WinUI dependency (unlike a Brush-typed +/// property would) so it stays constructible in a plain unit test — +/// resolves to a design-token brush itself, via a converter. +public sealed record DiagnosticFindingDisplay(DiagnosticSeverity Severity, string Message); diff --git a/src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs b/src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs new file mode 100644 index 0000000..323c206 --- /dev/null +++ b/src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs @@ -0,0 +1,219 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Diagnostics; +using Pulsemap.App.Services; + +namespace Pulsemap.App.ViewModels; + +/// Standalone "Swiss army knife" WLAN troubleshooting — no survey required. Reads this +/// machine's own live link state, checks local network health, and runs both through +/// . Workspace's own diagnostics (Phase 2, not this class) adds +/// a predicted-vs-actual comparison once a survey's propagation model is available. +public sealed partial class DiagnosticsViewModel( + IWlanAdapterService wlanAdapterService, + ILinkDiagnosticsService linkDiagnosticsService, + INetworkHealthService networkHealthService, + ILocalizationService localizationService) : ObservableObject, IDisposable +{ + private static readonly TimeSpan MonitoringInterval = TimeSpan.FromSeconds(5); + + private CancellationTokenSource? _monitoringCts; + + public ObservableCollection Adapters { get; } = []; + + public ObservableCollection Findings { get; } = []; + + public ObservableCollection MonitoringSamples { get; } = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RunDiagnosticsCommand))] + [NotifyCanExecuteChangedFor(nameof(ToggleMonitoringCommand))] + public partial NetworkAdapterInfo? SelectedAdapter { get; set; } + + [ObservableProperty] + public partial bool IsRunning { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ToggleMonitoringButtonText))] + public partial bool IsMonitoring { get; set; } + + [ObservableProperty] + public partial bool HasResult { get; set; } + + [ObservableProperty] + public partial string? SsidDisplay { get; set; } + + [ObservableProperty] + public partial string? BandAndChannelDisplay { get; set; } + + [ObservableProperty] + public partial string? LinkSummaryDisplay { get; set; } + + [ObservableProperty] + public partial string? NetworkHealthSummaryDisplay { get; set; } + + public string ToggleMonitoringButtonText => localizationService.GetString(IsMonitoring ? "DiagnosticsStopMonitoringButton" : "DiagnosticsStartMonitoringButton"); + + [RelayCommand] + private async Task LoadAdaptersAsync(CancellationToken cancellationToken) + { + var adapters = await wlanAdapterService.GetAdaptersAsync(cancellationToken); + Adapters.Clear(); + foreach (var adapter in adapters) + { + Adapters.Add(adapter); + } + + SelectedAdapter = Adapters.Count > 0 ? Adapters[0] : null; + } + + private bool CanRunDiagnostics() => SelectedAdapter is not null && !IsRunning; + + [RelayCommand(CanExecute = nameof(CanRunDiagnostics))] + private async Task RunDiagnosticsAsync(CancellationToken cancellationToken) + { + if (SelectedAdapter is not { } adapter) + { + return; + } + + IsRunning = true; + try + { + var (link, health) = await CaptureAsync(adapter.Id, cancellationToken); + ApplySnapshot(link, health); + } + finally + { + IsRunning = false; + } + } + + private async Task<(LinkDiagnosticsSnapshot Link, NetworkHealthSnapshot Health)> CaptureAsync(Guid adapterId, CancellationToken cancellationToken) + { + var link = await linkDiagnosticsService.GetCurrentLinkAsync(adapterId, cancellationToken); + var health = link.IsConnected + ? await networkHealthService.CheckHealthAsync(adapterId, cancellationToken) + : NetworkHealthSnapshot.Unavailable; + + return (link, health); + } + + private void ApplySnapshot(LinkDiagnosticsSnapshot link, NetworkHealthSnapshot health) + { + HasResult = true; + SsidDisplay = link.IsConnected ? link.Ssid : localizationService.GetString("DiagnosticsNotConnectedDisplay"); + BandAndChannelDisplay = link.IsConnected + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsBandAndChannelFormat"), BandDisplayName(link.Band), link.Channel) + : null; + LinkSummaryDisplay = link.IsConnected + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsLinkSummaryFormat"), link.SignalPercent, link.PhyType, link.RxLinkSpeedMbps, link.TxLinkSpeedMbps) + : null; + NetworkHealthSummaryDisplay = BuildNetworkHealthSummary(health); + + var findings = LinkDiagnosticsAnalyzer.Analyze(link, health); + Findings.Clear(); + foreach (var finding in findings) + { + Findings.Add(new DiagnosticFindingDisplay(finding.Severity, FormatFinding(finding))); + } + } + + private string? BuildNetworkHealthSummary(NetworkHealthSnapshot health) + { + if (health.GatewayAddress is null) + { + return null; + } + + string pingDisplay = health.GatewayPingMs is { } pingMs + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsPingMsFormat"), pingMs) + : localizationService.GetString("DiagnosticsNoPingReplyDisplay"); + string dnsDisplay = health.DnsSucceeded && health.DnsResolutionMs is { } dnsMs + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsDnsMsFormat"), dnsMs) + : localizationService.GetString("DiagnosticsDnsFailedDisplay"); + + return string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsNetworkHealthSummaryFormat"), health.GatewayAddress, pingDisplay, dnsDisplay); + } + + private string FormatFinding(DiagnosticFinding finding) + { + string template = localizationService.GetString(finding.MessageKey); + return finding.FormatArgs is null ? template : string.Format(CultureInfo.CurrentCulture, template, [.. finding.FormatArgs]); + } + + private string BandDisplayName(Core.Models.Band? band) => band switch + { + Core.Models.Band.TwoPointFourGhz => "2.4 GHz", + Core.Models.Band.FiveGhz => "5 GHz", + Core.Models.Band.SixGhz => "6 GHz", + _ => localizationService.GetString("DiagnosticsUnknownBandDisplay"), + }; + + private bool CanToggleMonitoring() => SelectedAdapter is not null; + + [RelayCommand(CanExecute = nameof(CanToggleMonitoring))] + private void ToggleMonitoring() + { + if (IsMonitoring) + { + StopMonitoring(); + } + else + { + StartMonitoring(); + } + } + + private void StartMonitoring() + { + if (SelectedAdapter is not { } adapter || IsMonitoring) + { + return; + } + + MonitoringSamples.Clear(); + _monitoringCts = new CancellationTokenSource(); + IsMonitoring = true; + _ = MonitorLoopAsync(adapter.Id, _monitoringCts.Token); + } + + public void StopMonitoring() + { + _monitoringCts?.Cancel(); + _monitoringCts?.Dispose(); + _monitoringCts = null; + IsMonitoring = false; + } + + private async Task MonitorLoopAsync(Guid adapterId, CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(MonitoringInterval); + try + { + do + { + var (link, health) = await CaptureAsync(adapterId, cancellationToken); + MonitoringSamples.Add(new MonitoringSampleDisplay( + DateTimeOffset.Now.ToString("T", CultureInfo.CurrentCulture), + string.Format(CultureInfo.CurrentCulture, "{0}%", link.SignalPercent), + string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsMbpsFormat"), link.RxLinkSpeedMbps), + health.GatewayPingMs is { } pingMs ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsPingMsFormat"), pingMs) : localizationService.GetString("DiagnosticsNoPingReplyDisplay"))); + } + while (await timer.WaitForNextTickAsync(cancellationToken)); + } + catch (OperationCanceledException) + { + // Expected when StopMonitoring cancels the token — not an error. + } + } + + public void Dispose() + { + _monitoringCts?.Cancel(); + _monitoringCts?.Dispose(); + } +} diff --git a/src/Pulsemap.App/ViewModels/HomeViewModel.cs b/src/Pulsemap.App/ViewModels/HomeViewModel.cs index 6bf3cdd..4017160 100644 --- a/src/Pulsemap.App/ViewModels/HomeViewModel.cs +++ b/src/Pulsemap.App/ViewModels/HomeViewModel.cs @@ -27,6 +27,10 @@ public partial class HomeViewModel( "HomeWellbeingMessage2", "HomeWellbeingMessage3", "HomeWellbeingMessage4", + "HomeWellbeingMessage5", + "HomeWellbeingMessage6", + "HomeWellbeingMessage7", + "HomeWellbeingMessage8", ]; private string? _releaseUrl; diff --git a/src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs b/src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs new file mode 100644 index 0000000..e5c7e50 --- /dev/null +++ b/src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs @@ -0,0 +1,6 @@ +namespace Pulsemap.App.ViewModels; + +/// One row of continuous-monitoring output — deliberately a flat display record (not the +/// raw Core snapshots) so can bind a plain ListView table +/// with no value converters. +public sealed record MonitoringSampleDisplay(string TimestampDisplay, string SignalPercentDisplay, string RxLinkSpeedDisplay, string GatewayPingDisplay); diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index e795cc9..a194b7e 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Diagnostics; using Pulsemap.App.Core.Export; using Pulsemap.App.Core.Interpolation; using Pulsemap.App.Core.Logging; @@ -44,6 +45,8 @@ public partial class WorkspaceViewModel : ObservableObject private readonly ISurveyExportFilePickerService _exportFilePickerService; private readonly IKrigingInterpolator _krigingInterpolator; private readonly IAppSettingsService _appSettingsService; + private readonly ILinkDiagnosticsService _linkDiagnosticsService; + private readonly INetworkHealthService _networkHealthService; private string? _filePath; @@ -58,7 +61,9 @@ public WorkspaceViewModel( IReportExporter reportExporter, ISurveyExportFilePickerService exportFilePickerService, IKrigingInterpolator krigingInterpolator, - IAppSettingsService appSettingsService) + IAppSettingsService appSettingsService, + ILinkDiagnosticsService linkDiagnosticsService, + INetworkHealthService networkHealthService) { _surveyFileService = surveyFileService; _propagationModel = propagationModel; @@ -71,6 +76,8 @@ public WorkspaceViewModel( _exportFilePickerService = exportFilePickerService; _krigingInterpolator = krigingInterpolator; _appSettingsService = appSettingsService; + _linkDiagnosticsService = linkDiagnosticsService; + _networkHealthService = networkHealthService; } public event EventHandler? FloorChanged; @@ -636,6 +643,42 @@ public async Task RecaptureTestPointAsync(TestPoint testPoint) await SaveAndRefreshAsync(); } + public ObservableCollection DiagnoseFindings { get; } = []; + + [ObservableProperty] + public partial string? DiagnoseSummaryDisplay { get; set; } + + /// Compares this machine's live link against what the survey's own propagation model + /// predicted at the clicked point — the Workspace-only half of diagnostics that the standalone + /// Diagnose page can't offer, since it has no survey/floor to predict against. Reuses the exact + /// same cross-floor/outdoor skip rules as the coverage heatmap via + /// , so the two never disagree. + public async Task DiagnoseAtPointAsync(Point2D position) + { + if (Survey is null || SelectedFloor is null || SelectedAdapter is null) + { + return; + } + + double? predicted = CoverageGridCalculator.StrongestSignalDbm(position, SelectedFloor, Survey.Floors, SelectedBand, _propagationModel); + + var link = await _linkDiagnosticsService.GetCurrentLinkAsync(SelectedAdapter.Id); + var health = link.IsConnected ? await _networkHealthService.CheckHealthAsync(SelectedAdapter.Id) : NetworkHealthSnapshot.Unavailable; + + DiagnoseSummaryDisplay = predicted is { } predictedDbm + ? string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceDiagnosePredictedFormat"), predictedDbm) + : _localizationService.GetString("WorkspaceDiagnoseNoPredictionDisplay"); + + var findings = LinkDiagnosticsAnalyzer.Analyze(link, health, predicted); + DiagnoseFindings.Clear(); + foreach (var finding in findings) + { + string template = _localizationService.GetString(finding.MessageKey); + string message = finding.FormatArgs is null ? template : string.Format(CultureInfo.CurrentCulture, template, [.. finding.FormatArgs]); + DiagnoseFindings.Add(new DiagnosticFindingDisplay(finding.Severity, message)); + } + } + [RelayCommand] private async Task SuggestPlacementsAsync() { diff --git a/src/Pulsemap.App/Views/DiagnosticsPage.xaml b/src/Pulsemap.App/Views/DiagnosticsPage.xaml new file mode 100644 index 0000000..7f5d207 --- /dev/null +++ b/src/Pulsemap.App/Views/DiagnosticsPage.xaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + -public sealed partial class SnapshotComparisonViewModel(IPropagationModel propagationModel) : ObservableObject +public sealed partial class SnapshotComparisonViewModel(IPropagationModel propagationModel, ISurveyFileService surveyFileService) : ObservableObject { private const double HeatmapGridSpacingMeters = 0.5; + private string? _filePath; + /// Fires whenever either side's resolved floor or heatmap changes — the page re-renders /// both canvases in response, mirroring WorkspaceViewModel's FloorChanged. public event EventHandler? Changed; @@ -27,9 +31,13 @@ public sealed partial class SnapshotComparisonViewModel(IPropagationModel propag public IReadOnlyList AvailableBands => Survey?.TargetBands ?? []; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDeleteLeftSnapshot))] + [NotifyCanExecuteChangedFor(nameof(DeleteLeftSnapshotCommand))] public partial SnapshotOption? LeftOption { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDeleteRightSnapshot))] + [NotifyCanExecuteChangedFor(nameof(DeleteRightSnapshotCommand))] public partial SnapshotOption? RightOption { get; set; } [ObservableProperty] @@ -46,9 +54,29 @@ public sealed partial class SnapshotComparisonViewModel(IPropagationModel propag public IReadOnlyList RightHeatmap { get; private set; } = []; - public void Initialize(Survey survey) + /// "Current" (SnapshotId null) isn't a real, deletable snapshot — only an actual saved + /// is. + public bool CanDeleteLeftSnapshot => LeftOption?.SnapshotId is not null; + + public bool CanDeleteRightSnapshot => RightOption?.SnapshotId is not null; + + public void Initialize(Survey survey, string filePath) { Survey = survey; + _filePath = filePath; + + RefreshOptions(); + + SelectedFloor = survey.Floors.Count > 0 ? survey.Floors[0] : null; + SelectedBand = survey.TargetBands.Count > 0 ? survey.TargetBands[0] : Band.TwoPointFourGhz; + } + + private void RefreshOptions(Guid? preferredLeftId = null, Guid? preferredRightId = null) + { + if (Survey is not { } survey) + { + return; + } List options = [ @@ -61,10 +89,30 @@ public void Initialize(Survey survey) OnPropertyChanged(nameof(Floors)); OnPropertyChanged(nameof(AvailableBands)); - LeftOption = options.Count > 0 ? options[0] : null; - RightOption = options.Count > 0 ? options[0] : null; - SelectedFloor = survey.Floors.Count > 0 ? survey.Floors[0] : null; - SelectedBand = survey.TargetBands.Count > 0 ? survey.TargetBands[0] : Band.TwoPointFourGhz; + LeftOption = options.FirstOrDefault(o => o.SnapshotId == preferredLeftId) ?? (options.Count > 0 ? options[0] : null); + RightOption = options.FirstOrDefault(o => o.SnapshotId == preferredRightId) ?? (options.Count > 0 ? options[0] : null); + } + + [RelayCommand(CanExecute = nameof(CanDeleteLeftSnapshot))] + private async Task DeleteLeftSnapshotAsync() => await DeleteSnapshotAsync(LeftOption, keepRightId: RightOption?.SnapshotId, keepLeftId: null); + + [RelayCommand(CanExecute = nameof(CanDeleteRightSnapshot))] + private async Task DeleteRightSnapshotAsync() => await DeleteSnapshotAsync(RightOption, keepRightId: null, keepLeftId: LeftOption?.SnapshotId); + + private async Task DeleteSnapshotAsync(SnapshotOption? toDelete, Guid? keepLeftId, Guid? keepRightId) + { + if (Survey is not { } survey || _filePath is null || toDelete?.SnapshotId is not { } snapshotId) + { + return; + } + + survey.Snapshots.RemoveAll(s => s.Id == snapshotId); + await surveyFileService.SaveAsync(survey, _filePath); + + // The side that wasn't just deleted keeps pointing at whatever it already had (falling back + // to "Current" only if that snapshot was also removed, e.g. both sides had it selected); the + // deleted side always falls back to "Current" via RefreshOptions' own null-preferred-id default. + RefreshOptions(preferredLeftId: keepLeftId, preferredRightId: keepRightId); } partial void OnLeftOptionChanged(SnapshotOption? value) => Recompute(); diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index a194b7e..3669437 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -50,6 +50,8 @@ public partial class WorkspaceViewModel : ObservableObject private string? _filePath; + public string? FilePath => _filePath; + public WorkspaceViewModel( ISurveyFileService surveyFileService, IPropagationModel propagationModel, @@ -117,6 +119,17 @@ public WorkspaceViewModel( public string SurveyNameDisplay => Survey?.Name ?? string.Empty; + /// Same survey-type summary the wizard shows on its final step — repeated here since + /// nothing in Workspace otherwise reminds a user which mode ("new deployment" vs. "existing + /// network audit") their survey is in once they've left the wizard. + public string SurveyTypeDisplay => Survey switch + { + null => string.Empty, + { Type: SurveyType.NewDeployment } => _localizationService.GetString("WizardSurveyTypeSummaryNewDeployment"), + { TargetNetworkSsid: null or "" } => _localizationService.GetString("WizardSurveyTypeSummaryExistingAuditNoSsid"), + _ => string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WizardSurveyTypeSummaryExistingAuditWithSsidFormat"), Survey.TargetNetworkSsid), + }; + public string CoveragePercentDisplay => string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceCoveragePercentFormat"), CoveragePercent); @@ -262,6 +275,7 @@ public async Task LoadAsync(string filePath, CancellationToken cancellationToken SelectedBand = AvailableBands.Count > 0 ? AvailableBands[0] : Band.TwoPointFourGhz; SelectedFloor = Survey.Floors.Count > 0 ? Survey.Floors[0] : null; OnPropertyChanged(nameof(SurveyNameDisplay)); + OnPropertyChanged(nameof(SurveyTypeDisplay)); OnPropertyChanged(nameof(AvailableBands)); OnPropertyChanged(nameof(Floors)); OnPropertyChanged(nameof(AccessPointSummaryDisplay)); @@ -679,6 +693,11 @@ public async Task DiagnoseAtPointAsync(Point2D position) } } + /// Whether running Suggest Placements again would discard previously suggested (not + /// user-placed) access points on this floor — lets the page ask for confirmation only when + /// there's actually something to lose, rather than always or never. + public bool HasReplaceableSuggestions => SelectedFloor?.AccessPoints.Any(ap => !ap.IsUserOverride) == true; + [RelayCommand] private async Task SuggestPlacementsAsync() { diff --git a/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml b/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml index f6ff8f8..95e6f15 100644 --- a/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml +++ b/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml @@ -33,12 +33,20 @@ ItemsSource="{x:Bind ViewModel.Options, Mode=OneWay}" SelectedItem="{x:Bind ViewModel.LeftOption, Mode=TwoWay}" DisplayMemberPath="Label" /> +