mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
test(v2): consolidate xUnit tests to Theories + extract shared Harness
Converts repeated Fact-clusters across the V2 Agent and DecisionCore test suites into data-driven Theories, and extracts two oft-duplicated inner fixture/rig classes into reusable Harness helpers. Net: smaller files and more coverage via InlineData rows for edge cases that had no own Fact before. Wave 1 — Fact→Theory consolidations (10 files): - RemoteConfigMergerTests: LogLevel parsing (3 Facts → 1 Theory, 9 rows including new Whitespace/Empty/Null + case-insensitive combos). - ImeLogTrackerAdapterTests: EspPhaseChanged phase-mapping (3 Facts → 1 Theory, 7 rows + Finalizing alias, Complete, preboot). - InformationalEventPostTests: eventType/source validation + evidenceSummary precedence (3 Facts → 2 Theories with 4+4 rows). - ProgramGuardsTests: CheckEnrollmentCompleteMarker matrix (3 Facts → 1 Theory with 4 rows incl. new (no-marker, no-selfdestruct) cell). - AuthFailureTrackerTests: distress dispatch (2 Facts → 1 Theory, 5 rows incl. 403 dedup + 500 catch-all). - SessionIdPersistenceTests: ctor rejection (1 Fact → 3 rows) + LoadSessionCreatedAt null-return (2 Facts → 5 rows incl. empty/whitespace /out-of-range dates). - TelemetrySpoolTests: MarkUploaded regression guard (1 Fact, 3 inline calls → 1 Theory with 5 rows incl. same-value idempotence). - ReducerReplayTests: null-arg rejection (1 Fact, 3 asserts → 1 Theory). - AppInstallSignalHandlerTests: newState → breakdown (4 Facts → 1 Theory, 7 rows incl. case-insensitive lowercase/uppercase + empty). - ClassicAwaitingHelloGuardTests: 8 Facts → 2 Theories with 6 rows each (full skipUser × accountSetupEntered matrix; 4 new cells). Wave 2 — Shared Harness extraction (2 new helpers): - ImeLogTrackerAdapterFixture.cs (60 LOC) replaces 5× duplicated inner Fixture classes across ImeLogTrackerAdapter*Tests.cs (~150 LOC saved). - EnrollmentOrchestratorRig.cs (65 LOC) replaces 6× near-identical Rig/ Build helpers across EnrollmentOrchestrator*Tests.cs (~200 LOC saved). Results: - AutopilotMonitor.Agent.V2.Core.Tests: 679 tests (was 650), all green. - AutopilotMonitor.DecisionCore.Tests: 220 tests (was 164), all green. - 22 files changed, +350 / -825 lines (-475 net). - No production code touched; no V1 code touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+24
-32
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-47
@@ -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.
|
||||
|
||||
+16
-48
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-31
@@ -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);
|
||||
|
||||
+5
-37
@@ -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;
|
||||
|
||||
+15
-48
@@ -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();
|
||||
|
||||
|
||||
+6
-27
@@ -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();
|
||||
|
||||
|
||||
+22
-22
@@ -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
|
||||
|
||||
+19
-22
@@ -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()
|
||||
{
|
||||
|
||||
+18
-14
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+8
-40
@@ -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
|
||||
|
||||
+7
-32
@@ -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);
|
||||
|
||||
|
||||
+6
-38
@@ -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");
|
||||
|
||||
+52
-105
@@ -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 = _ => { };
|
||||
|
||||
+7
-38
@@ -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!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user