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(); settingsService .SaveSettingsAsync(Arg.Any(), Arg.Any()) .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(); settingsService .LoadSettingsAsync(Arg.Any(), Arg.Any()) .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 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 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"); } private static DataModel CreateModel( ISettingsService? settingsService = null, ITestService? testService = null) { var mapper = Substitute.For(); var picSettings = new PicSettings(); var imageCreator = Substitute.For(); imageCreator .CreateImageAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); var imageCreationService = new ImageCreationService( Substitute.For>(), picSettings, imageCreator); var imageProcessingCoordinator = new ImageProcessingCoordinator( imageCreationService, Substitute.For>()); var aiExtractionService = new AiExtractionService( Substitute.For>()); return new DataModel( testService ?? Substitute.For(), settingsService ?? Substitute.For(), imageCreationService, aiExtractionService, imageProcessingCoordinator, picSettings, mapper, Substitute.For>(), versionProvider: null); } 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); } } } }