diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 48ba26ed..b3e94fc4 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -9,7 +9,7 @@
icon.jpg
true
9.1.1
- [9.0.0,10.0.0)
+ [10.0.0,11.0.0)
default
diff --git a/tests/TickerQ.Tests/PaginationResultTests.cs b/tests/TickerQ.Tests/PaginationResultTests.cs
new file mode 100644
index 00000000..3bdba89e
--- /dev/null
+++ b/tests/TickerQ.Tests/PaginationResultTests.cs
@@ -0,0 +1,156 @@
+using FluentAssertions;
+using TickerQ.Utilities.Models;
+
+namespace TickerQ.Tests;
+
+public class PaginationResultTests
+{
+ [Fact]
+ public void DefaultConstructor_InitializesEmptyItems()
+ {
+ var result = new PaginationResult();
+
+ result.Items.Should().NotBeNull();
+ result.Items.Should().BeEmpty();
+ result.TotalCount.Should().Be(0);
+ result.PageNumber.Should().Be(0);
+ result.PageSize.Should().Be(0);
+ }
+
+ [Fact]
+ public void ParameterizedConstructor_SetsAllProperties()
+ {
+ var items = new[] { "a", "b", "c" };
+ var result = new PaginationResult(items, totalCount: 10, pageNumber: 2, pageSize: 3);
+
+ result.Items.Should().BeEquivalentTo(items);
+ result.TotalCount.Should().Be(10);
+ result.PageNumber.Should().Be(2);
+ result.PageSize.Should().Be(3);
+ }
+
+ [Fact]
+ public void ParameterizedConstructor_HandlesNullItems()
+ {
+ var result = new PaginationResult(null, totalCount: 5, pageNumber: 1, pageSize: 5);
+
+ result.Items.Should().NotBeNull();
+ result.Items.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void TotalPages_CalculatesCorrectly()
+ {
+ var result = new PaginationResult
+ {
+ TotalCount = 25,
+ PageSize = 10
+ };
+
+ result.TotalPages.Should().Be(3); // ceil(25/10)
+ }
+
+ [Fact]
+ public void TotalPages_ReturnsOne_WhenItemsFitInOnePage()
+ {
+ var result = new PaginationResult
+ {
+ TotalCount = 5,
+ PageSize = 10
+ };
+
+ result.TotalPages.Should().Be(1);
+ }
+
+ [Fact]
+ public void TotalPages_ReturnsExact_WhenEvenlySplit()
+ {
+ var result = new PaginationResult
+ {
+ TotalCount = 20,
+ PageSize = 10
+ };
+
+ result.TotalPages.Should().Be(2);
+ }
+
+ [Fact]
+ public void HasPreviousPage_ReturnsFalse_OnFirstPage()
+ {
+ var result = new PaginationResult { PageNumber = 1 };
+
+ result.HasPreviousPage.Should().BeFalse();
+ }
+
+ [Fact]
+ public void HasPreviousPage_ReturnsTrue_OnLaterPages()
+ {
+ var result = new PaginationResult { PageNumber = 2 };
+
+ result.HasPreviousPage.Should().BeTrue();
+ }
+
+ [Fact]
+ public void HasNextPage_ReturnsTrue_WhenMorePagesExist()
+ {
+ var result = new PaginationResult
+ {
+ PageNumber = 1,
+ TotalCount = 20,
+ PageSize = 10
+ };
+
+ result.HasNextPage.Should().BeTrue();
+ }
+
+ [Fact]
+ public void HasNextPage_ReturnsFalse_OnLastPage()
+ {
+ var result = new PaginationResult
+ {
+ PageNumber = 2,
+ TotalCount = 20,
+ PageSize = 10
+ };
+
+ result.HasNextPage.Should().BeFalse();
+ }
+
+ [Fact]
+ public void FirstItemIndex_CalculatesCorrectly()
+ {
+ var result = new PaginationResult
+ {
+ PageNumber = 3,
+ PageSize = 10
+ };
+
+ result.FirstItemIndex.Should().Be(21); // (3-1)*10 + 1
+ }
+
+ [Fact]
+ public void LastItemIndex_CappedByTotalCount()
+ {
+ var result = new PaginationResult
+ {
+ PageNumber = 3,
+ PageSize = 10,
+ TotalCount = 25
+ };
+
+ result.LastItemIndex.Should().Be(25); // Min(30, 25)
+ }
+
+ [Fact]
+ public void LastItemIndex_EqualsPageEnd_WhenFullPage()
+ {
+ var result = new PaginationResult
+ {
+ PageNumber = 2,
+ PageSize = 10,
+ TotalCount = 30
+ };
+
+ result.LastItemIndex.Should().Be(20);
+ }
+}
diff --git a/tests/TickerQ.Tests/RestartThrottleManagerTests.cs b/tests/TickerQ.Tests/RestartThrottleManagerTests.cs
new file mode 100644
index 00000000..5d3ba2ad
--- /dev/null
+++ b/tests/TickerQ.Tests/RestartThrottleManagerTests.cs
@@ -0,0 +1,83 @@
+using FluentAssertions;
+
+namespace TickerQ.Tests;
+
+public class RestartThrottleManagerTests
+{
+ [Fact]
+ public async Task RequestRestart_TriggersCallback_AfterDebounceWindow()
+ {
+ var triggered = false;
+ using var manager = new RestartThrottleManager(() => triggered = true);
+
+ manager.RequestRestart();
+
+ // Debounce window is 50ms, give some extra time
+ await Task.Delay(200);
+
+ triggered.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task MultipleRequests_CoalesceIntoSingleCallback()
+ {
+ var triggerCount = 0;
+ using var manager = new RestartThrottleManager(() => Interlocked.Increment(ref triggerCount));
+
+ // Multiple rapid requests should coalesce
+ manager.RequestRestart();
+ manager.RequestRestart();
+ manager.RequestRestart();
+
+ await Task.Delay(200);
+
+ triggerCount.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task RequestRestart_ResetsTimer_OnSubsequentCalls()
+ {
+ var triggerCount = 0;
+ using var manager = new RestartThrottleManager(() => Interlocked.Increment(ref triggerCount));
+
+ manager.RequestRestart();
+ await Task.Delay(30); // Less than debounce window (50ms)
+ manager.RequestRestart(); // Should reset the timer
+ await Task.Delay(30); // Still less than full window from second request
+
+ // Should not have triggered yet since timer was reset
+ // After full debounce from the last request it should trigger
+ await Task.Delay(100);
+
+ triggerCount.Should().Be(1);
+ }
+
+ [Fact]
+ public void Dispose_CanBeCalledSafely()
+ {
+ var manager = new RestartThrottleManager(() => { });
+ var act = () => manager.Dispose();
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Dispose_BeforeAnyRequest_DoesNotThrow()
+ {
+ var manager = new RestartThrottleManager(() => { });
+
+ // Timer hasn't been created yet (lazy creation)
+ var act = () => manager.Dispose();
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Dispose_AfterRequest_DoesNotThrow()
+ {
+ var manager = new RestartThrottleManager(() => { });
+ manager.RequestRestart();
+
+ var act = () => manager.Dispose();
+ act.Should().NotThrow();
+ }
+}
diff --git a/tests/TickerQ.Tests/SoftSchedulerNotifyDebounceTests.cs b/tests/TickerQ.Tests/SoftSchedulerNotifyDebounceTests.cs
new file mode 100644
index 00000000..1d757e5a
--- /dev/null
+++ b/tests/TickerQ.Tests/SoftSchedulerNotifyDebounceTests.cs
@@ -0,0 +1,113 @@
+using FluentAssertions;
+
+namespace TickerQ.Tests;
+
+public class SoftSchedulerNotifyDebounceTests
+{
+ [Fact]
+ public void NotifySafely_InvokesCallback_WithLatestValue()
+ {
+ var receivedValues = new List();
+ using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v));
+
+ debounce.NotifySafely(5);
+
+ receivedValues.Should().Contain("5");
+ }
+
+ [Fact]
+ public void NotifySafely_SuppressesDuplicateValues()
+ {
+ var receivedValues = new List();
+ using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v));
+
+ debounce.NotifySafely(3);
+ var countAfterFirst = receivedValues.Count;
+
+ debounce.NotifySafely(3);
+ var countAfterSecond = receivedValues.Count;
+
+ // Second call with same non-zero value should be suppressed
+ countAfterSecond.Should().Be(countAfterFirst);
+ }
+
+ [Fact]
+ public void NotifySafely_AllowsDifferentValues()
+ {
+ var receivedValues = new List();
+ using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v));
+
+ debounce.NotifySafely(1);
+ debounce.NotifySafely(2);
+
+ receivedValues.Should().Contain("1");
+ receivedValues.Should().Contain("2");
+ }
+
+ [Fact]
+ public void Flush_InvokesCallbackImmediately()
+ {
+ var receivedValues = new List();
+ using var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v));
+
+ debounce.NotifySafely(10);
+ receivedValues.Clear();
+
+ debounce.NotifySafely(20);
+ debounce.Flush();
+
+ // Flush should ensure the latest value is pushed
+ receivedValues.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public void NotifySafely_DoesNotInvoke_AfterDispose()
+ {
+ var receivedValues = new List();
+ var debounce = new SoftSchedulerNotifyDebounce(v => receivedValues.Add(v));
+
+ debounce.Dispose();
+ var countBeforeNotify = receivedValues.Count;
+
+ debounce.NotifySafely(100);
+
+ receivedValues.Count.Should().Be(countBeforeNotify);
+ }
+
+ [Fact]
+ public void Dispose_CanBeCalledMultipleTimes()
+ {
+ var debounce = new SoftSchedulerNotifyDebounce(_ => { });
+
+ var act = () =>
+ {
+ debounce.Dispose();
+ debounce.Dispose();
+ };
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void NotifySafely_AlwaysInvokes_ForZeroValue()
+ {
+ var callCount = 0;
+ using var debounce = new SoftSchedulerNotifyDebounce(_ => callCount++);
+
+ // Zero is special: it always triggers the callback
+ // (the code checks `latest != 0 && latest == last` for suppression)
+ debounce.NotifySafely(0);
+ debounce.NotifySafely(0);
+
+ callCount.Should().BeGreaterOrEqualTo(2);
+ }
+
+ [Fact]
+ public void Constructor_DoesNotInvoke_Callback()
+ {
+ var callCount = 0;
+ using var debounce = new SoftSchedulerNotifyDebounce(_ => callCount++);
+
+ callCount.Should().Be(0);
+ }
+}
diff --git a/tests/TickerQ.Tests/TerminateExecutionExceptionTests.cs b/tests/TickerQ.Tests/TerminateExecutionExceptionTests.cs
new file mode 100644
index 00000000..2a15dcba
--- /dev/null
+++ b/tests/TickerQ.Tests/TerminateExecutionExceptionTests.cs
@@ -0,0 +1,70 @@
+using FluentAssertions;
+using TickerQ.Exceptions;
+using TickerQ.Utilities.Enums;
+
+namespace TickerQ.Tests;
+
+public class TerminateExecutionExceptionTests
+{
+ [Fact]
+ public void Constructor_MessageOnly_SetsSkippedStatus()
+ {
+ var ex = new TerminateExecutionException("test message");
+
+ ex.Message.Should().Be("test message");
+ ex.Status.Should().Be(TickerStatus.Skipped);
+ ex.InnerException.Should().BeNull();
+ }
+
+ [Fact]
+ public void Constructor_WithStatus_SetsCustomStatus()
+ {
+ var ex = new TerminateExecutionException(TickerStatus.Cancelled, "cancelled");
+
+ ex.Message.Should().Be("cancelled");
+ ex.Status.Should().Be(TickerStatus.Cancelled);
+ ex.InnerException.Should().BeNull();
+ }
+
+ [Fact]
+ public void Constructor_WithInnerException_PreservesInner()
+ {
+ var inner = new InvalidOperationException("inner");
+ var ex = new TerminateExecutionException("outer", inner);
+
+ ex.Message.Should().Be("outer");
+ ex.InnerException.Should().BeSameAs(inner);
+ ex.Status.Should().Be(TickerStatus.Skipped);
+ }
+
+ [Fact]
+ public void Constructor_WithStatusAndInnerException_SetsAll()
+ {
+ var inner = new InvalidOperationException("inner");
+ var ex = new TerminateExecutionException(TickerStatus.Failed, "failed", inner);
+
+ ex.Message.Should().Be("failed");
+ ex.Status.Should().Be(TickerStatus.Failed);
+ ex.InnerException.Should().BeSameAs(inner);
+ }
+
+ [Fact]
+ public void IsException_InheritsFromException()
+ {
+ var ex = new TerminateExecutionException("test");
+
+ ex.Should().BeAssignableTo();
+ }
+
+ [Theory]
+ [InlineData(TickerStatus.Done)]
+ [InlineData(TickerStatus.Failed)]
+ [InlineData(TickerStatus.Cancelled)]
+ [InlineData(TickerStatus.InProgress)]
+ [InlineData(TickerStatus.Idle)]
+ public void Constructor_WithStatus_SupportsAllStatusValues(TickerStatus status)
+ {
+ var ex = new TerminateExecutionException(status, "msg");
+ ex.Status.Should().Be(status);
+ }
+}
diff --git a/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs b/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs
new file mode 100644
index 00000000..ebc2444d
--- /dev/null
+++ b/tests/TickerQ.Tests/TickerExecutionTaskHandlerTests.cs
@@ -0,0 +1,572 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using TickerQ.Exceptions;
+using TickerQ.Utilities;
+using TickerQ.Utilities.Enums;
+using TickerQ.Utilities.Instrumentation;
+using TickerQ.Utilities.Interfaces;
+using TickerQ.Utilities.Interfaces.Managers;
+using TickerQ.Utilities.Models;
+
+namespace TickerQ.Tests;
+
+public class TickerExecutionTaskHandlerTests
+{
+ private readonly ITickerClock _clock;
+ private readonly IInternalTickerManager _internalManager;
+ private readonly ITickerQInstrumentation _instrumentation;
+ private readonly ServiceProvider _serviceProvider;
+ private readonly TickerExecutionTaskHandler _handler;
+
+ public TickerExecutionTaskHandlerTests()
+ {
+ _clock = Substitute.For();
+ _clock.UtcNow.Returns(DateTime.UtcNow);
+ _internalManager = Substitute.For();
+ _instrumentation = Substitute.For();
+
+ var services = new ServiceCollection();
+ services.AddSingleton(_internalManager);
+ services.AddSingleton(_instrumentation);
+ _serviceProvider = services.BuildServiceProvider();
+
+ _handler = new TickerExecutionTaskHandler(_serviceProvider, _clock, _instrumentation, _internalManager);
+ }
+
+ #region Success Path
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsStatusDone_WhenSucceeds_NotDue()
+ {
+ var context = CreateContext(ct: (_, _, _) => Task.CompletedTask);
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.Status.Should().Be(TickerStatus.Done);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsStatusDueDone_WhenSucceeds_IsDue()
+ {
+ var context = CreateContext(ct: (_, _, _) => Task.CompletedTask);
+
+ await _handler.ExecuteTaskAsync(context, isDue: true);
+
+ context.Status.Should().Be(TickerStatus.DueDone);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_CallsUpdateTickerAsync_OnSuccess()
+ {
+ var context = CreateContext(ct: (_, _, _) => Task.CompletedTask);
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ await _internalManager.Received(1).UpdateTickerAsync(
+ Arg.Is(c => c.TickerId == context.TickerId),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsElapsedTime_OnSuccess()
+ {
+ var context = CreateContext(ct: async (_, _, _) => await Task.Delay(10));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.ElapsedTime.Should().BeGreaterOrEqualTo(0);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsExecutedAt_OnSuccess()
+ {
+ var now = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc);
+ _clock.UtcNow.Returns(now);
+ var context = CreateContext(ct: (_, _, _) => Task.CompletedTask);
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.ExecutedAt.Should().Be(now);
+ }
+
+ #endregion
+
+ #region Failure Path
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsStatusFailed_WhenDelegateThrows()
+ {
+ var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("boom"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.Status.Should().Be(TickerStatus.Failed);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RecordsExceptionDetails_WhenDelegateThrows()
+ {
+ var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("boom"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.ExceptionDetails.Should().NotBeNullOrWhiteSpace();
+ context.ExceptionDetails.Should().Contain("boom");
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_CallsExceptionHandler_WhenRegistered_AndDelegateThrows()
+ {
+ var exceptionHandler = Substitute.For();
+ var services = new ServiceCollection();
+ services.AddSingleton(_internalManager);
+ services.AddSingleton(_instrumentation);
+ services.AddSingleton(exceptionHandler);
+ var sp = services.BuildServiceProvider();
+ var handler = new TickerExecutionTaskHandler(sp, _clock, _instrumentation, _internalManager);
+
+ var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("boom"));
+
+ await handler.ExecuteTaskAsync(context, isDue: false);
+
+ await exceptionHandler.Received(1).HandleExceptionAsync(
+ Arg.Any(),
+ Arg.Is(context.TickerId),
+ Arg.Is(context.Type));
+ }
+
+ #endregion
+
+ #region Cancellation Path
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsStatusCancelled_WhenTaskCancelledExceptionThrown()
+ {
+ var context = CreateContext(ct: (_, _, _) => throw new TaskCanceledException("cancelled"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.Status.Should().Be(TickerStatus.Cancelled);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_CallsCancelledExceptionHandler_WhenRegistered()
+ {
+ var exceptionHandler = Substitute.For();
+ var services = new ServiceCollection();
+ services.AddSingleton(_internalManager);
+ services.AddSingleton(_instrumentation);
+ services.AddSingleton(exceptionHandler);
+ var sp = services.BuildServiceProvider();
+ var handler = new TickerExecutionTaskHandler(sp, _clock, _instrumentation, _internalManager);
+
+ var context = CreateContext(ct: (_, _, _) => throw new TaskCanceledException("cancelled"));
+
+ await handler.ExecuteTaskAsync(context, isDue: false);
+
+ await exceptionHandler.Received(1).HandleCanceledExceptionAsync(
+ Arg.Any(),
+ Arg.Is(context.TickerId),
+ Arg.Is(context.Type));
+ }
+
+ #endregion
+
+ #region TerminateExecutionException Path
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsStatusSkipped_WhenTerminateExecutionExceptionThrown()
+ {
+ var context = CreateContext(ct: (_, _, _) => throw new TerminateExecutionException("skip me"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.Status.Should().Be(TickerStatus.Skipped);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsCustomStatus_WhenTerminateExecutionWithStatusThrown()
+ {
+ var context = CreateContext(ct: (_, _, _) =>
+ throw new TerminateExecutionException(TickerStatus.Cancelled, "terminate as cancelled"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.Status.Should().Be(TickerStatus.Cancelled);
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RecordsExceptionMessage_WhenTerminateExecutionThrown()
+ {
+ var context = CreateContext(ct: (_, _, _) =>
+ throw new TerminateExecutionException("skip reason"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.ExceptionDetails.Should().Contain("skip reason");
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RecordsInnerExceptionMessage_WhenTerminateExecutionHasInner()
+ {
+ var inner = new InvalidOperationException("inner reason");
+ var context = CreateContext(ct: (_, _, _) =>
+ throw new TerminateExecutionException("outer", inner));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.ExceptionDetails.Should().Contain("inner reason");
+ }
+
+ #endregion
+
+ #region Null CachedDelegate
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SetsStatusFailed_WhenCachedDelegateIsNull()
+ {
+ var context = CreateContext(ct: null);
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ context.Status.Should().Be(TickerStatus.Failed);
+ context.ExceptionDetails.Should().Contain("was not found");
+ }
+
+ #endregion
+
+ #region Parent-Child Execution (TimeTicker)
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RunsInProgressChildren_ConcurrentlyWithParent()
+ {
+ var childExecuted = false;
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) =>
+ {
+ childExecuted = true;
+ return Task.CompletedTask;
+ });
+ childContext.RunCondition = RunCondition.InProgress;
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ childExecuted.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RunsOnSuccessChild_AfterParentSucceeds()
+ {
+ var childExecuted = false;
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) =>
+ {
+ childExecuted = true;
+ return Task.CompletedTask;
+ });
+ childContext.RunCondition = RunCondition.OnSuccess;
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ childExecuted.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SkipsOnSuccessChild_WhenParentFails()
+ {
+ var childExecuted = false;
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => throw new InvalidOperationException("parent fail"));
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) =>
+ {
+ childExecuted = true;
+ return Task.CompletedTask;
+ });
+ childContext.RunCondition = RunCondition.OnSuccess;
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ childExecuted.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RunsOnFailureChild_WhenParentFails()
+ {
+ var childExecuted = false;
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => throw new InvalidOperationException("parent fail"));
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) =>
+ {
+ childExecuted = true;
+ return Task.CompletedTask;
+ });
+ childContext.RunCondition = RunCondition.OnFailure;
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ childExecuted.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_SkipsOnFailureChild_WhenParentSucceeds()
+ {
+ var childExecuted = false;
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) =>
+ {
+ childExecuted = true;
+ return Task.CompletedTask;
+ });
+ childContext.RunCondition = RunCondition.OnFailure;
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ childExecuted.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RunsOnAnyCompletedStatus_RegardlessOfParentOutcome()
+ {
+ var childExecutedOnSuccess = false;
+ var childExecutedOnFail = false;
+
+ // Test with parent success
+ var parentSuccess = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+ var childSuccess = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => { childExecutedOnSuccess = true; return Task.CompletedTask; });
+ childSuccess.RunCondition = RunCondition.OnAnyCompletedStatus;
+ parentSuccess.TimeTickerChildren.Add(childSuccess);
+ await _handler.ExecuteTaskAsync(parentSuccess, isDue: false);
+
+ // Test with parent failure
+ var parentFail = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => throw new InvalidOperationException("fail"));
+ var childFail = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => { childExecutedOnFail = true; return Task.CompletedTask; });
+ childFail.RunCondition = RunCondition.OnAnyCompletedStatus;
+ parentFail.TimeTickerChildren.Add(childFail);
+ await _handler.ExecuteTaskAsync(parentFail, isDue: false);
+
+ childExecutedOnSuccess.Should().BeTrue();
+ childExecutedOnFail.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_RunsOnFailureOrCancelled_WhenParentFails()
+ {
+ var childExecuted = false;
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => throw new InvalidOperationException("fail"));
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => { childExecuted = true; return Task.CompletedTask; });
+ childContext.RunCondition = RunCondition.OnFailureOrCancelled;
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ childExecuted.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_BulkSkipsDescendants_WhenChildConditionNotMet()
+ {
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+
+ var childContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+ childContext.RunCondition = RunCondition.OnFailure;
+
+ var grandChild = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+ grandChild.RunCondition = RunCondition.OnSuccess;
+ childContext.TimeTickerChildren.Add(grandChild);
+
+ parentContext.TimeTickerChildren.Add(childContext);
+
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+
+ // The skipped children should be bulk-updated
+ await _internalManager.Received().UpdateSkipTimeTickersWithUnifiedContextAsync(
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_ChildWithNullDelegate_IsSkipped()
+ {
+ var parentContext = CreateContext(
+ type: TickerType.TimeTicker,
+ ct: (_, _, _) => Task.CompletedTask);
+
+ var nullDelegateChild = new InternalFunctionContext
+ {
+ TickerId = Guid.NewGuid(),
+ FunctionName = "NullChild",
+ Type = TickerType.TimeTicker,
+ CachedDelegate = null,
+ RunCondition = RunCondition.OnSuccess,
+ TimeTickerChildren = []
+ };
+ parentContext.TimeTickerChildren.Add(nullDelegateChild);
+
+ // Should not throw
+ await _handler.ExecuteTaskAsync(parentContext, isDue: false);
+ }
+
+ #endregion
+
+ #region CronTickerOccurrence - direct path
+
+ [Fact]
+ public async Task ExecuteTaskAsync_CronTicker_GoesDirectlyToRunContextFunction()
+ {
+ var executed = false;
+ var context = CreateContext(
+ type: TickerType.CronTickerOccurrence,
+ ct: (_, _, _) => { executed = true; return Task.CompletedTask; });
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ executed.Should().BeTrue();
+ context.Status.Should().Be(TickerStatus.Done);
+ }
+
+ #endregion
+
+ #region Instrumentation
+
+ [Fact]
+ public async Task ExecuteTaskAsync_LogsJobEnqueued_OnExecution()
+ {
+ var context = CreateContext(ct: (_, _, _) => Task.CompletedTask);
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ _instrumentation.Received().LogJobEnqueued(
+ Arg.Any(),
+ Arg.Is(context.FunctionName),
+ Arg.Is(context.TickerId),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_LogsJobCompleted_OnSuccess()
+ {
+ var context = CreateContext(ct: (_, _, _) => Task.CompletedTask);
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ _instrumentation.Received().LogJobCompleted(
+ Arg.Is(context.TickerId),
+ Arg.Is(context.FunctionName),
+ Arg.Any(),
+ Arg.Is(true));
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_LogsJobFailed_OnFailure()
+ {
+ var context = CreateContext(ct: (_, _, _) => throw new InvalidOperationException("fail"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ _instrumentation.Received().LogJobFailed(
+ Arg.Is(context.TickerId),
+ Arg.Is(context.FunctionName),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_LogsJobCancelled_OnCancellation()
+ {
+ var context = CreateContext(ct: (_, _, _) => throw new TaskCanceledException());
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ _instrumentation.Received().LogJobCancelled(
+ Arg.Is(context.TickerId),
+ Arg.Is(context.FunctionName),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteTaskAsync_LogsJobSkipped_OnTerminateExecution()
+ {
+ var context = CreateContext(ct: (_, _, _) =>
+ throw new TerminateExecutionException("skipped reason"));
+
+ await _handler.ExecuteTaskAsync(context, isDue: false);
+
+ _instrumentation.Received().LogJobSkipped(
+ Arg.Is(context.TickerId),
+ Arg.Is(context.FunctionName),
+ Arg.Any());
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static InternalFunctionContext CreateContext(
+ TickerFunctionDelegate? ct = null,
+ TickerType type = TickerType.CronTickerOccurrence)
+ {
+ return new InternalFunctionContext
+ {
+ TickerId = Guid.NewGuid(),
+ FunctionName = "TestFunction",
+ Type = type,
+ ExecutionTime = DateTime.UtcNow,
+ RetryIntervals = [],
+ Retries = 0,
+ RetryCount = 0,
+ Status = TickerStatus.Idle,
+ CachedDelegate = ct,
+ TimeTickerChildren = []
+ };
+ }
+
+ #endregion
+}
diff --git a/tests/TickerQ.Tests/TickerHelperTests.cs b/tests/TickerQ.Tests/TickerHelperTests.cs
new file mode 100644
index 00000000..a6e06aee
--- /dev/null
+++ b/tests/TickerQ.Tests/TickerHelperTests.cs
@@ -0,0 +1,238 @@
+using System.Text;
+using System.Text.Json;
+using FluentAssertions;
+using TickerQ.Utilities;
+
+namespace TickerQ.Tests;
+
+public class TickerHelperTests : IDisposable
+{
+ // Store original state so we can restore after each test
+ private readonly bool _originalGZipEnabled;
+ private readonly JsonSerializerOptions _originalOptions;
+
+ public TickerHelperTests()
+ {
+ _originalGZipEnabled = TickerHelper.UseGZipCompression;
+ _originalOptions = TickerHelper.RequestJsonSerializerOptions;
+ }
+
+ public void Dispose()
+ {
+ TickerHelper.UseGZipCompression = _originalGZipEnabled;
+ TickerHelper.RequestJsonSerializerOptions = _originalOptions;
+ }
+
+ #region CreateTickerRequest - No Compression
+
+ [Fact]
+ public void CreateTickerRequest_WithoutCompression_SerializesObject()
+ {
+ TickerHelper.UseGZipCompression = false;
+ var data = new TestPayload { Name = "test", Value = 42 };
+
+ var bytes = TickerHelper.CreateTickerRequest(data);
+
+ var json = Encoding.UTF8.GetString(bytes);
+ json.Should().Contain("test");
+ json.Should().Contain("42");
+ }
+
+ [Fact]
+ public void CreateTickerRequest_WithoutCompression_ByteArray_ReturnsAsIs()
+ {
+ TickerHelper.UseGZipCompression = false;
+ var original = new byte[] { 1, 2, 3, 4, 5 };
+
+ var result = TickerHelper.CreateTickerRequest(original);
+
+ result.Should().BeEquivalentTo(original);
+ }
+
+ [Fact]
+ public void CreateTickerRequest_WithoutCompression_String_SerializesToJson()
+ {
+ TickerHelper.UseGZipCompression = false;
+
+ var bytes = TickerHelper.CreateTickerRequest("hello");
+
+ var result = Encoding.UTF8.GetString(bytes);
+ result.Should().Contain("hello");
+ }
+
+ #endregion
+
+ #region CreateTickerRequest - With GZip Compression
+
+ [Fact]
+ public void CreateTickerRequest_WithCompression_ProducesGZipBytes()
+ {
+ TickerHelper.UseGZipCompression = true;
+ var data = new TestPayload { Name = "compressed", Value = 99 };
+
+ var bytes = TickerHelper.CreateTickerRequest(data);
+
+ // GZip signature is appended at end: [0x1f, 0x8b, 0x08, 0x00]
+ bytes.Length.Should().BeGreaterThan(4);
+ bytes[^4].Should().Be(0x1f);
+ bytes[^3].Should().Be(0x8b);
+ bytes[^2].Should().Be(0x08);
+ bytes[^1].Should().Be(0x00);
+ }
+
+ [Fact]
+ public void CreateTickerRequest_WithCompression_AlreadyCompressed_ReturnsSameBytes()
+ {
+ TickerHelper.UseGZipCompression = true;
+ // Create compressed data first
+ var original = TickerHelper.CreateTickerRequest(new TestPayload { Name = "test", Value = 1 });
+
+ // Pass the already-compressed bytes back in
+ var result = TickerHelper.CreateTickerRequest(original);
+
+ result.Should().BeEquivalentTo(original);
+ }
+
+ #endregion
+
+ #region ReadTickerRequest - No Compression
+
+ [Fact]
+ public void ReadTickerRequest_WithoutCompression_DeserializesObject()
+ {
+ TickerHelper.UseGZipCompression = false;
+ var data = new TestPayload { Name = "read_test", Value = 7 };
+ var bytes = TickerHelper.CreateTickerRequest(data);
+
+ var result = TickerHelper.ReadTickerRequest(bytes);
+
+ result.Name.Should().Be("read_test");
+ result.Value.Should().Be(7);
+ }
+
+ [Fact]
+ public void ReadTickerRequestAsString_WithoutCompression_ReturnsJsonString()
+ {
+ TickerHelper.UseGZipCompression = false;
+ var json = """{"Name":"hello","Value":42}""";
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ var result = TickerHelper.ReadTickerRequestAsString(bytes);
+
+ result.Should().Be(json);
+ }
+
+ #endregion
+
+ #region ReadTickerRequest - With GZip Compression
+
+ [Fact]
+ public void ReadTickerRequest_WithCompression_DeserializesCompressedObject()
+ {
+ TickerHelper.UseGZipCompression = true;
+ var data = new TestPayload { Name = "gzip_test", Value = 100 };
+ var bytes = TickerHelper.CreateTickerRequest(data);
+
+ var result = TickerHelper.ReadTickerRequest(bytes);
+
+ result.Name.Should().Be("gzip_test");
+ result.Value.Should().Be(100);
+ }
+
+ [Fact]
+ public void ReadTickerRequestAsString_WithCompression_ThrowsForNonGzipBytes()
+ {
+ TickerHelper.UseGZipCompression = true;
+ var plainBytes = Encoding.UTF8.GetBytes("not compressed");
+
+ var act = () => TickerHelper.ReadTickerRequestAsString(plainBytes);
+
+ act.Should().Throw().WithMessage("*not GZip compressed*");
+ }
+
+ #endregion
+
+ #region Round-Trip Tests
+
+ [Fact]
+ public void RoundTrip_WithoutCompression_PreservesData()
+ {
+ TickerHelper.UseGZipCompression = false;
+ var original = new TestPayload { Name = "roundtrip", Value = 123 };
+
+ var bytes = TickerHelper.CreateTickerRequest(original);
+ var result = TickerHelper.ReadTickerRequest(bytes);
+
+ result.Name.Should().Be(original.Name);
+ result.Value.Should().Be(original.Value);
+ }
+
+ [Fact]
+ public void RoundTrip_WithCompression_PreservesData()
+ {
+ TickerHelper.UseGZipCompression = true;
+ var original = new TestPayload { Name = "gzip_roundtrip", Value = 456 };
+
+ var bytes = TickerHelper.CreateTickerRequest(original);
+ var result = TickerHelper.ReadTickerRequest(bytes);
+
+ result.Name.Should().Be(original.Name);
+ result.Value.Should().Be(original.Value);
+ }
+
+ [Fact]
+ public void RoundTrip_WithCustomJsonOptions_UsesConfiguredOptions()
+ {
+ TickerHelper.UseGZipCompression = false;
+ TickerHelper.RequestJsonSerializerOptions = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ var original = new TestPayload { Name = "camel", Value = 789 };
+ var bytes = TickerHelper.CreateTickerRequest(original);
+ var json = Encoding.UTF8.GetString(bytes);
+
+ json.Should().Contain("\"name\"");
+ json.Should().Contain("\"value\"");
+ }
+
+ [Fact]
+ public void RoundTrip_ComplexObject_PreservesStructure()
+ {
+ TickerHelper.UseGZipCompression = false;
+ var original = new ComplexPayload
+ {
+ Id = Guid.NewGuid(),
+ Items = ["a", "b", "c"],
+ Nested = new TestPayload { Name = "nested", Value = 10 }
+ };
+
+ var bytes = TickerHelper.CreateTickerRequest(original);
+ var result = TickerHelper.ReadTickerRequest(bytes);
+
+ result.Id.Should().Be(original.Id);
+ result.Items.Should().BeEquivalentTo(original.Items);
+ result.Nested.Name.Should().Be("nested");
+ result.Nested.Value.Should().Be(10);
+ }
+
+ #endregion
+
+ #region Test Models
+
+ private class TestPayload
+ {
+ public string Name { get; set; } = string.Empty;
+ public int Value { get; set; }
+ }
+
+ private class ComplexPayload
+ {
+ public Guid Id { get; set; }
+ public List Items { get; set; } = [];
+ public TestPayload Nested { get; set; } = new();
+ }
+
+ #endregion
+}
diff --git a/tests/TickerQ.Tests/TickerQDispatcherTests.cs b/tests/TickerQ.Tests/TickerQDispatcherTests.cs
new file mode 100644
index 00000000..12a562d8
--- /dev/null
+++ b/tests/TickerQ.Tests/TickerQDispatcherTests.cs
@@ -0,0 +1,116 @@
+using FluentAssertions;
+using NSubstitute;
+using TickerQ.Dispatcher;
+using TickerQ.Utilities.Enums;
+using TickerQ.Utilities.Interfaces;
+using TickerQ.Utilities.Models;
+
+namespace TickerQ.Tests;
+
+public class TickerQDispatcherTests
+{
+ private readonly ITickerQTaskScheduler _taskScheduler;
+ private readonly ITickerExecutionTaskHandler _taskHandler;
+ private readonly TickerQDispatcher _dispatcher;
+
+ public TickerQDispatcherTests()
+ {
+ _taskScheduler = Substitute.For();
+ _taskHandler = Substitute.For();
+ _dispatcher = new TickerQDispatcher(_taskScheduler, _taskHandler);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsArgumentNullException_WhenTaskSchedulerIsNull()
+ {
+ var act = () => new TickerQDispatcher(null!, _taskHandler);
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("taskScheduler");
+ }
+
+ [Fact]
+ public void Constructor_ThrowsArgumentNullException_WhenTaskHandlerIsNull()
+ {
+ var act = () => new TickerQDispatcher(_taskScheduler, null!);
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("taskHandler");
+ }
+
+ [Fact]
+ public void IsEnabled_ReturnsTrue()
+ {
+ _dispatcher.IsEnabled.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task DispatchAsync_DoesNothing_WhenContextsIsNull()
+ {
+ await _dispatcher.DispatchAsync(null!);
+
+ await _taskScheduler.DidNotReceive().QueueAsync(
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task DispatchAsync_DoesNothing_WhenContextsIsEmpty()
+ {
+ await _dispatcher.DispatchAsync([]);
+
+ await _taskScheduler.DidNotReceive().QueueAsync(
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task DispatchAsync_QueuesEachContext_InTaskScheduler()
+ {
+ var contexts = new[]
+ {
+ new InternalFunctionContext
+ {
+ TickerId = Guid.NewGuid(),
+ FunctionName = "Func1",
+ CachedPriority = TickerTaskPriority.Normal,
+ TimeTickerChildren = []
+ },
+ new InternalFunctionContext
+ {
+ TickerId = Guid.NewGuid(),
+ FunctionName = "Func2",
+ CachedPriority = TickerTaskPriority.High,
+ TimeTickerChildren = []
+ }
+ };
+
+ await _dispatcher.DispatchAsync(contexts);
+
+ await _taskScheduler.Received(2).QueueAsync(
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task DispatchAsync_UsesContextPriority_WhenQueuing()
+ {
+ var context = new InternalFunctionContext
+ {
+ TickerId = Guid.NewGuid(),
+ FunctionName = "Func1",
+ CachedPriority = TickerTaskPriority.LongRunning,
+ TimeTickerChildren = []
+ };
+
+ await _dispatcher.DispatchAsync([context]);
+
+ await _taskScheduler.Received(1).QueueAsync(
+ Arg.Any>(),
+ Arg.Is(TickerTaskPriority.LongRunning),
+ Arg.Any());
+ }
+}
diff --git a/tests/TickerQ.Tests/TickerResultTests.cs b/tests/TickerQ.Tests/TickerResultTests.cs
new file mode 100644
index 00000000..2897b2e8
--- /dev/null
+++ b/tests/TickerQ.Tests/TickerResultTests.cs
@@ -0,0 +1,96 @@
+using FluentAssertions;
+using TickerQ.Utilities.Models;
+
+namespace TickerQ.Tests;
+
+public class TickerResultTests
+{
+ // TickerResult has internal constructors, so we test via internal access (InternalsVisibleTo)
+ // The test project references TickerQ.Utilities, which has InternalsVisibleTo for the test assembly.
+ // If not accessible, we can use reflection.
+
+ [Fact]
+ public void Constructor_WithResult_SetsIsSucceeded()
+ {
+ var result = CreateSuccessResult("value");
+
+ result.IsSucceeded.Should().BeTrue();
+ result.Result.Should().Be("value");
+ result.Exception.Should().BeNull();
+ }
+
+ [Fact]
+ public void Constructor_WithException_SetsIsSucceededFalse()
+ {
+ var ex = new InvalidOperationException("fail");
+ var result = CreateFailureResult(ex);
+
+ result.IsSucceeded.Should().BeFalse();
+ result.Exception.Should().BeSameAs(ex);
+ result.Result.Should().BeNull();
+ }
+
+ [Fact]
+ public void Constructor_WithAffectedRows_SetsIsSucceeded()
+ {
+ var result = CreateAffectedRowsResult(5);
+
+ result.IsSucceeded.Should().BeTrue();
+ result.AffectedRows.Should().Be(5);
+ }
+
+ [Fact]
+ public void Constructor_WithResultAndAffectedRows_SetsBoth()
+ {
+ var result = CreateResultWithRows("value", 3);
+
+ result.IsSucceeded.Should().BeTrue();
+ result.Result.Should().Be("value");
+ result.AffectedRows.Should().Be(3);
+ }
+
+ // Use reflection to create instances since constructors are internal
+ private static TickerResult CreateSuccessResult(string value)
+ {
+ var ctor = typeof(TickerResult)
+ .GetConstructor(
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
+ null,
+ [typeof(string)],
+ null);
+ return (TickerResult)ctor!.Invoke([value]);
+ }
+
+ private static TickerResult CreateFailureResult(Exception exception)
+ {
+ var ctor = typeof(TickerResult)
+ .GetConstructor(
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
+ null,
+ [typeof(Exception)],
+ null);
+ return (TickerResult)ctor!.Invoke([exception]);
+ }
+
+ private static TickerResult CreateAffectedRowsResult(int rows)
+ {
+ var ctor = typeof(TickerResult)
+ .GetConstructor(
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
+ null,
+ [typeof(int)],
+ null);
+ return (TickerResult)ctor!.Invoke([rows]);
+ }
+
+ private static TickerResult CreateResultWithRows(string value, int rows)
+ {
+ var ctor = typeof(TickerResult)
+ .GetConstructor(
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
+ null,
+ [typeof(string), typeof(int)],
+ null);
+ return (TickerResult)ctor!.Invoke([value, rows]);
+ }
+}
diff --git a/tests/TickerQ.Tests/WorkItemTests.cs b/tests/TickerQ.Tests/WorkItemTests.cs
new file mode 100644
index 00000000..b38276c5
--- /dev/null
+++ b/tests/TickerQ.Tests/WorkItemTests.cs
@@ -0,0 +1,62 @@
+using FluentAssertions;
+using TickerQ.TickerQThreadPool;
+
+namespace TickerQ.Tests;
+
+public class WorkItemTests
+{
+ [Fact]
+ public void Constructor_SetsWorkAndToken()
+ {
+ using var cts = new CancellationTokenSource();
+ Func work = _ => Task.CompletedTask;
+
+ var item = new WorkItem(work, cts.Token);
+
+ item.Work.Should().BeSameAs(work);
+ item.UserToken.Should().Be(cts.Token);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsArgumentNullException_WhenWorkIsNull()
+ {
+ var act = () => new WorkItem(null!, CancellationToken.None);
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("work");
+ }
+
+ [Fact]
+ public void Constructor_AcceptsCancellationTokenNone()
+ {
+ var item = new WorkItem(_ => Task.CompletedTask, CancellationToken.None);
+
+ item.UserToken.Should().Be(CancellationToken.None);
+ }
+
+ [Fact]
+ public void Constructor_AcceptsCancelledToken()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var item = new WorkItem(_ => Task.CompletedTask, cts.Token);
+
+ item.UserToken.IsCancellationRequested.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Work_CanBeInvoked()
+ {
+ var executed = false;
+ var item = new WorkItem(_ =>
+ {
+ executed = true;
+ return Task.CompletedTask;
+ }, CancellationToken.None);
+
+ await item.Work(CancellationToken.None);
+
+ executed.Should().BeTrue();
+ }
+}