- Updated MainWindow.axaml to increase height and add new UI elements for SSH upload configuration. - Implemented commands for opening source and destination paths in file explorer. - Added FaceUploadPath and SSH configuration properties to DataModel and AiSettingsViewModel. - Introduced validation for FaceUploadPath format and commands for uploading face encoder output. - Enhanced PickerPreferenceService to manage SSH credentials and upload preferences. - Updated settings persistence to include FaceUploadPath and SSH preferences. - Added tests for FaceUploadPath validation and upload command enabling logic.
462 lines
17 KiB
C#
462 lines
17 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Threading.Tasks;
|
|
using ImageCatalog_2;
|
|
using ImageCatalog_2.Services;
|
|
using MaddoShared;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
using NSubstitute;
|
|
using Shouldly;
|
|
|
|
namespace MaddoShared.Tests;
|
|
|
|
[TestClass]
|
|
public class DataModelCharacterizationTests
|
|
{
|
|
[TestMethod]
|
|
public void SelectSourceFolderCommand_RaisesEvent()
|
|
{
|
|
var model = CreateModel();
|
|
var raised = false;
|
|
model.SelectSourceFolderRequested += (_, _) => raised = true;
|
|
|
|
model.SelectSourceFolderCommand.Execute(null);
|
|
|
|
raised.ShouldBeTrue();
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task SaveSettingsToFileAsync_DelegatesToSettingsService()
|
|
{
|
|
var settingsService = Substitute.For<ISettingsService>();
|
|
settingsService
|
|
.SaveSettingsAsync(Arg.Any<string>(), Arg.Any<object>())
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var model = CreateModel(settingsService: settingsService);
|
|
|
|
await model.SaveSettingsToFileAsync("settings.xml");
|
|
|
|
await settingsService.Received(1)
|
|
.SaveSettingsAsync("settings.xml", model);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task LoadSettingsFromFileAsync_DelegatesToSettingsService()
|
|
{
|
|
var settingsService = Substitute.For<ISettingsService>();
|
|
settingsService
|
|
.LoadSettingsAsync(Arg.Any<string>(), Arg.Any<object>())
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var model = CreateModel(settingsService: settingsService);
|
|
|
|
await model.LoadSettingsFromFileAsync("settings.xml");
|
|
|
|
await settingsService.Received(1)
|
|
.LoadSettingsAsync("settings.xml", model);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void ThumbnailOptionIndex_UpdatesAuthoritativeThumbnailState()
|
|
{
|
|
var model = CreateModel();
|
|
|
|
model.ThumbnailOptionIndex = (int)DataModel.ThumbnailOptionEnum.RaceTime;
|
|
|
|
model.ThumbnailOption.ShouldBe(DataModel.ThumbnailOptionEnum.RaceTime);
|
|
model.AddRaceTimeToThumbnails.ShouldBeTrue();
|
|
model.ThumbnailMode.ShouldBe("RaceTime");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void ProcessingChildChange_RaisesDataModelPropertyChanged()
|
|
{
|
|
var model = CreateModel();
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.Processing.SpeedCounter = "12.00 f/s";
|
|
|
|
changed.ShouldBe(nameof(DataModel.SpeedCounter));
|
|
model.SpeedCounter.ShouldBe("12.00 f/s");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void PathsNormalize_UpdatesFlattenedSourceAndDestination()
|
|
{
|
|
var model = CreateModel();
|
|
model.SourcePath = "\"C:/input\"";
|
|
model.DestinationPath = "C:/output";
|
|
|
|
model.Paths.NormalizePaths();
|
|
|
|
model.SourcePath.ShouldBe($"C:{System.IO.Path.DirectorySeparatorChar}input{System.IO.Path.DirectorySeparatorChar}");
|
|
model.DestinationPath.ShouldBe($"C:{System.IO.Path.DirectorySeparatorChar}output{System.IO.Path.DirectorySeparatorChar}");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void DestinationPathChange_UpdatesAiCsvFileNameToMatchFinalFolderSegment()
|
|
{
|
|
var model = CreateModel();
|
|
model.CsvOutputPath = @"K:\various\catalogtest\aioutput\test2.csv";
|
|
|
|
model.DestinationPath = @"K:\various\catalogtest\Dest\03.KM_8_A\";
|
|
|
|
model.CsvOutputPath.ShouldBe(@"K:\various\catalogtest\aioutput\03.KM_8_A.csv");
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task ConfirmAiCsvOverwriteIfNeededAsync_CanCancelWhenCsvAlreadyExists()
|
|
{
|
|
using var tempDirectory = new TemporaryDirectory();
|
|
var csvPath = Path.Combine(tempDirectory.Path, "existing.csv");
|
|
File.WriteAllText(csvPath, "existing");
|
|
|
|
var model = CreateModel();
|
|
model.CsvOutputPath = csvPath;
|
|
|
|
string? requestedTitle = null;
|
|
string? requestedMessage = null;
|
|
model.ConfirmAiCsvOverwriteAsync = (title, message) =>
|
|
{
|
|
requestedTitle = title;
|
|
requestedMessage = message;
|
|
return Task.FromResult(false);
|
|
};
|
|
|
|
var shouldContinue = await model.ConfirmAiCsvOverwriteIfNeededAsync();
|
|
|
|
shouldContinue.ShouldBeFalse();
|
|
requestedTitle.ShouldBe("File CSV gia esistente");
|
|
requestedMessage.ShouldNotBeNull();
|
|
requestedMessage.ShouldContain("Vuoi sovrascriverlo?");
|
|
requestedMessage.ShouldContain(csvPath);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void AiChildChange_RaisesDataModelPropertyChanged()
|
|
{
|
|
var model = CreateModel();
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.Ai.ModelsFolderPath = "K:/models";
|
|
|
|
changed.ShouldBe(nameof(DataModel.ModelsFolderPath));
|
|
model.ModelsFolderPath.ShouldBe("K:/models");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void NumberAiGpuChildChange_RaisesDataModelPropertyChanged()
|
|
{
|
|
var model = CreateModel();
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.Ai.UseNumberAiGpu = true;
|
|
|
|
changed.ShouldBe(nameof(DataModel.UseNumberAiGpu));
|
|
model.UseNumberAiGpu.ShouldBeTrue();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void NumberAiThumbnails_DefaultsOffAndRaisesDataModelPropertyChanged()
|
|
{
|
|
var model = CreateModel();
|
|
model.IncludeNumberAiThumbnails.ShouldBeFalse();
|
|
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.Ai.IncludeNumberAiThumbnails = true;
|
|
|
|
changed.ShouldBe(nameof(DataModel.IncludeNumberAiThumbnails));
|
|
model.IncludeNumberAiThumbnails.ShouldBeTrue();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void NumberAiWorkload_DefaultsToThreeAndClampsToFive()
|
|
{
|
|
var model = CreateModel();
|
|
model.NumberAiWorkloadLevel.ShouldBe(3);
|
|
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.NumberAiWorkloadLevel = 99;
|
|
|
|
changed.ShouldBe(nameof(DataModel.NumberAiWorkloadLevel));
|
|
model.NumberAiWorkloadLevel.ShouldBe(3);
|
|
|
|
model.Ai.NumberAiWorkloadLevel = 5;
|
|
model.NumberAiWorkloadLevel.ShouldBe(5);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void CommandLineOperationRunner_DetectsHeadlessRequest()
|
|
{
|
|
CommandLineOperationRunner.IsHeadlessRequest(["--config", "settings.xml", "--operation", "number-ai"]).ShouldBeTrue();
|
|
CommandLineOperationRunner.IsHeadlessRequest(["--config=settings.xml", "--operation=number-ai"]).ShouldBeTrue();
|
|
CommandLineOperationRunner.IsHeadlessRequest([]).ShouldBeFalse();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void RaceUploadChildChange_RaisesDataModelPropertyChanged()
|
|
{
|
|
var model = CreateModel();
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.RaceUpload.ApiLogin = "admin";
|
|
|
|
changed.ShouldBe(nameof(DataModel.ApiLogin));
|
|
model.ApiLogin.ShouldBe("admin");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void VisualChildChange_RaisesDataModelPropertyChanged()
|
|
{
|
|
var model = CreateModel();
|
|
string? changed = null;
|
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
|
|
|
model.Visual.FontSize = 42;
|
|
|
|
changed.ShouldBe(nameof(DataModel.FontSize));
|
|
model.FontSize.ShouldBe(42);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void FaceExecutableFolder_EnablesGpuToggleWhenBothVariantsExist()
|
|
{
|
|
using var root = new TemporaryDirectory();
|
|
CreateFaceEncoderExecutable(root.Path, "cpu");
|
|
CreateFaceEncoderExecutable(root.Path, "gpu");
|
|
|
|
var model = CreateModel();
|
|
|
|
model.FaceExecutablePath = root.Path;
|
|
|
|
model.FaceGpuOptionEnabled.ShouldBeTrue();
|
|
model.UseFaceGpu.ShouldBeFalse();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void UseFaceGpu_UpdatesUpsampleWhenUsingRecommendedDefault()
|
|
{
|
|
using var root = new TemporaryDirectory();
|
|
CreateFaceEncoderExecutable(root.Path, "cpu");
|
|
CreateFaceEncoderExecutable(root.Path, "gpu");
|
|
|
|
var model = CreateModel();
|
|
model.FaceExecutablePath = root.Path;
|
|
model.FaceUpsample.ShouldBeTrue();
|
|
|
|
model.UseFaceGpu = true;
|
|
|
|
model.FaceUpsample.ShouldBeFalse();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void ResolveConfiguredFaceEncoderExecutablePath_UsesFolderLayoutFromPowerShellScript()
|
|
{
|
|
using var root = new TemporaryDirectory();
|
|
var cpuExecutable = CreateFaceEncoderExecutable(root.Path, "cpu");
|
|
var gpuExecutable = CreateFaceEncoderExecutable(root.Path, "gpu");
|
|
|
|
DataModel.ResolveConfiguredFaceEncoderExecutablePath(root.Path, useGpu: false).ShouldBe(cpuExecutable);
|
|
DataModel.ResolveConfiguredFaceEncoderExecutablePath(root.Path, useGpu: true).ShouldBe(gpuExecutable);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildFaceEncoderOutputPaths_UsesTimestampAndSanitizedFolderName()
|
|
{
|
|
var timestamp = new DateTime(2026, 5, 9, 14, 30, 45);
|
|
var output = DataModel.BuildFaceEncoderOutputPaths(
|
|
@"C:\out",
|
|
@"C:\images\04 APRILE: gara?",
|
|
timestamp);
|
|
|
|
output.OutputFilePath.ShouldBe(@"C:\out\face_encodings_20260509_143045_04_APRILE_gara.pkl");
|
|
output.LogFilePath.ShouldBe(@"C:\out\encoder_log_20260509_143045_04_APRILE_gara.txt");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void FaceUploadPath_ValidatesExpectedRelativePathShape()
|
|
{
|
|
DataModel.IsValidFaceUploadPath("2026/05.MAGGIO/EMPOLI").ShouldBeTrue();
|
|
DataModel.IsValidFaceUploadPath("2026/5.MAGGIO/EMPOLI").ShouldBeFalse();
|
|
DataModel.IsValidFaceUploadPath("2026/00.MAGGIO/EMPOLI").ShouldBeFalse();
|
|
DataModel.IsValidFaceUploadPath("2026/13.MAGGIO/EMPOLI").ShouldBeFalse();
|
|
DataModel.IsValidFaceUploadPath("2026/05.MAGGIO").ShouldBeFalse();
|
|
|
|
DataModel.CombineRemoteUploadPath("/mnt/da1/foto/", "2026/05.MAGGIO/EMPOLI")
|
|
.ShouldBe("/mnt/da1/foto/2026/05.MAGGIO/EMPOLI");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void FaceUploadCommand_IsEnabledOnlyForValidUploadPath()
|
|
{
|
|
var model = CreateModel();
|
|
|
|
model.UploadFaceEncoderOutputCommand.CanExecute(null).ShouldBeFalse();
|
|
|
|
model.FaceUploadPath = "2026/05.MAGGIO/EMPOLI";
|
|
model.UploadFaceEncoderOutputCommand.CanExecute(null).ShouldBeTrue();
|
|
|
|
model.FaceUploadPath = "2026/5.MAGGIO/EMPOLI";
|
|
model.UploadFaceEncoderOutputCommand.CanExecute(null).ShouldBeFalse();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void FaceSshPreferences_AreStoredInUserPreferences()
|
|
{
|
|
using var tempDirectory = new TemporaryDirectory();
|
|
var preferencesFile = Path.Combine(tempDirectory.Path, "userprefs.xml");
|
|
var preferenceService = new PickerPreferenceService(new ImageCatalog.ParametriSetup(preferencesFile));
|
|
|
|
var model = CreateModel(pickerPreferenceService: preferenceService);
|
|
model.FaceSshUsername = "ssh-user";
|
|
model.FaceSshPassword = "ssh-password";
|
|
model.FaceSshAddress = "upload.example.org";
|
|
model.FaceSshPort = "2222";
|
|
model.FaceSshPathA = "/mnt/da1/foto/";
|
|
model.FaceSshPathB = "/mnt/nas12/foto/";
|
|
model.FaceUploadDryRun = true;
|
|
|
|
var reloadedPreferenceService = new PickerPreferenceService(new ImageCatalog.ParametriSetup(preferencesFile));
|
|
var reloaded = CreateModel(pickerPreferenceService: reloadedPreferenceService);
|
|
|
|
reloaded.FaceSshUsername.ShouldBe("ssh-user");
|
|
reloaded.FaceSshPassword.ShouldBe("ssh-password");
|
|
reloaded.FaceSshAddress.ShouldBe("upload.example.org");
|
|
reloaded.FaceSshPort.ShouldBe("2222");
|
|
reloaded.FaceSshPathA.ShouldBe("/mnt/da1/foto/");
|
|
reloaded.FaceSshPathB.ShouldBe("/mnt/nas12/foto/");
|
|
reloaded.FaceUploadDryRun.ShouldBeTrue();
|
|
}
|
|
|
|
[TestMethod]
|
|
public void ResolveLatestFaceUploadSourceFile_UsesLatestPklForCurrentRace()
|
|
{
|
|
using var tempDirectory = new TemporaryDirectory();
|
|
var outputFolder = Path.Combine(tempDirectory.Path, "out");
|
|
var currentRaceFolder = Path.Combine(tempDirectory.Path, "04 APRILE gara");
|
|
Directory.CreateDirectory(outputFolder);
|
|
Directory.CreateDirectory(currentRaceFolder);
|
|
|
|
var olderCurrentRace = Path.Combine(outputFolder, "face_encodings_20260509_143045_04_APRILE_gara.pkl");
|
|
var newerCurrentRace = Path.Combine(outputFolder, "face_encodings_20260509_153045_04_APRILE_gara.pkl");
|
|
var otherRace = Path.Combine(outputFolder, "face_encodings_20260509_163045_05_MAGGIO_gara.pkl");
|
|
|
|
File.WriteAllText(olderCurrentRace, "old");
|
|
File.WriteAllText(newerCurrentRace, "new");
|
|
File.WriteAllText(otherRace, "other");
|
|
|
|
File.SetLastWriteTimeUtc(olderCurrentRace, new DateTime(2026, 5, 9, 14, 30, 45, DateTimeKind.Utc));
|
|
File.SetLastWriteTimeUtc(newerCurrentRace, new DateTime(2026, 5, 9, 15, 30, 45, DateTimeKind.Utc));
|
|
File.SetLastWriteTimeUtc(otherRace, new DateTime(2026, 5, 9, 16, 30, 45, DateTimeKind.Utc));
|
|
|
|
var selected = DataModel.ResolveLatestFaceUploadSourceFile(outputFolder, currentRaceFolder);
|
|
|
|
selected.ShouldBe(newerCurrentRace);
|
|
}
|
|
|
|
[TestMethod]
|
|
public async Task SettingsService_PersistsFaceUploadPathButNotSshPreferences()
|
|
{
|
|
using var tempDirectory = new TemporaryDirectory();
|
|
var settingsFile = Path.Combine(tempDirectory.Path, "settings.xml");
|
|
var userPreferencesFile = Path.Combine(tempDirectory.Path, "userprefs.xml");
|
|
var preferenceService = new PickerPreferenceService(new ImageCatalog.ParametriSetup(userPreferencesFile));
|
|
var settingsService = new SettingsService(
|
|
new ImageCatalog.ParametriSetup(Path.Combine(tempDirectory.Path, "unused.xml")),
|
|
Substitute.For<ILogger<SettingsService>>());
|
|
var model = CreateModel(settingsService: settingsService, pickerPreferenceService: preferenceService);
|
|
|
|
model.FaceUploadPath = "2026/05.MAGGIO/EMPOLI";
|
|
model.FaceSshUsername = "ssh-user";
|
|
|
|
await settingsService.SaveSettingsAsync(settingsFile, model);
|
|
|
|
var xml = File.ReadAllText(settingsFile);
|
|
xml.ShouldContain("AI_FaceUploadPath");
|
|
xml.ShouldContain("2026/05.MAGGIO/EMPOLI");
|
|
xml.ShouldNotContain("AI_FaceUploadDryRun");
|
|
xml.ShouldNotContain("FaceAI.Ssh");
|
|
xml.ShouldNotContain("ssh-user");
|
|
|
|
var loaded = CreateModel(settingsService: settingsService);
|
|
await settingsService.LoadSettingsAsync(settingsFile, loaded);
|
|
loaded.FaceUploadPath.ShouldBe("2026/05.MAGGIO/EMPOLI");
|
|
}
|
|
|
|
private static DataModel CreateModel(
|
|
ISettingsService? settingsService = null,
|
|
ITestService? testService = null,
|
|
PickerPreferenceService? pickerPreferenceService = null)
|
|
{
|
|
var mapper = Substitute.For<AutoMapper.IMapper>();
|
|
var picSettings = new PicSettings();
|
|
|
|
var imageCreator = Substitute.For<IImageCreator>();
|
|
imageCreator
|
|
.CreateImageAsync(Arg.Any<ImageState>(), Arg.Any<byte[]?>())
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var imageCreationService = new ImageCreationService(
|
|
Substitute.For<ILogger<ImageCreationService>>(),
|
|
picSettings,
|
|
imageCreator);
|
|
|
|
var imageProcessingCoordinator = new ImageProcessingCoordinator(
|
|
imageCreationService,
|
|
Substitute.For<ILogger<ImageProcessingCoordinator>>());
|
|
|
|
var aiExtractionService = new AiExtractionService(
|
|
Substitute.For<ILogger<AiExtractionService>>());
|
|
|
|
return new DataModel(
|
|
testService ?? Substitute.For<ITestService>(),
|
|
settingsService ?? Substitute.For<ISettingsService>(),
|
|
imageCreationService,
|
|
aiExtractionService,
|
|
imageProcessingCoordinator,
|
|
picSettings,
|
|
mapper,
|
|
Substitute.For<ILogger<DataModel>>(),
|
|
versionProvider: null,
|
|
pickerPreferenceService: pickerPreferenceService);
|
|
}
|
|
|
|
private static string CreateFaceEncoderExecutable(string rootPath, string variant)
|
|
{
|
|
var variantDirectory = Path.Combine(rootPath, $"face_encoder_{variant}");
|
|
Directory.CreateDirectory(variantDirectory);
|
|
|
|
var executablePath = Path.Combine(variantDirectory, $"face_encoder_{variant}.exe");
|
|
File.WriteAllText(executablePath, "stub");
|
|
return executablePath;
|
|
}
|
|
|
|
private sealed class TemporaryDirectory : IDisposable
|
|
{
|
|
public TemporaryDirectory()
|
|
{
|
|
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName());
|
|
Directory.CreateDirectory(Path);
|
|
}
|
|
|
|
public string Path { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(Path))
|
|
{
|
|
Directory.Delete(Path, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
}
|