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
43 changes: 31 additions & 12 deletions src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@ private static string HighlightColorDisplayName(HighlightColor color)
return joined.Length == 0 ? joined : char.ToUpperInvariant(joined[0]) + joined[1..];
}

private static bool IsCategoricalOther(HistogramData data, int group) =>
group < data.Groups.Count && data.Groups[group].ColorClass == "histogram-cat-other";

private static string? SelectActiveOriginLog(LogTableState state)
{
var activeTab = state.EventTables.FirstOrDefault(tab => tab.Id == state.ActiveEventLogId);
Expand Down Expand Up @@ -534,8 +537,6 @@ private IReadOnlyList<AxisLabel> AxisLabels()
return labels;
}

private int BarsAreaHeight() => Math.Max(0, _plotHeightPx - AxisReservePx);

private string BarTooltip(HistogramRenderBin bin)
{
var start = ToDisplay(new DateTime(bin.StartTicks, DateTimeKind.Utc));
Expand All @@ -547,6 +548,8 @@ private string BarTooltip(HistogramRenderBin bin)
return $"{bin.Total} {_baseData?.EventNoun ?? "events"}{GroupBreakdown(bin)}, {startText} - {endText}";
}

private int BarsAreaHeight() => Math.Max(0, _plotHeightPx - AxisReservePx);

private string BinCursorAnnouncement(HistogramRenderBin bin)
{
var start = ToDisplay(new DateTime(bin.StartTicks, DateTimeKind.Utc));
Expand Down Expand Up @@ -680,9 +683,6 @@ private string GroupHighlightText(int group)
return ResolveGroupHighlight(masks[group]).Description;
}

private static bool IsCategoricalOther(HistogramData data, int group) =>
group < data.Groups.Count && data.Groups[group].ColorClass == "histogram-cat-other";

private void HandleKeyDown(KeyboardEventArgs args)
{
if (args is { ShiftKey: true, Key: "ArrowLeft" or "ArrowRight" })
Expand Down Expand Up @@ -827,6 +827,14 @@ private void RecomputeSegmentHeights()
if (_render is { } render && _baseData is { } data) { ComputeSegmentHeights(render, data.Groups.Count); }
}

// Refresh the cached assertive-region bin readout against the current (post-filter-change) state. The cached string
// embeds the highlight description, so it must be re-derived on every highlight change (disarm, plan change, or a
// color-only edit that does not rescan) or it can keep announcing a stale highlight after the cursor is dismissed.
private void RefreshBinAnnouncement() =>
_binAnnouncement = _binCursor is { } cursor && _render is { } render && cursor < render.Bins.Count
? BinCursorAnnouncement(render.Bins[cursor])
: string.Empty;

private void RefreshFindTicks() =>
_findTicks = FindMarkers.Owner == ActiveEventLogId.Value && FindMarkers.Ticks is { Count: > 0 } ticks
? [.. ticks]
Expand All @@ -847,16 +855,23 @@ private void RefreshTieFilters(bool rescanOnPredicateChange)

if (rescanNeeded && rescanOnPredicateChange)
{
// The rescan will republish _baseData, but until it lands the current masks were resolved against the OLD
// eligible list. Invalidate them synchronously so a render in the gap does not map stale ordinals through
// the new filters (masks are non-null only when the prior state was armed).
if (_baseData is { GroupHighlightMasks: not null } data)
{
_baseData = data with { GroupHighlightMasks = null };
}

RefreshBinAnnouncement();
StateHasChanged();

StartScan();

return;
}

if (_binCursor is { } cursor && _render is { } render && cursor < render.Bins.Count)
{
_binAnnouncement = BinCursorAnnouncement(render.Bins[cursor]);
}

RefreshBinAnnouncement();
StateHasChanged();
}

