Files
XplorePlane/XplorePlane.Tests/Services/ImagePersistenceServiceTests.cs
T

295 lines
12 KiB
C#

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
{
/// <summary>
/// Unit tests for ImagePersistenceService.
/// Validates: Requirements 1.3, 1.5, 1.7, 4.3
/// </summary>
public class ImagePersistenceServiceTests : IDisposable
{
private readonly string _tempDir;
private readonly Mock<IXpDataPathService> _mockDataPathService;
private readonly Mock<ILoggerService> _mockLogger;
private readonly ImagePersistenceService _service;
public ImagePersistenceServiceTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ImagePersistenceTests_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_mockDataPathService = new Mock<IXpDataPathService>();
_mockDataPathService.Setup(s => s.DataPath).Returns(_tempDir);
_mockLogger = new Mock<ILoggerService>();
_mockLogger.Setup(l => l.ForModule<ImagePersistenceService>()).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<PositionResult>
{
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<BatchCaptureResult>(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<PositionResult>()
};
// 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<Exception>(), It.IsAny<string>(), It.IsAny<object[]>()),
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<string>(), It.IsAny<object[]>()),
Times.Once);
}
}
}