diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index e91048da..87701f46 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -44,6 +44,10 @@ non-releasing PR leaves that version alone.
- [ ] CHANGELOG entry added, opening with a one-line plain-English lead under the version heading
before the first `###` category (CI checks the lead separately, because the release notes are
copied from it verbatim)
+- [ ] CHANGELOG heading dated **today in UTC** — and re-dated if the merge slips to another UTC day.
+ This is the only gate that runs *after* the squash merge, when the branch is already gone: the
+ tag push that triggers the release requires the date to be today, so a stale date fails the
+ release rather than the PR. It has published yesterday's date twice.
- [ ] `Version` / `FileVersion` / `AssemblyVersion` in `SysManager/SysManager/SysManager.csproj`
bumped one step from the newest release tag and equal to the new CHANGELOG heading
(`fix:` = patch, `feat:` = minor)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a565e8bd..4c33bf9d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -396,8 +396,7 @@ Key services:
output device). Feature-detected (`TryCreate` returns null when it can't bind), guarded (the
SET call is invoked only after a successful `QueryInterface` for the exact IID and any failure
returns false), and its endpoint-id/process-token string helpers are pure + unit-tested. The
- routing SET path can only be runtime-verified on a real desktop (laptop workstation), not the
- build box. The READ path (`GetPersistedDefaultEndpoint`) is deliberately unimplemented and returns
+ routing SET path can only be runtime-verified by running the app on real audio hardware. The READ path (`GetPersistedDefaultEndpoint`) is deliberately unimplemented and returns
null, so `IAudioMixerService.GetSessionOutputDevice` has a three-state contract — an endpoint id,
`string.Empty` for "read succeeded, no override", or null for "could not read" — and the row VM
renders null as unknown rather than as the default device.
diff --git a/SysManager/SysManager.Tests/ArchitectureTests.cs b/SysManager/SysManager.Tests/ArchitectureTests.cs
index 53141c0a..2e69b976 100644
--- a/SysManager/SysManager.Tests/ArchitectureTests.cs
+++ b/SysManager/SysManager.Tests/ArchitectureTests.cs
@@ -411,8 +411,12 @@ public void EveryViewModelCommand_IsReachableFromTheUi()
/// never went through the correction at all — four typed hex values could produce white on white while
/// every shipped preset was held to a contrast floor. It also silently discarded the shade slider's
/// position.
- /// Asserted on the source rather than by calling the method, because SetCustom ends in
- /// Save(), which writes the user's real theme file.
+ /// Asserted on the source, and the reason is coverage rather than safety. It used to say that
+ /// SetCustom could not be called because it ends in Save() and would write the user's real
+ /// theme file — true before #1741 made SettingsPath redirectable, and false since:
+ /// ThemeServiceTests now calls SetCustom for real against a temp directory. What a source
+ /// check still buys is the entry point nobody wrote a test for. A behaviour test covers the three methods
+ /// named below; this covers a fourth added later, before anyone thinks to test it.
///
[Fact]
public void EveryThemeEntryPoint_GoesThroughTheLegibilityCorrection()
@@ -425,11 +429,15 @@ public void EveryThemeEntryPoint_GoesThroughTheLegibilityCorrection()
var at = service.IndexOf($"public void {entry}(", StringComparison.Ordinal);
Assert.True(at > 0, $"ThemeService.{entry} was renamed — update this guard, don't drop it.");
- // To the next method declaration, so the slice is this method and nothing after it.
- var end = service.IndexOf("\n public ", at + 1, StringComparison.Ordinal);
- if (end < 0) end = service.IndexOf("\n private ", at + 1, StringComparison.Ordinal);
- Assert.True(end > at, $"could not bound {entry}'s body.");
- var body = service[at..end];
+ // Brace-matched, not "up to the next member declaration". That heuristic looked for the next
+ // `public` and only fell back to `private` when there was none — so for a method followed by
+ // private members it skipped past them to a later public one. SetCustom's slice measured 6727
+ // characters against a real body of 675, swallowing IsDarkBackground, ApplyShade, Shade and
+ // Legible. Nothing in that region calls ApplyShade() today, so the assertion below was still
+ // being satisfied by SetCustom's own call — but one added call anywhere in those six kilobytes
+ // and this guard could no longer tell whether SetCustom still does it.
+ var body = BalancedBlock(service, $"public void {entry}(");
+ Assert.True(body.Length > 0, $"could not bound {entry}'s body.");
Assert.Contains("ApplyShade();", body, StringComparison.Ordinal);
Assert.DoesNotContain("CurrentTheme = new ThemePreset", body, StringComparison.Ordinal);
@@ -610,11 +618,15 @@ public void EveryGoldElevationBanner_PromisesMoreAccess()
/// The two elevation banners that share a slot must share one geometry.
///
///
- /// 27 views render both banners in the same Grid.Row, swapped on IsElevated, and the two
- /// were hand-written with different geometry: CornerRadius="8" Padding="14,12" when not elevated
- /// in 27 of 27 views, CornerRadius="12" Padding="12,8" when elevated in 29 of 29. Measured, the
- /// banner in that fixed slot was 61.29px in one state and 35.29px in the other, so everything below it
- /// jumped 26px at the moment the user granted elevation — and the corners visibly changed shape with it.
+ /// 31 views render both banners in the same Grid.Row, swapped on IsElevated — 62 banners,
+ /// one pair per view — and the two states were hand-written with different geometry:
+ /// CornerRadius="8" Padding="14,12" when not elevated against CornerRadius="12" Padding="12,8"
+ /// when elevated. Measured, the banner in that fixed slot was 61.29px in one state and 35.29px in the
+ /// other, so everything below it jumped 26px at the moment the user granted elevation — and the corners
+ /// visibly changed shape with it. Both states are on CornerRadius="12" Padding="12,8" now.
+ /// The counts above are what this guard parses today, re-derived. It previously said "27 of 27" and
+ /// "29 of 29", which were the totals at the time it was written and disagreed with each other and with
+ /// the tree; a reader could not reconcile them against a failure message quoting the same numbers.
/// 18px of that jump is the "Run as administrator" button, which the elevated state has nothing to
/// replace with, and no geometry removes it. This asserts the part that was an accident: one radius, one
/// padding, both states.
@@ -664,9 +676,10 @@ public void BothAdminBanners_ShareOneGeometry()
.ToArray();
Assert.True(geometries.Length == 1,
- "the elevation banners do not agree on their geometry. Both states occupy the SAME slot in 27 "
- + "views, so a difference here is the layout below them jumping the moment the user elevates — "
- + "which is the one moment the app should look steady. Pick one radius and one padding:\n "
+ $"the elevation banners do not agree on their geometry. Both states occupy the SAME slot in the "
+ + $"{seen.Select(b => b.View).Distinct(StringComparer.Ordinal).Count()} views parsed here, so a "
+ + "difference is the layout below them jumping the moment the user elevates — which is the one "
+ + "moment the app should look steady. Pick one radius and one padding:\n "
+ string.Join("\n ", geometries));
}
@@ -1595,6 +1608,16 @@ public void ThePullRequestChecklist_AsksForWhatCiEnforces()
"dotnet format",
File.ReadAllText(Path.Combine(root, "CONTRIBUTING.md")),
StringComparison.Ordinal);
+
+ // The one gate that is NOT in ci.yml, which is why the checklist omitted it and why this guard could
+ // not see the omission: auto-release requires the newest CHANGELOG heading to be dated today in UTC,
+ // and it runs AFTER the squash merge, when the branch is gone. A stale date fails the release rather
+ // than the pull request, and it has published yesterday's date twice.
+ var autoRelease = File.ReadAllText(
+ Path.Combine(root, ".github", "workflows", "auto-release.yml"));
+ Assert.Contains("TODAY=$(date -u +%Y-%m-%d)", autoRelease, StringComparison.Ordinal);
+ Assert.Contains(items, item => item.Line > releaseSection
+ && item.Text.Contains("UTC", StringComparison.Ordinal));
}
///
@@ -4132,11 +4155,14 @@ public void EveryTabsSidebarLabel_MatchesItsPageHeader()
/// entries that render a PREVIEW pill beside the text.
///
///
- /// The sidebar is a fixed 220px column (MainWindow.xaml). After the row's 28px left padding, 14px
- /// right padding, the 13px glyph and its 10px margin, the label has roughly 155px — about 24
- /// characters at FontSize 13. The longest label that ships, "Profile Export / Import", is 23. So this
- /// budget says "no longer than what is already there", NOT "proven to fit": whether that one already
- /// ellipsizes cannot be settled without running the app, which happens on the other workstation.
+ /// The sidebar is a fixed 220px column (MainWindow.xaml). A leaf row's Padding="28,9,14,9" leaves
+ /// the label 178px — roughly 27 characters at FontSize 13. The longest label that ships, "Profile Export /
+ /// Import", is 23. So this budget says "no longer than what is already there", NOT "proven to fit":
+ /// whether that one already ellipsizes cannot be settled without running the app.
+ /// The derivation used to subtract "the 13px glyph and its 10px margin" as well, leaving 155px.
+ /// Leaf rows carry no glyph — NavItem has no such member, and the only two glyph bindings left in
+ /// MainWindow.xaml are the group header's own and the single-item row reading its group's — so the budget
+ /// was charging every leaf 23px it does not spend.
/// Two budgets, because one number cannot express the constraint. A PREVIEW pill takes fixed
/// width out of the same column, so a pilled row has less room for text — and the pill is exactly
/// where this went wrong before: a horizontal StackPanel measured with infinite width pushed it past
diff --git a/SysManager/SysManager.Tests/AudioPolicyConfigTests.cs b/SysManager/SysManager.Tests/AudioPolicyConfigTests.cs
index 466adb2c..c2ad5b79 100644
--- a/SysManager/SysManager.Tests/AudioPolicyConfigTests.cs
+++ b/SysManager/SysManager.Tests/AudioPolicyConfigTests.cs
@@ -9,7 +9,7 @@ namespace SysManager.Tests;
///
/// Tests for the pure, easy-to-get-wrong string helpers in —
/// the endpoint-id wrapping and the process token. The COM activation path itself is undocumented
-/// and can only be exercised on a real Windows desktop (verified on the laptop workstation), so it
+/// and can only be exercised by running the app on a real Windows desktop, so it
/// is intentionally not unit-tested here; these pin the formatting the routing SET call depends on.
///
public class AudioPolicyConfigTests
diff --git a/SysManager/SysManager.Tests/SidebarSelectionContractTests.cs b/SysManager/SysManager.Tests/SidebarSelectionContractTests.cs
index ee4a5828..909e3417 100644
--- a/SysManager/SysManager.Tests/SidebarSelectionContractTests.cs
+++ b/SysManager/SysManager.Tests/SidebarSelectionContractTests.cs
@@ -289,8 +289,8 @@ private static void AssertSetter(XElement owner, string property, string value)
/// clearly built for the keyboard — but opening the panel left focus behind on the chip, so none of
/// it could be reached and there was no way out but the mouse. Key.Escape appeared nowhere in
/// the app.
- /// Asserted rather than demonstrated: the behaviour needs the app running, which happens on the
- /// other workstation. What is pinned here is the wiring that makes it possible, on both sides — the
+ /// Asserted rather than demonstrated: the behaviour needs the app running, which a source-level
+ /// test cannot do. What is pinned here is the wiring that makes it possible, on both sides — the
/// XAML hooks and the handlers they name. Half of it is useless alone: an Opened attribute
/// with a handler that does not move focus reads as fixed and is not.
///