diff --git a/CLAUDE.md b/CLAUDE.md
index 4560db3..7561f4f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,8 +122,7 @@ Two projects, split on one axis: needs a container or not.
`SemiPlot.Tests.Unit/UI/HeadlessInput.cs` (`Click`, `Clear`, `Type`, `Press`) on the `TopLevel` that
shows the control; no test class carries a helper of its own for these four gestures.
`git grep -nE "static void (Click|Clear|Type|Press)\(" -- SemiPlot/SemiPlot.Tests.Unit` prints only
- `HeadlessInput.cs` lines. A hover or a held mouse button, which only `ThemeTests` drives, is outside
- the rule.
+ `HeadlessInput.cs` lines. A hover, a drag or a held mouse button is outside the rule.
- Neither project references the other. Core, `SemiPlot.DataSource.Postgres` and `SemiPlot.UI` each
name both in `InternalsVisibleTo`.
- `SemiPlot.Tests.Unit` sets `failSkips` in `xunit.runner.json`, so no gated test may live there.
@@ -134,7 +133,11 @@ Two projects, split on one axis: needs a container or not.
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`). The chart's redraw hangs nothing: it is a one-shot
+ (`UI/MainWindow/MainWindowTestBuilder.cs`). `MinimapViewModel` hangs the same way: each band read
+ 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`
diff --git a/SemiPlot/SemiPlot.Core/Trends/BandFigure.cs b/SemiPlot/SemiPlot.Core/Trends/BandFigure.cs
new file mode 100644
index 0000000..e114b71
--- /dev/null
+++ b/SemiPlot/SemiPlot.Core/Trends/BandFigure.cs
@@ -0,0 +1,6 @@
+namespace SemiPlot.Core.Trends;
+
+/// One unbroken run of the minimap band in control coordinates: max forward, min back, and the centre.
+public sealed record BandFigure(IReadOnlyList Outline, IReadOnlyList CenterLine);
+
+public readonly record struct BandPoint(double X, double Y);
diff --git a/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs b/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs
index f600b92..724668e 100644
--- a/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/MinimapGeometry.cs
@@ -1,7 +1,13 @@
namespace SemiPlot.Core.Trends;
+/// Where the hover time sits in the label row, and which end label it hides.
+public readonly record struct HoverTimePlacement(double Left, bool CoversFirst, bool CoversLast);
+
public static class MinimapGeometry
{
+ private const double FlatBandPadding = 1.0;
+ private const double LoneColumnWidth = 2.0;
+
// A zero-or-negative extent span (no data yet) yields the full strip so the highlight never collapses.
public static (double Start, double Width) WindowFraction(
DateTime extentFirst,
@@ -21,10 +27,192 @@ public static (double Start, double Width) WindowFraction(
return (start, end - start);
}
+ public static (double Left, double Width) MarkerSpan(
+ double startFraction,
+ double widthFraction,
+ double stripWidth,
+ double minimumWidth)
+ {
+ if (stripWidth <= 0.0)
+ {
+ return (0.0, 0.0);
+ }
+
+ var width = Math.Min(stripWidth, Math.Max(minimumWidth, widthFraction * stripWidth));
+
+ return (SpanLeftWithin(startFraction * stripWidth, width, stripWidth), width);
+ }
+
+ /// Moves a span's left edge so it lies inside [0, limit]; a wider span starts at 0.
+ public static double SpanLeftWithin(double left, double width, double limit)
+ {
+ return Math.Max(0.0, Math.Min(left, limit - width));
+ }
+
+ /// Centres the hover time on the pointer in a label row whose end labels sit at its edges.
+ public static HoverTimePlacement PlaceHoverTime(
+ double pointerX,
+ double timeWidth,
+ double rowWidth,
+ double firstLabelWidth,
+ double lastLabelWidth,
+ double coverDistance)
+ {
+ var left = SpanLeftWithin(pointerX - (timeWidth / 2.0), timeWidth, rowWidth);
+
+ return new HoverTimePlacement(
+ left,
+ CoversFirst: left < firstLabelWidth + coverDistance,
+ CoversLast: left + timeWidth > rowWidth - lastLabelWidth - coverDistance);
+ }
+
+ public static DateTime RightBound(DateTime extentLast, DateTime? newestSample)
+ {
+ return newestSample > extentLast ? newestSample.Value : extentLast;
+ }
+
public static DateTime TimeAtFraction(DateTime extentFirst, DateTime extentLast, double fraction)
{
var span = extentLast - extentFirst;
return extentFirst + (span * Math.Clamp(fraction, 0.0, 1.0));
}
+
+ public static IReadOnlyList BandFigures(
+ PenHistoryEnvelope band,
+ DateTime first,
+ DateTime last,
+ double width,
+ double height)
+ {
+ var spanSeconds = (last - first).TotalSeconds;
+ if (spanSeconds <= 0.0 || width <= 0.0 || height <= 0.0
+ || !TryReadBandRange(band, first, last, out var bottom, out var top))
+ {
+ return [];
+ }
+
+ var scale = new BandScale(first, spanSeconds, width, bottom, top, height);
+ var figures = new List();
+ var runStart = -1;
+
+ for (var index = 0; index <= band.Timestamps.Count; index++)
+ {
+ if (index < band.Timestamps.Count && IsDrawn(band, index, first, last))
+ {
+ runStart = runStart < 0 ? index : runStart;
+ continue;
+ }
+
+ if (runStart >= 0)
+ {
+ figures.Add(Figure(band, runStart, index, scale));
+ runStart = -1;
+ }
+ }
+
+ return figures;
+ }
+
+ private static bool TryReadBandRange(
+ PenHistoryEnvelope band,
+ DateTime first,
+ DateTime last,
+ out double bottom,
+ out double top)
+ {
+ bottom = double.MaxValue;
+ top = double.MinValue;
+ var hasValue = false;
+
+ for (var index = 0; index < band.Timestamps.Count; index++)
+ {
+ if (IsDrawn(band, index, first, last))
+ {
+ hasValue |= ValueRange.Widen(band.Min[index], isLogarithmic: false, ref bottom, ref top);
+ hasValue |= ValueRange.Widen(band.Max[index], isLogarithmic: false, ref bottom, ref top);
+ }
+ }
+
+ if (hasValue && bottom == top)
+ {
+ bottom -= FlatBandPadding;
+ top += FlatBandPadding;
+ }
+
+ return hasValue;
+ }
+
+ private static bool IsDrawn(PenHistoryEnvelope band, int index, DateTime first, DateTime last)
+ {
+ var timestamp = band.Timestamps[index];
+
+ return timestamp >= first && timestamp <= last
+ && double.IsFinite(band.Min[index])
+ && double.IsFinite(band.Max[index])
+ && double.IsFinite(band.Center[index]);
+ }
+
+ private static BandFigure Figure(PenHistoryEnvelope band, int start, int end, BandScale scale)
+ {
+ if (end - start == 1)
+ {
+ return LoneColumn(band, start, scale);
+ }
+
+ var outline = new List(2 * (end - start));
+ var centerLine = new List(end - start);
+
+ for (var index = start; index < end; index++)
+ {
+ var x = scale.X(band.Timestamps[index]);
+ outline.Add(new BandPoint(x, scale.Y(band.Max[index])));
+ centerLine.Add(new BandPoint(x, scale.Y(band.Center[index])));
+ }
+
+ for (var index = end - 1; index >= start; index--)
+ {
+ outline.Add(new BandPoint(scale.X(band.Timestamps[index]), scale.Y(band.Min[index])));
+ }
+
+ return new BandFigure(outline, centerLine);
+ }
+
+ private static BandFigure LoneColumn(PenHistoryEnvelope band, int index, BandScale scale)
+ {
+ var center = scale.X(band.Timestamps[index]);
+ var left = SpanLeftWithin(center - (LoneColumnWidth / 2.0), LoneColumnWidth, scale.Width);
+ var right = Math.Min(scale.Width, left + LoneColumnWidth);
+ var top = scale.Y(band.Max[index]);
+ var bottom = scale.Y(band.Min[index]);
+ var middle = scale.Y(band.Center[index]);
+
+ return new BandFigure(
+ [
+ new BandPoint(left, top),
+ new BandPoint(right, top),
+ new BandPoint(right, bottom),
+ new BandPoint(left, bottom)
+ ],
+ [new BandPoint(left, middle), new BandPoint(right, middle)]);
+ }
+
+ private readonly record struct BandScale(
+ DateTime First,
+ double SpanSeconds,
+ double Width,
+ double Bottom,
+ double Top,
+ double Height)
+ {
+ public double X(DateTime timestamp)
+ {
+ return (timestamp - First).TotalSeconds / SpanSeconds * Width;
+ }
+
+ public double Y(double value)
+ {
+ return (Top - value) / (Top - Bottom) * Height;
+ }
+ }
}
diff --git a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
index e81f94a..94784ea 100644
--- a/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
+++ b/SemiPlot/SemiPlot.Core/Trends/PenScaleModel.cs
@@ -103,26 +103,13 @@ private static bool TryReadRange(
break;
}
- hasValue |= Widen(envelope.Min[index], isLogarithmic, ref min, ref max);
- hasValue |= Widen(envelope.Max[index], isLogarithmic, ref min, ref max);
+ hasValue |= ValueRange.Widen(envelope.Min[index], isLogarithmic, ref min, ref max);
+ hasValue |= ValueRange.Widen(envelope.Max[index], isLogarithmic, ref min, ref max);
}
return hasValue;
}
- private static bool Widen(double value, bool isLogarithmic, ref double min, ref double max)
- {
- if (double.IsNaN(value) || (isLogarithmic && value <= 0.0))
- {
- return false;
- }
-
- min = Math.Min(min, value);
- max = Math.Max(max, value);
-
- return true;
- }
-
// PenHistoryEnvelope enforces strictly ascending timestamps.
private static int FirstAtOrAfter(IReadOnlyList timestamps, DateTime windowStart)
{
diff --git a/SemiPlot/SemiPlot.Core/Trends/ValueRange.cs b/SemiPlot/SemiPlot.Core/Trends/ValueRange.cs
new file mode 100644
index 0000000..6ac7996
--- /dev/null
+++ b/SemiPlot/SemiPlot.Core/Trends/ValueRange.cs
@@ -0,0 +1,17 @@
+namespace SemiPlot.Core.Trends;
+
+internal static class ValueRange
+{
+ public static bool Widen(double value, bool isLogarithmic, ref double min, ref double max)
+ {
+ if (double.IsNaN(value) || (isLogarithmic && value <= 0.0))
+ {
+ return false;
+ }
+
+ min = Math.Min(min, value);
+ max = Math.Max(max, value);
+
+ return true;
+ }
+}
diff --git a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs
index 4b9e378..afa3536 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/Core/Trends/MinimapGeometryTests.cs
@@ -11,6 +11,12 @@ namespace SemiPlot.Tests.Unit.Core.Trends;
[Trait("Category", "Unit")]
public sealed class MinimapGeometryTests
{
+ private const double StripWidth = 900.0;
+ private const double MinimumMarkerWidth = 6.0;
+ private const double BandWidth = 400.0;
+ private const double BandHeight = 30.0;
+ private const double LabelWidth = 60.0;
+ private const double CoverDistance = 6.0;
private static readonly DateTime _first = new(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc);
private static readonly DateTime _last = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc);
@@ -56,6 +62,230 @@ public void WindowFraction_ZeroSpanExtent_ReturnsFullStrip()
width.Should().Be(1.0);
}
+ [Theory]
+ [InlineData(1.0)]
+ [InlineData(0.999)]
+ public void MarkerSpan_AtTheRightEnd_StaysInsideTheStrip(double startFraction)
+ {
+ var (left, width) = MinimapGeometry.MarkerSpan(startFraction, 0.0, StripWidth, MinimumMarkerWidth);
+
+ width.Should().Be(MinimumMarkerWidth);
+ (left + width).Should().BeLessThanOrEqualTo(StripWidth);
+ }
+
+ [Fact]
+ public void MarkerSpan_WindowWiderThanTheStrip_FillsItExactly()
+ {
+ var (left, width) = MinimapGeometry.MarkerSpan(-0.5, 2.0, StripWidth, MinimumMarkerWidth);
+
+ left.Should().Be(0.0);
+ width.Should().Be(StripWidth);
+ }
+
+ [Fact]
+ public void MarkerSpan_ZeroStripWidth_IsEmpty()
+ {
+ var span = MinimapGeometry.MarkerSpan(0.5, 0.1, 0.0, MinimumMarkerWidth);
+
+ span.Should().Be((0.0, 0.0));
+ }
+
+ [Fact]
+ public void MarkerSpan_WindowAtTheLeftEdge_KeepsLeftAtZero()
+ {
+ var (left, width) = MinimapGeometry.MarkerSpan(0.0, 0.001, StripWidth, MinimumMarkerWidth);
+
+ left.Should().Be(0.0);
+ width.Should().Be(MinimumMarkerWidth);
+ }
+
+ [Theory]
+ [InlineData(100.0, 100.0)]
+ [InlineData(-5.0, 0.0)]
+ [InlineData(899.5, StripWidth - 1.0)]
+ public void SpanLeftWithin_KeepsAOnePixelSpanInsideTheLimit(double left, double expected)
+ {
+ MinimapGeometry.SpanLeftWithin(left, 1.0, StripWidth).Should().Be(expected);
+ }
+
+ [Fact]
+ public void SpanLeftWithin_ASpanWiderThanTheLimit_StartsAtItsLeftEdge()
+ {
+ MinimapGeometry.SpanLeftWithin(10.0, StripWidth + 50.0, StripWidth).Should().Be(0.0);
+ }
+
+ [Fact]
+ public void PlaceHoverTime_InTheMiddle_CentresOnThePointerAndCoversNeitherEndLabel()
+ {
+ var placement = MinimapGeometry.PlaceHoverTime(
+ 450.0, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance);
+
+ placement.Should().Be(
+ new HoverTimePlacement(450.0 - (LabelWidth / 2.0), CoversFirst: false, CoversLast: false));
+ }
+
+ [Theory]
+ [InlineData(0.0, 0.0)]
+ [InlineData(StripWidth, StripWidth - LabelWidth)]
+ public void PlaceHoverTime_AtAnEnd_StaysInTheRowAndCoversThatEndLabelOnly(double pointerX, double expectedLeft)
+ {
+ var placement = MinimapGeometry.PlaceHoverTime(
+ pointerX, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance);
+
+ var atLeftEnd = pointerX == 0.0;
+ placement.Should().Be(new HoverTimePlacement(expectedLeft, CoversFirst: atLeftEnd, CoversLast: !atLeftEnd));
+ }
+
+ [Theory]
+ [InlineData(CoverDistance - 3.0, true)]
+ [InlineData(CoverDistance + 1.0, false)]
+ public void PlaceHoverTime_ATimeWithinTheCoverDistanceOfAnEndLabel_CoversIt(double distance, bool covers)
+ {
+ var nearFirst = LabelWidth + distance + (LabelWidth / 2.0);
+ var nearLast = StripWidth - LabelWidth - distance - (LabelWidth / 2.0);
+
+ MinimapGeometry.PlaceHoverTime(nearFirst, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance)
+ .CoversFirst.Should().Be(covers);
+ MinimapGeometry.PlaceHoverTime(nearLast, LabelWidth, StripWidth, LabelWidth, LabelWidth, CoverDistance)
+ .CoversLast.Should().Be(covers);
+ }
+
+ [Fact]
+ public void RightBound_IsTheLaterOfTheExtentAndTheNewestSample()
+ {
+ var newer = _last + TimeSpan.FromMinutes(5.0);
+ var older = _last - TimeSpan.FromMinutes(5.0);
+
+ MinimapGeometry.RightBound(_last, newer).Should().Be(newer);
+ MinimapGeometry.RightBound(_last, older).Should().Be(_last);
+ MinimapGeometry.RightBound(_last, null).Should().Be(_last);
+ }
+
+ [Fact]
+ public void ABandWithABreak_DrawsTwoFigures()
+ {
+ var band = Envelope(
+ (0.0, 1.0, 3.0, 2.0),
+ (1.0, 2.0, 4.0, 3.0),
+ (2.0, double.NaN, double.NaN, double.NaN),
+ (3.0, 0.0, 2.0, 1.0),
+ (4.0, 1.0, 5.0, 3.0));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight);
+
+ // The drawn range is [0, 5] over 30 px, so a value v sits at 6 * (5 - v).
+ figures.Should().HaveCount(2);
+ figures[0].Outline.Should().Equal(
+ new BandPoint(0.0, 12.0),
+ new BandPoint(100.0, 6.0),
+ new BandPoint(100.0, 18.0),
+ new BandPoint(0.0, 24.0));
+ figures[0].CenterLine.Should().Equal(new BandPoint(0.0, 18.0), new BandPoint(100.0, 12.0));
+ figures[1].Outline.Should().Equal(
+ new BandPoint(300.0, 18.0),
+ new BandPoint(400.0, 0.0),
+ new BandPoint(400.0, 24.0),
+ new BandPoint(300.0, BandHeight));
+ figures[1].CenterLine.Should().Equal(new BandPoint(300.0, 24.0), new BandPoint(400.0, 12.0));
+ }
+
+ [Fact]
+ public void ALoneColumnBetweenBreaks_DrawsAFigureTwoPixelsWide()
+ {
+ var band = Envelope(
+ (0.0, 1.0, 3.0, 2.0),
+ (1.0, 2.0, 4.0, 3.0),
+ (2.0, double.NaN, double.NaN, double.NaN),
+ (3.0, 2.0, 4.0, 3.0),
+ (4.0, double.NaN, double.NaN, double.NaN));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight);
+
+ figures.Should().HaveCount(2);
+ figures[1].Outline.Select(point => point.X).Should().Equal(299.0, 301.0, 301.0, 299.0);
+ figures[1].CenterLine.Select(point => point.X).Should().Equal(299.0, 301.0);
+ }
+
+ [Fact]
+ public void AnInfiniteColumn_IsABreakAndEveryPointStaysFinite()
+ {
+ var band = Envelope(
+ (0.0, 0.0, 2.0, 1.0),
+ (1.0, 1.0, 5.0, 3.0),
+ (2.0, double.NegativeInfinity, double.PositiveInfinity, 0.0),
+ (3.0, 1.0, 3.0, 2.0),
+ (4.0, 0.0, 5.0, 2.0));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight);
+
+ figures.Should().HaveCount(2);
+ figures.SelectMany(figure => figure.Outline.Concat(figure.CenterLine))
+ .Should().OnlyContain(point => double.IsFinite(point.Y));
+ }
+
+ [Fact]
+ public void AColumnWithNoCentre_IsLeftOutOfTheRange()
+ {
+ var band = Envelope(
+ (0.0, 0.0, 5.0, 2.0),
+ (1.0, 1.0, 4.0, 2.0),
+ (2.0, -100.0, 100.0, double.NaN),
+ (3.0, 1.0, 4.0, 2.0),
+ (4.0, 0.0, 5.0, 3.0));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(4.0), BandWidth, BandHeight);
+
+ var outlineYs = figures.SelectMany(figure => figure.Outline).Select(point => point.Y).ToArray();
+ outlineYs.Min().Should().Be(0.0, "the drawn maximum 5 maps to the top");
+ outlineYs.Max().Should().Be(BandHeight, "the drawn minimum 0 maps to the bottom");
+ }
+
+ [Fact]
+ public void AFlatPen_DrawsAMidLineBand()
+ {
+ var band = Envelope((0.0, 5.0, 5.0, 5.0), (1.0, 5.0, 5.0, 5.0));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(1.0), BandWidth, BandHeight);
+
+ figures.Should().ContainSingle();
+ figures[0].Outline.Should().OnlyContain(point => point.Y == BandHeight / 2.0);
+ figures[0].CenterLine.Should().OnlyContain(point => point.Y == BandHeight / 2.0);
+ }
+
+ [Fact]
+ public void AnAllNaNBand_DrawsNothing()
+ {
+ var band = Envelope(
+ (0.0, double.NaN, double.NaN, double.NaN),
+ (1.0, double.NaN, double.NaN, double.NaN));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(1.0), BandWidth, BandHeight);
+
+ figures.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void BandFigures_DropColumnsOutsideTheBoundsAndMapTheMaxToTheTop()
+ {
+ var band = Envelope(
+ (-1.0, -100.0, 100.0, 0.0),
+ (0.0, 0.0, 10.0, 5.0),
+ (1.0, 2.0, 8.0, 5.0),
+ (2.0, -100.0, 100.0, 0.0));
+
+ var figures = MinimapGeometry.BandFigures(band, _first, _first.AddDays(1.0), BandWidth, BandHeight);
+
+ figures.Should().ContainSingle();
+ figures[0].Outline.Should().Equal(
+ new BandPoint(0.0, 0.0),
+ new BandPoint(BandWidth, 6.0),
+ new BandPoint(BandWidth, 24.0),
+ new BandPoint(0.0, BandHeight));
+ figures[0].CenterLine.Should().Equal(
+ new BandPoint(0.0, BandHeight / 2.0),
+ new BandPoint(BandWidth, BandHeight / 2.0));
+ }
+
[Fact]
public void TimeAtFraction_Half_ReturnsExtentMidpoint()
{
@@ -74,4 +304,14 @@ public void TimeAtFraction_OutOfRange_ClampsToExtentEdge(double fraction)
time.Should().BeOnOrAfter(_first);
time.Should().BeOnOrBefore(_last);
}
+
+ private static PenHistoryEnvelope Envelope(params (double Days, double Min, double Max, double Center)[] columns)
+ {
+ return new PenHistoryEnvelope(
+ 1,
+ [.. columns.Select(column => _first.AddDays(column.Days))],
+ [.. columns.Select(column => column.Min)],
+ [.. columns.Select(column => column.Max)],
+ [.. columns.Select(column => column.Center)]);
+ }
}
diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs
index 085f4f6..e978e86 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs
@@ -9,6 +9,7 @@
using SemiPlot.Core.Data;
using SemiPlot.Core.Data.Errors;
using SemiPlot.Core.Trends;
+using SemiPlot.UI.Minimap;
namespace SemiPlot.Tests.Unit.UI.Bridge;
@@ -29,6 +30,10 @@ internal sealed class FakeDataProvider(
// instead of waiting for a tick that this fake never runs.
private readonly Subject _connectionFaults = new();
+ // A test waits for a query on another thread and then reads it, so the log is appended and copied whole.
+ private readonly Lock _historyQueriesGate = new();
+ private readonly List _historyQueries = [];
+
public DateTime ArchiveFirstUtc { get; set; } = new(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc);
public DateTime ArchiveLastUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
@@ -75,19 +80,42 @@ internal sealed class FakeDataProvider(
// for a pen holding no row in the window. A requested pen missing from the result is not an error.
public HashSet OmittedPenIds { get; } = [];
- public int HistoryQueryCount { get; private set; }
+ // A history read that throws instead of answering, the shape of a defect rather than of an outage.
+ public Exception? HistoryReadException { get; set; }
+
+ public IReadOnlyList ChartHistoryQueries =>
+ [.. HistoryQueries.Where(query => query.TargetColumnCount != MinimapBandFeed.MinimapColumns)];
+
+ public IReadOnlyList BandHistoryQueries =>
+ [.. HistoryQueries.Where(query => query.TargetColumnCount == MinimapBandFeed.MinimapColumns)];
+
+ /// The chart's query count; like every LastQueried member below, it ignores the band's reads.
+ public int HistoryQueryCount => ChartHistoryQueries.Count;
+
+ public IReadOnlyList? LastQueriedPenIds => LastHistoryQuery?.PenIds;
- public IReadOnlyList? LastQueriedPenIds { get; private set; }
+ public AggregationLayer? LastQueriedLayer => LastHistoryQuery?.Layer;
- public AggregationLayer? LastQueriedLayer { get; private set; }
+ public DateTime? LastQueriedFromUtc => LastHistoryQuery?.FromUtc;
- public DateTime? LastQueriedFromUtc { get; private set; }
+ public DateTime? LastQueriedToUtc => LastHistoryQuery?.ToUtc;
- public DateTime? LastQueriedToUtc { get; private set; }
+ public int? LastQueriedTargetColumnCount => LastHistoryQuery?.TargetColumnCount;
- public int? LastQueriedTargetColumnCount { get; private set; }
+ public CancellationToken LastQueriedCancellationToken => LastHistoryQuery?.CancellationToken ?? default;
- public CancellationToken LastQueriedCancellationToken { get; private set; }
+ private HistoryQuery? LastHistoryQuery => ChartHistoryQueries is [.., var last] ? last : null;
+
+ private IReadOnlyList HistoryQueries
+ {
+ get
+ {
+ lock (_historyQueriesGate)
+ {
+ return [.. _historyQueries];
+ }
+ }
+ }
public int PensQueryCount { get; private set; }
@@ -211,14 +239,15 @@ public Task>> QueryHistoryAsync(
int targetColumnCount,
CancellationToken cancellationToken = default)
{
- // The count moves last: a test that waits for it on another thread then reads the query it counts.
- LastQueriedPenIds = penIds;
- LastQueriedLayer = layer;
- LastQueriedFromUtc = fromUtc;
- LastQueriedToUtc = toUtc;
- LastQueriedTargetColumnCount = targetColumnCount;
- LastQueriedCancellationToken = cancellationToken;
- HistoryQueryCount++;
+ lock (_historyQueriesGate)
+ {
+ _historyQueries.Add(new HistoryQuery(penIds, fromUtc, toUtc, layer, targetColumnCount, cancellationToken));
+ }
+
+ if (HistoryReadException is { } readException)
+ {
+ return Task.FromException>>(readException);
+ }
if (FailHistory)
{
@@ -288,3 +317,11 @@ private ArchiveExtent CatalogueExtent()
return Pens.Count == 0 ? ArchiveExtent.Empty : new ArchiveExtent(ArchiveFirstUtc, ArchiveLastUtc);
}
}
+
+internal sealed record HistoryQuery(
+ IReadOnlyList PenIds,
+ DateTime FromUtc,
+ DateTime ToUtc,
+ AggregationLayer Layer,
+ int TargetColumnCount,
+ CancellationToken CancellationToken);
diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs
index 935d466..c0261c9 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartNavigationControllerTests.cs
@@ -101,6 +101,76 @@ public void OnLiveEdge_WhenNotSticky_LeavesWindowUnchanged()
controller.To.Should().Be(toBefore);
}
+ [Fact]
+ public void NewestSample_OnAFreshController_IsNull()
+ {
+ var controller = new ChartNavigationController();
+
+ controller.NewestSample.Should().BeNull("the constructor's wall clock is no sample");
+ }
+
+ [Fact]
+ public void TrackDataExtents_SetsTheNewestSampleAndRaisesItsMove()
+ {
+ var controller = new ChartNavigationController();
+ var moves = new List();
+ controller.NewestSampleMoved += (_, sample) => moves.Add(sample);
+
+ controller.TrackDataExtents(_first, _last);
+
+ controller.NewestSample.Should().Be(_last);
+ moves.Should().Equal(_last);
+ }
+
+ [Fact]
+ public void OnLiveEdge_SetsTheNewestSampleAndRaisesEachMove_StickyOrNot()
+ {
+ var controller = Loaded();
+ var moves = new List();
+ controller.NewestSampleMoved += (_, sample) => moves.Add(sample);
+ var whileSticky = _last.AddMinutes(1.0);
+ var whileDetached = _last.AddMinutes(2.0);
+
+ controller.OnLiveEdge(whileSticky);
+ controller.SetSticky(false);
+ controller.OnLiveEdge(whileDetached);
+
+ controller.NewestSample.Should().Be(whileDetached);
+ moves.Should().Equal(whileSticky, whileDetached);
+ }
+
+ [Fact]
+ public void NewestSampleMoved_SeesTheMovedWindowAndPrecedesWindowChanged()
+ {
+ var controller = new ChartNavigationController();
+ var raised = new List<(string Event, DateTime To)>();
+ controller.NewestSampleMoved += (_, _) => raised.Add((nameof(controller.NewestSampleMoved), controller.To));
+ controller.WindowChanged += (_, window) => raised.Add((nameof(controller.WindowChanged), window.To));
+ var advanced = _last.AddMinutes(2.0);
+
+ controller.TrackDataExtents(_first, _last);
+ controller.OnLiveEdge(advanced);
+
+ raised.Should().Equal(
+ (nameof(controller.NewestSampleMoved), _last),
+ (nameof(controller.WindowChanged), _last),
+ (nameof(controller.NewestSampleMoved), advanced),
+ (nameof(controller.WindowChanged), advanced));
+ }
+
+ [Fact]
+ public void OnLiveEdge_WithASampleOlderThanTheNewest_LeavesItAndRaisesNothing()
+ {
+ var controller = Loaded();
+ var moves = 0;
+ controller.NewestSampleMoved += (_, _) => moves++;
+
+ controller.OnLiveEdge(_last.AddMinutes(-1.0));
+
+ controller.NewestSample.Should().Be(_last);
+ moves.Should().Be(0);
+ }
+
// Half a layer's ceiling sits well inside it, clear of the 25% zoom quantisation step and the 10%
// hysteresis band. The Day row's 1.2x hour ceiling stays inside the 365-day maximum window at 1024
// columns, so the answer comes from the ladder, not from TrendNavigationModel truncating the width.
diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs
index f8df1eb..df2c1b5 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Di/TrendWindowBuildTests.cs
@@ -56,6 +56,7 @@ public async Task TheChartAndTheMinimap_ReportIntoThePanelTheWindowShows()
var panel = container.GetRequiredService();
+ // The failed extent leaves the minimap band unread, so the history failure here is the chart's own.
panel.Entries.Select(entry => entry.View.Title).Should().Contain(
[
ArchiveFailureMapper.Map(new Error("Forced history failure.")).Title,
diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs
index 10c269c..1388679 100644
--- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs
+++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/MainWindowViewTests.cs
@@ -16,11 +16,13 @@
using SemiPlot.Core.Data;
using SemiPlot.Core.Trends;
+using SemiPlot.Tests.Unit.UI.Minimap;
using SemiPlot.Tests.Unit.UI.PenEditor;
using SemiPlot.Tests.Unit.UI.Settings;
using SemiPlot.UI.Legend;
using SemiPlot.UI.MainWindow;
using SemiPlot.UI.Messages;
+using SemiPlot.UI.Minimap;
using SemiPlot.UI.PenEditor;
using SemiPlot.UI.Settings;
@@ -212,6 +214,27 @@ public void EveryViewMenuRow_FollowsItsOwnFlagOnTheRealisedWindow()
}
}
+ [AvaloniaFact]
+ public void TheMinimapRow_ShowsTheWholeLabelRowUnderTheStrip()
+ {
+ using var stand = NewWindowStand();
+ var window = new MainWindowView { DataContext = stand.ViewModel };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ var row = window.FindControl("MinimapRow")!;
+ var minimap = window.FindControl("Minimap")!;
+ var strip = minimap.FindControl