Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions NetworkInspector.Sessions.Tests/ExitPointCoverageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright © 2026 DevAM. All rights reserved. Licensed under MIT license. See license in the repository root for license information.

using FieldInfo = System.Reflection.FieldInfo;

namespace NetworkInspector.Sessions.Tests;

/// <summary>Exit-point coverage for session error paths.</summary>
[NotInParallel(nameof(ExitPointCoverageTests))]
internal sealed class ExitPointCoverageTests
{
[Test]
public async Task TryGetPacket_ReparseWithMismatchedStack_ReturnsFalse()
{
using Stack stack = TestHarness.CreateStack();
using TestFrameSource source = TestFrameSource.WithUdpFrames(3);

using Session session = new(stack);
session.TryAddFrameSource(source, out _);
session.TryStart();
session.WaitForCompletion();

_GetPacketStore(session).Clear();

Stack wrongStack = TestHarness.CreateStack();
FieldInfo stackField = typeof(Session).GetField(
"_Stack",
BindingFlags.Instance | BindingFlags.NonPublic)!;
Stack originalStack = (Stack)stackField.GetValue(session)!;
try
{
stackField.SetValue(session, wrongStack);

bool found = session.TryGetPacket(new PacketId(0), out Packet? packet);

await Assert.That(found).IsFalse();
await Assert.That(packet).IsNull();
}
finally
{
stackField.SetValue(session, originalStack);
wrongStack.Dispose();
}
}

[Test]
public async Task RunSourceLoop_MappingCapacityExceeded_FailsSourceJob()
{
using Stack stack = TestHarness.CreateStack();
using TestFrameSource source = TestFrameSource.WithUdpFrames(1);

using Session session = new(stack);
session.TryAddFrameSource(source, out _);

FieldInfo nextPacketIdField = typeof(Session).GetField(
"_NextPacketId",
BindingFlags.Instance | BindingFlags.NonPublic)!;
nextPacketIdField.SetValue(session, PacketToFrameMap.MaxEntries);

session.TryStart();

JobInfo sourceJob = session.GetJobs().First(j => j.UiName == source.UiName);
WaitHelper.WaitUntil(() => sourceJob.Status == JobStatus.Failed);

await Assert.That(sourceJob.FailureException).IsNotNull();
await Assert.That(sourceJob.FailureException!.Message)
.Contains(PacketToFrameMap.MaxEntries.ToString(CultureInfo.InvariantCulture));
}

[Test]
public async Task RunSourceLoop_WithoutPacketIndex_UsesNonIndexedParse()
{
using Stack stack = TestHarness.CreateStack();
using BlockingTestFrameSource source = new(3);

using Session session = new(stack);
session.TryAddFrameSource(source, out _);
session.TryStart();

WaitHelper.WaitUntil(() => session.Phase == SessionPhase.Running);

_SetPacketIndex(session, null);
source.Release();

WaitHelper.WaitUntil(() => session.PacketCount >= 3);

await Assert.That(session.PacketIndex).IsNull();
await Assert.That(session.PacketCount).IsGreaterThanOrEqualTo(3);
}

[Test]
public async Task AllocateListenerId_AtCapacity_ThrowsInvalidOperationException()
{
using Stack stack = TestHarness.CreateStack();
using Session session = new(stack);

FieldInfo stateField = typeof(Session).GetField(
"_State",
BindingFlags.Instance | BindingFlags.NonPublic)!;
SessionState state = (SessionState)stateField.GetValue(session)!;
FieldInfo nextListenerIdField = typeof(SessionState).GetField(
"_NextListenerId",
BindingFlags.Instance | BindingFlags.NonPublic)!;
nextListenerIdField.SetValue(state, (long)int.MaxValue);

TestSessionListener listener = new();

try
{
session.TryAddListener(listener, out _);
throw new InvalidOperationException("Expected InvalidOperationException was not thrown.");
}
catch (InvalidOperationException ex)
{
await Assert.That(ex.Message).Contains("listener ID");
}
}

private static void _SetPacketIndex(Session session, PacketIndex? index)
{
FieldInfo field = typeof(Session).GetField(
"_PacketIndex",
BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(session, index);
}

private static PacketStore _GetPacketStore(Session session)
{
FieldInfo field = typeof(Session).GetField(
"_PacketStore",
BindingFlags.Instance | BindingFlags.NonPublic)!;
return (PacketStore)field.GetValue(session)!;
}
}
1 change: 1 addition & 0 deletions NetworkInspector.Sessions.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
#endregion

#region Test Framework
global using NetworkInspector.Sessions.Tests.Helpers;
global using TUnit.Assertions;
global using TUnit.Assertions.Extensions;
global using TUnit.Core;
Expand Down
27 changes: 27 additions & 0 deletions NetworkInspector.Sessions.Tests/Helpers/WaitHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright © 2026 DevAM. All rights reserved. Licensed under MIT license. See license in the repository root for license information.

namespace NetworkInspector.Sessions.Tests.Helpers;

/// <summary>Shared spin-wait helpers for session integration tests.</summary>
internal static class WaitHelper
{
/// <summary>
/// Spins for up to <paramref name="timeoutMs"/> until <paramref name="condition"/>
/// returns <see langword="true"/>.
/// </summary>
internal static void WaitUntil(Func<bool> condition, int timeoutMs = 5000)
{
Stopwatch sw = Stopwatch.StartNew();
SpinWait wait = new();
while (!condition())
{
if (sw.ElapsedMilliseconds > timeoutMs)
{
throw new TimeoutException(
$"Condition was not met within {timeoutMs.ToString(CultureInfo.InvariantCulture)} ms.");
}

wait.SpinOnce();
}
}
}
2 changes: 2 additions & 0 deletions NetworkInspector.Sessions.Tests/NotifyFlagsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ public async Task Flags_IndividualBitsAreDistinct()
NotifyFlags.AllSourcesCompleted,
NotifyFlags.JobAdded,
NotifyFlags.JobStatusChanged,
NotifyFlags.JobRemoved,
NotifyFlags.PhaseChanged,
NotifyFlags.ShuttingDown,
NotifyFlags.StackChanged,
];