Expand Down Expand Up @@ -915,7 +930,7 @@ private async Task RunScanAsync(
{
await InvokeAsync(() =>
{
if (_disposed || epoch != _scanEpoch || !ReferenceEquals(view, ActiveView.Value)) { return; }
if (_disposed || token.IsCancellationRequested || epoch != _scanEpoch || !ReferenceEquals(view, ActiveView.Value)) { return; }

if (useHighlightTie && tiePlanKey != _tiePlanKey) { return; }

Expand Down Expand Up @@ -1030,10 +1045,14 @@ private void StartScan()
_scanCts?.Dispose();
_scanCts = null;

// Bump the epoch on every supersede -- even when we bail below for a zero-size viewport -- so a prior scan whose
// UI publication is already queued is rejected by RunScanAsync's epoch guard and cannot restore stale data (for
// example, re-arm masks that a disarm just cleared).
int epoch = ++_scanEpoch;

if (_viewportWidthPx <= 0 || _plotHeightPx <= 0) { return; }

IEventColumnView view = ActiveView.Value;
int epoch = ++_scanEpoch;
HistogramDimension dimension = _dimension;
SavedFilter[] tieHighlightFilters = _tieHighlightFilters;
int tiePlanKey = _tiePlanKey;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,44 +101,99 @@ public HistogramPaneTests()
}

[Fact]
public async Task FilterRefresh_WhenOnlyHighlightColorChanges_DoesNotReadActiveViewForScan()
public async Task FilterRefresh_WhenColorOnlyEditWithNoActiveCursor_ClearsStaleBinAnnouncement()
{
// Red -> blue keeps the tie armed AND the same predicate-plan key (color is excluded from the plan key), so no
// rescan occurs. The non-rescan tail must still drop the stale "Light red" announcement when no cursor is active.
SavedFilter red = Filter(HighlightColor.LightRed);
SavedFilter blue = red with { Color = HighlightColor.LightBlue };
_highlightSelector.Select(Arg.Any<ImmutableList<SavedFilter>>()).Returns([red], [blue]);
SavedFilter blue = Filter(HighlightColor.LightBlue);
_highlightSelector.Select(Arg.Any<ImmutableList<SavedFilter>>()).Returns([blue]);
_highlightSelector.ComputePredicatePlanKey(Arg.Any<ImmutableList<SavedFilter>>()).Returns(7);
var cut = Render<HistogramPane>();
await cut.InvokeAsync(() => cut.Instance.OnHistogramResized(500, 100));

// The resize starts a background scan that reads ActiveView.Value twice: once synchronously at StartScan (before
// this await returns) and once in its post-scan continuation (the "did the view change mid-scan?" check). Wait for
// that second read so the continuation's read is not miscounted against the colour-only change under test (it
// otherwise races ClearReceivedCalls under load). The markup never reads ActiveView.Value, so the count is exact.
cut.WaitForAssertion(() => _ = _activeView.Received(2).Value);
_activeView.ClearReceivedCalls();
SetPrivateField(cut, "_tieHighlightFilters", new[] { red });
SetPrivateField(cut, "_binCursor", null);
SetPrivateField(cut, "_binAnnouncement", "2 events (1 Alpha, Light red highlight)");
await cut.InvokeAsync(() => { });
Assert.Contains("Light red", GetPrivateField<string>(cut, "_binAnnouncement"));

_filters.SelectedValueChanged +=
Raise.Event<EventHandler<ImmutableList<SavedFilter>>>(_filters, ImmutableList.Create(blue));
await cut.InvokeAsync(() => { });

_ = _activeView.DidNotReceive().Value;
Assert.Equal(string.Empty, GetPrivateField<string>(cut, "_binAnnouncement"));
}

[Fact]
public async Task FilterRefresh_WhenPredicatePlanChanges_ReadsActiveViewForScan()
public async Task FilterRefresh_WhenDisarmedWithNoActiveCursor_ClearsStaleBinAnnouncement()
{
SavedFilter red = Filter(HighlightColor.LightRed);
SavedFilter blue = Filter(HighlightColor.LightBlue);
_highlightSelector.Select(Arg.Any<ImmutableList<SavedFilter>>()).Returns([]);
_highlightSelector.ComputePredicatePlanKey(Arg.Any<ImmutableList<SavedFilter>>()).Returns(7);
var cut = Render<HistogramPane>();

// A prior armed bin readout was cached, then the cursor was dismissed (Escape) which leaves the text behind.
SetPrivateField(cut, "_tieHighlightFilters", new[] { red });
SetPrivateField(cut, "_binCursor", null);
SetPrivateField(cut, "_binAnnouncement", "2 events (1 Alpha, Light red highlight)");
await cut.InvokeAsync(() => { });
Assert.Contains("highlight", GetPrivateField<string>(cut, "_binAnnouncement"));

_filters.SelectedValueChanged +=
Raise.Event<EventHandler<ImmutableList<SavedFilter>>>(_filters, ImmutableList<SavedFilter>.Empty);
await cut.InvokeAsync(() => { });

Assert.Equal(string.Empty, GetPrivateField<string>(cut, "_binAnnouncement"));
}

[Fact]
public async Task FilterRefresh_WhenDisarmed_ClearsGroupHighlightMasksSynchronously()
{
// Arm on init, disarm on the filter change. Keep the viewport at 0 (never resize) so StartScan no-ops and cannot
// publish mask-free data on its own -- the synchronous mask clear in RefreshTieFilters is the only mutation.
SavedFilter red = Filter(HighlightColor.LightRed);
_highlightSelector.Select(Arg.Any<ImmutableList<SavedFilter>>()).Returns([]);
_highlightSelector.ComputePredicatePlanKey(Arg.Any<ImmutableList<SavedFilter>>()).Returns(7);
var cut = Render<HistogramPane>();

SetPrivateField(cut, "_tieHighlightFilters", new[] { red });
await PublishBaseDataAsync(cut, ArmedCategoryData());

// Non-vacuous: the armed legend swatch renders the highlight class.
cut.WaitForAssertion(() => Assert.Equal("histogram-cat-hl", AlphaSwatchClass(cut)));

_filters.SelectedValueChanged +=
Raise.Event<EventHandler<ImmutableList<SavedFilter>>>(_filters, ImmutableList<SavedFilter>.Empty);
await cut.InvokeAsync(() => { });

// histogram-cat-hl comes only from GroupHighlightMasks; with no scan publishing (viewport 0), its disappearance
// proves the synchronous clear ran.
cut.WaitForAssertion(() => Assert.NotEqual("histogram-cat-hl", AlphaSwatchClass(cut)));
Assert.Null(GetPrivateField<HistogramData>(cut, "_baseData").GroupHighlightMasks);
}

[Fact]
public async Task FilterRefresh_WhenOnlyHighlightColorChanges_DoesNotReadActiveViewForScan()
{
SavedFilter red = Filter(HighlightColor.LightRed);
SavedFilter blue = red with { Color = HighlightColor.LightBlue };
_highlightSelector.Select(Arg.Any<ImmutableList<SavedFilter>>()).Returns([red], [blue]);
_highlightSelector.ComputePredicatePlanKey(Arg.Any<ImmutableList<SavedFilter>>()).Returns(7, 8);
_highlightSelector.ComputePredicatePlanKey(Arg.Any<ImmutableList<SavedFilter>>()).Returns(7);
var cut = Render<HistogramPane>();
await cut.InvokeAsync(() => cut.Instance.OnHistogramResized(500, 100));

// The resize starts a background scan that reads ActiveView.Value twice: once synchronously at StartScan (before
// this await returns) and once in its post-scan continuation (the "did the view change mid-scan?" check). Wait for
// that second read so the continuation's read is not miscounted against the colour-only change under test (it
// otherwise races ClearReceivedCalls under load). The markup never reads ActiveView.Value, so the count is exact.
cut.WaitForAssertion(() => _ = _activeView.Received(2).Value);
_activeView.ClearReceivedCalls();

_filters.SelectedValueChanged +=
Raise.Event<EventHandler<ImmutableList<SavedFilter>>>(_filters, ImmutableList.Create(blue));
await cut.InvokeAsync(() => { });

_ = _activeView.Received().Value;
_ = _activeView.DidNotReceive().Value;
}

[Fact]
Expand Down Expand Up @@ -166,6 +221,28 @@ public async Task FilterRefresh_WhenPredicatePlanChangesButTieStaysUnarmed_DoesN
_ = _activeView.DidNotReceive().Value;
}

[Fact]
public async Task FilterRefresh_WhenPredicatePlanChanges_ReadsActiveViewForScan()
{
SavedFilter red = Filter(HighlightColor.LightRed);
SavedFilter blue = Filter(HighlightColor.LightBlue);
_highlightSelector.Select(Arg.Any<ImmutableList<SavedFilter>>()).Returns([red], [blue]);
_highlightSelector.ComputePredicatePlanKey(Arg.Any<ImmutableList<SavedFilter>>()).Returns(7, 8);
var cut = Render<HistogramPane>();
await cut.InvokeAsync(() => cut.Instance.OnHistogramResized(500, 100));

// Wait for the resize scan's two ActiveView.Value reads (sync StartScan + post-scan continuation) to settle
// before clearing, so the continuation's late read is not miscounted against the plan-change rescan under test.
cut.WaitForAssertion(() => _ = _activeView.Received(2).Value);
_activeView.ClearReceivedCalls();

_filters.SelectedValueChanged +=
Raise.Event<EventHandler<ImmutableList<SavedFilter>>>(_filters, ImmutableList.Create(blue));

// The plan-change rescan dispatches via InvokeAsync; wait for its ActiveView.Value read rather than checking eagerly.
cut.WaitForAssertion(() => _ = _activeView.Received().Value);
}

[Fact]
public void Render_WhenDimensionRequestExists_AppliesRequestedDimensionOnMount()
{
Expand Down Expand Up @@ -300,6 +377,37 @@ public void SourceFiles_DefineExtendedHistogramPaletteAndForcedColorHatches()
}
}

[Fact]
public async Task StartScan_WhenViewportZero_BumpsScanEpochToSupersedeQueuedPublish()
{
// A zero-size viewport makes StartScan bail without launching a scan, but it must still bump the epoch so a prior
// scan whose UI publication is already queued is rejected and cannot restore stale (for example, armed) data.
var cut = Render<HistogramPane>();
int before = GetPrivateField<int>(cut, "_scanEpoch");

await cut.InvokeAsync(() => InvokePrivate(cut, "StartScan"));

Assert.Equal(before + 1, GetPrivateField<int>(cut, "_scanEpoch"));
}

private static string? AlphaSwatchClass(IRenderedComponent<HistogramPane> cut) =>
cut.FindAll(".histogram-legend-item")
.Single(button => button.TextContent == "Alpha")
.QuerySelector("rect")?
.GetAttribute("class");

private static HistogramData ArmedCategoryData() =>
new(
[1, 1],
2,
1,
new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2024, 1, 1, 1, 0, 0, DateTimeKind.Utc),
2,
TimeSpan.FromHours(1).Ticks,
HistogramGroups.ForCategories(["alpha"], ["Alpha"], "Other"),
[1u << 1, 1u << 1]);

