diff --git a/src/PlanViewer.App/App.axaml b/src/PlanViewer.App/App.axaml
index 03f3a9c2..d6c41328 100644
--- a/src/PlanViewer.App/App.axaml
+++ b/src/PlanViewer.App/App.axaml
@@ -43,11 +43,11 @@
-
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index 8df78fab..f7b1b85c 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -525,7 +525,7 @@
Background="{DynamicResource BackgroundBrush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,0,1">
ShowConfirmationDialog(string title, string message, string c
private Window GetParentWindow()
{
- var parent = this.VisualRoot;
+ /* GetTopLevel rather than VisualRoot because Avalonia 12 hosts a Window inside a
+ TopLevelHost, so the visual root is no longer the Window and casting it to one always
+ misses — silently, since VisualRoot still compiles and still returns something. */
+ var parent = TopLevel.GetTopLevel(this);
return parent as Window ?? throw new InvalidOperationException("No parent window");
}
}
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index d7a9a952..dcff0565 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -1,6 +1,7 @@
@@ -94,7 +95,7 @@
@@ -170,12 +171,12 @@
-
+
-
+
-
+
-
+
-
+
-
+
@@ -487,7 +488,8 @@
Background="#80000000" CornerRadius="0"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
-
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml
index adda15b0..b59a90c1 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml
@@ -1,6 +1,7 @@
@@ -71,7 +72,8 @@
FontSize="12" Foreground="{DynamicResource ForegroundBrush}"/>
-
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml
index b5c24f24..2fbae420 100644
--- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml
@@ -1,6 +1,7 @@
diff --git a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
index d8368daf..45680e50 100644
--- a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
+++ b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml
@@ -1,6 +1,7 @@
@@ -35,7 +36,8 @@
Background="#80000000" CornerRadius="0"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
-
+
diff --git a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml
index d6c02969..684d339c 100644
--- a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml
+++ b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml
@@ -15,7 +15,7 @@
FontSize="12" Margin="0,0,0,4"/>
-
@@ -66,7 +66,7 @@
@@ -94,7 +94,7 @@
diff --git a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
index 6783c50d..bc9958b9 100644
--- a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
@@ -375,7 +375,7 @@ private void EnsureIntegrationsLoaded()
/* The stored proxy password is deliberately not put into the TextBox. PasswordChar only
masks the glyph — the cleartext still sits in the visual and accessibility trees. The
- watermark says it is saved, and an empty box at save time means "keep what is there". */
+ placeholder says it is saved, and an empty box at save time means "keep what is there". */
var proxy = ProxySettings.Load();
_hasStoredProxyPassword = !string.IsNullOrEmpty(proxy.Password);
_proxyMode = proxy.Mode;
@@ -506,10 +506,10 @@ void OnProxyModeChanged(object? sender, RoutedEventArgs _)
return panel;
}
- private static TextBox CreateProxyInput(string text, string watermark) => new()
+ private static TextBox CreateProxyInput(string text, string placeholder) => new()
{
Text = text,
- Watermark = watermark,
+ PlaceholderText = placeholder,
FontSize = 13,
Height = 32,
Padding = new Thickness(6, 2)
@@ -582,7 +582,7 @@ private void ResetIntegrations(bool clearStoredPassword)
/* This is the only way to delete a stored proxy password, because an empty password box
always means "keep what is there" — otherwise anyone who edited the proxy address would
- have to retype the password. Clearing the flag too stops the watermark claiming a
+ have to retype the password. Clearing the flag too stops the placeholder claiming a
password is saved after the reset has taken it away. */
_clearStoredProxyPassword = _hasStoredProxyPassword;
_hasStoredProxyPassword = false;
diff --git a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs
index cd3d9a8a..1f0445cc 100644
--- a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs
+++ b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs
@@ -35,7 +35,7 @@ public static void AttachCopyGuard(DataGrid grid, Func formatRo
{
// Mirror the built-in copy's exact gate: platform command modifier
// (Ctrl; Cmd on macOS), no Shift, no Alt.
- var commandModifiers = TopLevel.GetTopLevel(grid)?.PlatformSettings?.HotkeyConfiguration.CommandModifiers
+ var commandModifiers = grid.GetPlatformSettings()?.HotkeyConfiguration.CommandModifiers
?? KeyModifiers.Control;
if (e.Key is not (Key.C or Key.Insert)
|| !e.KeyModifiers.HasFlag(commandModifiers)
diff --git a/src/PlanViewer.App/Helpers/ProgressBarBehaviors.cs b/src/PlanViewer.App/Helpers/ProgressBarBehaviors.cs
new file mode 100644
index 00000000..ad10b80e
--- /dev/null
+++ b/src/PlanViewer.App/Helpers/ProgressBarBehaviors.cs
@@ -0,0 +1,65 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Threading;
+
+namespace PlanViewer.App.Helpers;
+
+///
+/// Restarts an indeterminate 's animation when it re-enters the
+/// visual tree.
+///
+/// Avalonia 12 completes a style-applied animation when its visual detaches — a handler
+/// its changelog does not document, sitting outside the PlaybackBehavior pause guard —
+/// and re-attaching re-applies the style without resurrecting the finished animation. Switch
+/// tabs while a fetch is running and its loading bar comes back frozen mid-track, indistinguishable
+/// from a hang. Avalonia 11 had no detach handling at all, so the bar just kept animating; this
+/// restores that behavior at the seven bars whose hosts can detach mid-run.
+///
+/// The restart works by deactivating and reactivating the :indeterminate style:
+/// flipping off and, one dispatcher hop later, back on
+/// makes the style system spawn a fresh animation instance. The hop is load-bearing — both flips
+/// in the same pass coalesce into no style change at all.
+///
+/// Verified against the live symptom, not assumed: before this, five window captures
+/// spanning a full animation cycle after a tab round-trip were pixel-identical in the bar's
+/// region; with it, the sweep resumes. The headless suite cannot see any of this (animations
+/// need frames), which is why the proof lives in a driven run rather than a test.
+///
+public static class ProgressBarBehaviors
+{
+ ///
+ /// Set true on any indeterminate bar whose host can detach while it runs — tab content,
+ /// overlays inside documents, detachable panels.
+ ///
+ public static readonly AttachedProperty RestartOnReattachProperty =
+ AvaloniaProperty.RegisterAttached(
+ "RestartOnReattach", typeof(ProgressBarBehaviors));
+
+ public static void SetRestartOnReattach(ProgressBar bar, bool value) =>
+ bar.SetValue(RestartOnReattachProperty, value);
+
+ public static bool GetRestartOnReattach(ProgressBar bar) =>
+ bar.GetValue(RestartOnReattachProperty);
+
+ static ProgressBarBehaviors()
+ {
+ RestartOnReattachProperty.Changed.AddClassHandler((bar, e) =>
+ {
+ if (e.NewValue is true)
+ bar.AttachedToVisualTree += Restart;
+ else
+ bar.AttachedToVisualTree -= Restart;
+ });
+ }
+
+ private static void Restart(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ /* An idle bar (Overview's LoadingBar between loads) has nothing to restart, and
+ flipping it would wrongly start one. */
+ if (sender is not ProgressBar bar || !bar.IsIndeterminate)
+ return;
+
+ bar.IsIndeterminate = false;
+ Dispatcher.UIThread.Post(() => bar.IsIndeterminate = true, DispatcherPriority.Loaded);
+ }
+}
diff --git a/src/PlanViewer.App/MainWindow.PlanViewer.cs b/src/PlanViewer.App/MainWindow.PlanViewer.cs
index 7bba16c8..34faebec 100644
--- a/src/PlanViewer.App/MainWindow.PlanViewer.cs
+++ b/src/PlanViewer.App/MainWindow.PlanViewer.cs
@@ -475,6 +475,8 @@ emailed around. Name the target so "which server was that?" is answered
Height = 4,
Margin = new Avalonia.Thickness(0, 0, 0, 12)
};
+ // This loading tab detaches whenever the user switches top-level tabs mid-capture.
+ Helpers.ProgressBarBehaviors.SetRestartOnReattach(progressBar, true);
var statusText = new TextBlock
{
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 5ac60b51..8b5ba158 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -882,13 +882,8 @@ private void About_Click(object? sender, RoutedEventArgs e)
about.ShowDialog(this);
}
-#pragma warning disable CS0618 // Data/DataFormats.Files deprecated but IDataTransfer API differs
private static readonly string[] _supportedExtensions = { ".sqlplan", ".xml", ".sql" };
-
-#pragma warning restore CS0618
-
-
///
/// Re-decides whether Compare Plans is offered, for every Compare button in the window (#447).
///
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index fd197b06..22027976 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -9,16 +9,14 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -28,11 +26,18 @@
-
+
diff --git a/tests/PlanViewer.Core.Tests/HeadlessUi.cs b/tests/PlanViewer.Core.Tests/HeadlessUi.cs
index e7308d41..a7df54ab 100644
--- a/tests/PlanViewer.Core.Tests/HeadlessUi.cs
+++ b/tests/PlanViewer.Core.Tests/HeadlessUi.cs
@@ -16,11 +16,47 @@ namespace PlanViewer.Core.Tests;
///
/// A headless Avalonia session, so UI code can be tested without a display.
///
-/// Why this is hand-rolled rather than Avalonia.Headless.XUnit. That package exists and
-/// would be less code, but at 11.3.20 it depends on xunit.core 2.4.0 — xunit v2 — and this
-/// suite runs on xunit.v3. Putting two xunit frameworks in one test project to get an attribute is a
-/// worse trade than owning fifteen lines. is runner-agnostic
-/// and is what that package wraps anyway.
+/// Why this is hand-rolled rather than Avalonia.Headless.XUnit. The original objection
+/// has expired and the conclusion has not. That package used to depend on xunit.core 2.4.0 —
+/// xunit v2 — against a suite running xunit.v3, so adopting it meant two xunit frameworks in one
+/// project. At 12.1.2 it asks for xunit.v3.extensibility.core 3.2.2 , which unifies upward
+/// against this project's own 4.0.1, so it is now merely possible. It is still the wrong trade:
+/// is runner-agnostic and is what that package wraps anyway,
+/// while [AvaloniaFact] offers neither of the two behaviours this class exists for — the #474
+/// queue drain and the canary, which is the only reason a
+/// session-poisoning test fails with its own name on it. Rewriting every entry point to lose both
+/// would be a migration dressed as a simplification.
+///
+/// Inter ships here and is never used. Avalonia.Headless 12.1.2 pulls
+/// Avalonia.Fonts.Inter and Avalonia.HarfBuzz in transitively, so the Inter assembly
+/// sits in the test output directory. Nothing registers it: .WithInterFont() lives on
+/// Program.BuildAvaloniaApp , and the session below boots App , which has no such
+/// method, so the builder never runs it. Text in this suite is measured against headless's own
+/// embedded font, not Inter — see the metrics paragraph below.
+///
+/// What text measures, and why the layout numbers in this suite moved. Stated once
+/// here because several files pin measured widths and heights, and a number repeated with its
+/// reason in every file is a number that goes stale in some of them. Headless 11 bound a stub text
+/// shaper that gave every character a flat 10 DIP advance at any font size, over synthetic font
+/// metrics that worked out to a line height of 0.8 em. Headless 12 drops the stub and shapes for
+/// real through HarfBuzz against its own embedded BareMinimum font, which has no glyph for
+/// ordinary text and so measures every character at one em. Both numbers therefore changed, on
+/// different axes: a string is now exactly FontSize DIP per character, so widths scale by
+/// FontSize / 10 — unchanged at font size 10, 1.1x at the toolbar's 11, 1.4x at Fluent's
+/// default 14 — while a line is 1.0898 em tall, rounded up to whole DIPs, which measures as desired
+/// heights of 11, 12, 14 and 16 at font sizes 10, 11, 12 and 14. That is 1.36x the old line height
+/// at every size, a flat ratio rather than something that scales with the size.
+///
+/// The height figure is worth pinning down because the plausible wrong answer is 1.25x. That
+/// would be the em box alone; 1.36x is the em box plus the font's line gap. The font manager
+/// reports this face as ascent 819, descent 205, line gap 92 over an em of 1024 — the OS/2
+/// typographic values, not the hhea pair (782 and 0) that would have given 0.854 em and a 7%
+/// change. So when a height assertion moves, 1.36x is the tell; if a height moves by something
+/// else, the cause is not this.
+///
+/// Padding, margins and fixed sizes did not move at all, so text-driven measurements grew and
+/// everything else stayed put. Thresholds elsewhere in the suite carry their new numbers and point
+/// back here rather than re-deriving this.
///
/// The real App, not a stub. A bare Application looked tidier but does not load the
/// application XAML, and MainWindow's toolbars resolve styles from it — FindResource("AppButton")
@@ -115,30 +151,104 @@ unusable is still described best by the assertion it failed. */
///
/// Runs one body on the UI thread and hands back whatever it threw rather than throwing here,
- /// so can decide which of two failures to report.
+ /// so can decide which of two failures to report. Retries once, and only,
+ /// when Avalonia 12's own session setup lost a race before the body was ever reached — see
+ /// .
+ ///
+ /// The retry, and why it is not a test retry. Avalonia 12's headless session setup
+ /// has a race that fails one arbitrary test per run. Measured on the 12 bump: 2 of 6 full-suite
+ /// runs, 1 of 6 more with the two classes that were red for an unrelated reason excluded, and 1
+ /// of 6 with xunit parallelization disabled outright — so roughly a quarter of runs, a
+ /// different victim each time, and not caused by test concurrency. The same measurement on
+ /// 11.3.22 was 0 of 6. A quarter of CI runs failing on an upstream race nobody can act on is
+ /// not shippable, and there is no fixed Avalonia release to take instead.
+ ///
+ /// What makes this safe is that it does not re-run tests. It re-runs a dispatch whose
+ /// delegate was never entered, which cannot tell apart from the first
+ /// attempt because nothing of it ran. A test that started and then failed — for any reason,
+ /// including a thread-affinity bug of our own — is reported, never retried: that is what the
+ /// started flag in and the narrow match in
+ /// are for, and why each occurrence writes a line to stderr.
+ /// One retry, no more; if the race hits twice in a row, the run goes red and says so.
+ ///
+ /// Remove this when there is an Avalonia release that fixes the race. Delete the
+ /// retry, and the started flag, then run the full suite ten
+ /// times: at the rate above, ten clean runs leave about a 6% chance of having missed it.
///
/// Why the queue is drained before returning (#474). Avalonia's per-dispatch
/// teardown disposes the session's FontManager and only then calls
/// Dispatcher.ResetForUnitTests , which executes whatever is still queued. A window whose
/// content is involved enough to leave a deferred render pass behind — a
/// PlanViewerControl reliably does, a TextBlock does not — therefore renders text against
- /// a font manager that has just been disposed, throws KeyNotFoundException for
- /// fonts:SystemFonts , and that exception escapes the teardown delegate before it reaches
- /// scope.Dispose() . The locator scope is then never popped: every later dispatch nests
- /// inside the leaked one, resolves the disposed font manager through its parent chain, and dies
- /// constructing any at all. The guilty test passes, because the throw
- /// happens after its result has been recorded.
+ /// a font manager that has just been disposed, and throws KeyNotFoundException for
+ /// fonts:SystemFonts . That ordering is unchanged in 12.1.2 — the dispose and the reset are
+ /// still consecutive lines of EnsureIsolatedApplication 's teardown — so draining here,
+ /// which leaves the teardown nothing to run, is still the only thing that prevents the throw.
+ /// It is done even when the body failed, because a failing test is no less capable of poisoning
+ /// the session than a passing one.
///
- /// Draining here leaves the teardown nothing to run, which is the whole fix. It is done
- /// even when the body failed, because a failing test is no less capable of poisoning the
- /// session than a passing one.
+ /// What 12 fixed, and why the drain is not now redundant. Under 11.3.22 the
+ /// escaping exception also skipped scope.Dispose() , so the locator scope was never
+ /// popped: every later dispatch nested inside the leaked one, resolved the disposed font manager
+ /// through its parent chain, and died constructing any at all — while the
+ /// guilty test passed, because the throw happened after its result had been recorded. 12 moved
+ /// the scope disposal into a finally and routes the teardown failure into the dispatch's
+ /// task. So the blast radius is now one test instead of every test after it, and the failure
+ /// lands on the test that caused it. That makes #474 survivable, not absent. Deleting the drain
+ /// would trade a prevented failure for a reported one.
///
private static Exception? Dispatch(Action body)
+ {
+ var failure = DispatchOnce(body, out var bodyStarted);
+
+ if (failure is null || bodyStarted || !IsHeadlessSetupRace(failure))
+ {
+ return failure;
+ }
+
+ /* Loud on purpose, every single time. A retry nobody can see is how a 1-in-4 flake becomes
+ a 1-in-400 mystery that outlives everyone who remembers this comment. */
+ Console.Error.WriteLine(
+ "Avalonia 12 headless setup race — dispatch retried once; see issue #544.");
+
+ return DispatchOnce(body, out _);
+ }
+
+ ///
+ /// Whether is Avalonia 12's session-setup race rather than anything
+ /// this repo wrote.
+ ///
+ /// The failure is an from
+ /// Dispatcher.VerifyAccess , thrown while EnsureIsolatedApplication builds the
+ /// per-dispatch Application: AvaloniaHeadlessPlatform.Initialize constructs a
+ /// Compositor , whose DefaultRenderLoop.Add verifies dispatcher access and finds a
+ /// different thread owning it. No repo code appears above in the stack.
+ /// The match is deliberately narrow — the exception type AND one of those two upstream frames —
+ /// so that a thread-affinity bug in our own code, which would name our own frames, is reported
+ /// rather than retried.
+ ///
+ private static bool IsHeadlessSetupRace(Exception failure) =>
+ failure is InvalidOperationException
+ && failure.StackTrace is { } stack
+ && (stack.Contains("EnsureIsolatedApplication", StringComparison.Ordinal)
+ || stack.Contains("AvaloniaHeadlessPlatform.Initialize", StringComparison.Ordinal));
+
+ ///
+ /// One attempt. reports whether the dispatched delegate was
+ /// entered at all, which is what makes the retry above safe: it is the difference between
+ /// re-running a test and starting one that never ran.
+ ///
+ private static Exception? DispatchOnce(Action body, out bool bodyStarted)
{
Exception? failure = null;
+ var started = false;
- Session.Value.Dispatch(() =>
+ var dispatch = Session.Value.Dispatch(() =>
{
+ /* First statement in the delegate, before anything that could throw, so that "the body
+ never started" is a fact rather than an inference. */
+ started = true;
+
try
{
body();
@@ -158,8 +268,24 @@ unusable is still described best by the assertion it failed. */
}
return Task.CompletedTask;
- }, default).GetAwaiter().GetResult();
+ }, default);
+
+ /* A teardown failure arrives here rather than in either catch above: 12 reports it through
+ the dispatch's own task, which is awaited outside the delegate. Measured under 12.1.2,
+ not assumed — a queued job that throws during teardown comes out of GetResult(), and when
+ the body had failed too, the teardown exception is the one the caller sees, silently
+ replacing the assertion message. That inverts the rule Run documents, so catch it and let
+ the body's failure keep precedence. */
+ try
+ {
+ dispatch.GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ failure ??= ex;
+ }
+ bodyStarted = started;
return failure;
}
diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
index 1abc2c2e..ea2973a7 100644
--- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
+++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
@@ -32,7 +32,7 @@
-
+
diff --git a/tests/PlanViewer.Core.Tests/ScrollBarVisibilityTests.cs b/tests/PlanViewer.Core.Tests/ScrollBarVisibilityTests.cs
index a381b560..0e73fe03 100644
--- a/tests/PlanViewer.Core.Tests/ScrollBarVisibilityTests.cs
+++ b/tests/PlanViewer.Core.Tests/ScrollBarVisibilityTests.cs
@@ -137,16 +137,20 @@ public void AnIdleScrollBarIsNarrowerThanAnExpandedOne()
/// Two selectors are needed in App.axaml, not one, and that is the whole reason this case is
/// asserted separately. A ScrollViewer rule alone looks like it covers the app and does not:
/// DataGrid does not scroll through a ScrollViewer, its template hosts PART_HorizontalScrollbar
- /// and PART_VerticalScrollbar as bare s, and it assigns their
- /// AllowAutoHide in code from the ATTACHED ScrollViewer property it reads off itself —
- /// where a local value outranks any style. A style that compiles is not a style that matches.
+ /// and PART_VerticalScrollbar as bare s, and it binds their
+ /// AllowAutoHide to the ATTACHED ScrollViewer property on the grid — which nothing sets
+ /// unless this rule does. A style that compiles is not a style that matches.
///
- /// Why only the flag is checked here. A DataGrid decides it overflows by measuring
- /// its rows, rows measure their text, and text needs a font — which this suite has no Skia for,
- /// so headlessly the rows come out zero-high, the grid concludes it fits, and both bars stay
- /// IsVisible=false with no template and no thumb to measure. The rail thickness is not
- /// DataGrid-specific anyway: it comes from app-level resources on the shared ScrollBar
- /// ControlTheme, and the ScrollViewer cases above prove those resources land.
+ /// Why only the flag is checked here. Nothing in this grid overflows, so neither
+ /// bar ever applies its template: measured under 12, both stay IsVisible=false with no
+ /// thumb to measure, which is why only AllowAutoHide is asserted and why asserting it on
+ /// an untemplated bar is still meaningful — the value is bound, not assigned during
+ /// OnApplyTemplate . The reason the grid does not overflow is worth stating because the
+ /// obvious guess is wrong: it is not that text measures short in this harness — under 12 it
+ /// measures against a real font with real metrics — it is that the grid generates no columns
+ /// for its 50 rows, so no row is ever realized. The rail thickness is not DataGrid-specific
+ /// anyway: it comes from app-level resources on the shared ScrollBar ControlTheme, and the
+ /// ScrollViewer cases above prove those resources land.
///
[Fact]
public void ADataGridsOwnScrollBarsFollowTheSameContract()
@@ -161,6 +165,15 @@ so this is a real grid rather than an empty one. */
};
Show(grid);
+ /* A shown grid is also the only place the suite can falsify the Avalonia 12 migration
+ of DataGridBehaviors.AttachCopyGuard, which moved off the removed
+ TopLevel.PlatformSettings onto Visual.GetPlatformSettings(). Pressing Ctrl+C by hand
+ on Windows cannot falsify it: the guard falls back to KeyModifiers.Control when the
+ lookup yields nothing, and Control is exactly what Windows reports anyway, so a dead
+ lookup and a live one behave identically under the fingers. An attached grid has
+ platform settings, so this asserts the lookup itself rather than its fallback. */
+ Assert.NotNull(grid.GetPlatformSettings());
+
var bars = grid.GetVisualDescendants().OfType().ToList();
Assert.NotEmpty(bars);
diff --git a/tests/PlanViewer.Core.Tests/SessionToolbarLayoutTests.cs b/tests/PlanViewer.Core.Tests/SessionToolbarLayoutTests.cs
index e9132600..12b85c42 100644
--- a/tests/PlanViewer.Core.Tests/SessionToolbarLayoutTests.cs
+++ b/tests/PlanViewer.Core.Tests/SessionToolbarLayoutTests.cs
@@ -40,8 +40,9 @@ public void ConnectingMovesNothingInTheToolbarOrTheSubTabRowBelowIt()
{
HeadlessUi.Run(() =>
{
- /* Wide enough for the whole row (natural 1910px in this harness's font metrics), because
- the first half of this test is about every slot HAVING a geometry to hold still. */
+ /* Wide enough for the whole row (natural 2116px in this harness's font metrics, which
+ HeadlessUi explains), because the first half of this test is about every slot HAVING
+ a geometry to hold still. */
var window = new MainWindow { Width = 2200, Height = 800 };
try
{
diff --git a/tests/PlanViewer.Core.Tests/ToolbarOverflowTests.cs b/tests/PlanViewer.Core.Tests/ToolbarOverflowTests.cs
index f2736a48..ef558c64 100644
--- a/tests/PlanViewer.Core.Tests/ToolbarOverflowTests.cs
+++ b/tests/PlanViewer.Core.Tests/ToolbarOverflowTests.cs
@@ -15,10 +15,13 @@ namespace PlanViewer.Core.Tests;
///
/// The session toolbar is one fixed row of slots inside a ScrollViewer whose rail is deliberately
-/// suppressed. Measured in this harness, that row wants 1910px. Erik's maximized display is 1536
-/// logical, so Format and part of Run Repro were simply not on screen, behind a scrollbar that had
-/// been hidden on purpose because a rail under a 28px button row grows the strip and drags the
-/// sub-tab row with it. Hidden content, hidden affordance.
+/// suppressed. Measured in this harness, that row wants 2116px — it wanted 1910px until the headless
+/// text metrics changed under Avalonia 12, which HeadlessUi explains once for every number in these
+/// files. Neither figure is what the row measures on the fonts a user has; they are the widths the
+/// thresholds below are chosen against. What put this file here is real: on Erik's maximized
+/// 1536-logical display, Format and part of Run Repro were simply not on screen, behind a scrollbar
+/// that had been hidden on purpose because a rail under a 28px button row grows the strip and drags
+/// the sub-tab row with it. Hidden content, hidden affordance.
///
/// The fix is a chevron that holds whatever did not fit. What these tests pin is the part of
/// it that is easy to get subtly wrong and impossible to see in a screenshot: that a command in the
@@ -105,7 +108,7 @@ private static readonly (string Name, string Label)[] CollapseOrder =
private static string[] CollapseLabels => CollapseOrder.Select(c => c.Label).ToArray();
///
- /// The width Erik actually runs at. At a 1520px row the three trailing commands move into the
+ /// The width Erik actually runs at. At a 1520px row the four trailing commands move into the
/// menu, in the order they left it, and every button in front of them is exactly where it was
/// on a row wide enough for all of them.
///
@@ -140,16 +143,29 @@ public void ANarrowRowMovesItsTailIntoTheMenuAndMovesNothingElse()
SetViewport(window, scroll, session.Overflow, 1520);
- Assert.True(chevron.IsVisible, "a 1520px row cannot hold a 1910px toolbar");
+ Assert.True(chevron.IsVisible, "a 1520px row cannot hold a 2116px toolbar");
/* The one failure every other assertion here would sail past: a chevron that
appears on cue, holds the right commands, and opens nothing when you press it. */
Assert.Same(session.Overflow.Menu, chevron.Flyout);
- /* Three commands, in the order the row gives them up: the end of the row first.
+ /* Four commands, in the order the row gives them up: the end of the row first.
Format being the first entry is the contract — the menu grows and shrinks at its
- end, so an entry never changes position under the pointer as the window moves. */
- Assert.Equal(new[] { "Format", "Run Repro", "Copy Repro" }, MenuHeaders(session.Overflow));
+ end, so an entry never changes position under the pointer as the window moves.
+
+ How many end up here is a function of the harness's text metrics and is allowed
+ to move with them; it was three until Avalonia 12 made text wider, and QS
+ Overview is simply the next name in CollapseOrder. What is NOT allowed to move
+ is which commands may leave at all: Connect, Execute and Execute-with-estimate
+ are absent from CollapseOrder and must never appear in this menu at any width.
+ A metric change adds the next name in the documented order; a regression takes
+ a protected one. This exact-collection assertion is itself the guard: a
+ protected command that collapsed would appear in this list and fail it. That is
+ why it stays an exact collection and never becomes a count or a containment
+ check. (The X comparison below skips invisible buttons, so it would not catch
+ a stayer leaving — it pins that the survivors did not shift.) */
+ Assert.Equal(new[] { "Format", "Run Repro", "Copy Repro", "QS Overview" },
+ MenuHeaders(session.Overflow));
Assert.False(format.IsVisible);
// Everything still on the row is where it was, to the pixel, and the sub-tab row
@@ -513,7 +529,7 @@ public void TheScrollingRowIsStillThereBelowTheCollapseFloor()
var subTabs = session.FindControl("SubTabControl")!;
var subTabsY = subTabs.Bounds.Y;
- /* Connect, the server label, the database picker and the two plan verbs want 708px
+ /* Connect, the server label, the database picker and the two plan verbs want 746px
between them and cannot be collapsed, so under that the row has to scroll. */
window.Width = 640;
Settle(window, session.Overflow);
@@ -746,7 +762,8 @@ Clear it first so the width this test names is the one it keeps coming back to.
Settle(window, session.Overflow, viewer.Overflow);
Assert.Equal(planRevisions, viewer.Overflow.Revisions);
- Assert.Equal(new[] { "Format", "Run Repro", "Copy Repro" }, MenuHeaders(session.Overflow));
+ Assert.Equal(new[] { "Format", "Run Repro", "Copy Repro", "QS Overview" },
+ MenuHeaders(session.Overflow));
}
finally
{