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