diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..d52df66f --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,36 @@ +name: Unit Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj + + - name: Build tests + run: dotnet build tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj --no-restore + + - name: Run tests + run: dotnet test tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./tests/PepperDash.Essentials.Core.Tests/TestResults/*/coverage.cobertura.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index bd60ff8d..395d700d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,15 @@ bld/ [Ll]og/ [Ll]ogs/ +# Test results and coverage +TestResults/ +coverage/ +*.trx +*.coverage +*.coveragexml +coverage.cobertura.xml +coverage-report/ + # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot diff --git a/PepperDash.Essentials.4Series.sln b/PepperDash.Essentials.4Series.sln index 7423c50a..1b6c120d 100644 --- a/PepperDash.Essentials.4Series.sln +++ b/PepperDash.Essentials.4Series.sln @@ -36,6 +36,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{02EA681E-C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Core", "src\PepperDash.Core\PepperDash.Core.csproj", "{E5336563-1194-501E-BC4A-79AD9283EF90}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C8EAB8E7-4F14-4E5C-8D23-1A3956D46DE8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PepperDash.Essentials.Core.Tests", "tests\PepperDash.Essentials.Core.Tests\PepperDash.Essentials.Core.Tests.csproj", "{A1234567-8901-2345-6789-ABCDEF012345}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug 4.7.2|Any CPU = Debug 4.7.2|Any CPU @@ -79,6 +83,12 @@ Global {E5336563-1194-501E-BC4A-79AD9283EF90}.Debug|Any CPU.Build.0 = Debug|Any CPU {E5336563-1194-501E-BC4A-79AD9283EF90}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5336563-1194-501E-BC4A-79AD9283EF90}.Release|Any CPU.Build.0 = Release|Any CPU + {A1234567-8901-2345-6789-ABCDEF012345}.Debug 4.7.2|Any CPU.ActiveCfg = Debug|Any CPU + {A1234567-8901-2345-6789-ABCDEF012345}.Debug 4.7.2|Any CPU.Build.0 = Debug|Any CPU + {A1234567-8901-2345-6789-ABCDEF012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1234567-8901-2345-6789-ABCDEF012345}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1234567-8901-2345-6789-ABCDEF012345}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1234567-8901-2345-6789-ABCDEF012345}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -90,6 +100,7 @@ Global {F6D362DE-2256-44B1-927A-8CE4705D839A} = {B24989D7-32B5-48D5-9AE1-5F3B17D25206} {B438694F-8FF7-464A-9EC8-10427374471F} = {B24989D7-32B5-48D5-9AE1-5F3B17D25206} {E5336563-1194-501E-BC4A-79AD9283EF90} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {A1234567-8901-2345-6789-ABCDEF012345} = {C8EAB8E7-4F14-4E5C-8D23-1A3956D46DE8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6907A4BF-7201-47CF-AAB1-3597F3B8E1C3} diff --git a/docs/testing/QuickStart.md b/docs/testing/QuickStart.md new file mode 100644 index 00000000..773415e8 --- /dev/null +++ b/docs/testing/QuickStart.md @@ -0,0 +1,188 @@ +# Quick Start Guide: Unit Testing + +This guide helps you get started with writing unit tests for PepperDash Essentials components. + +## Prerequisites + +- .NET 8 SDK +- Visual Studio 2022 or VS Code with C# extension + +## Running Existing Tests + +```bash +# Navigate to the test project +cd tests/PepperDash.Essentials.Core.Tests + +# Run all tests +dotnet test + +# Run with coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +## Creating Your First Test + +### 1. Identify the Component + +Choose a component that has business logic you want to test. Look for classes that: +- Have clear inputs and outputs +- Contain conditional logic or algorithms +- Are used by multiple parts of the system + +### 2. Create Test File + +Create a new test file in `tests/PepperDash.Essentials.Core.Tests/Unit/`: + +```csharp +using PepperDash.Essentials.Core.Tests.Abstractions; + +namespace PepperDash.Essentials.Core.Tests.Unit +{ + public class YourComponentTests + { + [Fact] + public void YourMethod_WithValidInput_ReturnsExpectedResult() + { + // Arrange + var component = new YourComponent(); + var input = "test data"; + + // Act + var result = component.YourMethod(input); + + // Assert + Assert.Equal("expected result", result); + } + } +} +``` + +### 3. Abstract Dependencies + +If your component uses Crestron SDK or other external dependencies: + +```csharp +// Create interface in Abstractions/ +public interface IYourDependency +{ + string DoSomething(string input); +} + +// Modify your component to use the interface +public class YourComponent +{ + private readonly IYourDependency _dependency; + + public YourComponent(IYourDependency dependency) + { + _dependency = dependency; + } + + public string YourMethod(string input) + { + return _dependency.DoSomething(input); + } +} + +// Test with mocks +[Fact] +public void YourMethod_CallsDependency_ReturnsResult() +{ + // Arrange + var mockDependency = new Mock(); + mockDependency.Setup(x => x.DoSomething("input")).Returns("output"); + var component = new YourComponent(mockDependency.Object); + + // Act + var result = component.YourMethod("input"); + + // Assert + Assert.Equal("output", result); + mockDependency.Verify(x => x.DoSomething("input"), Times.Once); +} +``` + +## Testing Patterns + +### Testing Exceptions + +```csharp +[Fact] +public void Constructor_WithNullDependency_ThrowsArgumentNullException() +{ + // Act & Assert + Assert.Throws(() => new YourComponent(null)); +} +``` + +### Testing Async Methods + +```csharp +[Fact] +public async Task YourAsyncMethod_WithValidInput_ReturnsExpectedResult() +{ + // Arrange + var component = new YourComponent(); + + // Act + var result = await component.YourAsyncMethod(); + + // Assert + Assert.True(result); +} +``` + +### Testing Collections + +```csharp +[Fact] +public void GetItems_ReturnsExpectedCount() +{ + // Arrange + var component = new YourComponent(); + + // Act + var items = component.GetItems(); + + // Assert + Assert.Equal(3, items.Count()); + Assert.Contains(items, x => x.Name == "Expected Item"); +} +``` + +## Common Mistakes to Avoid + +1. **Testing Implementation Details** - Test behavior, not internal implementation +2. **Overly Complex Tests** - Keep tests simple and focused on one thing +3. **Not Testing Edge Cases** - Include null values, empty collections, boundary conditions +4. **Ignoring Test Performance** - Tests should run quickly to enable fast feedback + +## Debugging Tests + +### In Visual Studio +- Set breakpoints in test methods +- Use Test Explorer to run individual tests +- Check test output window for detailed information + +### From Command Line +```bash +# Run specific test +dotnet test --filter "YourComponentTests.YourMethod_WithValidInput_ReturnsExpectedResult" + +# Run with detailed output +dotnet test --verbosity diagnostic +``` + +## Next Steps + +1. **Review Examples** - Look at `TestableActionSequenceTests.cs` for comprehensive examples +2. **Start Small** - Begin with simple components and build up complexity +3. **Read Documentation** - Check `docs/testing/README.md` for detailed patterns +4. **Get Feedback** - Have your tests reviewed by team members + +## Need Help? + +- Check existing test examples in the codebase +- Review the full testing documentation +- Ask team members for guidance on complex scenarios +- Consider pair programming for your first few tests \ No newline at end of file diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 00000000..913062b6 --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,255 @@ +# Unit Testing Infrastructure + +This document outlines the unit testing infrastructure and patterns for PepperDash Essentials. + +## Overview + +The testing infrastructure is designed to enable comprehensive testing of business logic while abstracting away Crestron SDK dependencies. This allows for: + +- Fast, reliable unit tests that don't require hardware +- Isolated testing of business logic +- Better code quality through testability +- Easier debugging and maintenance + +## Project Structure + +``` +tests/ +├── PepperDash.Essentials.Core.Tests/ +│ ├── Abstractions/ # Interface abstractions for SDK dependencies +│ ├── TestableComponents/ # Demonstration components showing abstraction patterns +│ ├── Unit/ # Unit test files +│ └── GlobalUsings.cs # Global using statements for tests +``` + +## Test Project Configuration + +The test projects use: +- **.NET 8** - Modern testing framework with better performance +- **xUnit** - Primary testing framework +- **Moq** - Mocking framework for dependencies +- **Coverlet** - Code coverage analysis + +## Abstraction Patterns + +### Interface Segregation + +Create focused interfaces that abstract SDK functionality: + +```csharp +public interface IThreadService +{ + void Sleep(int milliseconds); + object CreateAndStartThread(Func threadFunction, object parameter); + void AbortThread(object thread); + bool IsThreadRunning(object thread); +} + +public interface ILogger +{ + void LogDebug(object source, string message, params object[] args); + void LogVerbose(object source, string message, params object[] args); +} +``` + +### Dependency Injection + +Components should accept dependencies via constructor injection: + +```csharp +public class TestableActionSequence +{ + private readonly IQueue _actionQueue; + private readonly IThreadService _threadService; + private readonly ILogger _logger; + + public TestableActionSequence( + IQueue actionQueue, + IThreadService threadService, + ILogger logger, + List actions) + { + _actionQueue = actionQueue ?? throw new ArgumentNullException(nameof(actionQueue)); + _threadService = threadService ?? throw new ArgumentNullException(nameof(threadService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + // ... + } +} +``` + +## Writing Tests + +### Test Organization + +- **One test class per component** +- **Descriptive test method names** that describe the scenario and expected outcome +- **Arrange-Act-Assert pattern** for clear test structure + +### Example Test Structure + +```csharp +public class TestableActionSequenceTests +{ + private readonly Mock> _mockQueue; + private readonly Mock _mockThreadService; + private readonly Mock _mockLogger; + private readonly TestableActionSequence _actionSequence; + + public TestableActionSequenceTests() + { + // Arrange - Set up mocks and dependencies + _mockQueue = new Mock>(); + _mockThreadService = new Mock(); + _mockLogger = new Mock(); + + _actionSequence = new TestableActionSequence( + _mockQueue.Object, + _mockThreadService.Object, + _mockLogger.Object, + new List()); + } + + [Fact] + public void StartSequence_WhenNoThreadRunning_StartsNewThread() + { + // Arrange + _mockThreadService.Setup(x => x.IsThreadRunning(It.IsAny())).Returns(false); + + // Act + _actionSequence.StartSequence(); + + // Assert + _mockLogger.Verify(x => x.LogDebug(_actionSequence, "Starting Action Sequence"), Times.Once); + _mockThreadService.Verify(x => x.CreateAndStartThread(It.IsAny>(), null), Times.Once); + } +} +``` + +### Test Naming Convention + +Use descriptive names that follow the pattern: +`MethodName_Scenario_ExpectedBehavior` + +Examples: +- `StartSequence_WhenNoThreadRunning_StartsNewThread` +- `Constructor_WithNullQueue_ThrowsArgumentNullException` +- `StopSequence_SetsAllowActionsToFalseAndAbortsThread` + +## Running Tests + +### From Command Line + +```bash +# Run all tests +dotnet test + +# Run tests with coverage +dotnet test --collect:"XPlat Code Coverage" + +# Run specific test class +dotnet test --filter "TestableActionSequenceTests" + +# Run with verbose output +dotnet test --verbosity normal +``` + +### From Visual Studio + +- Use Test Explorer to run and debug tests +- Right-click on test methods to run individually +- Use "Run Tests in Parallel" for faster execution + +## Code Coverage + +The test projects include Coverlet for code coverage analysis. After running tests with coverage: + +```bash +# Generate coverage report +dotnet tool install -g reportgenerator +reportgenerator -reports:"coverage.cobertura.xml" -targetdir:"coverage-report" -reporttypes:Html +``` + +## Best Practices + +### 1. Test Independence +- Each test should be independent and able to run in isolation +- Use fresh mock objects for each test +- Don't rely on test execution order + +### 2. Mocking Guidelines +- Mock external dependencies (SDK calls, file system, network) +- Don't mock the component under test +- Use strict mocks when behavior verification is important + +### 3. Assertions +- Make assertions specific and meaningful +- Test both positive and negative scenarios +- Verify state changes and behavior calls + +### 4. Test Data +- Use meaningful test data that represents real scenarios +- Consider edge cases and boundary conditions +- Use constants or factory methods for complex test data + +## Migrating Existing Code + +To make existing components testable: + +1. **Identify SDK Dependencies** - Look for direct Crestron SDK usage +2. **Extract Interfaces** - Create abstractions for SDK functionality +3. **Inject Dependencies** - Modify constructors to accept abstractions +4. **Create Tests** - Write comprehensive unit tests for business logic +5. **Implement Wrappers** - Create concrete implementations for production use + +## Example Implementation Wrapper + +When implementing the abstractions for production use: + +```csharp +public class CrestronThreadService : IThreadService +{ + public void Sleep(int milliseconds) + { + Thread.Sleep(milliseconds); + } + + public object CreateAndStartThread(Func threadFunction, object parameter) + { + var thread = new Thread(threadFunction, parameter, Thread.eThreadStartOptions.Running); + return thread; + } + + public void AbortThread(object thread) + { + if (thread is Thread crestronThread) + { + crestronThread.Abort(); + } + } + + public bool IsThreadRunning(object thread) + { + return thread is Thread crestronThread && + crestronThread.ThreadState == Thread.eThreadStates.ThreadRunning; + } +} +``` + +## Future Enhancements + +The testing infrastructure can be extended with: + +- **Integration test support** for end-to-end scenarios +- **Performance testing** utilities +- **Test data builders** for complex object creation +- **Custom assertions** for domain-specific validations +- **Automated test generation** for common patterns + +## Getting Help + +For questions about testing patterns or infrastructure: + +1. Review existing test examples in the `tests/` directory +2. Check this documentation for guidance +3. Consult the team for complex abstraction scenarios +4. Consider the impact on existing plugin interfaces before major changes \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/Abstractions/ILogger.cs b/tests/PepperDash.Essentials.Core.Tests/Abstractions/ILogger.cs new file mode 100644 index 00000000..1209a83d --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/Abstractions/ILogger.cs @@ -0,0 +1,26 @@ +using System; + +namespace PepperDash.Essentials.Core.Tests.Abstractions +{ + /// + /// Abstraction for logging operations to enable testing + /// + public interface ILogger + { + /// + /// Logs a debug message + /// + /// Source of the log message + /// Message to log + /// Format arguments + void LogDebug(object source, string message, params object[] args); + + /// + /// Logs a verbose message + /// + /// Source of the log message + /// Message to log + /// Format arguments + void LogVerbose(object source, string message, params object[] args); + } +} \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/Abstractions/IQueue.cs b/tests/PepperDash.Essentials.Core.Tests/Abstractions/IQueue.cs new file mode 100644 index 00000000..92d0ffae --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/Abstractions/IQueue.cs @@ -0,0 +1,28 @@ +using System; + +namespace PepperDash.Essentials.Core.Tests.Abstractions +{ + /// + /// Abstraction for queue operations to enable testing + /// + /// Type of items in the queue + public interface IQueue + { + /// + /// Number of items in the queue + /// + int Count { get; } + + /// + /// Adds an item to the queue + /// + /// Item to add + void Enqueue(T item); + + /// + /// Removes and returns the next item from the queue + /// + /// The next item, or default(T) if queue is empty + T Dequeue(); + } +} \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/Abstractions/IThreadService.cs b/tests/PepperDash.Essentials.Core.Tests/Abstractions/IThreadService.cs new file mode 100644 index 00000000..4e336a65 --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/Abstractions/IThreadService.cs @@ -0,0 +1,37 @@ +using System; + +namespace PepperDash.Essentials.Core.Tests.Abstractions +{ + /// + /// Abstraction for thread operations to enable testing + /// + public interface IThreadService + { + /// + /// Sleeps the current thread for the specified milliseconds + /// + /// Time to sleep in milliseconds + void Sleep(int milliseconds); + + /// + /// Creates and starts a new thread + /// + /// The function to execute in the thread + /// Parameter to pass to the thread function + /// Thread identifier or handle + object CreateAndStartThread(Func threadFunction, object parameter); + + /// + /// Aborts the specified thread + /// + /// The thread to abort + void AbortThread(object thread); + + /// + /// Checks if the thread is currently running + /// + /// The thread to check + /// True if the thread is running, false otherwise + bool IsThreadRunning(object thread); + } +} \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/GlobalUsings.cs b/tests/PepperDash.Essentials.Core.Tests/GlobalUsings.cs new file mode 100644 index 00000000..5865af81 --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Xunit; +global using Moq; +global using System; +global using System.Collections.Generic; +global using System.Linq; \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj b/tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj new file mode 100644 index 00000000..5f69f9fb --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/PepperDash.Essentials.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/TestableComponents/TestableActionSequence.cs b/tests/PepperDash.Essentials.Core.Tests/TestableComponents/TestableActionSequence.cs new file mode 100644 index 00000000..f5024e73 --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/TestableComponents/TestableActionSequence.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using PepperDash.Essentials.Core.Tests.Abstractions; + +namespace PepperDash.Essentials.Core.Tests.TestableComponents +{ + /// + /// A simplified, testable action sequence that demonstrates abstraction patterns + /// This shows how we can separate business logic from SDK dependencies + /// + public class TestableActionSequence + { + private readonly IQueue _actionQueue; + private readonly IThreadService _threadService; + private readonly ILogger _logger; + private readonly List _configuredActions; + + private object _workerThread; + private bool _allowActionsToExecute; + + public TestableActionSequence( + IQueue actionQueue, + IThreadService threadService, + ILogger logger, + List actions) + { + _actionQueue = actionQueue ?? throw new ArgumentNullException(nameof(actionQueue)); + _threadService = threadService ?? throw new ArgumentNullException(nameof(threadService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configuredActions = actions ?? new List(); + } + + /// + /// Starts executing the sequenced actions + /// + public void StartSequence() + { + if (_workerThread != null && _threadService.IsThreadRunning(_workerThread)) + { + _logger.LogDebug(this, "Thread already running. Cannot Start Sequence"); + return; + } + + _logger.LogDebug(this, "Starting Action Sequence"); + _allowActionsToExecute = true; + AddActionsToQueue(); + _workerThread = _threadService.CreateAndStartThread(ProcessActions, null); + } + + /// + /// Stops executing the sequenced actions + /// + public void StopSequence() + { + _logger.LogDebug(this, "Stopping Action Sequence"); + _allowActionsToExecute = false; + if (_workerThread != null) + { + _threadService.AbortThread(_workerThread); + } + } + + /// + /// Gets the current status of the sequence + /// + public bool IsRunning => _allowActionsToExecute && _threadService.IsThreadRunning(_workerThread); + + /// + /// Gets the number of pending actions + /// + public int PendingActionsCount => _actionQueue.Count; + + /// + /// Populates the queue from the configuration information + /// + private void AddActionsToQueue() + { + _logger.LogDebug(this, "Adding {0} actions to queue", _configuredActions.Count); + + foreach (var action in _configuredActions) + { + _actionQueue.Enqueue(action); + } + } + + private object ProcessActions(object obj) + { + while (_allowActionsToExecute && _actionQueue.Count > 0) + { + var action = _actionQueue.Dequeue(); + if (action == null) + break; + + // Delay before executing + if (action.DelayMs > 0) + _threadService.Sleep(action.DelayMs); + + ExecuteAction(action); + } + + return null; + } + + private void ExecuteAction(TestableSequencedAction action) + { + if (action == null) + return; + + try + { + _logger.LogDebug(this, "Executing action: {0} with delay: {1}ms", action.Name, action.DelayMs); + action.Execute(); + } + catch (Exception e) + { + _logger.LogVerbose(this, "Error Executing Action: {0}", e); + } + } + } + + /// + /// A testable action that can be sequenced + /// + public class TestableSequencedAction + { + public string Name { get; set; } + public int DelayMs { get; set; } + public Action ActionToExecute { get; set; } + + public TestableSequencedAction(string name, int delayMs = 0, Action actionToExecute = null) + { + Name = name; + DelayMs = delayMs; + ActionToExecute = actionToExecute ?? (() => { }); + } + + public void Execute() + { + ActionToExecute?.Invoke(); + } + } +} \ No newline at end of file diff --git a/tests/PepperDash.Essentials.Core.Tests/Unit/TestableActionSequenceTests.cs b/tests/PepperDash.Essentials.Core.Tests/Unit/TestableActionSequenceTests.cs new file mode 100644 index 00000000..74f27720 --- /dev/null +++ b/tests/PepperDash.Essentials.Core.Tests/Unit/TestableActionSequenceTests.cs @@ -0,0 +1,160 @@ +using PepperDash.Essentials.Core.Tests.Abstractions; +using PepperDash.Essentials.Core.Tests.TestableComponents; + +namespace PepperDash.Essentials.Core.Tests.Unit +{ + public class TestableActionSequenceTests + { + private readonly Mock> _mockQueue; + private readonly Mock _mockThreadService; + private readonly Mock _mockLogger; + private readonly TestableActionSequence _actionSequence; + private readonly List _testActions; + + public TestableActionSequenceTests() + { + _mockQueue = new Mock>(); + _mockThreadService = new Mock(); + _mockLogger = new Mock(); + + _testActions = new List + { + new TestableSequencedAction("Action1", 100), + new TestableSequencedAction("Action2", 200) + }; + + _actionSequence = new TestableActionSequence( + _mockQueue.Object, + _mockThreadService.Object, + _mockLogger.Object, + _testActions); + } + + [Fact] + public void Constructor_WithNullQueue_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new TestableActionSequence(null, _mockThreadService.Object, _mockLogger.Object, _testActions)); + } + + [Fact] + public void Constructor_WithNullThreadService_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new TestableActionSequence(_mockQueue.Object, null, _mockLogger.Object, _testActions)); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new TestableActionSequence(_mockQueue.Object, _mockThreadService.Object, null, _testActions)); + } + + [Fact] + public void StartSequence_WhenNoThreadRunning_StartsNewThread() + { + // Arrange + _mockThreadService.Setup(x => x.IsThreadRunning(It.IsAny())).Returns(false); + + // Act + _actionSequence.StartSequence(); + + // Assert + _mockLogger.Verify(x => x.LogDebug(_actionSequence, "Starting Action Sequence"), Times.Once); + _mockThreadService.Verify(x => x.CreateAndStartThread(It.IsAny>(), null), Times.Once); + } + + [Fact] + public void StartSequence_WhenThreadAlreadyRunning_DoesNotStartNewThread() + { + // Arrange + var mockThread = new object(); + _mockThreadService.Setup(x => x.CreateAndStartThread(It.IsAny>(), null)) + .Returns(mockThread); + _mockThreadService.Setup(x => x.IsThreadRunning(mockThread)).Returns(true); + + // First call to set up the worker thread + _actionSequence.StartSequence(); + + // Reset invocations to verify only the second call behavior + _mockLogger.Invocations.Clear(); + _mockThreadService.Invocations.Clear(); + + // Act - Second call should detect running thread + _actionSequence.StartSequence(); + + // Assert + _mockLogger.Verify(x => x.LogDebug(_actionSequence, "Thread already running. Cannot Start Sequence"), Times.Once); + _mockThreadService.Verify(x => x.CreateAndStartThread(It.IsAny>(), null), Times.Never); + } + + [Fact] + public void StartSequence_AddsConfiguredActionsToQueue() + { + // Arrange + _mockThreadService.Setup(x => x.IsThreadRunning(It.IsAny())).Returns(false); + + // Act + _actionSequence.StartSequence(); + + // Assert + _mockLogger.Verify(x => x.LogDebug(_actionSequence, "Adding {0} actions to queue", 2), Times.Once); + _mockQueue.Verify(x => x.Enqueue(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public void StopSequence_SetsAllowActionsToFalseAndAbortsThread() + { + // Arrange + var mockThread = new object(); + + // Act + _actionSequence.StopSequence(); + + // Assert + _mockLogger.Verify(x => x.LogDebug(_actionSequence, "Stopping Action Sequence"), Times.Once); + } + + [Fact] + public void PendingActionsCount_ReturnsQueueCount() + { + // Arrange + _mockQueue.Setup(x => x.Count).Returns(5); + + // Act + var count = _actionSequence.PendingActionsCount; + + // Assert + Assert.Equal(5, count); + } + + [Fact] + public void TestableSequencedAction_ExecutesActionWhenCalled() + { + // Arrange + bool actionExecuted = false; + var action = new TestableSequencedAction("TestAction", 0, () => actionExecuted = true); + + // Act + action.Execute(); + + // Assert + Assert.True(actionExecuted); + } + + [Fact] + public void TestableSequencedAction_WithNullAction_DoesNotThrow() + { + // Arrange + var action = new TestableSequencedAction("TestAction", 0, null); + + // Act & Assert + var exception = Record.Exception(() => action.Execute()); + Assert.Null(exception); + } + } +} \ No newline at end of file