Merge pull request #53 from okieselbach/v2/test-consolidation-theories

test(v2): consolidate xUnit tests to Theories + extract shared Harness
This commit is contained in:
Oliver Kieselbach
2026-04-24 16:50:31 +02:00
committed by GitHub
22 changed files with 481 additions and 821 deletions
@@ -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]
@@ -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
{
/// <summary>
/// Shared rig for every <c>EnrollmentOrchestrator*Tests</c> file. Previously each of the
/// six test files declared its own inner <c>Rig</c>/<c>Build</c> class with an overlapping
/// constructor signature; consolidating eliminates ~200 LOC of boilerplate and keeps the
/// orchestrator-construction surface in one place.
/// <para>
/// The rig owns its own <see cref="TempDirectory"/>, a <see cref="VirtualClock"/> starting
/// at <paramref name="clockStart"/>, a fake uploader, and an empty classifier list. Tests
/// that need additional orchestrator parameters pass them to <see cref="Build"/>; everything
/// else takes a sensible default (drain interval = 1 day so periodic drain doesn't fire
/// during short tests).
/// </para>
/// </summary>
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<IClassifier> Classifiers { get; } = new List<IClassifier>();
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<string>? 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();
}
}
@@ -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
{
/// <summary>
/// Shared fixture for every <c>ImeLogTrackerAdapter*Tests</c> 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).
/// <para>
/// Ctor parameter <paramref name="clockStart"/> is optional — tests that don't care pass
/// nothing and get <see cref="DefaultClockStart"/>; tests that drive time-sensitive behaviour
/// (Timing) pass their own start.
/// </para>
/// </summary>
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<ImeLogPattern>(),
logger: Logger);
}
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> DecisionSignals(DecisionSignalKind kind) =>
Ingress.Posted.Where(p => p.Kind == kind).ToList();
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> 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<FakeSignalIngressSink.PostedSignal> AllInfoEvents() =>
Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.InformationalEvent).ToList();
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> NonInfoSignals() =>
Ingress.Posted.Where(p => p.Kind != DecisionSignalKind.InformationalEvent).ToList();
public void Dispose()
{
Tracker.Dispose();
Tmp.Dispose();
}
}
}
@@ -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<DecisionSignal> 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<DecisionSignal>();
var signals = new List<DecisionSignal>();
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<IClassifier>(),
drainInterval: TimeSpan.FromDays(1),
terminalDrainTimeout: TimeSpan.FromSeconds(2));
public IReadOnlyList<DecisionSignal> ReadSignalLog()
{
var path = Path.Combine(StateDir, "signal-log.jsonl");
if (!File.Exists(path)) return Array.Empty<DecisionSignal>();
var signals = new List<DecisionSignal>();
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.
@@ -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<IClassifier> Classifiers { get; } = new List<IClassifier>();
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));
}
}
}
@@ -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<IClassifier>(),
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<ArgumentOutOfRangeException>(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero));
Assert.Throws<ArgumentOutOfRangeException>(() => Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(-1)));
using var rig = new EnrollmentOrchestratorRig(At);
Assert.Throws<ArgumentOutOfRangeException>(() =>
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);
@@ -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<IClassifier> Classifiers { get; } = new List<IClassifier>();
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;
@@ -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<IClassifier> Classifiers { get; } = new List<IClassifier>();
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<ArgumentException>(() => new EnrollmentOrchestrator(
"", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
Assert.Throws<ArgumentException>(() => 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<ArgumentNullException>(() => new EnrollmentOrchestrator(
"S1", "T1", rig.StateDir, rig.TransportDir, null!, rig.Logger, rig.Uploader, rig.Classifiers));
Assert.Throws<ArgumentNullException>(() => 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<InvalidOperationException>(() => sut.CurrentState);
Assert.Throws<InvalidOperationException>(() => 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();
@@ -33,33 +33,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
}
private EnrollmentOrchestrator Build(
TempDirectory tmp,
IComponentFactory? factory = null,
IReadOnlyCollection<string>? 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<IClassifier>(),
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<string>());
var sut = rig.Build(componentFactory: factory, whiteGloveSealingPatternIds: Array.Empty<string>());
sut.Start();
@@ -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<ArgumentException>(() => sut.Emit(eventType: "", source: "Network"));
Assert.Throws<ArgumentException>(() => sut.Emit(eventType: null!, source: "Network"));
}
[Fact]
public void Empty_source_throws()
{
var (sut, _, _) = BuildRig();
Assert.Throws<ArgumentException>(() => sut.Emit(eventType: "x", source: ""));
Assert.Throws<ArgumentException>(() => sut.Emit(eventType: "x", source: null!));
Assert.Throws<ArgumentException>(() => sut.Emit(eventType: eventType!, source: source!));
}
[Fact]
@@ -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
@@ -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()
{
@@ -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<ArgumentNullException>(() => new SessionIdPersistence(null!));
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(""));
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(" "));
Assert.Throws<ArgumentNullException>(() => 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());
}
@@ -24,43 +24,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
/// </summary>
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<ImeLogPattern>(),
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
@@ -19,36 +19,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
/// </summary>
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<ImeLogPattern>(),
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<string>();
f.Tracker.OnPatternMatched = id => chainInvocations.Add("prev:" + id);
@@ -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<ImeLogPattern>(),
logger: Logger);
}
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> 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");
@@ -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<ImeLogPattern>(),
logger: Logger);
}
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> DecisionSignals(DecisionSignalKind kind) =>
Ingress.Posted.Where(p => p.Kind == kind).ToList();
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> 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<FakeSignalIngressSink.PostedSignal> AllInfoEvents() =>
Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.InformationalEvent).ToList();
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> 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<string> 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<ArgumentNullException>(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock));
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock));
Assert.Throws<ArgumentNullException>(() => 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<string> priorVersion = _ => { };
Action<AppPackageState> priorDo = _ => { };
Action<ScriptExecutionState> priorScript = _ => { };
@@ -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<ImeLogPattern>(),
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);
@@ -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);
}
@@ -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()
{
@@ -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
@@ -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<DecisionSignal>();
Assert.Throws<ArgumentNullException>(() =>
ReducerReplay.Replay(null!, seed, Array.Empty<DecisionSignal>()));
Assert.Throws<ArgumentNullException>(() =>
ReducerReplay.Replay(engine, null!, Array.Empty<DecisionSignal>()));
Assert.Throws<ArgumentNullException>(() =>
ReducerReplay.Replay(engine, seed, null!));
Assert.Throws<ArgumentNullException>(() => ReducerReplay.Replay(engine!, seed!, signals!));
}
}
}