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!));
}
}
}