Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -393,6 +399,15 @@ public Task<Result<IReadOnlyList<PenHistoryEnvelope>>> 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<HeldQuery> AwaitStart(int index)
{
await AwaitCondition(() => Started.Count > index);

return Started[index];
}

public async Task LandAndAwaitTheNextStart(HeldQuery query)
{
var startedBefore = Started.Count;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, double?> { [1] = 2.0, [2] = 20.0 };

Expand All @@ -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<int, double?> { [1] = 2.0, [2] = 20.0 };

Expand Down
27 changes: 27 additions & 0 deletions SemiPlot/SemiPlot.Tests.Unit/UI/Chart/EnvelopeLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Avalonia.Headless.XUnit;
using Avalonia.Threading;

using AwesomeAssertions;

Expand All @@ -8,6 +9,7 @@

using SemiPlot.Core.Trends;
using SemiPlot.Tests.Unit.UI.Bridge;
using SemiPlot.UI.Chart;

using Xunit;

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading