295 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|