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
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,15 @@ Two projects, split on one axis: needs a container or not.
that lands schedules the next 2-60 s ahead, so over `ImmediateScheduler` and a synchronous fake it
reads forever once an extent and a drawn pen exist, and `TestScheduler.Start()` never returns after a
band read landed. A test builds it over a `TestScheduler` and never calls `Start()`
(`UI/Minimap/MinimapStand.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`
(`UI/Minimap/MinimapStand.cs`). A headless test that realises the chart never passes an immediate
UI scheduler, because a history result can then raise the redraw off the UI thread and the view's
`RequestAnimationFrame` throws there; it passes a `TestScheduler` or `AvaloniaScheduler.Instance`
(`docs/architecture/testing-strategy.md#the-ui-scheduler-in-a-realised-view`).
- The chart view redraws on an animation frame. A test drives one frame as
`Dispatcher.UIThread.RunJobs(); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Dispatcher.UIThread.RunJobs();`
(`ChartViewTestBuilder.DriveOneFrame`) and counts frames through `TrendChartView.ServedFrameCount`,
never per `RunJobs()` call, because a plain `RunJobs()` can also fire the real-time 60 Hz headless
render timer (`docs/architecture/testing-strategy.md#frames-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
146 changes: 143 additions & 3 deletions SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartAxisBinderTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using AwesomeAssertions;

using ScottPlot;
using ScottPlot.TickGenerators;

using SemiPlot.Core.Trends;
using SemiPlot.UI.Chart;
Expand All @@ -22,6 +21,9 @@ public sealed class ChartAxisBinderTests
private const string FinerExponentMask = "0.00E+0";
private const string FixedPointMask = "0.000";

private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(10.0);
private static readonly TimeSpan _heldRenderWindow = TimeSpan.FromMilliseconds(200.0);

[Fact]
public void EveryAxisTheBinderCreates_IsALeftAxis()
{
Expand All @@ -32,6 +34,55 @@ public void EveryAxisTheBinderCreates_IsALeftAxis()

binder.AxesByPenId.Should().HaveCount(3);
binder.AxesByPenId.Values.Should().OnlyContain(axis => axis.Edge == Edge.Left);
binder.AxesByPenId.Values.Should().OnlyContain(axis => axis.TickGenerator is LinearTickGenerator);
}

[Fact]
public void OnlyTheActiveVisiblePensGenerator_IsDrawn()
{
using var plot = new Plot();
var binder = new ChartAxisBinder(plot);
var pens = Pens(1, 2, 3);

binder.Apply([Scale(1, isActive: true), Scale(2), Scale(3)], pens);
DrawnPenIds(binder).Should().Equal(1);

binder.Apply([Scale(1), Scale(2, isActive: true), Scale(3)], pens);
DrawnPenIds(binder).Should().Equal(2);

pens[2].SetVisibility(false);
binder.Apply([Scale(1), Scale(2, isActive: true), Scale(3)], pens);
DrawnPenIds(binder).Should().BeEmpty();
}

[Fact]
public void AHiddenLogPensAxis_StaysLogarithmic()
{
using var plot = new Plot();
var binder = new ChartAxisBinder(plot);
var pens = Pens(1, 2);

binder.Apply([LogScale(1, 1e-6, 1e-2, isActive: true), Scale(2)], pens);
binder.Apply([LogScale(1, 1e-6, 1e-2), Scale(2, isActive: true)], pens);

var axis = binder.AxesByPenId[1];
axis.IsVisible.Should().BeFalse();
axis.TickGenerator.Should().BeOfType<LogTickGenerator>().Which.IsDrawn.Should().BeFalse();
axis.Min.Should().BeApproximately(-6.0, DecadeTolerance);
}

[Fact]
public void HideAxis_StopsTheAxisAndItsGenerator()
{
using var plot = new Plot();
var binder = new ChartAxisBinder(plot);
binder.Apply([Scale(1, isActive: true)], Pens(1));

binder.HideAxis(1);

var axis = binder.AxesByPenId[1];
axis.IsVisible.Should().BeFalse();
axis.TickGenerator.Should().BeOfType<LinearTickGenerator>().Which.IsDrawn.Should().BeFalse();
}

[Fact]
Expand Down Expand Up @@ -91,7 +142,7 @@ public void ALogScale_SetsTheAxisLimitsInDecades()
}

[Fact]
public void ALinearScale_GivesALogAxisItsStockGeneratorBack()
public void ALinearScale_GivesALogAxisItsLinearGeneratorBack()
{
using var plot = new Plot();
var binder = new ChartAxisBinder(plot);
Expand All @@ -101,7 +152,7 @@ public void ALinearScale_GivesALogAxisItsStockGeneratorBack()
binder.Apply([Scale(1, isActive: true)], pens);

var axis = binder.AxesByPenId[1];
axis.TickGenerator.Should().BeOfType<NumericAutomatic>();
axis.TickGenerator.Should().BeOfType<LinearTickGenerator>().Which.IsDrawn.Should().BeTrue();
axis.Min.Should().Be(0.0);
axis.Max.Should().Be(1.0);
}
Expand Down Expand Up @@ -158,6 +209,58 @@ public void TheMinorGridWidth_FollowsTheDrawnAxis()
plot.Grid.YAxisStyle.MinorLineStyle.Width.Should().Be(0f, "the drawn axis turned linear");
}

[Fact]
public async Task APanStep_DoesNotWaitForARenderInProgress()
{
using var plot = new Plot();
var binder = new ChartAxisBinder(plot);
var pens = Pens(1, 2);
binder.Apply([Scale(1, isActive: true), Scale(2)], pens);
PenScale[] panned =
[
new(1, Min: 0.5, Max: 1.5, ScaleMode.Auto, IsActive: true, IsLogarithmic: false),
new(2, Min: 0.5, Max: 1.5, ScaleMode.Auto, IsActive: false, IsLogarithmic: false)
];

var cancellation = TestContext.Current.CancellationToken;

using var render = new RenderInProgress(plot);
var panStep = Task.Run(() => binder.Apply(panned, pens), cancellation);
var finished = await Task.WhenAny(panStep, Task.Delay(_lockTimeout, cancellation));

finished.Should().BeSameAs(panStep, "a pan step changes no drawn axis, so it takes no plot lock");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AVisibilityChange_WaitsForTheRenderInProgress(bool isShown)
{
using var plot = new Plot();
var binder = new ChartAxisBinder(plot);
var pens = Pens(1);
binder.Apply([Scale(1, isActive: true)], pens);
if (isShown)
{
pens[1].SetVisibility(false);
binder.Apply([Scale(1, isActive: true)], pens);
}

pens[1].SetVisibility(isShown);
var cancellation = TestContext.Current.CancellationToken;
Task change;
using (new RenderInProgress(plot))
{
change = Task.Run(() => binder.Apply([Scale(1, isActive: true)], pens), cancellation);
var finished = await Task.WhenAny(change, Task.Delay(_heldRenderWindow, cancellation));

finished.Should().NotBeSameAs(change, "the axis and its generator change together between two renders");
}

await change.WaitAsync(_lockTimeout, cancellation);
binder.AxesByPenId[1].IsVisible.Should().Be(isShown);
}

private static List<Tick> MajorTicks(IYAxis axis)
{
using var paint = Paint.NewDisposablePaint();
Expand All @@ -176,6 +279,13 @@ private static IEnumerable<IYAxis> VisibleAxes(ChartAxisBinder binder)
return binder.AxesByPenId.Values.Where(axis => axis.IsVisible);
}

private static IEnumerable<int> DrawnPenIds(ChartAxisBinder binder)
{
return binder.AxesByPenId
.Where(entry => entry.Value.TickGenerator is IDrawnTickGenerator { IsDrawn: true })
.Select(entry => entry.Key);
}

private static Dictionary<int, TrendPenState> Pens(params int[] penIds)
{
return penIds.ToDictionary(
Expand All @@ -192,4 +302,34 @@ private static PenScale LogScale(int penId, double min, double max, bool isActiv
{
return new PenScale(penId, min, max, ScaleMode.Manual, isActive, IsLogarithmic: true);
}

/// <summary>Holds <c>Plot.Sync</c> on a thread of its own, as ScottPlot's render does, until disposed.</summary>
private sealed class RenderInProgress : IDisposable
{
private readonly ManualResetEventSlim _entered = new();
private readonly ManualResetEventSlim _finished = new();
private readonly Thread _thread;

public RenderInProgress(Plot plot)
{
_thread = new Thread(() =>
{
lock (plot.Sync)
{
_entered.Set();
_finished.Wait();
}
});
_thread.Start();
_entered.Wait();
}

public void Dispose()
{
_finished.Set();
_thread.Join();
_entered.Dispose();
_finished.Dispose();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public async Task AGestureEndingOnTheWindowBeingReadLeavesTheReadRunning()
debouncer.Request(RequestAtNotch(notch));
}

scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks);
scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks + 1);
var running = queries.Started.Should().ContainSingle().Which;
running.Request.FromUtc.Should().Be(From.AddSeconds(LastNotchBeforeTheFirstTick));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;

Expand All @@ -8,6 +9,7 @@
using Microsoft.Reactive.Testing;

using SemiPlot.Core.Trends;
using SemiPlot.UI.Chart;

using Xunit;

Expand All @@ -20,6 +22,9 @@ namespace SemiPlot.Tests.Unit.UI.Chart;
[Trait("Category", "Unit")]
public sealed class ChartHistoryRequestDebouncerTests
{
// Well under the query's own block, so a request that waited for the query cannot pass.
private static readonly TimeSpan _promptReturn = TimeSpan.FromSeconds(5.0);

[Fact]
public void RapidRequests_CollapseToOneTrailingQuery()
{
Expand Down Expand Up @@ -188,13 +193,56 @@ public void AContinuousGestureFetchesAtTheCapInterval()
debouncer.Request(RequestAtNotch(notch));
}

// The 2000 ms tick's admission runs one tick after it, on the data scheduler.
scheduler.AdvanceBy(1);

queryCount.Should().Be(5);

scheduler.AdvanceBy(DebounceWindow.Ticks + 1);

queryCount.Should().Be(6);
}

// A debounce window no request outlives leaves the sample head as the only admission. The query blocks on
// a real thread before it returns its task, as a provider that opens its connection synchronously does.
[Fact]
public async Task RequestReturnsWhileAPacedAdmissionIsStartingItsQuery()
{
using var queryRelease = new ManualResetEventSlim();
var queryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var debouncer = new ChartHistoryRequestDebouncer(
(request, cancellation) =>
{
queryStarted.TrySetResult();
queryRelease.Wait(TestDeadline, cancellation);

return Task.FromResult(Ok(request));
},
(_, _) => { },
_ => { },
TimeSpan.FromHours(1.0),
TimeSpan.FromMilliseconds(10),
DefaultScheduler.Instance,
ImmediateScheduler.Instance);

try
{
debouncer.Request(RequestAtNotch(1));
await queryStarted.Task.WaitAsync(TestDeadline, TestContext.Current.CancellationToken);

var secondRequest = Task.Run(() => debouncer.Request(RequestAtNotch(2)), TestContext.Current.CancellationToken);
var returnedFirst = await Task.WhenAny(
secondRequest,
Task.Delay(_promptReturn, TestContext.Current.CancellationToken));

returnedFirst.Should().BeSameAs(secondRequest);
}
finally
{
queryRelease.Set();
}
}

[Fact]
public void TheSameWindowRequestedTwiceQueriesOnce()
{
Expand Down
Loading
Loading