using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Moq; using XP.Common.Logging.Interfaces; using XplorePlane.Models; using XplorePlane.Services.Cnc; using XplorePlane.Services.Storage; using Xunit; namespace XplorePlane.Tests.Services { /// /// Unit tests for ImagePersistenceService. /// Validates: Requirements 1.3, 1.5, 1.7, 4.3 /// public class ImagePersistenceServiceTests : IDisposable { private readonly string _tempDir; private readonly Mock _mockDataPathService; private readonly Mock _mockLogger; private readonly ImagePersistenceService _service; public ImagePersistenceServiceTests() { _tempDir = Path.Combine(Path.GetTempPath(), "ImagePersistenceTests_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); _mockDataPathService = new Mock(); _mockDataPathService.Setup(s => s.DataPath).Returns(_tempDir); _mockLogger = new Mock(); _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); _service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); } public void Dispose() { try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } catch { // Best-effort cleanup } } // ── Test: Directory auto-creation ──────────────────────────────────── [Fact] public async Task SaveImageAsync_CreatesDirectory_WhenItDoesNotExist() { // Arrange: use a sub-path that doesn't exist yet var nestedDir = Path.Combine(_tempDir, "nested", "deep"); _mockDataPathService.Setup(s => s.DataPath).Returns(nestedDir); var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); var imageData = new byte[] { 0x42, 0x4D, 0x00, 0x01 }; // minimal data var programName = "TestProgram"; var nodeName = "Position1"; // Act var result = await service.SaveImageAsync(imageData, nodeName, programName); // Assert Assert.True(result.Success); Assert.True(Directory.Exists(Path.GetDirectoryName(result.FilePath))); Assert.True(File.Exists(result.FilePath)); } // ── Test: Successful save returns correct ImageSaveResult ───────────── [Fact] public async Task SaveImageAsync_ReturnsCorrectResult_OnSuccess() { // Arrange var imageData = new byte[1024]; new Random(42).NextBytes(imageData); var programName = "MyProgram"; var nodeName = "Node_A"; // Act var result = await _service.SaveImageAsync(imageData, nodeName, programName); // Assert Assert.True(result.Success); Assert.NotNull(result.FilePath); Assert.Contains(nodeName, result.FilePath); Assert.EndsWith(".bmp", result.FilePath); Assert.Equal(1024, result.FileSizeBytes); Assert.Null(result.ErrorMessage); // Verify file actually exists with correct content var savedBytes = await File.ReadAllBytesAsync(result.FilePath); Assert.Equal(imageData, savedBytes); } // ── Test: Timeout returns failure result ────────────────────────────── [Fact] public async Task SaveImageAsync_ReturnsFailure_WhenCancelled() { // Arrange: pre-cancel the token to simulate timeout/cancellation scenario var imageData = new byte[] { 0x01, 0x02, 0x03 }; using var cts = new CancellationTokenSource(); cts.Cancel(); // Pre-cancel to trigger OperationCanceledException immediately // Act var result = await _service.SaveImageAsync(imageData, "TestNode", "TestProgram", cts.Token); // Assert Assert.False(result.Success); Assert.Null(result.FilePath); Assert.Equal(0, result.FileSizeBytes); Assert.NotNull(result.ErrorMessage); } [Fact] public async Task SaveImageAsync_ReturnsFailure_WhenTimeoutOccurs() { // Arrange: Use a read-only directory to force an IOException, simulating a timeout-like failure // We test the timeout path by using a very short internal timeout via a linked token // Since we can't easily trigger a real 10s timeout in a unit test, // we verify the cancellation path works correctly with a pre-cancelled external token // and verify the error message format for the external cancellation path. // To truly test the timeout path, we'd need to mock File.WriteAllBytesAsync, // but since the service uses static File methods, we test the cancellation behavior instead. // The timeout mechanism uses CancellationTokenSource.CreateLinkedTokenSource + CancelAfter(10s). // Verify that when external cancellation is NOT requested but operation is cancelled // (simulating internal timeout), the error message mentions timeout. // We achieve this by making the directory path invalid so the write fails. var invalidPath = Path.Combine(_tempDir, new string('x', 300)); // Path too long _mockDataPathService.Setup(s => s.DataPath).Returns(invalidPath); var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); var imageData = new byte[] { 0x01, 0x02, 0x03 }; // Act var result = await service.SaveImageAsync(imageData, "TestNode", "TestProgram"); // Assert - should fail gracefully (IOException path) Assert.False(result.Success); Assert.NotNull(result.ErrorMessage); Assert.Equal(0, result.FileSizeBytes); } // ── Test: WriteSummaryAsync generates valid JSON ────────────────────── [Fact] public async Task WriteSummaryAsync_GeneratesValidJson() { // Arrange var summary = new BatchCaptureResult { ProgramName = "TestProgram", StartTime = "2025-01-15T14:30:25", DurationSeconds = 45.5, TotalPositions = 3, SucceededPositions = 2, FailedPositions = 1, SavedImageCount = 2, Status = "Completed", Positions = new List { new PositionResult { NodeName = "Pos1", NodeIndex = 0, Status = "Success", ImagePath = @"C:\img1.bmp" }, new PositionResult { NodeName = "Pos2", NodeIndex = 1, Status = "Success", ImagePath = @"C:\img2.bmp" }, new PositionResult { NodeName = "Pos3", NodeIndex = 2, Status = "Failed", ErrorMessage = "Detector timeout" } } }; var programName = "TestProgram"; // Act var success = await _service.WriteSummaryAsync(summary, programName); // Assert Assert.True(success); // Verify the JSON file exists and can be deserialized back var directory = _service.GetBatchCaptureDirectory(programName); var summaryPath = Path.Combine(directory, "summary.json"); Assert.True(File.Exists(summaryPath)); var json = await File.ReadAllTextAsync(summaryPath); var deserialized = JsonSerializer.Deserialize(json); Assert.NotNull(deserialized); Assert.Equal(summary.ProgramName, deserialized.ProgramName); Assert.Equal(summary.StartTime, deserialized.StartTime); Assert.Equal(summary.DurationSeconds, deserialized.DurationSeconds); Assert.Equal(summary.TotalPositions, deserialized.TotalPositions); Assert.Equal(summary.SucceededPositions, deserialized.SucceededPositions); Assert.Equal(summary.FailedPositions, deserialized.FailedPositions); Assert.Equal(summary.SavedImageCount, deserialized.SavedImageCount); Assert.Equal(summary.Status, deserialized.Status); Assert.Equal(3, deserialized.Positions.Count); Assert.Equal("Pos1", deserialized.Positions[0].NodeName); Assert.Equal("Failed", deserialized.Positions[2].Status); } // ── Test: WriteSummaryAsync returns false on I/O failure ────────────── [Fact] public async Task WriteSummaryAsync_ReturnsFalse_OnIOFailure() { // Arrange: use an invalid path that will cause I/O failure var invalidPath = Path.Combine(_tempDir, new string('x', 300)); _mockDataPathService.Setup(s => s.DataPath).Returns(invalidPath); var service = new ImagePersistenceService(_mockDataPathService.Object, _mockLogger.Object); var summary = new BatchCaptureResult { ProgramName = "TestProgram", TotalPositions = 1, Positions = new List() }; // Act var result = await service.WriteSummaryAsync(summary, "TestProgram"); // Assert - should return false, not throw Assert.False(result); // Verify error was logged _mockLogger.Verify( l => l.Error(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } // ── Test: GetBatchCaptureDirectory returns correct path format ──────── [Fact] public void GetBatchCaptureDirectory_ReturnsCorrectPathFormat() { // Arrange var programName = "MyProgram"; var expectedDate = DateTime.Now.ToString("yyyy-MM-dd"); // Act var result = _service.GetBatchCaptureDirectory(programName); // Assert Assert.Contains("CapturedImages", result); Assert.Contains(expectedDate, result); Assert.Contains(programName, result); Assert.StartsWith(_tempDir, result); } // ── Test: SaveImageAsync sanitizes node name in file path ───────────── [Fact] public async Task SaveImageAsync_SanitizesNodeName_InFilePath() { // Arrange var imageData = new byte[] { 0x42, 0x4D }; var unsafeNodeName = "Node:With*Invalid?Chars"; // Act var result = await _service.SaveImageAsync(imageData, unsafeNodeName, "Program1"); // Assert Assert.True(result.Success); var fileName = Path.GetFileName(result.FilePath); Assert.DoesNotContain(":", fileName); Assert.DoesNotContain("*", fileName); Assert.DoesNotContain("?", fileName); Assert.Contains("Node_With_Invalid_Chars", fileName); } // ── Test: SaveImageAsync logs on success ────────────────────────────── [Fact] public async Task SaveImageAsync_LogsInfo_OnSuccess() { // Arrange var imageData = new byte[512]; // Act await _service.SaveImageAsync(imageData, "TestNode", "TestProgram"); // Assert _mockLogger.Verify( l => l.Info(It.IsAny(), It.IsAny()), Times.Once); } } }