diff --git a/CHANGELOG.md b/CHANGELOG.md index 012d8c61..e8a28997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,11 @@ is not part of this repository. about three thousand characters. `--json` is unchanged and still carries both whole lists. A snapshot edited by hand into a trigger this program never writes - an empty one, or data without its type or values - still shows the two whole lists for that entry. +- A trigger of the kind the Windows SDK calls an aggregate trigger is named *Aggregate*, in + `bws show`, the window, a snapshot and `--json`. It used to be *Unknown* - `sc qtriggerinfo` names no + kind for it either - so a change from it to another kind nobody could name went unseen. + `trigger:aggregate` asks for it, and `trigger:unknown` no longer finds it. Compared with a snapshot + taken by an earlier version, where it is still *Unknown*, it does not show as a change. ### Fixed diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 75f7e133..6da3ef62 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -310,6 +310,7 @@ "cli.cell.trigger.networkEndpoint": "Network endpoint", "cli.cell.trigger.custom": "Custom", "cli.cell.trigger.systemState": "System state", + "cli.cell.trigger.aggregate": "Aggregate", "cli.cell.startDelayed": "{0} (delayed)", "cli.cell.startDelayedUnknown": "{0} (delay unknown)", "cli.cell.startTrigger": "{0}, on trigger", diff --git a/src/Bws.Cli/TriggerWords.cs b/src/Bws.Cli/TriggerWords.cs index 231cd93b..59a3a516 100644 --- a/src/Bws.Cli/TriggerWords.cs +++ b/src/Bws.Cli/TriggerWords.cs @@ -9,7 +9,8 @@ namespace Bws.Cli; /// /// WRITTEN 2026-09-01 FOR BACKLOG 260, the third and last family with this fault - the /// argument for all three is in and is not repeated here. Seven of the -/// nine members read as two or three words jammed together: DeviceArrival, +/// nine members of that day read as two or three words jammed together (Aggregate, the +/// tenth, came on 2026-10-28 for backlog 627 and is one word): DeviceArrival, /// IpAddress, DomainJoin, FirewallPort, GroupPolicy, /// NetworkEndpoint and CustomSystemStateChange. /// @@ -110,6 +111,7 @@ private static IEnumerable Items(TriggerDocument trigger) => TriggerKind.NetworkEndpoint => Texts.Of("cli.cell.trigger.networkEndpoint"), TriggerKind.Custom => Texts.Of("cli.cell.trigger.custom"), TriggerKind.CustomSystemStateChange => Texts.Of("cli.cell.trigger.systemState"), + TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.aggregate"), _ => Texts.Of("cli.cell.unknown") }; } diff --git a/src/Bws.Core/ManagerTerms.cs b/src/Bws.Core/ManagerTerms.cs index 492870b0..4977a535 100644 --- a/src/Bws.Core/ManagerTerms.cs +++ b/src/Bws.Core/ManagerTerms.cs @@ -55,10 +55,16 @@ internal static class ManagerTerms // Value seven, which this interop metadata does not name. Identified from the // machine rather than from memory: sc qtriggerinfo calls every trigger that landed // here "CUSTOM SYSTEM STATE CHANGE EVENT", and mapping it took the count of kinds - // we could not name from 89 to 1 on a machine with 810 entries. The one left is a - // kind sc.exe cannot name either - it prints the action and no type line at all. + // we could not name from 89 to 1 on a machine with 810 entries. (SERVICE_TRIGGER_TYPE)7 => TriggerKind.CustomSystemStateChange, + // Value thirty, the one that was left. sc.exe cannot name it either - it prints the + // action and no type line at all - but winsvc.h of the Windows SDK 10.0.28000.0 does, + // as SERVICE_TRIGGER_TYPE_AGGREGATE, on the line after SERVICE_TRIGGER_TYPE_CUSTOM. + // Measured on 2026-10-28: CDPSvc's one trigger, and after it no kind on that machine + // comes back Unknown. + (SERVICE_TRIGGER_TYPE)30 => TriggerKind.Aggregate, + _ => TriggerKind.Unknown }; diff --git a/src/Bws.Core/Querying/QueryValueNames.cs b/src/Bws.Core/Querying/QueryValueNames.cs index e9426f27..7d1b3630 100644 --- a/src/Bws.Core/Querying/QueryValueNames.cs +++ b/src/Bws.Core/Querying/QueryValueNames.cs @@ -111,6 +111,7 @@ internal static class QueryValueNames new QueryValueName("network", "networkendpoint"), new QueryValueName("custom", "custom"), new QueryValueName("state", "customsystemstatechange"), + new QueryValueName("aggregate", "aggregate"), new QueryValueName("unknown", "unknown"), // Not a kind but an action, and worth asking about on its own: a trigger diff --git a/src/Bws.Core/ServiceTrigger.cs b/src/Bws.Core/ServiceTrigger.cs index dbf58d60..73c1cefa 100644 --- a/src/Bws.Core/ServiceTrigger.cs +++ b/src/Bws.Core/ServiceTrigger.cs @@ -44,7 +44,19 @@ public enum TriggerKind /// entries. Leaving it as Unknown would have made the second largest group on the /// machine indistinguishable from anything else we cannot name. /// - CustomSystemStateChange + CustomSystemStateChange, + + /// + /// A trigger Windows assembles from others, identified by a subtype - kind thirty. + /// + /// The interop metadata has no name for this one either, and sc qtriggerinfo prints no kind for + /// it at all, but winsvc.h of the Windows SDK does: SERVICE_TRIGGER_TYPE_AGGREGATE. Measured on + /// 2026-10-28 it is one trigger on a machine with 252, the one CDPSvc has. Until then it was held + /// as Unknown, which made a change from it to some other kind nobody can name invisible to a + /// comparison. Named inside schema seven, before that schema was released, so a file older than + /// seven is the only one that can still say Unknown for it - see SnapshotDiff. + /// + Aggregate } /// What the trigger does when it fires. diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs b/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs index a911ff97..bc487c46 100644 --- a/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs +++ b/src/Bws.Core/Snapshots/SnapshotDiff.Values.cs @@ -101,12 +101,23 @@ .. triggers.Select(trigger => trigger is null ? null : new JsonObject { - ["kind"] = trigger["kind"]?.DeepClone(), + ["kind"] = KindBeforeSeven(trigger["kind"]), ["action"] = trigger["action"]?.DeepClone() }) ]); } + /// + /// A kind as a file older than schema seven holds it. Kind thirty was named Aggregate on 2026-10-28 + /// (backlog 627), inside schema seven and before it was released, and every build until then wrote it + /// Unknown - so against an older file the new name would read as a change on a machine where nothing + /// changed. Both sides are folded, which costs nothing on the older one: it cannot hold the name. + /// + private static JsonNode? KindBeforeSeven(JsonNode? kind) => + kind is JsonValue value && value.TryGetValue(out var text) && text == nameof(TriggerKind.Aggregate) + ? JsonValue.Create(nameof(TriggerKind.Unknown)) + : kind?.DeepClone(); + /// /// The triggers each side holds and the other does not, for the triggers field - null for any other. /// diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index 605121ff..30c4282c 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -747,6 +747,7 @@ "gui.cell.trigger.networkEndpoint": "Network endpoint", "gui.cell.trigger.custom": "Custom", "gui.cell.trigger.systemState": "System state", + "gui.cell.trigger.aggregate": "Aggregate", "gui.trigger.head": "{0}/{1}", "gui.trigger.withSubtype": "{0} {1}", "gui.trigger.withData": "{0} {1}", diff --git a/src/Bws.Gui/ViewModels/CellFaces.cs b/src/Bws.Gui/ViewModels/CellFaces.cs index ace524dc..c1505105 100644 --- a/src/Bws.Gui/ViewModels/CellFaces.cs +++ b/src/Bws.Gui/ViewModels/CellFaces.cs @@ -463,6 +463,7 @@ public static string TriggerList(Reading> reading) TriggerKind.NetworkEndpoint => Texts.Of("gui.cell.trigger.networkEndpoint"), TriggerKind.Custom => Texts.Of("gui.cell.trigger.custom"), TriggerKind.CustomSystemStateChange => Texts.Of("gui.cell.trigger.systemState"), + TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.aggregate"), _ => Texts.Of("gui.cell.unknown") }; diff --git a/tests/Bws.Cli.Tests/TriggerWordsTests.cs b/tests/Bws.Cli.Tests/TriggerWordsTests.cs new file mode 100644 index 00000000..2fecc559 --- /dev/null +++ b/tests/Bws.Cli.Tests/TriggerWordsTests.cs @@ -0,0 +1,43 @@ +using Bws.Core; + +namespace Bws.Cli.Tests; + +/// +/// Every trigger kind the core names has a word of its own in the terminal - its own word, not one it +/// shares with another kind and not the word for a kind nobody can name. +/// +/// Written 2026-10-28 for backlog 627, when the core named a tenth kind, Aggregate. The words are a +/// switch with a fallback, so a kind added to the core and forgotten here prints "Unknown" - the very word +/// the new name was added to replace - and nothing else would say so. The first version asked only that, +/// and the review of PR #99 caught what it let through: Aggregate pointed at the word for Custom would have +/// passed. Now the table below pins each word, and the guard after it asks that no two kinds share one, so +/// a kind added later and missing from the table is still held. CellFaceTests asks the same of the window. +/// +public sealed class TriggerWordsTests +{ + [Theory] + [InlineData(TriggerKind.DeviceArrival, "cli.cell.trigger.deviceArrival")] + [InlineData(TriggerKind.IpAddress, "cli.cell.trigger.ipAddress")] + [InlineData(TriggerKind.DomainJoin, "cli.cell.trigger.domainJoin")] + [InlineData(TriggerKind.FirewallPort, "cli.cell.trigger.firewallPort")] + [InlineData(TriggerKind.GroupPolicy, "cli.cell.trigger.groupPolicy")] + [InlineData(TriggerKind.NetworkEndpoint, "cli.cell.trigger.networkEndpoint")] + [InlineData(TriggerKind.Custom, "cli.cell.trigger.custom")] + [InlineData(TriggerKind.CustomSystemStateChange, "cli.cell.trigger.systemState")] + [InlineData(TriggerKind.Aggregate, "cli.cell.trigger.aggregate")] + [InlineData(TriggerKind.Unknown, "cli.cell.unknown")] + public void Each_kind_reads_as_its_word(TriggerKind kind, string key) + { + Assert.Equal(Texts.Of(key), TriggerWords.Of(kind)); + } + + [Fact] + public void Every_kind_the_core_names_has_a_word_of_its_own() + { + var named = Enum.GetValues().Where(kind => kind != TriggerKind.Unknown).ToList(); + var words = named.Select(TriggerWords.Of).ToList(); + + Assert.DoesNotContain(Texts.Of("cli.cell.unknown"), words); + Assert.Equal(named.Count, words.Distinct(StringComparer.Ordinal).Count()); + } +} diff --git a/tests/Bws.Core.Tests/TriggerChangeTests.cs b/tests/Bws.Core.Tests/TriggerChangeTests.cs index 1f197bfe..769649b1 100644 --- a/tests/Bws.Core.Tests/TriggerChangeTests.cs +++ b/tests/Bws.Core.Tests/TriggerChangeTests.cs @@ -165,6 +165,41 @@ public void Against_an_older_file_a_line_holds_the_kind_and_the_action_alone() Assert.Equal(new TriggerDocument("CustomSystemStateChange", "Start", null, null), Assert.Single(change.Added)); } + /// + /// Kind thirty was named Aggregate inside schema seven (backlog 627), and every file older than seven + /// holds it as Unknown - so against such a file the name is folded back, and a machine where nothing + /// changed shows nothing. + /// + [Fact] + public void Against_an_older_file_an_aggregate_trigger_held_there_as_unknown_is_the_same_trigger() + { + var before = Tree(Six); + Entry(before, "sppsvc")["triggers"]![1]!["kind"] = "Unknown"; + var after = Tree(Seven); + Trigger(after, "sppsvc", 1)["kind"] = "Aggregate"; + + Assert.Empty(Diff(before, after).Changed); + } + + /// + /// The fold is for a file older than seven alone. Two files of schema seven compare the kind as written, + /// so a file a build before backlog 627 wrote shows the change once - the price the owner accepted on + /// 2026-10-28 for naming it without a schema eight. + /// + [Fact] + public void Between_two_files_of_schema_seven_unknown_and_aggregate_are_two_kinds() + { + var before = Tree(Seven); + Trigger(before, "sppsvc", 1)["kind"] = "Unknown"; + var after = Tree(Seven); + Trigger(after, "sppsvc", 1)["kind"] = "Aggregate"; + + var change = Assert.Single(Assert.Single(Diff(before, after).Changed).Differences).Triggers!; + + Assert.Equal("Unknown", Assert.Single(change.Removed).Kind); + Assert.Equal("Aggregate", Assert.Single(change.Added).Kind); + } + /// Only the triggers field carries the lines - every other difference is its two values. [Fact] public void Only_the_triggers_field_carries_a_trigger_change() diff --git a/tests/Bws.Core.Tests/TriggerKindTests.cs b/tests/Bws.Core.Tests/TriggerKindTests.cs new file mode 100644 index 00000000..4f811096 --- /dev/null +++ b/tests/Bws.Core.Tests/TriggerKindTests.cs @@ -0,0 +1,38 @@ +using Windows.Win32.System.Services; + +namespace Bws.Core.Tests; + +/// +/// The manager's number for a trigger kind, in our words - above all the two numbers the interop +/// metadata does not name. +/// +/// Written 2026-10-28 for backlog 627, when kind thirty got the name winsvc.h of the Windows SDK +/// gives it, SERVICE_TRIGGER_TYPE_AGGREGATE. Until then nothing held the mapping of an unnamed number: +/// seven was read right on every real machine, and a change sending it back to Unknown would have +/// passed every test. +/// +public sealed class TriggerKindTests +{ + [Theory] + [InlineData(7u, TriggerKind.CustomSystemStateChange)] + [InlineData(30u, TriggerKind.Aggregate)] + public void A_kind_the_metadata_does_not_name_is_read_by_its_number(uint number, TriggerKind kind) + { + Assert.Equal(kind, ManagerTerms.Trigger((SERVICE_TRIGGER_TYPE)number)); + } + + /// + /// A number no header names stays Unknown rather than being folded into a neighbour - the numbers + /// right beside the named ones as winsvc.h lists them: before one, after seven, after twenty and + /// after thirty. + /// + [Theory] + [InlineData(0u)] + [InlineData(8u)] + [InlineData(21u)] + [InlineData(31u)] + public void A_number_no_header_names_stays_unknown(uint number) + { + Assert.Equal(TriggerKind.Unknown, ManagerTerms.Trigger((SERVICE_TRIGGER_TYPE)number)); + } +} diff --git a/tests/Bws.Core.Tests/TriggerQueryTests.cs b/tests/Bws.Core.Tests/TriggerQueryTests.cs index 15b7ed6b..7c0bff71 100644 --- a/tests/Bws.Core.Tests/TriggerQueryTests.cs +++ b/tests/Bws.Core.Tests/TriggerQueryTests.cs @@ -80,6 +80,24 @@ public void A_kind_can_be_asked_for_by_name() Assert.Contains("sppsvc", Match("trigger:state")); } + /// + /// Kind thirty, named on 2026-10-28 (backlog 627) after the name winsvc.h gives it. Until that day it + /// answered to trigger:unknown - now it answers to its own word, and no longer to that one. + /// + [Fact] + public void An_aggregate_trigger_is_asked_for_by_its_own_word() + { + var entry = Specimens.DelayedAutomaticNotRunning with + { + ServiceName = "CDPSvc", + Triggers = Reading>.Present( + [new ServiceTrigger(TriggerKind.Aggregate, TriggerAction.Start, new Guid("a086ff1e-d6dc-45f7-b3e4-6cd5c9fdd6d7"), [])]) + }; + + Assert.True(QueryParser.Parse("trigger:aggregate").Query!.Match(entry).Matched); + Assert.False(QueryParser.Parse("trigger:unknown").Query!.Match(entry).Matched); + } + [Fact] public void The_action_is_askable_next_to_the_kind() { diff --git a/tests/Bws.Gui.Tests/CellFaceTests.cs b/tests/Bws.Gui.Tests/CellFaceTests.cs index 6130ca9c..cb2a56e5 100644 --- a/tests/Bws.Gui.Tests/CellFaceTests.cs +++ b/tests/Bws.Gui.Tests/CellFaceTests.cs @@ -72,6 +72,42 @@ public void The_shape_is_never_the_word() } } + /// + /// Each trigger kind reads as its own word in the window. Written 2026-10-28 for backlog 627, when the + /// core named Aggregate: the label is a switch with a fallback, so a kind forgotten here reads "Unknown" + /// in the column and the panel, and the review of PR #99 caught that a first version asking only that + /// would have let Aggregate point at the word for Custom. TriggerWordsTests asks the same of the terminal. + /// + [Theory] + [InlineData(TriggerKind.DeviceArrival, "gui.cell.trigger.deviceArrival")] + [InlineData(TriggerKind.IpAddress, "gui.cell.trigger.ipAddress")] + [InlineData(TriggerKind.DomainJoin, "gui.cell.trigger.domainJoin")] + [InlineData(TriggerKind.FirewallPort, "gui.cell.trigger.firewallPort")] + [InlineData(TriggerKind.GroupPolicy, "gui.cell.trigger.groupPolicy")] + [InlineData(TriggerKind.NetworkEndpoint, "gui.cell.trigger.networkEndpoint")] + [InlineData(TriggerKind.Custom, "gui.cell.trigger.custom")] + [InlineData(TriggerKind.CustomSystemStateChange, "gui.cell.trigger.systemState")] + [InlineData(TriggerKind.Aggregate, "gui.cell.trigger.aggregate")] + [InlineData(TriggerKind.Unknown, "gui.cell.unknown")] + public void Each_trigger_kind_reads_as_its_word(TriggerKind kind, string key) + { + Assert.Equal(Bws.Gui.Texts.Of(key), CellFaces.TriggerKindLabel(kind)); + } + + /// + /// The guard for a kind added to the core later and missing from the table above: none reads as the + /// word for an unnamed kind, and no two share a word. + /// + [Fact] + public void Every_trigger_kind_the_core_names_has_a_word_of_its_own() + { + var named = Enum.GetValues().Where(kind => kind != TriggerKind.Unknown).ToList(); + var words = named.Select(CellFaces.TriggerKindLabel).ToList(); + + Assert.DoesNotContain(Bws.Gui.Texts.Of("gui.cell.unknown"), words); + Assert.Equal(named.Count, words.Distinct(StringComparer.Ordinal).Count()); + } + [Fact] public void The_start_type_carries_what_changes_its_meaning() { diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index fbd39853..2d3bd089 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -15065,6 +15065,70 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'private const string ItemSeparator = " / ";' Test = 'TriggerPartsTests.The_items_of_one_trigger_are_joined_by_a_bar' } + @{ + # Backlog 627: kind thirty is read as Aggregate, the name winsvc.h gives it. + Name = 'triggers: kind thirty is read as aggregate' + File = 'src\Bws.Core\ManagerTerms.cs' + From = '(SERVICE_TRIGGER_TYPE)30 => TriggerKind.Aggregate,' + To = '(SERVICE_TRIGGER_TYPE)30 => TriggerKind.Unknown,' + Test = 'TriggerKindTests.A_kind_the_metadata_does_not_name_is_read_by_its_number' + } + @{ + # Backlog 627: seven had no test of its own until the day thirty got one beside it. + Name = 'triggers: kind seven is read as a system state change' + File = 'src\Bws.Core\ManagerTerms.cs' + From = '(SERVICE_TRIGGER_TYPE)7 => TriggerKind.CustomSystemStateChange,' + To = '(SERVICE_TRIGGER_TYPE)7 => TriggerKind.Unknown,' + Test = 'TriggerKindTests.A_kind_the_metadata_does_not_name_is_read_by_its_number' + } + @{ + # Backlog 627: against a file older than seven, Aggregate is folded back to the Unknown that file holds. + Name = 'diff: aggregate is folded to unknown against a file older than seven' + File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs' + From = '? JsonValue.Create(nameof(TriggerKind.Unknown))' + To = '? JsonValue.Create(nameof(TriggerKind.Aggregate))' + Test = 'TriggerChangeTests.Against_an_older_file_an_aggregate_trigger_held_there_as_unknown_is_the_same_trigger' + } + @{ + # Backlog 627: trigger:aggregate is a word the query language accepts. + Name = 'query: trigger accepts aggregate' + File = 'src\Bws.Core\Querying\QueryValueNames.cs' + From = 'new QueryValueName("aggregate", "aggregate"),' + To = '' + Test = 'TriggerQueryTests.An_aggregate_trigger_is_asked_for_by_its_own_word' + } + @{ + # Backlog 627: the terminal has a word for every kind the core names. + Name = 'cli words: aggregate has a word of its own' + File = 'src\Bws.Cli\TriggerWords.cs' + From = 'TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.aggregate"),' + To = '' + Test = 'TriggerWordsTests.Every_kind_the_core_names_has_a_word_of_its_own' + } + @{ + # Backlog 627: the window has a word for every kind the core names. + Name = 'cell faces: aggregate has a word of its own' + File = 'src\Bws.Gui\ViewModels\CellFaces.cs' + From = 'TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.aggregate"),' + To = '' + Test = 'CellFaceTests.Every_trigger_kind_the_core_names_has_a_word_of_its_own' + } + @{ + # Review of PR #99: a kind pointed at another kind's word is caught, not only a kind with no word. + Name = "cli words: aggregate does not borrow custom's word" + File = 'src\Bws.Cli\TriggerWords.cs' + From = 'TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.aggregate"),' + To = 'TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.custom"),' + Test = 'TriggerWordsTests.Every_kind_the_core_names_has_a_word_of_its_own' + } + @{ + # Review of PR #99: the same in the window. + Name = "cell faces: aggregate does not borrow custom's word" + File = 'src\Bws.Gui\ViewModels\CellFaces.cs' + From = 'TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.aggregate"),' + To = 'TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.custom"),' + Test = 'CellFaceTests.Every_trigger_kind_the_core_names_has_a_word_of_its_own' + } ) $mutations