for (int i = 0; i < allFlags.Length; i++)
Expand Down
87 changes: 71 additions & 16 deletions NetworkInspector.Sessions.Tests/SessionApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public async Task TryRemoveJob_TerminalJob_Succeeds()
session.WaitForCompletion();

JobInfo sourceJob = session.GetJobs().First(j => j.UiName == source.UiName);
_WaitForCondition(() => sourceJob.Status is JobStatus.Completed or JobStatus.Cancelled);
WaitHelper.WaitUntil(() => sourceJob.Status is JobStatus.Completed or JobStatus.Cancelled);

bool removed = session.TryRemoveJob(sourceJob);

Expand All @@ -84,6 +84,47 @@ public async Task TryRemoveJob_TerminalJob_Succeeds()
session.Shutdown();
}

[Test]
public async Task TryRemoveJob_AlreadyRemoved_ReturnsFalse()
{
using Stack stack = TestHarness.CreateStack();
using TestFrameSource source = TestFrameSource.WithUdpFrames(3);

using Session session = new(stack);
session.TryAddFrameSource(source, out _);
session.TryStart();
session.WaitForCompletion();

JobInfo sourceJob = session.GetJobs().First(j => j.UiName == source.UiName);
WaitHelper.WaitUntil(() => sourceJob.Status is JobStatus.Completed or JobStatus.Cancelled);

await Assert.That(session.TryRemoveJob(sourceJob)).IsTrue();
await Assert.That(session.TryRemoveJob(sourceJob)).IsFalse();

session.Shutdown();
}

[Test]
public async Task TryRemoveJob_ForeignJob_ReturnsFalse()
{
using Stack stack = TestHarness.CreateStack();
using Session session = new(stack);

using Job foreignJob = new(
new JobId(999),
"Foreign",
"Not in session",
_ => { },
static (_, _) => { });
foreignJob.Start();
foreignJob.Join();
JobInfo foreignInfo = new(foreignJob);

bool removed = session.TryRemoveJob(foreignInfo);

await Assert.That(removed).IsFalse();
}

[Test]
public async Task TryRemoveJob_RunningJob_ThrowsSessionException()
{
Expand All @@ -95,7 +136,7 @@ public async Task TryRemoveJob_RunningJob_ThrowsSessionException()
session.TryStart();

JobInfo sourceJob = session.GetJobs().First(j => j.UiName == source.UiName);
_WaitForCondition(() => sourceJob.Status == JobStatus.Running);
WaitHelper.WaitUntil(() => sourceJob.Status == JobStatus.Running);

try
{
Expand Down Expand Up @@ -322,6 +363,34 @@ public async Task TryGetPacket_AfterStoreClear_ReparsesWithIndex()
session.Shutdown();
}

[Test]
public async Task AllocateJobId_AtCapacity_ThrowsInvalidOperationException()
{
using Stack stack = TestHarness.CreateStack();
using Session session = new(stack);

FieldInfo stateField = typeof(Session).GetField(
"_State",
BindingFlags.Instance | BindingFlags.NonPublic)!;
SessionState state = (SessionState)stateField.GetValue(session)!;
FieldInfo nextJobIdField = typeof(SessionState).GetField(
"_NextJobId",
BindingFlags.Instance | BindingFlags.NonPublic)!;
nextJobIdField.SetValue(state, (long)int.MaxValue);

using TestFrameSource source = TestFrameSource.WithUdpFrames(1);

try
{
session.TryAddFrameSource(source, out _);
throw new InvalidOperationException("Expected InvalidOperationException was not thrown.");
}
catch (InvalidOperationException ex)
{
await Assert.That(ex.Message).Contains("job ID");
}
}

[Test]
public async Task UseAfterDispose_ThrowsSessionException()
{
Expand All @@ -348,20 +417,6 @@ private static PacketStore _GetPacketStore(Session session)
return (PacketStore)field.GetValue(session)!;
}

private static void _WaitForCondition(Func<bool> condition, int timeoutMs = 5000)
{
Stopwatch sw = Stopwatch.StartNew();
SpinWait wait = new();
while (!condition())
{
if (sw.ElapsedMilliseconds > timeoutMs)
{
throw new TimeoutException($"Condition was not met within {timeoutMs} ms.");
}
wait.SpinOnce();
}
}

private sealed class EmptyNameListener : ISessionListener
{
public string UiName => " ";
Expand Down
Loading