private static SavedFilter Filter(HighlightColor color) =>
new() { Color = color, IsEnabled = true, ComparisonText = "Id == 1", Compiled = null! };

Expand All @@ -313,6 +421,20 @@ private static HistogramDimension GetDimension(IRenderedComponent<HistogramPane>
return Assert.IsType<HistogramDimension>(field.GetValue(cut.Instance));
}

private static T GetPrivateField<T>(IRenderedComponent<HistogramPane> cut, string name)
{
var field = typeof(HistogramPane).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
return Assert.IsType<T>(field.GetValue(cut.Instance));
}

private static void InvokePrivate(IRenderedComponent<HistogramPane> cut, string name)
{
var method = typeof(HistogramPane).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
method.Invoke(cut.Instance, null);
}

private static Task PublishBaseDataAsync(IRenderedComponent<HistogramPane> cut, HistogramData data)
{
var field = typeof(HistogramPane).GetField(
Expand Down Expand Up @@ -351,4 +473,11 @@ private static Task SelectDimensionAsync(IRenderedComponent<HistogramPane> cut,

return cut.InvokeAsync(() => select.Instance.UpdateValue(dimension));
}

private static void SetPrivateField(IRenderedComponent<HistogramPane> cut, string name, object? value)
{
var field = typeof(HistogramPane).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(field);
field.SetValue(cut.Instance, value);
}
}
Loading