From d22da04f071750ee9b61405ddc90282ae340e53c Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:22:59 +0300 Subject: [PATCH] fix(chart): let the render path rest when idle The chart's redraw ran through Sample(33 ms), a periodic timer that woke the UI thread every 33 ms for the chart's whole life, idle or not. The render thread read a view field the UI thread wrote, the column buffer shifted 100 000 columns on every append past its cap, and a sidebar box and the chart each wrote a pen's visibility, kept apart by a guard flag. A redraw request now schedules one emission 33 ms ahead; requests inside that span join it, nothing is scheduled while no view subscribes, and Dispose cancels a pending one. The schedule lives in ChartRedrawSchedule. RenderFinished posts every frame's width to the view model the frame was drawn for, on the UI thread, and SetTargetColumnCount drops a repeat. The buffer trims one tenth of its cap at once. TrendPenState.IsVisible has one writer, SetPenVisibility: its setter is private, the row box reads it one way and writes through its command, and the group switch takes the same route. The sidebar row binds the pen's name, colour and unit through the pen state, so a revised pen shows at once. Two tests that raced the code under load now wait for the event they assert on: the history cancellation test for the read to start, and the settings restart tests for the button to be enabled. Claude-Session: https://claude.ai/code/session_01PfZJa7neMMKPzycFR6iuhD --- CLAUDE.md | 19 +-- .../UI/Chart/ChartAxisBinderTests.cs | 2 +- .../ChartHistoryRequestCancellationTests.cs | 25 +++- .../UI/Chart/ChartHoverReadoutTests.cs | 6 +- .../UI/Chart/EnvelopeLineTests.cs | 27 ++++ .../UI/Chart/TrendChartRenderThreadTests.cs | 70 +++++++++- .../UI/Chart/TrendChartViewModelTests.cs | 104 ++++++++++++++- .../UI/Legend/TrendLegendViewModelTests.cs | 104 ++++++--------- .../UI/Legend/TrendLegendViewTests.cs | 111 +++++++++++++++- .../UI/MainWindow/MainWindowViewTests.cs | 7 +- .../UI/MainWindow/PenCatalogueApplierTests.cs | 4 +- .../UI/Minimap/MinimapPointerInputTests.cs | 3 +- .../UI/Settings/SettingsViewTests.cs | 2 +- .../UI/Startup/StartupFailureWindowTests.cs | 5 +- SemiPlot/SemiPlot.UI/Chart/ChartPenSet.cs | 2 +- .../SemiPlot.UI/Chart/ChartRedrawSchedule.cs | 48 +++++++ SemiPlot/SemiPlot.UI/Chart/EnvelopeLine.cs | 6 +- .../SemiPlot.UI/Chart/TrendChartView.axaml.cs | 27 ++-- .../SemiPlot.UI/Chart/TrendChartViewModel.cs | 36 ++--- SemiPlot/SemiPlot.UI/Chart/TrendPenState.cs | 10 +- .../Legend/TrendLegendGroupViewModel.cs | 4 +- .../Legend/TrendLegendRowViewModel.cs | 67 ++-------- .../SemiPlot.UI/Legend/TrendLegendView.axaml | 9 +- .../Legend/TrendLegendViewModel.cs | 4 +- docs/architecture/bench.md | 10 +- docs/architecture/charting.md | 102 ++++++++------ docs/architecture/testing-strategy.md | 10 +- docs/architecture/trend-feature-spec.md | 3 +- docs/architecture/trend-interaction.md | 16 +-- docs/plans/20260929-window-per-process.md | 124 ++++++++++-------- 30 files changed, 644 insertions(+), 323 deletions(-) create mode 100644 SemiPlot/SemiPlot.UI/Chart/ChartRedrawSchedule.cs diff --git a/CLAUDE.md b/CLAUDE.md index f64befc..4560db3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,14 +130,15 @@ Two projects, split on one axis: needs a container or not. `SemiPlot.Tests.Integration` carries no `xunit.runner.json`. - An xunit v3 test project is an executable: a hung test leaves `SemiPlot.Tests.Unit.exe` locked and the next build fails with MSB3027 until it is killed. The container half is bounded at two minutes. -- The way to hang one: give `TrendChartViewModel` `ImmediateScheduler.Instance` as its UI scheduler and - then realise a view that subscribes to `RedrawRequested`. Its `Sample` schedules periodically, and - `ImmediateScheduler` runs a periodic schedule by sleeping on the calling thread, so the subscription - never returns. A headless test that realises the chart passes both schedulers a `TestScheduler` - (`UI/Chart/TrendChartViewTests.cs`, `UI/MainWindow/MainWindowTestBuilder.cs`). `PenCatalogueSync` - hangs the same way: its loop runs through `ScheduleAsync` and waits on `Schedule(ReadInterval)`, so a +- The way to hang one: build `PenCatalogueSync` over `ImmediateScheduler.Instance`. Its loop runs + through `ScheduleAsync` and waits on `Schedule(ReadInterval)` before every read, and + `ImmediateScheduler` runs each wait by sleeping on the calling thread, so `Start()` never returns. A test builds it over a `TestScheduler` and advances one tick after `Start()` - (`UI/MainWindow/MainWindowTestBuilder.cs`). + (`UI/MainWindow/MainWindowTestBuilder.cs`). The chart's redraw hangs nothing: it is a one-shot + schedule, which `ImmediateScheduler` runs inline after a 33 ms sleep. A headless test that realises + the chart still never passes an immediate UI scheduler, because it blocks the dispatcher on every + redraw; it passes a `TestScheduler` or `AvaloniaScheduler.Instance` + (`docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view`). - A plain `[Fact]` body runs with no `SynchronizationContext`, so an `await` on a `TaskCompletionSource` completed by production code resumes inline on the completing thread. A gate awaited by the test and completed by production code takes @@ -313,7 +314,9 @@ No abbreviations in names. imperative `IsSticky =` assignments. - A checkable menu item reads its flag `Mode=OneWay` and writes it only through the command it invokes, so the command is the flag's single writer. A two-way `IsChecked` would make the control a - second writer and the two halves would drift. + second writer and the two halves would drift. The sidebar's visibility box follows the same rule: it + reads `PenState.IsVisible` `Mode=OneWay` and writes through `ToggleVisibilityCommand`, which reaches + the chart's `SetPenVisibility`, the flag's one writer. - A menu item without a command, or without children, is not added. A disabled placeholder renders and does nothing, and it also forces an exemption into `AppMenuBarTests`, which walks the declared `Items` and requires every leaf to carry one or the other. diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs index bc6481d..5ed9609 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs @@ -48,7 +48,7 @@ public void TheActivePenSwitchedOff_DrawsNoAxisAtAll() using var plot = new Plot(); var binder = new ChartAxisBinder(plot); var pens = Pens(1, 2); - pens[1].IsVisible = false; + pens[1].SetVisibility(false); binder.Apply([Scale(1, isActive: true), Scale(2)], pens); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs index 107aafa..a7c3fe7 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs @@ -122,7 +122,7 @@ public async Task AGestureEndingOnTheDrawnWindowCancelsTheReadItLeftAndReadsNoth debouncer.Request(RequestAtNotch(1)); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); - var farRead = queries.Started[1]; + var farRead = await queries.AwaitStart(1); debouncer.RequestNothing(); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); @@ -154,7 +154,7 @@ public async Task AGestureEndingOnTheDrawnWindowAfterACancelledReadReadsNothing( debouncer.Request(RequestAtNotch(2)); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); - var cancelled = queries.Started[1]; + var cancelled = await queries.AwaitStart(1); cancelled.Cancellation.IsCancellationRequested.Should().BeTrue(); cancelled.Gate.SetCanceled(cancelled.Cancellation); await AwaitCondition(() => queries.Started.Count == 3); @@ -191,18 +191,24 @@ public async Task AGestureEndingOnTheDrawnWindowAfterAFailedReadCancelsTheReadIt debouncer.Request(RequestAtNotch(1)); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); - queries.Started[1].Gate.SetResult(Unreachable()); + (await queries.AwaitStart(1)).Gate.SetResult(Unreachable()); await AwaitCondition(() => !reportedFailures.IsEmpty); debouncer.Request(RequestAtNotch(2)); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); - var farRead = queries.Started[2]; + var farRead = await queries.AwaitStart(2); debouncer.RequestNothing(); scheduler.AdvanceBy(DebounceWindow.Ticks + 1); farRead.Cancellation.IsCancellationRequested.Should().BeTrue(); - queries.Started.Should().HaveCount(3); + + debouncer.Request(RequestAtNotch(3)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + await queries.AwaitStart(3); + + queries.Started.Select(query => query.Request.FromUtc).Should() + .Equal(From, From.AddSeconds(1), From.AddSeconds(2), From.AddSeconds(3)); } [Fact] @@ -393,6 +399,15 @@ public Task>> QueryAsync( return query.Gate.Task; } + // A read the pipeline queued behind a landed one starts once Rx unwinds that one on the thread it + // resumed on, after the delivery the test observed. + public async Task AwaitStart(int index) + { + await AwaitCondition(() => Started.Count > index); + + return Started[index]; + } + public async Task LandAndAwaitTheNextStart(HeldQuery query) { var startedBefore = Started.Count; diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHoverReadoutTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHoverReadoutTests.cs index bc9012e..049bab2 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHoverReadoutTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHoverReadoutTests.cs @@ -47,7 +47,7 @@ public void BuildContent_SkipsHiddenPens() { var visible = CreatePen(1, "Pen 1"); var hidden = CreatePen(2, "Pen 2"); - hidden.IsVisible = false; + hidden.SetVisibility(false); var pens = new[] { visible, hidden }; var values = new Dictionary { [1] = 2.0, [2] = 20.0 }; @@ -72,8 +72,8 @@ public void BuildContent_AllPensHidden_RendersTimestampOnly() { var first = CreatePen(1, "Pen 1"); var second = CreatePen(2, "Pen 2"); - first.IsVisible = false; - second.IsVisible = false; + first.SetVisibility(false); + second.SetVisibility(false); var pens = new[] { first, second }; var values = new Dictionary { [1] = 2.0, [2] = 20.0 }; diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs index d80bf88..9b66569 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs @@ -26,6 +26,8 @@ public sealed class EnvelopeLineTests private const int ShortColumnCount = 2000; private const int GapEvery = 500; private const int TestTimeoutMilliseconds = 120_000; + private const int ColumnCap = 100_000; + private const int TrimChunk = ColumnCap / 10; private static readonly TimeSpan _runBudget = TimeSpan.FromSeconds(60); private static readonly TimeSpan _joinBudget = TimeSpan.FromSeconds(30); @@ -89,6 +91,31 @@ public async Task Render_WhileTheUiThreadRewritesTheColumns_DoesNotThrow() frames.Should().Be(FrameBudget); } + [Fact] + public void AppendingPastTheCapTrimsOneChunk() + { + var line = new EnvelopeLine(); + var peakCount = 0; + + for (var index = 0; index <= ColumnCap; index++) + { + line.AppendColumn(new EnvelopeColumn(index, 0.0, 1.0, 0.5)).Should().BeTrue(); + peakCount = Math.Max(peakCount, line.Columns.Count); + } + + line.Columns.Should().HaveCount(ColumnCap + 1 - TrimChunk); + line.Columns[0].X.Should().Be(TrimChunk); + peakCount.Should().Be(ColumnCap); + + for (var index = ColumnCap + 1; index < ColumnCap + TrimChunk; index++) + { + line.AppendColumn(new EnvelopeColumn(index, 0.0, 1.0, 0.5)); + } + + line.Columns.Should().HaveCount(ColumnCap); + line.Columns[0].X.Should().Be(TrimChunk); + } + private static void RenderFrame(Plot plot) { using var image = plot.GetImage(PlotWidth, PlotHeight); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartRenderThreadTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartRenderThreadTests.cs index eeef869..687742b 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartRenderThreadTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartRenderThreadTests.cs @@ -1,4 +1,5 @@ using Avalonia.Headless.XUnit; +using Avalonia.Threading; using AwesomeAssertions; @@ -8,6 +9,7 @@ using SemiPlot.Core.Trends; using SemiPlot.Tests.Unit.UI.Bridge; +using SemiPlot.UI.Chart; using Xunit; @@ -23,6 +25,10 @@ public sealed class TrendChartRenderThreadTests { private const int PlotWidth = 400; private const int PlotHeight = 300; + + // The narrow canvas leaves a data area under the minimum column count, the wide one over the maximum. + private const int NarrowPlotWidth = 320; + private const int WidePlotWidth = 2600; private const int FrameBudget = 300; private const int JoiningPenCount = 20; private const int TestTimeoutMilliseconds = 120_000; @@ -119,8 +125,70 @@ public void ApplyCatalogue_RaisesNoNotificationWhileThePlotIsLocked() raised.Should().AllSatisfy(entry => entry.IsLockHeld.Should().BeFalse()); } + // docs/architecture/charting.md#module-layout-avalonia-views--view-models--core-models + [AvaloniaFact] + public async Task ARepeatedDataAreaWidth_ChangesNothing() + { + using var chart = CreateChart(new TestScheduler()); + _ = new TrendChartView { DataContext = chart }; + await RenderFrameAsTheRenderThreadDoes(chart.Plot, NarrowPlotWidth, PlotHeight); + var windowChanges = 0; + chart.Navigation.WindowChanged += (_, _) => windowChanges++; + var revisionBefore = chart.ScalesRevision; + + await RenderFrameAsTheRenderThreadDoes(chart.Plot, NarrowPlotWidth, PlotHeight); + await RenderFrameAsTheRenderThreadDoes(chart.Plot, NarrowPlotWidth, PlotHeight); + + chart.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MinColumns); + windowChanges.Should().Be(0); + chart.ScalesRevision.Should().Be(revisionBefore); + } + + [AvaloniaFact] + public async Task ANewDataAreaWidth_RetargetsTheColumnCount() + { + using var chart = CreateChart(new TestScheduler()); + _ = new TrendChartView { DataContext = chart }; + await RenderFrameAsTheRenderThreadDoes(chart.Plot, NarrowPlotWidth, PlotHeight); + chart.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MinColumns); + var windowChanges = 0; + chart.Navigation.WindowChanged += (_, _) => windowChanges++; + + await RenderFrameAsTheRenderThreadDoes(chart.Plot, WidePlotWidth, PlotHeight); + + chart.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MaxColumns); + windowChanges.Should().Be(1); + } + + [AvaloniaFact] + public async Task AReboundView_ReportsTheWidthToTheNewChartAlone() + { + using var first = CreateChart(new TestScheduler()); + using var second = CreateChart(new TestScheduler()); + var view = new TrendChartView { DataContext = first }; + + view.DataContext = second; + + await RenderFrameAsTheRenderThreadDoes(first.Plot, NarrowPlotWidth, PlotHeight); + await RenderFrameAsTheRenderThreadDoes(second.Plot, NarrowPlotWidth, PlotHeight); + + first.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MaxColumns, "the view let go of it"); + second.Navigation.TargetColumnCount.Should().Be(HistoryColumnTarget.MinColumns); + } + + private static async Task RenderFrameAsTheRenderThreadDoes(Plot plot, int width, int height) + { + await Task.Run(() => RenderFrame(plot, width, height), TestContext.Current.CancellationToken); + Dispatcher.UIThread.RunJobs(); + } + private static void RenderFrame(Plot plot) { - using var image = plot.GetImage(PlotWidth, PlotHeight); + RenderFrame(plot, PlotWidth, PlotHeight); + } + + private static void RenderFrame(Plot plot, int width, int height) + { + using var image = plot.GetImage(width, height); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs index f20f6f8..ece1163 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs @@ -36,6 +36,7 @@ namespace SemiPlot.Tests.Unit.UI.Chart; public sealed class TrendChartViewModelTests { private static readonly TimeSpan _testDeadline = TimeSpan.FromSeconds(10.0); + private static readonly TimeSpan _redrawDelay = TimeSpan.FromMilliseconds(33.0); private static readonly DateTime _from = new(2026, 6, 15, 8, 0, 0, DateTimeKind.Utc); private static readonly DateTime _to = new(2026, 6, 15, 9, 0, 0, DateTimeKind.Utc); @@ -45,7 +46,7 @@ public void SetPenVisibility_TogglesPenAndPlottableState() var (viewModel, _, _, _) = CreateViewModel(); var state = viewModel.AddPen(new Pen(7, "Heater", ["Group A"], "#ff0000")); - viewModel.SetPenVisibility(7, false).Should().BeTrue(); + viewModel.SetPenVisibility(7, false); state.IsVisible.Should().BeFalse(); state.Line.IsVisible.Should().BeFalse(); @@ -326,6 +327,74 @@ public void InitialScale_RequestsARedraw() redraws.Should().Be(1); } + [AvaloniaFact] + public void AnIdleChartSchedulesNoRedraw() + { + var uiScheduler = new QueueReadingScheduler(); + using var viewModel = CreateChartOn(uiScheduler); + var redraws = 0; + using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++); + + uiScheduler.HasQueuedWork.Should().BeFalse("a chart with nothing to redraw schedules nothing"); + + viewModel.SetDeltaModeEnabled(true); + uiScheduler.AdvanceBy(_redrawDelay.Ticks); + + redraws.Should().Be(1); + uiScheduler.HasQueuedWork.Should().BeFalse("the redraw was one emission, not a timer left running"); + } + + [AvaloniaFact] + public void AChartNoViewSubscribesToSchedulesNoRedraw() + { + var uiScheduler = new QueueReadingScheduler(); + using var viewModel = CreateChartOn(uiScheduler); + + viewModel.SetDeltaModeEnabled(true); + + uiScheduler.HasQueuedWork.Should().BeFalse("no view would draw the frame"); + } + + [AvaloniaFact] + public void DisposingTheChartCancelsTheScheduledRedraw() + { + var uiScheduler = new QueueReadingScheduler(); + var viewModel = CreateChartOn(uiScheduler); + var redraws = 0; + using var subscription = viewModel.RedrawRequested.Subscribe(_ => redraws++); + viewModel.SetDeltaModeEnabled(true); + + viewModel.Dispose(); + + uiScheduler.HasQueuedWork.Should().BeFalse("the disposed chart leaves nothing on the UI scheduler"); + var advance = () => uiScheduler.AdvanceBy(_redrawDelay.Ticks * 2); + advance.Should().NotThrow(); + redraws.Should().Be(0); + } + + [AvaloniaFact] + public void ABurstOfRequestsRedrawsOncePerSpanAndOnceAfterTheLastRequest() + { + var uiScheduler = new QueueReadingScheduler(); + using var viewModel = CreateChartOn(uiScheduler); + var redrawTimes = new List(); + using var subscription = viewModel.RedrawRequested + .Subscribe(_ => redrawTimes.Add(TimeSpan.FromTicks(uiScheduler.Clock))); + + for (var request = 0; request <= 10; request++) + { + viewModel.SetDeltaModeEnabled(request % 2 == 0); + uiScheduler.AdvanceBy(TimeSpan.FromMilliseconds(10.0).Ticks); + } + + uiScheduler.AdvanceBy(TimeSpan.FromSeconds(1.0).Ticks); + + redrawTimes.Should().Equal( + TimeSpan.FromMilliseconds(33.0), + TimeSpan.FromMilliseconds(73.0), + TimeSpan.FromMilliseconds(113.0)); + } + [AvaloniaFact] public void ManualLimits_DriveTheOwningAxisRange() { @@ -744,17 +813,19 @@ public void Realtime_AfterClearHistory_AppendsAnyTimestampAgain() public void Realtime_PastTheBufferCap_DropsTheOldestColumns() { const int Cap = 100_000; + const int Appended = Cap + 10; var (viewModel, _, _, _) = CreateViewModel(); var state = viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); var t0 = new DateTime(2026, 6, 15, 8, 0, 0, DateTimeKind.Utc); - for (var index = 0; index < Cap + 10; index++) + for (var index = 0; index < Appended; index++) { state.AppendRealtime(t0.AddSeconds(index), index); } - state.Line.Columns.Should().HaveCount(Cap); - state.Line.Columns[0].Center.Should().Be(10.0); + state.Line.Columns.Count.Should().BeLessThanOrEqualTo(Cap); + state.Line.Columns[0].Center.Should().BeGreaterThan(0.0, "the oldest columns go"); + state.Line.Columns[^1].Center.Should().Be(Appended - 1, "the newest column stays"); } [AvaloniaFact] @@ -1643,7 +1714,7 @@ public void AFailedHistoryQueryReachesTheMessagePanelAndStillRedraws() provider.FailHistory = true; chart.RequestInitialHistory(); - // Past the debounce window and the one sample period the recovery redraw falls into, and short of the + // Past the debounce window and the 33 ms the recovery redraw waits, and short of the // 400 ms cap interval, which would admit a second query for the same window. scheduler.AdvanceBy(TimeSpan.FromMilliseconds(200.0).Ticks); @@ -1769,8 +1840,8 @@ public void AThrowOutOfTheMessagePanel_IsLoggedAndTheRealtimeApplyContinues() valueWhenTheReportThrew, "the batches after the failed report still reach the pen"); } - // Both schedulers are virtual, unlike CreateViewModel's: these tests subscribe to RedrawRequested, whose - // Sample never returns on ImmediateScheduler, and they drive the realtime stream by advancing time. + // Both schedulers are virtual, unlike CreateViewModel's: these tests count the redraws a request schedules + // 33 ms ahead, and they drive the realtime stream by advancing time. private static (TrendChartViewModel ViewModel, TestScheduler Scheduler, MessagePanelViewModel Panel) CreateReportingViewModel(TimeSpan realtimeInterval, out FakeDataProvider provider) { @@ -1793,6 +1864,20 @@ private static (TrendChartViewModel ViewModel, TestScheduler Scheduler, MessageP return (viewModel, scheduler, panel); } + // The UI scheduler serves the chart alone, so whatever it holds queued is the chart's. + private static TrendChartViewModel CreateChartOn(QueueReadingScheduler uiScheduler) + { + var dataScheduler = new TestScheduler(); + var provider = new FakeDataProvider(dataScheduler, TimeSpan.FromHours(1.0)); + + return new TrendChartViewModel( + CreateCoordinator(dataScheduler, provider), + dataScheduler, + uiScheduler, + new MessagePanelViewModel(), + NullLogger.Instance); + } + // The value a held query carries, distinct from every value the fake answers with, so the assertion says // which of the two results the chart ended up holding. private static Result> StaleEnvelopes() @@ -1886,4 +1971,9 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } } + + private sealed class QueueReadingScheduler : TestScheduler + { + public bool HasQueuedWork => GetNext() is not null; + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs index 7f0ecef..7b7eec3 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewModelTests.cs @@ -60,9 +60,9 @@ public void APenInTwoGroups_IsOneRowListedUnderBothHeaders() var underPressures = legend.Groups.Single(group => group.Name == "Pressures").Rows.Single(); underPressures.Should().BeSameAs(underHeaters); - underHeaters.IsVisible = false; + underHeaters.ToggleVisibilityCommand.Execute().Subscribe(); - underPressures.IsVisible.Should().BeFalse(); + underPressures.PenState.IsVisible.Should().BeFalse(); chart.FindPen(1)!.IsVisible.Should().BeFalse(); } @@ -76,7 +76,7 @@ public void AnUngroupedPen_LandsUnderTheUngroupedHeader() using var legend = new TrendLegendViewModel(chart); legend.Groups.Select(group => group.Name).Should().Equal("Heaters", Resources.LegendUngroupedHeader); - legend.Groups[^1].Rows.Single().Name.Should().Be("Pen 2"); + legend.Groups[^1].Rows.Single().PenState.Pen.Name.Should().Be("Pen 2"); } // The rows arrive ordered by pen name, so first-appearance order and name order only agree by @@ -128,26 +128,25 @@ public void AGroupNamedLikeTheUngroupedHeader_TakesTheUngroupedRowsRatherThanASe legend.Groups.Should().ContainSingle(); legend.Groups.Single().Name.Should().Be(Resources.LegendUngroupedHeader); - legend.Groups.Single().Rows.Select(row => row.Name).Should().Equal("Pen 1", "Pen 2"); + legend.Groups.Single().Rows.Select(row => row.PenState.Pen.Name).Should().Equal("Pen 1", "Pen 2"); } - // Dispose walks the distinct rows, not the flattened ones, and a row listed under two headers is one - // instance: it is reached once and its mirror subscription goes with it. [AvaloniaFact] public void Dispose_ReachesARowListedUnderTwoHeaders() { var chart = CreateChart(); chart.AddPen(new Pen(1, "Pen 1", ["Heaters", "Watchlist"], "#ff0000")); + chart.AddPen(new Pen(2, "Pen 2", ["Heaters"], "#00ff00")); var legend = new TrendLegendViewModel(chart); - var row = legend.Groups[0].Rows.Single(); + var row = SingleRow(legend, 1); legend.Dispose(); - chart.SetPenVisibility(1, false); + chart.SetActivePen(2); - row.IsVisible.Should().BeTrue("a disposed row no longer mirrors the chart"); + row.IsActive.Should().BeTrue("a disposed row no longer follows the chart"); } - // The row's own notification still fires after the legend is gone, so only the group's disposal stops + // The pen state's notification still fires after the legend is gone, so only the group's disposal stops // the header re-deriving from it. [AvaloniaFact] public void Dispose_StopsEveryHeaderFollowingItsRows() @@ -158,7 +157,7 @@ public void Dispose_StopsEveryHeaderFollowingItsRows() var heaters = Group(legend, "Heaters"); legend.Dispose(); - heaters.Rows.Single().IsVisible = false; + chart.SetPenVisibility(1, false); heaters.SwitchState.Should().BeTrue("a disposed header no longer follows its rows"); } @@ -208,7 +207,7 @@ public void SwitchingOnePen_ReDerivesEveryHeaderItSitsUnder() chart.AddPen(new Pen(3, "Pen 3", ["Watchlist"], "#0000ff")); using var legend = new TrendLegendViewModel(chart); - SingleRow(legend, 1).IsVisible = false; + SingleRow(legend, 1).ToggleVisibilityCommand.Execute().Subscribe(); Group(legend, "Heaters").SwitchState.Should().BeNull(); Group(legend, "Watchlist").SwitchState.Should().BeNull(); @@ -364,33 +363,36 @@ public void FitPanel_NarrowsTheShownWidthAndKeepsBothSlots() } [AvaloniaFact] - public void TogglingRowCheckbox_FlipsChartPenVisibility() + public void ToggleVisibilityCommand_SwitchesThePenOffAndOnAgain() { var chart = CreateChart(); chart.AddPen(new Pen(1, "Pen 1", ["Heaters"], "#ff0000")); using var legend = new TrendLegendViewModel(chart); var row = SingleRow(legend, 1); - row.IsVisible = false; + row.ToggleVisibilityCommand.Execute().Subscribe(); chart.FindPen(1)!.IsVisible.Should().BeFalse(); - row.IsVisible = true; + row.ToggleVisibilityCommand.Execute().Subscribe(); chart.FindPen(1)!.IsVisible.Should().BeTrue(); } [AvaloniaFact] - public void RowVisibility_MirrorsChartDrivenVisibilityChange() + public void TheRow_FollowsAChangeOfTheChartsPenState() { var chart = CreateChart(); - chart.AddPen(new Pen(1, "Pen 1", ["Heaters"], "#ff0000")); + var pen = new Pen(1, "Pen 1", ["Heaters"], "#ff0000"); + chart.AddPen(pen); using var legend = new TrendLegendViewModel(chart); var row = SingleRow(legend, 1); chart.SetPenVisibility(1, false); + chart.ApplyCatalogue([pen with { Color = "#00ff00" }]); - row.IsVisible.Should().BeFalse(); + row.PenState.IsVisible.Should().BeFalse(); + row.PenState.Pen.Color.Should().Be("#00ff00"); } [AvaloniaFact] @@ -409,19 +411,6 @@ public void SelectingRow_SetsTheActivePenOnChart() SingleRow(legend, 1).IsActive.Should().BeFalse(); } - [AvaloniaFact] - public void CurrentValue_ReflectsChartHistoryLoad() - { - var chart = CreateChart(); - chart.AddPen(new Pen(1, "Pen 1", ["Heaters"], "#ff0000")); - using var legend = new TrendLegendViewModel(chart); - var row = SingleRow(legend, 1); - - LoadInitialHistory(chart, _from, _to); - - row.CurrentValue.Should().Be(2.0); - } - [AvaloniaFact] public void CurrentValueText_RendersTheReadingInThePensOwnMask() { @@ -434,7 +423,6 @@ public void CurrentValueText_RendersTheReadingInThePensOwnMask() row.CurrentValueText.Should().Be(PenValueFormat.Format(2.0, "0.000")); row.CurrentValueText.Should().NotBe(PenValueFormat.Format(2.0, PenValueFormat.FallbackMask)); - row.Unit.Should().Be("kPa"); } [AvaloniaFact] @@ -446,7 +434,6 @@ public void RowText_WithoutAValue_ReadsTheNoValuePlaceholder() using var row = new TrendLegendRowViewModel(chart, penState); row.CurrentValueText.Should().Be(Resources.NoValuePlaceholder); - row.Unit.Should().BeEmpty("a pen with no stored unit renders no unit run"); } [AvaloniaFact] @@ -462,9 +449,9 @@ public void Rebuild_ShowsARevisedNameColourUnitAndMask() legend.Rebuild(); var row = legend.Groups.Single().Rows.Single(); - row.Name.Should().Be("Heater 01"); - row.ColorHex.Should().Be("#00ff00"); - row.Unit.Should().Be("Pa"); + row.PenState.Pen.Name.Should().Be("Heater 01"); + row.PenState.Pen.Color.Should().Be("#00ff00"); + row.PenState.Pen.Unit.Should().Be("Pa"); row.CurrentValueText.Should().Be(PenValueFormat.Format(2.0, "0.000")); } @@ -481,7 +468,7 @@ public void Rebuild_ListsAnAddedPenUnderItsGroupAndDropsARemovedOne() legend.Rebuild(); legend.Groups.Select(group => group.Name).Should().Equal("Dampers", "Heaters"); - SingleRow(legend, 3).IsVisible.Should().BeFalse("a pen joins with the visibility it starts with"); + SingleRow(legend, 3).PenState.IsVisible.Should().BeFalse("a pen joins with the visibility it starts with"); } [AvaloniaFact] @@ -498,7 +485,7 @@ public void Rebuild_KeepsThePanelWidthTheCollapsedStateAndEveryRowsVisibility() legend.FitPanel(900); legend.ToggleExpandedCommand.Execute().Subscribe(); legend.ResizePanel(-30); - SingleRow(legend, 2).IsVisible = false; + SingleRow(legend, 2).ToggleVisibilityCommand.Execute().Subscribe(); chart.ApplyCatalogue( [ @@ -510,9 +497,9 @@ public void Rebuild_KeepsThePanelWidthTheCollapsedStateAndEveryRowsVisibility() legend.IsExpanded.Should().BeFalse(); legend.PanelWidth.Should().Be(TrendLegendViewModel.CollapsedWidth + 30); - SingleRow(legend, 1).IsVisible.Should().BeTrue(); - SingleRow(legend, 2).IsVisible.Should().BeFalse(); - SingleRow(legend, 3).IsVisible.Should().BeFalse("an EnabledOnStart revision switches no pen on"); + SingleRow(legend, 1).PenState.IsVisible.Should().BeTrue(); + SingleRow(legend, 2).PenState.IsVisible.Should().BeFalse(); + SingleRow(legend, 3).PenState.IsVisible.Should().BeFalse("an EnabledOnStart revision switches no pen on"); Group(legend, "Heaters").SwitchState.Should().BeNull(); } @@ -521,43 +508,26 @@ public void Rebuild_AnnouncesTheNewGroupsBeforeItDisposesTheReplacedRows() { var chart = CreateChart(); var pen = new Pen(1, "Pen 1", ["Heaters"], "#ff0000"); + var other = new Pen(2, "Pen 2", ["Heaters"], "#00ff00"); chart.AddPen(pen); + chart.AddPen(other); using var legend = new TrendLegendViewModel(chart); var replacedRow = SingleRow(legend, 1); - var replacedMirroredOnAnnouncement = false; + var replacedFollowedOnAnnouncement = false; legend.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(TrendLegendViewModel.Groups)) { - chart.SetPenVisibility(1, false); - replacedMirroredOnAnnouncement = !replacedRow.IsVisible; + chart.SetActivePen(other.PenId); + replacedFollowedOnAnnouncement = !replacedRow.IsActive; } }; - chart.ApplyCatalogue([pen with { Name = "Heater 01" }]); - legend.Rebuild(); - chart.SetPenVisibility(1, true); - - replacedMirroredOnAnnouncement.Should().BeTrue(); - replacedRow.IsVisible.Should().BeFalse("a disposed row no longer mirrors the chart"); - SingleRow(legend, "Heater 01").IsVisible.Should().BeTrue(); - } - - [AvaloniaFact] - public void AVisibilityWriteOnAReplacedRow_ReachesNoChart() - { - var chart = CreateChart(); - var pen = new Pen(1, "Pen 1", ["Heaters"], "#ff0000"); - chart.AddPen(pen); - using var legend = new TrendLegendViewModel(chart); - var replacedRow = SingleRow(legend, 1); - - chart.ApplyCatalogue([pen with { Color = "#00ff00" }]); + chart.ApplyCatalogue([pen with { Name = "Heater 01" }, other]); legend.Rebuild(); - replacedRow.IsVisible = false; - chart.FindPen(1)!.IsVisible.Should().BeTrue(); - SingleRow(legend, 1).IsVisible.Should().BeTrue(); + replacedFollowedOnAnnouncement.Should().BeTrue(); + SingleRow(legend, 2).IsActive.Should().BeTrue(); } private void LoadInitialHistory(TrendChartViewModel chart, DateTime from, DateTime to) @@ -577,7 +547,7 @@ private static TrendLegendRowViewModel SingleRow(TrendLegendViewModel legend, st return legend.Groups .SelectMany(group => group.Rows) .Distinct() - .Single(row => row.Name == name); + .Single(row => row.PenState.Pen.Name == name); } private static TrendLegendGroupViewModel Group(TrendLegendViewModel legend, string name) diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs index 237418a..2691c0e 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Legend/TrendLegendViewTests.cs @@ -213,8 +213,7 @@ public void APenInTwoGroups_RendersUnderBothHeadersAndSwitchesOffInBoth() var boxes = RowBoxes(window); boxes.Should().HaveCount(2); - boxes[0].IsChecked = false; - Dispatcher.UIThread.RunJobs(); + HeadlessInput.Click(window, boxes[0]); boxes[1].IsChecked.Should().BeFalse(); chart.FindPen(1)!.IsVisible.Should().BeFalse(); @@ -301,6 +300,88 @@ public void ClicksOnTheHeader_SwitchTheGroupOnThenOffAndAMixedHeaderOnAgain() chart.Pens.Should().AllSatisfy(pen => pen.IsVisible.Should().BeTrue()); } + [AvaloniaFact] + public void AClickOnARowBox_SwitchesItsPenAndTheBoxFollows() + { + var chart = CreateChart(); + chart.AddPen(new Pen(1, "Heater 01", ["Heaters"], "#ff0000")); + chart.AddPen(new Pen(2, "Heater 02", ["Heaters"], "#00ff00")); + using var legend = new TrendLegendViewModel(chart); + var window = Realize(legend); + + HeadlessInput.Click(window, RowBoxes(window)[0]); + + BoxStates(window).Should().Equal(false, true); + PenStates(chart).Should().Equal(false, true); + + HeadlessInput.Click(window, RowBoxes(window)[0]); + + BoxStates(window).Should().Equal(true, true); + PenStates(chart).Should().Equal(true, true); + } + + [AvaloniaFact] + public void AClickOnTheGroupSwitch_LeavesEveryRowBoxAgreeingWithItsPen() + { + var chart = CreateChart(); + chart.AddPen(new Pen(1, "Heater 01", ["Heaters"], "#ff0000")); + chart.AddPen(new Pen(2, "Heater 02", ["Heaters"], "#00ff00")); + using var legend = new TrendLegendViewModel(chart); + var window = Realize(legend); + + HeadlessInput.Click(window, HeaderSwitches(window).Single()); + + BoxStates(window).Should().Equal(false, false); + PenStates(chart).Should().Equal(false, false); + + HeadlessInput.Click(window, HeaderSwitches(window).Single()); + + BoxStates(window).Should().Equal(true, true); + PenStates(chart).Should().Equal(true, true); + } + + // The chart switches the pen back after a click, so the box follows only if its one-way binding + // outlived the click. + [AvaloniaFact] + public void APenSwitchedOnTheChart_MovesItsRowBoxBeforeAndAfterAClick() + { + var chart = CreateChart(); + chart.AddPen(new Pen(1, "Heater 01", ["Heaters"], "#ff0000")); + chart.AddPen(new Pen(2, "Heater 02", ["Heaters"], "#00ff00")); + using var legend = new TrendLegendViewModel(chart); + var window = Realize(legend); + + chart.SetPenVisibility(2, false); + Dispatcher.UIThread.RunJobs(); + + BoxStates(window).Should().Equal(true, false); + + HeadlessInput.Click(window, RowBoxes(window)[1]); + + BoxStates(window).Should().Equal(true, true); + PenStates(chart).Should().Equal(true, true); + + chart.SetPenVisibility(2, false); + Dispatcher.UIThread.RunJobs(); + + BoxStates(window).Should().Equal(true, false); + PenStates(chart).Should().Equal(true, false); + } + + [AvaloniaFact] + public void ARowBoxSetInCode_WritesNothingToThePen() + { + var chart = CreateChart(); + chart.AddPen(new Pen(1, "Heater 01", ["Heaters"], "#ff0000")); + using var legend = new TrendLegendViewModel(chart); + var window = Realize(legend); + + RowBoxes(window).Single().IsChecked = false; + Dispatcher.UIThread.RunJobs(); + + chart.FindPen(1)!.IsVisible.Should().BeTrue("the box is not a writer of the pen's visibility"); + } + [AvaloniaFact] public void APenSwitchedOnTheChart_ReDerivesEveryRealisedHeaderItSitsUnder() { @@ -407,6 +488,22 @@ public void TheRealisedSidebar_ShowsARenamedPensNewNameAfterARebuild() RowBoxes(window).Single().IsChecked.Should().BeTrue(); } + [AvaloniaFact] + public void TheRealisedRow_ShowsARevisedUnitWithoutARebuild() + { + var chart = CreateChart(); + var pen = new Pen(1, "Chamber pressure", ["Pressures"], "#ff0000", "kPa", "0.000"); + chart.AddPen(pen); + using var legend = new TrendLegendViewModel(chart); + LoadInitialHistory(chart); + var window = Realize(legend); + + chart.ApplyCatalogue([pen with { Unit = "Pa" }]); + Dispatcher.UIThread.RunJobs(); + + RowTexts(SingleRow(window)).Should().Equal("Chamber pressure", PenValueFormat.Format(2.0, "0.000"), "Pa"); + } + private static Window Realize(TrendLegendViewModel legend) { var window = new Window { Width = 320, Height = 400, Content = new TrendLegendView { DataContext = legend } }; @@ -442,6 +539,16 @@ private static IReadOnlyList RowBoxes(Window window) return [.. Descendants(window).Where(box => box.Name != "GroupSwitch")]; } + private static IReadOnlyList BoxStates(Window window) + { + return [.. RowBoxes(window).Select(box => box.IsChecked)]; + } + + private static IReadOnlyList PenStates(TrendChartViewModel chart) + { + return [.. chart.Pens.Select(pen => pen.IsVisible)]; + } + private static Border SingleRow(Window window) { return Descendants(window).Single(border => border.Name == "RowRoot"); diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs index 1d9fd29..10c269c 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs @@ -133,12 +133,13 @@ public async Task TheRestartNowButton_OfTheSettingsDialog_StartsACopyAndClosesTh await stand.ViewModel.ShowSettingsCommand.Execute(); await HeadlessWait.Until(() => window.OwnedWindows.OfType().Any()); var dialog = window.OwnedWindows.OfType().Single(); - var settings = dialog.DataContext.Should().BeOfType().Which; + dialog.DataContext.Should().BeOfType(); HeadlessInput.Type(dialog, dialog.FindControl("SettingsPassword")!, "secret"); HeadlessInput.Click(dialog, dialog.FindControl