diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs index 4f9bb69c..3f01a1b4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs @@ -139,44 +139,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.False(agent.RebootOnComplete); } - [Fact] - public void Merge_remote_wins_over_prior_agent_log_level() + // ============================================================= LogLevel parsing + + [Theory] + // Valid levels (Info/Debug/Verbose/Trace) — remote wins, case-insensitive TryParse + // (RemoteConfigMerger.cs:59). + [InlineData("Debug", AgentLogLevel.Verbose, AgentLogLevel.Debug)] + [InlineData("trace", AgentLogLevel.Info, AgentLogLevel.Trace)] + [InlineData("VERBOSE", AgentLogLevel.Info, AgentLogLevel.Verbose)] + [InlineData("Info", AgentLogLevel.Trace, AgentLogLevel.Info)] + // Invalid / empty / whitespace / null → IsNullOrWhiteSpace or TryParse-fail → keeps prior. + [InlineData("NotAValidLevel", AgentLogLevel.Info, AgentLogLevel.Info)] + [InlineData("Warning", AgentLogLevel.Info, AgentLogLevel.Info)] // not in enum → keeps + [InlineData("", AgentLogLevel.Info, AgentLogLevel.Info)] + [InlineData(" ", AgentLogLevel.Info, AgentLogLevel.Info)] + [InlineData(null, AgentLogLevel.Info, AgentLogLevel.Info)] + public void Merge_log_level_applies_valid_values_and_ignores_invalid_ones( + string? remoteLogLevel, AgentLogLevel priorLevel, AgentLogLevel expectedLevel) { var agent = NewAgentConfig(); - agent.LogLevel = AgentLogLevel.Verbose; + agent.LogLevel = priorLevel; var remote = FullRemote(); - remote.LogLevel = "Debug"; + remote.LogLevel = remoteLogLevel!; - RemoteConfigMerger.Merge(agent, remote); + var result = RemoteConfigMerger.Merge(agent, remote); - Assert.Equal(AgentLogLevel.Debug, agent.LogLevel); - } - - [Fact] - public void Merge_parses_remote_log_level_case_insensitive() - { - var agent = NewAgentConfig(); - var remote = FullRemote(); - remote.LogLevel = "trace"; - - RemoteConfigMerger.Merge(agent, remote); - - Assert.Equal(AgentLogLevel.Trace, agent.LogLevel); - } - - [Fact] - public void Merge_keeps_log_level_when_remote_value_is_unparseable() - { - var agent = NewAgentConfig(); - agent.LogLevel = AgentLogLevel.Info; - - var remote = FullRemote(); - remote.LogLevel = "NotAValidLevel"; - - RemoteConfigMerger.Merge(agent, remote); - - Assert.Equal(AgentLogLevel.Info, agent.LogLevel); + Assert.Equal(expectedLevel, agent.LogLevel); + Assert.Equal(priorLevel != expectedLevel, result.LogLevelChanged); + Assert.Equal(priorLevel, result.OldLogLevel); + Assert.Equal(expectedLevel, result.NewLogLevel); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/EnrollmentOrchestratorRig.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/EnrollmentOrchestratorRig.cs new file mode 100644 index 00000000..280498eb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/EnrollmentOrchestratorRig.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.DecisionCore.Classifiers; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness +{ + /// + /// Shared rig for every EnrollmentOrchestrator*Tests file. Previously each of the + /// six test files declared its own inner Rig/Build class with an overlapping + /// constructor signature; consolidating eliminates ~200 LOC of boilerplate and keeps the + /// orchestrator-construction surface in one place. + /// + /// The rig owns its own , a starting + /// at , a fake uploader, and an empty classifier list. Tests + /// that need additional orchestrator parameters pass them to ; everything + /// else takes a sensible default (drain interval = 1 day so periodic drain doesn't fire + /// during short tests). + /// + /// + internal sealed class EnrollmentOrchestratorRig : IDisposable + { + public static readonly DateTime DefaultClockStart = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + public TempDirectory Tmp { get; } = new TempDirectory(); + public VirtualClock Clock { get; } + public AgentLogger Logger { get; } + public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); + public List Classifiers { get; } = new List(); + public string StateDir => Path.Combine(Tmp.Path, "State"); + public string TransportDir => Path.Combine(Tmp.Path, "Transport"); + + public EnrollmentOrchestratorRig(DateTime? clockStart = null) + { + Clock = new VirtualClock(clockStart ?? DefaultClockStart); + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + } + + public EnrollmentOrchestrator Build( + IComponentFactory? componentFactory = null, + IReadOnlyCollection? whiteGloveSealingPatternIds = null, + TimeSpan? drainInterval = null, + TimeSpan? terminalDrainTimeout = null, + TimeSpan? agentMaxLifetime = null) => + new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: StateDir, + transportDirectory: TransportDir, + clock: Clock, + logger: Logger, + uploader: Uploader, + classifiers: Classifiers, + componentFactory: componentFactory, + whiteGloveSealingPatternIds: whiteGloveSealingPatternIds, + drainInterval: drainInterval ?? TimeSpan.FromDays(1), + terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2), + agentMaxLifetime: agentMaxLifetime); + + public void Dispose() => Tmp.Dispose(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/ImeLogTrackerAdapterFixture.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/ImeLogTrackerAdapterFixture.cs new file mode 100644 index 00000000..f0c058e4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/ImeLogTrackerAdapterFixture.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness +{ + /// + /// Shared fixture for every ImeLogTrackerAdapter*Tests file. Previously each of the + /// five test files redeclared the same inner Fixture class (~30 LOC × 5 files of boilerplate); + /// consolidating keeps the setup uniform and cuts duplication without collapsing the + /// feature-separated test files (navigation via filename still works). + /// + /// Ctor parameter is optional — tests that don't care pass + /// nothing and get ; tests that drive time-sensitive behaviour + /// (Timing) pass their own start. + /// + /// + internal sealed class ImeLogTrackerAdapterFixture : IDisposable + { + public static readonly DateTime DefaultClockStart = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ImeLogTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } + + public ImeLogTrackerAdapterFixture(DateTime? clockStart = null) + { + Clock = new VirtualClock(clockStart ?? DefaultClockStart); + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ImeLogTracker( + logFolder: Tmp.Path, + patterns: new List(), + logger: Logger); + } + + public IReadOnlyList DecisionSignals(DecisionSignalKind kind) => + Ingress.Posted.Where(p => p.Kind == kind).ToList(); + + public IReadOnlyList InfoEvents(string eventType) => + Ingress.Posted + .Where(p => p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == eventType) + .ToList(); + + public FakeSignalIngressSink.PostedSignal InfoEvent(string eventType) => + InfoEvents(eventType).Single(); + + public IReadOnlyList AllInfoEvents() => + Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.InformationalEvent).ToList(); + + public IReadOnlyList NonInfoSignals() => + Ingress.Posted.Where(p => p.Kind != DecisionSignalKind.InformationalEvent).ToList(); + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEspConfigBootstrapTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEspConfigBootstrapTests.cs index 86cebeb3..4009cdb9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEspConfigBootstrapTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEspConfigBootstrapTests.cs @@ -29,54 +29,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration { private static DateTime At => new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc); - private sealed class Rig : IDisposable + private static IReadOnlyList ReadSignalLog(string stateDir) { - public TempDirectory Tmp { get; } = new TempDirectory(); - public VirtualClock Clock { get; } = new VirtualClock(At); - public AgentLogger Logger { get; } - public string StateDir { get; } - public string TransportDir { get; } - - public Rig() + var path = Path.Combine(stateDir, "signal-log.jsonl"); + if (!File.Exists(path)) return Array.Empty(); + var signals = new List(); + foreach (var line in File.ReadAllLines(path)) { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - StateDir = Path.Combine(Tmp.Path, "State"); - TransportDir = Path.Combine(Tmp.Path, "Transport"); + if (string.IsNullOrWhiteSpace(line)) continue; + signals.Add(SignalSerializer.Deserialize(line)); } - - public EnrollmentOrchestrator Build() => - new EnrollmentOrchestrator( - sessionId: "S1", - tenantId: "T1", - stateDirectory: StateDir, - transportDirectory: TransportDir, - clock: Clock, - logger: Logger, - uploader: new FakeBackendTelemetryUploader(), - classifiers: new List(), - drainInterval: TimeSpan.FromDays(1), - terminalDrainTimeout: TimeSpan.FromSeconds(2)); - - public IReadOnlyList ReadSignalLog() - { - var path = Path.Combine(StateDir, "signal-log.jsonl"); - if (!File.Exists(path)) return Array.Empty(); - var signals = new List(); - foreach (var line in File.ReadAllLines(path)) - { - if (string.IsNullOrWhiteSpace(line)) continue; - signals.Add(SignalSerializer.Deserialize(line)); - } - return signals; - } - - public void Dispose() => Tmp.Dispose(); + return signals; } [Fact] public void Start_posts_EspConfigDetected_before_collectors_when_FirstSync_available() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var _ = new EspSkipConfigurationProbe.ScopedOverride( _log => ((bool?)true, (bool?)false)); using var orchestrator = rig.Build(); @@ -84,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration orchestrator.Start(); orchestrator.Stop(); - var signals = rig.ReadSignalLog(); + var signals = ReadSignalLog(rig.StateDir); var espConfig = Assert.Single(signals, s => s.Kind == DecisionSignalKind.EspConfigDetected); Assert.Equal("EnrollmentOrchestrator", espConfig.SourceOrigin); Assert.Equal("true", espConfig.Payload![SignalPayloadKeys.SkipUserEsp]); @@ -97,7 +66,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration // Defensive: on a machine where FirstSync has not yet been populated (edge case on // very early boot) the bootstrap no-ops so the SignalLog does not carry a // meaningless signal. The collector's later CollectAll is the backup path. - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var _ = new EspSkipConfigurationProbe.ScopedOverride( _log => ((bool?)null, (bool?)null)); using var orchestrator = rig.Build(); @@ -105,7 +74,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration orchestrator.Start(); orchestrator.Stop(); - var signals = rig.ReadSignalLog(); + var signals = ReadSignalLog(rig.StateDir); Assert.DoesNotContain(signals, s => s.Kind == DecisionSignalKind.EspConfigDetected); } @@ -115,7 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration // Partial payload is valid — the reducer's per-fact set-once fills in SkipUserEsp // later from a subsequent post (DeviceInfoCollector.CollectEspConfiguration or // CollectAtEnrollmentStart). - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var _ = new EspSkipConfigurationProbe.ScopedOverride( _log => ((bool?)null, (bool?)false)); using var orchestrator = rig.Build(); @@ -123,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration orchestrator.Start(); orchestrator.Stop(); - var signals = rig.ReadSignalLog(); + var signals = ReadSignalLog(rig.StateDir); var espConfig = Assert.Single(signals, s => s.Kind == DecisionSignalKind.EspConfigDetected); Assert.False(espConfig.Payload!.ContainsKey(SignalPayloadKeys.SkipUserEsp)); Assert.Equal("false", espConfig.Payload[SignalPayloadKeys.SkipDeviceEsp]); @@ -137,7 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration // bootstrap signal's SessionSignalOrdinal must be the lowest among any signal whose // kind could drive Classic stage promotion. SessionStarted itself is always first // (ordinal 0); EspConfigDetected should come right after. - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var _ = new EspSkipConfigurationProbe.ScopedOverride( _log => ((bool?)true, (bool?)false)); using var orchestrator = rig.Build(); @@ -145,7 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration orchestrator.Start(); orchestrator.Stop(); - var signals = rig.ReadSignalLog(); + var signals = ReadSignalLog(rig.StateDir); var espConfig = signals.First(s => s.Kind == DecisionSignalKind.EspConfigDetected); // Nothing of Kind EspPhaseChanged or EspExiting may precede EspConfigDetected. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs index 41450411..282d7293 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs @@ -73,41 +73,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration } } - private sealed class Rig : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public VirtualClock Clock { get; } = new VirtualClock(At); - public AgentLogger Logger { get; } - public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); - public List Classifiers { get; } = new List(); - public FakeComponentFactory Factory { get; } - public string StateDir { get; } - public string TransportDir { get; } - - public Rig(FakeComponentFactory? factory = null) - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - StateDir = Path.Combine(Tmp.Path, "State"); - TransportDir = Path.Combine(Tmp.Path, "Transport"); - Factory = factory ?? new FakeComponentFactory(); - } - - public EnrollmentOrchestrator Build() => - new EnrollmentOrchestrator( - sessionId: "S1", - tenantId: "T1", - stateDirectory: StateDir, - transportDirectory: TransportDir, - clock: Clock, - logger: Logger, - uploader: Uploader, - classifiers: Classifiers, - componentFactory: Factory, - drainInterval: TimeSpan.FromDays(1), - terminalDrainTimeout: TimeSpan.FromSeconds(2)); - - public void Dispose() => Tmp.Dispose(); - } [Fact] public void Start_without_factory_does_not_throw_and_spawns_no_hosts() @@ -135,13 +100,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_with_factory_creates_all_hosts_and_starts_them() { - using var rig = new Rig(); - var sut = rig.Build(); + using var rig = new EnrollmentOrchestratorRig(At); + var factory = new FakeComponentFactory(); + var sut = rig.Build(componentFactory: factory); sut.Start(); - Assert.Equal(5, rig.Factory.Hosts.Count); - Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StartCalls)); + Assert.Equal(5, factory.Hosts.Count); + Assert.All(factory.Hosts, h => Assert.Equal(1, h.StartCalls)); sut.Stop(); } @@ -149,14 +115,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Factory_receives_ingress_and_clock_from_orchestrator() { - using var rig = new Rig(); - var sut = rig.Build(); + using var rig = new EnrollmentOrchestratorRig(At); + var factory = new FakeComponentFactory(); + var sut = rig.Build(componentFactory: factory); sut.Start(); - Assert.NotNull(rig.Factory.CapturedIngress); - Assert.NotNull(rig.Factory.CapturedClock); - Assert.Same(rig.Clock, rig.Factory.CapturedClock); + Assert.NotNull(factory.CapturedIngress); + Assert.NotNull(factory.CapturedClock); + Assert.Same(rig.Clock, factory.CapturedClock); sut.Stop(); } @@ -164,14 +131,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Stop_stops_and_disposes_all_hosts_in_creation_order() { - using var rig = new Rig(); - var sut = rig.Build(); + using var rig = new EnrollmentOrchestratorRig(At); + var factory = new FakeComponentFactory(); + var sut = rig.Build(componentFactory: factory); sut.Start(); sut.Stop(); - Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StopCalls)); - Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.DisposeCalls)); + Assert.All(factory.Hosts, h => Assert.Equal(1, h.StopCalls)); + Assert.All(factory.Hosts, h => Assert.Equal(1, h.DisposeCalls)); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs index e7bd180d..9baca0a6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs @@ -22,31 +22,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration { private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); - private static EnrollmentOrchestrator Build( - TempDirectory tmp, - TimeSpan? agentMaxLifetime) - { - var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); - return new EnrollmentOrchestrator( - sessionId: "S1", - tenantId: "T1", - stateDirectory: Path.Combine(tmp.Path, "State"), - transportDirectory: Path.Combine(tmp.Path, "Transport"), - clock: new VirtualClock(At), - logger: logger, - uploader: new FakeBackendTelemetryUploader(), - classifiers: new List(), - componentFactory: null, - drainInterval: TimeSpan.FromDays(1), - terminalDrainTimeout: TimeSpan.FromSeconds(2), - agentMaxLifetime: agentMaxLifetime); - } - [Fact] public void Terminated_fires_once_when_max_lifetime_elapses() { - using var tmp = new TempDirectory(); - var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(200)); + using var rig = new EnrollmentOrchestratorRig(At); + var sut = rig.Build(agentMaxLifetime: TimeSpan.FromMilliseconds(200)); using var fired = new ManualResetEventSlim(false); EnrollmentTerminatedEventArgs? captured = null; @@ -78,8 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Terminated_not_fired_when_max_lifetime_is_null() { - using var tmp = new TempDirectory(); - var sut = Build(tmp, agentMaxLifetime: null); + using var rig = new EnrollmentOrchestratorRig(At); + var sut = rig.Build(agentMaxLifetime: null); var fireCount = 0; sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount); @@ -91,19 +71,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration Assert.Equal(0, fireCount); } - [Fact] - public void Ctor_rejects_non_positive_max_lifetime() + [Theory] + [InlineData(0)] // TimeSpan.Zero — must reject + [InlineData(-1)] // negative ms — must reject + public void Ctor_rejects_non_positive_max_lifetime(int milliseconds) { - using var tmp = new TempDirectory(); - Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero)); - Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(-1))); + using var rig = new EnrollmentOrchestratorRig(At); + Assert.Throws(() => + rig.Build(agentMaxLifetime: TimeSpan.FromMilliseconds(milliseconds))); } [Fact] public void Stop_before_lifetime_elapses_silences_the_watchdog() { - using var tmp = new TempDirectory(); - var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromSeconds(5)); + using var rig = new EnrollmentOrchestratorRig(At); + var sut = rig.Build(agentMaxLifetime: TimeSpan.FromSeconds(5)); var fireCount = 0; sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs index 417e0a2d..89e88841 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs @@ -24,43 +24,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration { private static DateTime At => new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc); - private sealed class Rig : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public VirtualClock Clock { get; } = new VirtualClock(At); - public AgentLogger Logger { get; } - public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); - public List Classifiers { get; } = new List(); - public string StateDir { get; } - public string TransportDir { get; } - - public Rig() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - StateDir = Path.Combine(Tmp.Path, "State"); - TransportDir = Path.Combine(Tmp.Path, "Transport"); - } - - public EnrollmentOrchestrator Build() => - new EnrollmentOrchestrator( - sessionId: "S1", - tenantId: "T1", - stateDirectory: StateDir, - transportDirectory: TransportDir, - clock: Clock, - logger: Logger, - uploader: Uploader, - classifiers: Classifiers, - drainInterval: TimeSpan.FromDays(1), - terminalDrainTimeout: TimeSpan.FromSeconds(2)); - - public void Dispose() => Tmp.Dispose(); - } [Fact] public void Start_without_hook_still_succeeds_for_backward_compatibility() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var orchestrator = rig.Build(); // No onIngressReady argument — existing callers must keep compiling and running. @@ -72,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_invokes_hook_synchronously_with_a_live_ingress_sink() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var orchestrator = rig.Build(); ISignalIngressSink? captured = null; @@ -98,7 +66,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration // not throw NullReferenceException or "ingress not started" on the caller thread. // The full signal-to-event pipeline is covered by dedicated reducer + EffectRunner // tests; this assertion only guards the timing contract exposed by Start. - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var orchestrator = rig.Build(); Exception? captured = null; @@ -131,7 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration { // A broken caller hook must not brick the agent — the error is logged, Start // continues to launch collectors, and the orchestrator is usable afterwards. - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var orchestrator = rig.Build(); orchestrator.Start(_ => throw new InvalidOperationException("boom")); @@ -143,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Hook_runs_exactly_once_even_if_Start_is_invoked_twice() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); using var orchestrator = rig.Build(); int invocations = 0; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs index c3f93cb2..e633a520 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs @@ -22,46 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration { private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); - private sealed class Rig : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public VirtualClock Clock { get; } = new VirtualClock(At); - public AgentLogger Logger { get; } - public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); - public List Classifiers { get; } = new List(); - public string StateDir { get; } - public string TransportDir { get; } - - public Rig() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - StateDir = Path.Combine(Tmp.Path, "State"); - TransportDir = Path.Combine(Tmp.Path, "Transport"); - } - - public EnrollmentOrchestrator Build(TimeSpan? drainInterval = null) => - new EnrollmentOrchestrator( - sessionId: "S1", - tenantId: "T1", - stateDirectory: StateDir, - transportDirectory: TransportDir, - clock: Clock, - logger: Logger, - uploader: Uploader, - classifiers: Classifiers, - // Default drain-interval = 1 day → periodic drain won't fire during short tests. - drainInterval: drainInterval ?? TimeSpan.FromDays(1), - terminalDrainTimeout: TimeSpan.FromSeconds(2)); - - public void Dispose() => Tmp.Dispose(); - } // ========================================================================= Ctor [Fact] public void Ctor_rejects_empty_required_strings() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); Assert.Throws(() => new EnrollmentOrchestrator( "", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers)); Assert.Throws(() => new EnrollmentOrchestrator( @@ -75,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Ctor_rejects_null_dependencies() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); Assert.Throws(() => new EnrollmentOrchestrator( "S1", "T1", rig.StateDir, rig.TransportDir, null!, rig.Logger, rig.Uploader, rig.Classifiers)); Assert.Throws(() => new EnrollmentOrchestrator( @@ -89,7 +56,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Observability_before_start_throws() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); Assert.Throws(() => sut.CurrentState); Assert.Throws(() => sut.IngressSink); @@ -102,7 +69,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Transport_is_available_after_start() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); Assert.NotNull(sut.Transport); @@ -113,7 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_instantiates_pipeline_no_exception() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -130,7 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_creates_state_and_transport_directories() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); Assert.False(Directory.Exists(rig.StateDir)); Assert.False(Directory.Exists(rig.TransportDir)); @@ -146,7 +113,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_creates_initial_state_when_no_snapshot() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -159,7 +126,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_loads_recovered_snapshot_when_file_exists() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); Directory.CreateDirectory(rig.StateDir); // Seed a snapshot with a non-initial stage so recovery is detectable. var persistedState = DecisionState.CreateInitial("S1", "T1") @@ -183,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Start_twice_throws() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -197,7 +164,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Stop_before_start_is_noop() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Stop(); // does not throw @@ -206,7 +173,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Stop_is_idempotent() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -217,7 +184,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Stop_flushes_final_snapshot_with_current_state() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -248,7 +215,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Dispose_stops_orchestrator() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -264,7 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Deadline_fired_posts_DeadlineFired_signal_to_ingress() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); @@ -292,7 +259,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void TriggerQuarantine_sets_flag_and_reason() { - using var rig = new Rig(); + using var rig = new EnrollmentOrchestratorRig(At); var sut = rig.Build(); sut.Start(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs index 3a9d9a8c..020742c0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs @@ -33,33 +33,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration } } - private EnrollmentOrchestrator Build( - TempDirectory tmp, - IComponentFactory? factory = null, - IReadOnlyCollection? whiteGloveSealingPatternIds = null) - { - var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); - return new EnrollmentOrchestrator( - sessionId: "S1", - tenantId: "T1", - stateDirectory: Path.Combine(tmp.Path, "State"), - transportDirectory: Path.Combine(tmp.Path, "Transport"), - clock: new VirtualClock(At), - logger: logger, - uploader: new FakeBackendTelemetryUploader(), - classifiers: new List(), - componentFactory: factory, - whiteGloveSealingPatternIds: whiteGloveSealingPatternIds, - drainInterval: TimeSpan.FromDays(1), - terminalDrainTimeout: TimeSpan.FromSeconds(2)); - } - [Fact] public void Null_pattern_ids_reach_factory_as_empty_collection() { - using var tmp = new TempDirectory(); + using var rig = new EnrollmentOrchestratorRig(At); var factory = new CapturingFactory(); - var sut = Build(tmp, factory, whiteGloveSealingPatternIds: null); + var sut = rig.Build(componentFactory: factory, whiteGloveSealingPatternIds: null); sut.Start(); @@ -72,10 +51,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Configured_pattern_ids_reach_factory_verbatim() { - using var tmp = new TempDirectory(); + using var rig = new EnrollmentOrchestratorRig(At); var factory = new CapturingFactory(); var ids = new[] { "WG_SEAL_1", "WG_SEAL_2", "WG_SEAL_3" }; - var sut = Build(tmp, factory, whiteGloveSealingPatternIds: ids); + var sut = rig.Build(componentFactory: factory, whiteGloveSealingPatternIds: ids); sut.Start(); @@ -88,9 +67,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration [Fact] public void Empty_pattern_ids_collection_forwards_as_empty_not_null() { - using var tmp = new TempDirectory(); + using var rig = new EnrollmentOrchestratorRig(At); var factory = new CapturingFactory(); - var sut = Build(tmp, factory, whiteGloveSealingPatternIds: Array.Empty()); + var sut = rig.Build(componentFactory: factory, whiteGloveSealingPatternIds: Array.Empty()); sut.Start(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/InformationalEventPostTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/InformationalEventPostTests.cs index b460d10a..5fd8eea2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/InformationalEventPostTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/InformationalEventPostTests.cs @@ -171,37 +171,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration Assert.Equal("-124.02", payload["offsetSeconds"]); } - [Fact] - public void Evidence_summary_prefers_explicit_argument_then_message_then_eventType() + [Theory] + // Precedence: explicit evidenceSummary > message > eventType fallback. The helper uses + // string.IsNullOrEmpty (not IsNullOrWhiteSpace), so a whitespace-only explicit value is + // treated as "present" and passed through unchanged. + [InlineData("explicit", "the_message", "ntp_time_check", "explicit")] + [InlineData(null, "fallback_message", "ntp_time_check", "fallback_message")] + [InlineData(null, null, "ntp_time_check", "ntp_time_check")] + // Empty ("") evidenceSummary is treated as absent (IsNullOrEmpty) → fall through to message. + [InlineData("", "fallback_message", "ntp_time_check", "fallback_message")] + public void Evidence_summary_prefers_explicit_argument_then_message_then_eventType( + string? evidenceSummary, string? message, string eventType, string expectedSummary) { var (sut, sink, _) = BuildRig(); - sut.Emit("ntp_time_check", "Network", evidenceSummary: "explicit"); - Assert.Equal("explicit", sink.Posted.Last().Evidence.Summary); + sut.Emit(eventType, "Network", message: message, evidenceSummary: evidenceSummary); - sut.Emit("ntp_time_check", "Network", message: "fallback_message"); - Assert.Equal("fallback_message", sink.Posted.Last().Evidence.Summary); - - sut.Emit("ntp_time_check", "Network"); - Assert.Equal("ntp_time_check", sink.Posted.Last().Evidence.Summary); + Assert.Equal(expectedSummary, sink.Posted.Last().Evidence.Summary); } - [Fact] - public void Empty_eventType_throws() + [Theory] + // Empty / null eventType or source must be rejected eagerly — callers writing payloads + // without either of these end up with a signal the reducer cannot interpret. + [InlineData("", "Network")] + [InlineData(null, "Network")] + [InlineData("x", "")] + [InlineData("x", null)] + public void Emit_throws_on_empty_or_null_eventType_or_source(string? eventType, string? source) { var (sut, _, _) = BuildRig(); - Assert.Throws(() => sut.Emit(eventType: "", source: "Network")); - Assert.Throws(() => sut.Emit(eventType: null!, source: "Network")); - } - - [Fact] - public void Empty_source_throws() - { - var (sut, _, _) = BuildRig(); - - Assert.Throws(() => sut.Emit(eventType: "x", source: "")); - Assert.Throws(() => sut.Emit(eventType: "x", source: null!)); + Assert.Throws(() => sut.Emit(eventType: eventType!, source: source!)); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs index 134a15a0..0250c9ff 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs @@ -77,90 +77,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program // ================================================================= CheckEnrollmentCompleteMarker - [Fact] - public void CheckEnrollmentCompleteMarker_returns_false_when_marker_file_is_absent() + [Theory] + // (markerPresent, selfDestructOnComplete) → (expectedShouldExit, expectedFactoryCalls) + [InlineData(false, false, false, 0)] // no marker, no cleanup → proceed normally (coverage gap filled) + [InlineData(false, true, false, 0)] // no marker, cleanup armed → proceed (guard stays silent) + [InlineData(true, false, true, 0)] // marker + no cleanup → exit, leave ProgramData intact (post-mortem) + [InlineData(true, true, true, 1)] // marker + cleanup armed → exit and retry cleanup once + public void CheckEnrollmentCompleteMarker_gates_exit_and_cleanup_on_marker_and_selfdestruct_flag( + bool markerPresent, bool selfDestructOnComplete, bool expectedShouldExit, int expectedFactoryCalls) { - // No enrollment-complete.marker present → guard must stay silent and leave the - // agent to proceed with normal startup, whether or not a SessionId exists. using var tmp = new TempDirectory(); var logger = NewLogger(tmp.Path); new SessionIdPersistence(tmp.Path).GetOrCreate(logger); var stateDir = Path.Combine(tmp.Path, "State"); + if (markerPresent) + { + Directory.CreateDirectory(stateDir); + File.WriteAllText(Path.Combine(stateDir, "enrollment-complete.marker"), "previous completion"); + } var factoryCalls = 0; var shouldExit = AutopilotMonitor.Agent.V2.Program.CheckEnrollmentCompleteMarker( stateDirectory: stateDir, - selfDestructOnComplete: true, // worst case — cleanup armed + selfDestructOnComplete: selfDestructOnComplete, cleanupServiceFactory: () => { factoryCalls++; + // The real factory would spawn a PowerShell cleanup script; throwing here + // forces TryRetryCleanup to swallow + log so we can assert factoryCalls==1 + // without a real side-effect. throw new InvalidOperationException( - "test-harness: cleanup factory must not be invoked in this scenario"); + "test-harness: cleanup factory must not spawn real PowerShell in unit tests"); }, logger: logger, consoleMode: false); - Assert.False(shouldExit); - Assert.Equal(0, factoryCalls); - } - - [Fact] - public void CheckEnrollmentCompleteMarker_returns_true_without_firing_cleanup_when_marker_present_and_no_selfdestruct() - { - // Remote config said "don't self-destruct": agent must exit cleanly but leave the - // ProgramData directory intact for post-mortem inspection. - using var tmp = new TempDirectory(); - var logger = NewLogger(tmp.Path); - new SessionIdPersistence(tmp.Path).GetOrCreate(logger); - var stateDir = Path.Combine(tmp.Path, "State"); - Directory.CreateDirectory(stateDir); - File.WriteAllText(Path.Combine(stateDir, "enrollment-complete.marker"), "previous completion"); - var factoryCalls = 0; - - var shouldExit = AutopilotMonitor.Agent.V2.Program.CheckEnrollmentCompleteMarker( - stateDirectory: stateDir, - selfDestructOnComplete: false, - cleanupServiceFactory: () => - { - factoryCalls++; - throw new InvalidOperationException( - "test-harness: cleanup factory must not be invoked in this scenario"); - }, - logger: logger, - consoleMode: false); - - Assert.True(shouldExit); - Assert.Equal(0, factoryCalls); - } - - [Fact] - public void CheckEnrollmentCompleteMarker_fires_cleanup_retry_when_marker_present_and_selfdestruct_armed() - { - // Scheduled-task-survived-self-destruct retry path. CleanupService actually spawns a - // PowerShell cleanup script — we throw from the factory so the real side-effect does - // not fire; TryRetryCleanup swallows the exception and logs a warning. - using var tmp = new TempDirectory(); - var logger = NewLogger(tmp.Path); - new SessionIdPersistence(tmp.Path).GetOrCreate(logger); - var stateDir = Path.Combine(tmp.Path, "State"); - Directory.CreateDirectory(stateDir); - File.WriteAllText(Path.Combine(stateDir, "enrollment-complete.marker"), "previous completion"); - var factoryCalls = 0; - - var shouldExit = AutopilotMonitor.Agent.V2.Program.CheckEnrollmentCompleteMarker( - stateDirectory: stateDir, - selfDestructOnComplete: true, - cleanupServiceFactory: () => - { - factoryCalls++; - throw new InvalidOperationException( - "test-harness: must not spawn real PowerShell cleanup in unit tests"); - }, - logger: logger, - consoleMode: false); - - Assert.True(shouldExit); - Assert.Equal(1, factoryCalls); + Assert.Equal(expectedShouldExit, shouldExit); + Assert.Equal(expectedFactoryCalls, factoryCalls); } // ================================================================= Emergency break diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs index 8b87e372..e2d0c856 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs @@ -232,37 +232,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security // ============================================================= Distress dispatch (V1 parity) - [Fact] - public void RecordFailure_dispatches_distress_only_on_first_failure_for_401() + [Theory] + // V1 parity: 403 → DeviceNotRegistered, everything else (including 401 and any non-403 + // error the tenant happened to misconfigure) → AuthCertificateRejected. Distress dispatch + // fires exactly once per tracker lifetime so we don't spam the channel with the same + // auth problem repeating on every request. + [InlineData(401, 1, DistressErrorType.AuthCertificateRejected)] + [InlineData(401, 3, DistressErrorType.AuthCertificateRejected)] // dedup: 3 failures → 1 dispatch + [InlineData(403, 1, DistressErrorType.DeviceNotRegistered)] + [InlineData(403, 3, DistressErrorType.DeviceNotRegistered)] // dedup for 403 too (NEW coverage) + [InlineData(500, 1, DistressErrorType.AuthCertificateRejected)] // non-auth 5xx uses catch-all mapping (NEW) + public void RecordFailure_dispatches_distress_once_with_correct_error_type_per_http_status( + int statusCode, int nFailures, DistressErrorType expectedErrorType) { var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); var fakeDistress = new FakeDistressReporter(); - var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + // maxFailures high enough that the threshold short-circuit (_thresholdFired=1 path) + // does not interfere with the per-call distress semantics we're exercising. + var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); - tracker.RecordFailure(401, "agent/config"); - tracker.RecordFailure(401, "agent/config"); - tracker.RecordFailure(401, "agent/config"); + for (int i = 0; i < nFailures; i++) + tracker.RecordFailure(statusCode, "agent/config"); Assert.Single(fakeDistress.Calls); - Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType); - Assert.Equal(401, fakeDistress.Calls[0].HttpStatusCode); + Assert.Equal(expectedErrorType, fakeDistress.Calls[0].ErrorType); + Assert.Equal(statusCode, fakeDistress.Calls[0].HttpStatusCode); Assert.Contains("agent/config", fakeDistress.Calls[0].Message); } - [Fact] - public void RecordFailure_dispatches_device_not_registered_for_403() - { - var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); - var fakeDistress = new FakeDistressReporter(); - var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); - - tracker.RecordFailure(403, "agent/telemetry"); - - Assert.Single(fakeDistress.Calls); - Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType); - Assert.Equal(403, fakeDistress.Calls[0].HttpStatusCode); - } - [Fact] public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs index f677276d..0ddda5b6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs @@ -86,12 +86,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.True(Guid.TryParse(id, out _)); } - [Fact] - public void Ctor_rejects_null_or_empty_data_directory() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Ctor_rejects_null_empty_or_whitespace_data_directory(string? dataDirectory) { - Assert.Throws(() => new SessionIdPersistence(null!)); - Assert.Throws(() => new SessionIdPersistence("")); - Assert.Throws(() => new SessionIdPersistence(" ")); + Assert.Throws(() => new SessionIdPersistence(dataDirectory!)); } [Fact] @@ -124,18 +125,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind); } - [Fact] - public void LoadSessionCreatedAt_returns_null_when_file_missing() + [Theory] + // null content sentinel → do NOT write the file (tests the "missing file" path). + // Any other value is written verbatim; LoadSessionCreatedAt must return null unless + // the content round-trips through DateTime.TryParse RoundtripKind. + [InlineData(null)] // file missing + [InlineData("not-a-date")] // unparseable garbage + [InlineData("")] // empty file (NEW coverage — Trim → "" → TryParse fails) + [InlineData(" ")] // whitespace only (NEW coverage — Trim → "" → TryParse fails) + [InlineData("2026-13-45")] // out-of-range date components (NEW coverage) + public void LoadSessionCreatedAt_returns_null_when_file_missing_or_unparseable(string? fileContent) { using var tmp = new TempDirectory(); - Assert.Null(new SessionIdPersistence(tmp.Path).LoadSessionCreatedAt()); - } + if (fileContent != null) + File.WriteAllText(Path.Combine(tmp.Path, "session.created"), fileContent); - [Fact] - public void LoadSessionCreatedAt_returns_null_when_file_is_corrupt() - { - using var tmp = new TempDirectory(); - File.WriteAllText(Path.Combine(tmp.Path, "session.created"), "not-a-date"); Assert.Null(new SessionIdPersistence(tmp.Path).LoadSessionCreatedAt()); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterImmediateUploadTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterImmediateUploadTests.cs index 27c2285f..171e4e1a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterImmediateUploadTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterImmediateUploadTests.cs @@ -24,43 +24,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters /// public sealed class ImeLogTrackerAdapterImmediateUploadTests { - private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); - - private sealed class Fixture : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public AgentLogger Logger { get; } - public ImeLogTracker Tracker { get; } - public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); - public VirtualClock Clock { get; } = new VirtualClock(Fixed); - - public Fixture() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - Tracker = new ImeLogTracker( - logFolder: Tmp.Path, - patterns: new List(), - logger: Logger); - } - - public FakeSignalIngressSink.PostedSignal InfoEvent(string eventType) => - Ingress.Posted.Single(p => - p.Kind == DecisionSignalKind.InformationalEvent - && p.Payload != null - && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et == eventType); - - public void Dispose() - { - Tracker.Dispose(); - Tmp.Dispose(); - } - } [Fact] public void EspPhaseChanged_info_event_is_marked_immediate_upload() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -79,7 +47,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters public void AppState_lifecycle_events_are_marked_immediate_upload( AppInstallationState newState, string expectedEventType, string expectedImmediate) { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState($"app-{newState}", listPos: 0); @@ -92,7 +60,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppDownloadStarted_is_marked_immediate_upload() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-a", 0); @@ -107,7 +75,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { // Progress ticks run every ~3s and only during an active download (bounded by // download duration, not polling). Live UI bar > small extra request volume. - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-a", 0); @@ -120,7 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void DoTelemetry_is_marked_immediate_upload() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-do", 0); @@ -134,7 +102,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters public void ImeUserSessionCompleted_stays_batched() { // Not a hot lifecycle event for UI gating — keep batched per Fix 1 scope. - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerUserSessionCompletedFromTest(); @@ -146,7 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ImeAgentVersion_stays_batched() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerImeAgentVersionFromTest("1.101.109.0"); @@ -158,7 +126,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ScriptCompleted_stays_batched() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs index aeeb73aa..ffe765f6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs @@ -19,36 +19,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters /// public sealed class ImeLogTrackerAdapterSealingPatternTests { - private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); - - private sealed class Fixture : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public AgentLogger Logger { get; } - public ImeLogTracker Tracker { get; } - public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); - public VirtualClock Clock { get; } = new VirtualClock(Fixed); - - public Fixture() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - Tracker = new ImeLogTracker( - logFolder: Tmp.Path, - patterns: new List(), - logger: Logger); - } - - public void Dispose() - { - Tracker.Dispose(); - Tmp.Dispose(); - } - } [Fact] public void Pattern_match_in_sealing_set_emits_WhiteGloveSealingPatternDetected() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter( f.Tracker, f.Ingress, f.Clock, whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1", "IME-WG-SEAL-2" }); @@ -65,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Pattern_match_NOT_in_sealing_set_does_not_emit() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter( f.Tracker, f.Ingress, f.Clock, whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1" }); @@ -80,7 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { // Default ctor — whiteGloveSealingPatternIds null → no emissions (backwards-compat // with pre-M4.4.4 M3 behavior). - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerPatternMatchedFromTest("IME-WG-SEAL-1"); @@ -92,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Sealing_pattern_emission_is_fire_once_per_session() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter( f.Tracker, f.Ingress, f.Clock, whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1", "IME-WG-SEAL-2" }); @@ -108,7 +83,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Empty_or_null_patternId_does_not_emit() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter( f.Tracker, f.Ingress, f.Clock, whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1" }); @@ -125,7 +100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters // Directly verify the hook surface on ImeLogTracker — adapter subscribes via this // Action. We simulate the tracker's HandlePatternMatch entry by invoking the // property the same way HandlePatternMatch does. - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter( f.Tracker, f.Ingress, f.Clock, whiteGloveSealingPatternIds: new[] { "ANY" }); @@ -140,7 +115,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Previously_wired_OnPatternMatched_is_chain_preserved_and_restored_on_Dispose() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); var chainInvocations = new List(); f.Tracker.OnPatternMatched = id => chainInvocations.Add("prev:" + id); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSubPhaseDeclarationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSubPhaseDeclarationTests.cs index 3e569747..067095b1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSubPhaseDeclarationTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSubPhaseDeclarationTests.cs @@ -23,42 +23,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc); - private sealed class Fixture : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public AgentLogger Logger { get; } - public ImeLogTracker Tracker { get; } - public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); - public VirtualClock Clock { get; } = new VirtualClock(Fixed); - - public Fixture() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - Tracker = new ImeLogTracker( - logFolder: Tmp.Path, - patterns: new List(), - logger: Logger); - } - - public IReadOnlyList InfoEvents(string eventType) => - Ingress.Posted - .Where(p => p.Kind == DecisionSignalKind.InformationalEvent - && p.Payload != null - && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et == eventType) - .ToList(); - - public void Dispose() - { - Tracker.Dispose(); - Tmp.Dispose(); - } - } - [Fact] public void First_app_activity_in_DeviceSetup_emits_phase_transition_AppsDevice() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(Fixed); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -75,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void First_app_activity_in_AccountSetup_emits_phase_transition_AppsUser() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(Fixed); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -95,7 +63,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void SubPhase_declaration_fires_once_per_ESP_phase() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(Fixed); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -111,7 +79,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void App_activity_before_any_ESP_phase_does_not_emit_phase_transition() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(Fixed); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); // WDP v2 / device-only paths have no ESP phase — adapter must not emit an @@ -125,7 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Unmapped_ESP_phase_does_not_emit_phase_transition() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(Fixed); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); // "FinalizingSetup" maps to an EnrollmentPhase value, but the reducer emits the @@ -141,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Phase_transition_fires_before_the_app_state_event_on_the_wire() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(Fixed); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs index d0d1d5d7..fc4891a6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -16,53 +16,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { public sealed class ImeLogTrackerAdapterTests { - private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); - - private sealed class Fixture : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public AgentLogger Logger { get; } - public ImeLogTracker Tracker { get; } - public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); - public VirtualClock Clock { get; } = new VirtualClock(Fixed); - - public Fixture() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - Tracker = new ImeLogTracker( - logFolder: Tmp.Path, - patterns: new List(), - logger: Logger); - } - - public IReadOnlyList DecisionSignals(DecisionSignalKind kind) => - Ingress.Posted.Where(p => p.Kind == kind).ToList(); - - public IReadOnlyList InfoEvents(string eventType) => - Ingress.Posted - .Where(p => p.Kind == DecisionSignalKind.InformationalEvent - && p.Payload != null - && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et == eventType) - .ToList(); - - public IReadOnlyList AllInfoEvents() => - Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.InformationalEvent).ToList(); - - public IReadOnlyList NonInfoSignals() => - Ingress.Posted.Where(p => p.Kind != DecisionSignalKind.InformationalEvent).ToList(); - - public void Dispose() - { - Tracker.Dispose(); - Tmp.Dispose(); - } - } [Fact] public void EspPhaseChanged_first_phase_emits_decision_signal_with_phase_in_payload() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -72,51 +30,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Equal("DeviceSetup", decisionPost.Payload![SignalPayloadKeys.EspPhase]); } - [Fact] - public void EspPhaseChanged_first_phase_also_emits_esp_phase_changed_informational_event() + [Theory] + // Known raw phases → mapped to matching EnrollmentPhase enum string on info-event payload. + // Mapping table lives in ImeLogTrackerAdapter.MapEspPhaseToEnrollmentPhase (near the end + // of the file); this Theory is the regression guard against drift in that table. + [InlineData("DeviceSetup", "DeviceSetup")] + [InlineData("AccountSetup", "AccountSetup")] + [InlineData("FinalizingSetup", "FinalizingSetup")] + [InlineData("Finalizing", "FinalizingSetup")] // V1 legacy alias → same enum + [InlineData("Complete", "Complete")] + // Unknown raw phases → omit the "phase" key entirely so the event downstream defaults + // to EnrollmentPhase.Unknown (feedback_phase_strategy). + [InlineData("SomethingElse", null)] + [InlineData("preboot", null)] + public void EspPhaseChanged_info_event_maps_raw_phase_to_enrollment_phase_enum( + string rawPhase, string? expectedMappedPhase) { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); - adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest(rawPhase); var info = Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]); - Assert.Equal("DeviceSetup", info.Payload["espPhase"]); - // Phase-declaration event — carries non-Unknown EnrollmentPhase. - Assert.Equal(EnrollmentPhase.DeviceSetup.ToString(), info.Payload["phase"]); - } - - [Fact] - public void EspPhaseChanged_AccountSetup_maps_phase_to_AccountSetup_enum() - { - using var f = new Fixture(); - using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); - - adapter.TriggerEspPhaseFromTest("AccountSetup"); - - var info = Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); - Assert.Equal(EnrollmentPhase.AccountSetup.ToString(), info.Payload!["phase"]); - } - - [Fact] - public void EspPhaseChanged_unknown_phase_omits_phase_override() - { - using var f = new Fixture(); - using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); - - adapter.TriggerEspPhaseFromTest("SomethingElse"); - - var info = Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); - // Unknown mapping → no phase override, emitter will default to EnrollmentPhase.Unknown. - Assert.False(info.Payload!.ContainsKey("phase")); - Assert.Equal("SomethingElse", info.Payload["espPhase"]); + Assert.Equal(rawPhase, info.Payload["espPhase"]); + if (expectedMappedPhase is null) + Assert.False(info.Payload.ContainsKey("phase")); + else + Assert.Equal(expectedMappedPhase, info.Payload["phase"]); } [Fact] public void EspPhaseChanged_same_phase_repeated_is_deduped_for_both_rails() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -130,7 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void EspPhaseChanged_distinct_phases_emit_separate_signals_and_info_events() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); @@ -151,7 +98,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void EspPhaseChanged_null_or_empty_phase_is_skipped() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest(null!); @@ -163,7 +110,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void UserSessionCompleted_emits_decision_signal_fire_once() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerUserSessionCompletedFromTest(); @@ -175,7 +122,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void UserSessionCompleted_also_emits_ime_user_session_completed_info_event() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerUserSessionCompletedFromTest(); @@ -193,7 +140,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters public void AppStateChange_to_terminal_state_emits_correct_decision_signal_kind( AppInstallationState newState, DecisionSignalKind expectedKind) { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState($"app-{newState}", listPos: 0); @@ -214,7 +161,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters public void AppStateChange_transition_emits_matching_informational_event_type( AppInstallationState newState, string expectedEventType) { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState($"app-{newState}", listPos: 0); @@ -228,7 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_into_Downloading_emits_app_download_started() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-a", 0); @@ -242,7 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_Downloading_to_Downloading_emits_download_progress_debug() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-a", 0); @@ -255,7 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_to_Unknown_or_NotInstalled_emits_nothing() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-x", 0); @@ -268,7 +215,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_terminal_decision_signal_is_fire_once_per_app() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-1", 0); @@ -285,7 +232,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_different_apps_emit_independent_decision_signals() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerAppStateFromTest(new AppPackageState("a", 0), AppInstallationState.Installing, AppInstallationState.Installed); @@ -304,7 +251,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_null_app_is_ignored() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerAppStateFromTest(null!, AppInstallationState.Installing, AppInstallationState.Installed); @@ -315,7 +262,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_payload_carries_V1_compatible_fields() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); // Build a fully-populated package via Restore to exercise all optional fields. var app = AppPackageState.Restore( @@ -363,7 +310,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppStateChange_Error_payload_carries_error_fields() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = AppPackageState.Restore( id: "app-err", @@ -399,7 +346,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Adapter_preserves_prior_Action_handlers_chain_invoke() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); int priorEspCalls = 0; int priorUserCalls = 0; f.Tracker.OnEspPhaseChanged = _ => priorEspCalls++; @@ -420,7 +367,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Dispose_restores_prior_Action_handlers() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); int priorCalls = 0; Action priorAction = _ => priorCalls++; f.Tracker.OnEspPhaseChanged = priorAction; @@ -435,7 +382,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Ctor_null_args_throw() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); Assert.Throws(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock)); Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock)); Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!)); @@ -446,7 +393,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ImeAgentVersion_emits_ime_agent_version_info_event() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerImeAgentVersionFromTest("1.101.109.0"); @@ -460,7 +407,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ImeAgentVersion_null_or_empty_is_skipped() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerImeAgentVersionFromTest(null!); @@ -474,7 +421,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { // V1 reference session shows 2 ime_agent_version events for the same version // in the same session (seq 24 and 60). Adapter must not dedup. - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerImeAgentVersionFromTest("1.101.109.0"); @@ -486,7 +433,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void DoTelemetry_emits_do_telemetry_info_event_with_do_fields() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = AppPackageState.Restore( @@ -530,7 +477,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void DoTelemetry_null_app_or_empty_id_is_ignored() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerDoTelemetryFromTest(null!); @@ -541,7 +488,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ScriptCompleted_platform_success_emits_script_completed_info_source_IME() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var script = new ScriptExecutionState @@ -571,7 +518,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ScriptCompleted_platform_failure_marks_severity_Error() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var script = new ScriptExecutionState @@ -595,7 +542,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ScriptCompleted_remediation_carries_compliance_result() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var script = new ScriptExecutionState @@ -620,7 +567,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void ScriptCompleted_null_or_empty_policyId_is_ignored() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerScriptCompletedFromTest(null!); @@ -633,7 +580,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void New_callbacks_preserve_prior_action_handlers_chain_invoke() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); int priorVersionCalls = 0; int priorDoCalls = 0; int priorScriptCalls = 0; @@ -656,7 +603,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Dispose_restores_all_prior_action_handlers() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(); Action priorVersion = _ => { }; Action priorDo = _ => { }; Action priorScript = _ => { }; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTimingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTimingTests.cs index 22972c51..491ef98d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTimingTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTimingTests.cs @@ -25,41 +25,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { private static readonly DateTime T0 = new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc); - private sealed class Fixture : IDisposable - { - public TempDirectory Tmp { get; } = new TempDirectory(); - public AgentLogger Logger { get; } - public ImeLogTracker Tracker { get; } - public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); - public VirtualClock Clock { get; } = new VirtualClock(T0); - - public Fixture() - { - Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); - Tracker = new ImeLogTracker( - logFolder: Tmp.Path, - patterns: new List(), - logger: Logger); - } - - public FakeSignalIngressSink.PostedSignal InfoEvent(string eventType) => - Ingress.Posted.Single(p => - p.Kind == DecisionSignalKind.InformationalEvent - && p.Payload != null - && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et == eventType); - - public void Dispose() - { - Tracker.Dispose(); - Tmp.Dispose(); - } - } - [Fact] public void First_Installing_transition_stamps_StartedAt_on_adapter_and_payload() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-A", 0); @@ -78,7 +47,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Downloading_transition_also_stamps_StartedAt() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-D", 0); @@ -92,7 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Terminal_transition_stamps_CompletedAt_and_emits_DurationSeconds() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-T", 0); @@ -114,7 +83,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Error_transition_also_sets_CompletedAt() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-E", 0); @@ -130,7 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void Timing_is_set_once_not_overwritten_by_subsequent_same_state_events() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-Z", 0); @@ -150,7 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [Fact] public void AppTimings_snapshot_is_an_independent_copy() { - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerAppStateFromTest(new AppPackageState("a", 0), AppInstallationState.Unknown, AppInstallationState.Installing); @@ -172,7 +141,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters { // Fix 1 policy: download_progress is ImmediateUpload=true. Fix 4c must not let a // mid-download progress tick rewrite the earlier Downloading-start stamp. - using var f = new Fixture(); + using var f = new ImeLogTrackerAdapterFixture(T0); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-P", 0); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs index d321309a..f90ab1db 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs @@ -123,16 +123,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport Assert.Equal(4, remaining[1].TelemetryItemId); } - [Fact] - public void MarkUploaded_regression_is_ignored_idempotent() + [Theory] + // Cursor starts at high-water LastUploadedItemId=2; any value at or below must be a no-op + // so upload retries cannot rewind the spool pointer and replay already-flushed items. + [InlineData(0)] // strict regression + [InlineData(1)] // one below high-water + [InlineData(2)] // same value (idempotent; NEW coverage) + [InlineData(-1)] // negative edge value (NEW coverage) + [InlineData(-5)] // deep negative + public void MarkUploaded_at_or_below_last_uploaded_is_ignored(int regressionValue) { using var tmp = new TempDirectory(); var spool = new TelemetrySpool(tmp.Path, Clock()); for (int i = 0; i < 3; i++) spool.Enqueue(EventDraft($"r{i}")); spool.MarkUploaded(2); - spool.MarkUploaded(0); // regression — must be no-op - spool.MarkUploaded(-5); + spool.MarkUploaded(regressionValue); + Assert.Equal(2, spool.LastUploadedItemId); } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs index 8eb3d511..6f8b5586 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AppInstallSignalHandlerTests.cs @@ -68,71 +68,40 @@ namespace AutopilotMonitor.DecisionCore.Tests // ---- Completed handler ----------------------------------------------------------- - [Fact] - public void Completed_Installed_increments_Completed_and_Installed_breakdown_only() + [Theory] + // newState payload → (Completed, Installed, Skipped, Postponed) — mapping is case-insensitive + // (AppInstallFacts.WithCompleted uses OrdinalIgnoreCase). + [InlineData("Installed", 1, 1, 0, 0)] + [InlineData("installed", 1, 1, 0, 0)] // case-insensitive (NEW coverage) + [InlineData("Skipped", 1, 0, 1, 0)] + [InlineData("SKIPPED", 1, 0, 1, 0)] // case-insensitive (NEW coverage) + [InlineData("Postponed", 1, 0, 0, 1)] + // Forward-compat: any unknown label (future adapter vocabulary, empty payload) still + // advances CompletedCount but does not hit any breakdown bucket. + [InlineData("Replaced", 1, 0, 0, 0)] + [InlineData("", 1, 0, 0, 0)] // empty newState (NEW coverage) + public void Completed_signal_updates_breakdown_by_newState_and_records_taken_transition( + string newState, + int expectedCompleted, int expectedInstalled, int expectedSkipped, int expectedPostponed) { var engine = new DecisionEngine(); - var state = DecisionState.CreateInitial(SessionId, TenantId); - var step = engine.Reduce(state, MakeCompleted(0, "app-1", "Installed")); + var step = engine.Reduce( + DecisionState.CreateInitial(SessionId, TenantId), + MakeCompleted(0, "app-1", newState)); Assert.True(step.Transition.Taken); Assert.Null(step.Transition.DeadEndReason); Assert.Equal(nameof(DecisionSignalKind.AppInstallCompleted), step.Transition.Trigger); var facts = step.NewState.AppInstallFacts; - Assert.Equal(1, facts.CompletedCount); - Assert.Equal(1, facts.InstalledCount); - Assert.Equal(0, facts.SkippedCount); - Assert.Equal(0, facts.PostponedCount); + Assert.Equal(expectedCompleted, facts.CompletedCount); + Assert.Equal(expectedInstalled, facts.InstalledCount); + Assert.Equal(expectedSkipped, facts.SkippedCount); + Assert.Equal(expectedPostponed, facts.PostponedCount); Assert.Equal(0, facts.FailedCount); } - [Fact] - public void Completed_Skipped_increments_Completed_and_Skipped_breakdown_only() - { - var engine = new DecisionEngine(); - var step = engine.Reduce( - DecisionState.CreateInitial(SessionId, TenantId), - MakeCompleted(0, "app-1", "Skipped")); - - var facts = step.NewState.AppInstallFacts; - Assert.Equal(1, facts.CompletedCount); - Assert.Equal(0, facts.InstalledCount); - Assert.Equal(1, facts.SkippedCount); - Assert.Equal(0, facts.PostponedCount); - } - - [Fact] - public void Completed_Postponed_increments_Completed_and_Postponed_breakdown_only() - { - var engine = new DecisionEngine(); - var step = engine.Reduce( - DecisionState.CreateInitial(SessionId, TenantId), - MakeCompleted(0, "app-1", "Postponed")); - - var facts = step.NewState.AppInstallFacts; - Assert.Equal(1, facts.CompletedCount); - Assert.Equal(1, facts.PostponedCount); - } - - [Fact] - public void Completed_unknown_newState_still_increments_CompletedCount_but_no_breakdown() - { - // Forward-compat: an adapter adding a future terminal label (e.g. "Replaced") - // should not throw; the bucket count goes up even if no breakdown matches. - var engine = new DecisionEngine(); - var step = engine.Reduce( - DecisionState.CreateInitial(SessionId, TenantId), - MakeCompleted(0, "app-1", "Replaced")); - - var facts = step.NewState.AppInstallFacts; - Assert.Equal(1, facts.CompletedCount); - Assert.Equal(0, facts.InstalledCount); - Assert.Equal(0, facts.SkippedCount); - Assert.Equal(0, facts.PostponedCount); - } - [Fact] public void Completed_advances_step_bookkeeping_without_changing_stage_or_outcome() { diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs index 5ccc0492..8d5a0df1 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs @@ -26,131 +26,72 @@ namespace AutopilotMonitor.DecisionCore.Tests { private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 18, 57, 45, DateTimeKind.Utc); - // ========================================================= EspPhaseChanged (Finalizing) + // ======================================================= Guard matrix (shared contract) + // The AwaitingHello guard applies the SAME unlock logic to both EspPhaseChanged + // (FinalizingSetup) and EspExiting: + // unlock ⇔ (skipUser == true) OR (accountSetupEntered) + // The two theories below exercise every cell of the 3×2 matrix (skipUser ∈ + // {true, false, null} × accountSetupEntered ∈ {false, true}) for each signal. + // Rows marked NEW fill pre-existing coverage gaps (both-unlock combinations, and the + // "null skipUser but AccountSetup-entered" unlock path that wasn't tested before). - [Fact] - public void EspPhaseChanged_FinalizingSetup_withSkipUserTrue_transitionsToAwaitingHello() + [Theory] + [InlineData(true, false, SessionStage.AwaitingHello)] // skipUser unlocks + [InlineData(true, true, SessionStage.AwaitingHello)] // both unlocks present — NEW coverage + [InlineData(false, false, SessionStage.EspDeviceSetup)] // neither unlock → guard blocks + [InlineData(false, true, SessionStage.AwaitingHello)] // accountSetup unlocks + [InlineData(null, false, SessionStage.EspDeviceSetup)] // skipUser unknown → guard blocks + [InlineData(null, true, SessionStage.AwaitingHello)] // accountSetup overrides unknown skipUser — NEW coverage + public void EspPhaseChanged_FinalizingSetup_guard_applies_skipUser_and_accountSetup_unlock_matrix( + bool? skipUser, bool accountSetupEntered, SessionStage expectedStage) { var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: true); + var initialStage = accountSetupEntered ? SessionStage.EspAccountSetup : SessionStage.EspDeviceSetup; + var state = BuildState(stage: initialStage, skipUser: skipUser, accountSetupEntered: accountSetupEntered); var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5)); - Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); + Assert.Equal(expectedStage, step.NewState.Stage); Assert.True(step.Transition.Taken); - Assert.Equal(SessionStage.EspDeviceSetup, step.Transition.FromStage); - Assert.Equal(SessionStage.AwaitingHello, step.Transition.ToStage); + // Observability is recorded regardless of the guard outcome. Assert.NotNull(step.NewState.FinalizingEnteredUtc); Assert.Equal(EnrollmentPhase.FinalizingSetup, step.NewState.CurrentEnrollmentPhase!.Value); - } - - [Fact] - public void EspPhaseChanged_FinalizingSetup_withSkipUserFalse_beforeAccountSetup_keepsStage() - { - // Classic intermediate Device-ESP exit synthesized as FinalizingSetup. Guard blocks - // the AwaitingHello promotion; the stage stays on EspDeviceSetup. - var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: false); - - var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5)); - - Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage); - Assert.True(step.Transition.Taken); - Assert.Equal(step.Transition.FromStage, step.Transition.ToStage); - // Observability is still recorded. - Assert.NotNull(step.NewState.FinalizingEnteredUtc); - Assert.Equal(EnrollmentPhase.FinalizingSetup, step.NewState.CurrentEnrollmentPhase!.Value); - // No HelloSafety on this path (HandleEspPhaseChangedV1 never arms it — but verify - // anyway for belt-and-suspenders). + // EspPhaseChanged never arms HelloSafety (only EspExiting does — see sibling theory). Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); } - [Fact] - public void EspPhaseChanged_FinalizingSetup_withSkipUserFalse_afterAccountSetup_transitionsToAwaitingHello() - { - // Post-Account-ESP final exit: guard allows promotion. - var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspAccountSetup, skipUser: false, accountSetupEntered: true); - - var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 9)); - - Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); - Assert.NotNull(step.NewState.FinalizingEnteredUtc); - } - - [Fact] - public void EspPhaseChanged_FinalizingSetup_withSkipUserUnknown_beforeAccountSetup_keepsStage() - { - // Defensive: unknown SkipUser does NOT unlock AwaitingHello. Otherwise a missing - // EspConfigDetected signal (e.g. registry race at enrollment start) would reopen the - // original bug. - var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: null); - - var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 3)); - - Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage); - } - - // =============================================================== EspExiting (real arm) - - [Fact] - public void EspExiting_withSkipUserTrue_transitionsToAwaitingHello_armsHelloSafety() + [Theory] + [InlineData(true, false, SessionStage.AwaitingHello, true)] + [InlineData(true, true, SessionStage.AwaitingHello, true)] // NEW coverage + [InlineData(false, false, SessionStage.EspDeviceSetup, false)] + [InlineData(false, true, SessionStage.AwaitingHello, true)] + [InlineData(null, false, SessionStage.EspDeviceSetup, false)] + [InlineData(null, true, SessionStage.AwaitingHello, true)] // NEW coverage + public void EspExiting_guard_controls_AwaitingHello_transition_and_arms_HelloSafety_only_on_unlock( + bool? skipUser, bool accountSetupEntered, SessionStage expectedStage, bool expectHelloSafetyArm) { var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: true); + var initialStage = accountSetupEntered ? SessionStage.EspAccountSetup : SessionStage.EspDeviceSetup; + var state = BuildState(stage: initialStage, skipUser: skipUser, accountSetupEntered: accountSetupEntered); var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5)); - Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); - Assert.NotNull(step.NewState.EspFinalExitUtc); - Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); - Assert.Contains(step.Effects, - e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety); - } - - [Fact] - public void EspExiting_withSkipUserFalse_beforeAccountSetup_staysInStage_noHelloSafety_butRecordsFact() - { - // Device-ESP intermediate exit. Classic's HandleEspExitingV1 must preserve the - // EspFinalExitUtc fact for observability but block the stage change + deadline arm. - var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: false); - - var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5)); - - Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage); - Assert.NotNull(step.NewState.EspFinalExitUtc); - Assert.Empty(step.NewState.Deadlines); - Assert.Empty(step.Effects); + Assert.Equal(expectedStage, step.NewState.Stage); Assert.True(step.Transition.Taken); - } + // EspFinalExitUtc is always recorded for observability — even when the guard blocks. + Assert.NotNull(step.NewState.EspFinalExitUtc); - [Fact] - public void EspExiting_withSkipUserFalse_afterAccountSetup_transitionsToAwaitingHello_armsHelloSafety() - { - var engine = new DecisionEngine(); - var state = BuildState( - stage: SessionStage.EspAccountSetup, - skipUser: false, - accountSetupEntered: true); - - var step = engine.Reduce(state, MakeExitingSignal(ordinal: 9)); - - Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); - Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); - } - - [Fact] - public void EspExiting_withSkipUserUnknown_beforeAccountSetup_staysInStage() - { - var engine = new DecisionEngine(); - var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: null); - - var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5)); - - Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage); - Assert.Empty(step.NewState.Deadlines); + if (expectHelloSafetyArm) + { + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); + Assert.Contains(step.Effects, + e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety); + } + else + { + Assert.Empty(step.NewState.Deadlines); + Assert.Empty(step.Effects); + } } // ====================================================== Codex review — ordering contract diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerReplayTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerReplayTests.cs index ec32c2d3..ef7a4924 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerReplayTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerReplayTests.cs @@ -251,18 +251,20 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.Equal(2, invocations); // aborted before the 3rd signal } - [Fact] - public void Replay_NullArgs_ThrowArgumentNullException() + [Theory] + // Each row nulls exactly one required argument; all three must throw ArgumentNullException. + // Splitting into a Theory gives per-argument failure isolation so a regression that only + // affects one parameter position is identified at once. + [InlineData(0)] // engine + [InlineData(1)] // seed + [InlineData(2)] // signals + public void Replay_NullArgs_ThrowArgumentNullException(int nullPosition) { - var engine = new DecisionEngine(); - var seed = DecisionState.CreateInitial("s", "t"); + var engine = nullPosition == 0 ? null : new DecisionEngine(); + var seed = nullPosition == 1 ? null : DecisionState.CreateInitial("s", "t"); + var signals = nullPosition == 2 ? null : Array.Empty(); - Assert.Throws(() => - ReducerReplay.Replay(null!, seed, Array.Empty())); - Assert.Throws(() => - ReducerReplay.Replay(engine, null!, Array.Empty())); - Assert.Throws(() => - ReducerReplay.Replay(engine, seed, null!)); + Assert.Throws(() => ReducerReplay.Replay(engine!, seed!, signals!)); } } }