From 41f069e6b182aba21f2f65421d3650410ad3b26e Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Tue, 24 Feb 2026 00:27:36 +0100 Subject: [PATCH 01/12] feat(core,ui): add pipeline architecture with input overlay feedback - Introduce IPipelineStage, PipelineContext, and PipelineContextKeys abstractions in Core for composable stage-based operations - Add Core stages: ScanFilesStage, RefreshStaleMetadataStage, InitializeTrackConfigStage, ValidateStage - Add Uno stages: FilterDuplicateFilesStage, AddFilesToBatchStage, RemoveFilesFromBatchStage - Add PipelineRunner with sequential execution and minimum overlay duration (350ms) for user-visible stage feedback - Add BatchOperationOrchestrator composing stages into import, remove, and revalidate operations - Add InputOperationFeedbackService publishing overlay state via events - Add InputOperationOverlay UserControl bound to ViewModel state - Refactor InputViewModel to delegate to orchestrator - Refactor SettingsViewModel to use orchestrator for revalidation - Remove CollectionChanged-based reactive revalidation from ValidationStateService - Add IProgress support to IFileScanner.ScanAsync - Extract LoggerMessage definitions into .Logging.cs partial files - Add and update unit tests for all new and modified components --- .serena/project.yml | 4 + .../Abstractions/Pipeline/IPipelineStage.cs | 39 +++ .../Abstractions/Pipeline/PipelineContext.cs | 56 +++++ .../Pipeline/PipelineContextKeys.cs | 22 ++ .../Services/FileScanner.cs | 18 +- .../ValidationStateService.Logging.cs | 3 - .../FileValidation/ValidationStateService.cs | 10 - .../Services/IFileScanner.cs | 2 +- .../Pipeline/InitializeTrackConfigStage.cs | 51 ++++ .../RefreshStaleMetadataStage.Logging.cs} | 17 +- .../Pipeline/RefreshStaleMetadataStage.cs | 74 ++++++ .../Pipeline/ScanFilesStage.Logging.cs | 15 ++ .../Services/Pipeline/ScanFilesStage.cs | 41 ++++ .../Services/Pipeline/ValidateStage.cs | 27 ++ src/MatroskaBatchFlow.Uno/App.xaml.cs | 1 + .../Services/IBatchOperationOrchestrator.cs | 29 +++ .../IInputOperationFeedbackService.cs | 34 +++ .../Contracts/Services/IPipelineRunner.cs | 19 ++ .../Extensions/ServiceCollectionExtensions.cs | 27 ++ .../Models/InputOperationOverlayState.cs | 11 + .../Controls/InputOperationOverlay.xaml | 47 ++++ .../Controls/InputOperationOverlay.xaml.cs | 63 +++++ .../Presentation/InputPage.xaml | 96 ++++---- .../Presentation/InputViewModel.cs | 232 ++---------------- .../Presentation/SettingsViewModel.cs | 7 +- .../BatchOperationOrchestrator.Logging.cs | 10 + .../Services/BatchOperationOrchestrator.cs | 81 ++++++ .../Services/InputOperationFeedbackService.cs | 25 ++ .../Services/Pipeline/AddFilesToBatchStage.cs | 32 +++ .../FilterDuplicateFilesStage.Logging.cs | 10 + .../Pipeline/FilterDuplicateFilesStage.cs | 81 ++++++ .../Pipeline/PipelineRunner.Logging.cs | 13 + .../Services/Pipeline/PipelineRunner.cs | 79 ++++++ .../Pipeline/RemoveFilesFromBatchStage.cs | 32 +++ .../Services/FileScannerTests.cs | 19 ++ .../ValidationStateServiceTests.cs | 93 +------ .../Presentation/InputViewModelTests.cs | 31 +-- .../Presentation/SettingsViewModelTests.cs | 17 ++ .../BatchOperationOrchestratorTests.cs | 228 +++++++++++++++++ .../InputOperationFeedbackServiceTests.cs | 64 +++++ 40 files changed, 1365 insertions(+), 395 deletions(-) create mode 100644 src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs create mode 100644 src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs create mode 100644 src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs create mode 100644 src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs rename src/{MatroskaBatchFlow.Uno/Presentation/InputViewModel.Logging.cs => MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.Logging.cs} (54%) create mode 100644 src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs create mode 100644 src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.Logging.cs create mode 100644 src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs create mode 100644 src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs create mode 100644 src/MatroskaBatchFlow.Uno/Contracts/Services/IBatchOperationOrchestrator.cs create mode 100644 src/MatroskaBatchFlow.Uno/Contracts/Services/IInputOperationFeedbackService.cs create mode 100644 src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs create mode 100644 src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs create mode 100644 src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml create mode 100644 src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.Logging.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/InputOperationFeedbackService.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.Logging.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs create mode 100644 src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs create mode 100644 tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs create mode 100644 tests/MatroskaBatchFlow.Uno.UnitTests/Services/InputOperationFeedbackServiceTests.cs diff --git a/.serena/project.yml b/.serena/project.yml index 3c6ffe7..2473364 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -103,3 +103,7 @@ default_modes: # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] + +# override of the corresponding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +symbol_info_budget: diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs new file mode 100644 index 0000000..bc04939 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MatroskaBatchFlow.Core.Abstractions.Pipeline; + +/// +/// Represents a single stage in a composable pipeline. +/// Each stage performs a discrete unit of work and communicates with other stages via a shared . +/// +/// +/// Stages should be stateless singletons — all per-run state lives in the . +/// If a stage has no work to do (e.g., no files in context), it should return gracefully without throwing. +/// +public interface IPipelineStage +{ + /// + /// User-visible label for overlay display (e.g., "Scanning files…"). + /// + string DisplayName { get; } + + /// + /// Whether progress is indeterminate (unknown duration) or determinate (known current/total). + /// + bool IsIndeterminate { get; } + + /// + /// Whether this stage should display an overlay to the user. + /// Near-instant stages can return to avoid visual flicker. + /// + [ExcludeFromCodeCoverage] + bool ShowsOverlay => true; + + /// + /// Executes this stage's work. + /// + /// Shared data bag for passing data between stages. + /// Optional progress reporter for determinate stages. + /// Cancellation token for cooperative cancellation. + Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct); +} diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs new file mode 100644 index 0000000..2768646 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MatroskaBatchFlow.Core.Abstractions.Pipeline; + +/// +/// Shared data bag passed between pipeline stages. +/// Stages write outputs for downstream stages to consume. +/// +/// +/// This is a per-run object, create a new instance for each pipeline execution. +/// This class is not thread-safe. The executes stages sequentially, +/// so concurrent access is not expected. +/// +public sealed class PipelineContext +{ + private readonly Dictionary _data = []; + + /// + /// Stores a value in the context under the specified key. + /// + /// The type of the value. + /// The context key. + /// The value to store. + public void Set(string key, T value) where T : notnull + => _data[key] = value; + + /// + /// Retrieves a value from the context by key. + /// + /// The expected type of the value. + /// The context key. + /// The stored value. + /// Thrown when the key does not exist in the context. + /// Thrown when the stored value cannot be cast to . + public T Get(string key) + => (T)_data[key]; + + /// + /// Attempts to retrieve a value from the context by key. + /// + /// The expected type of the value. + /// The context key. + /// When this method returns, contains the value if found; otherwise, the default value. + /// if the key was found and the value is of the expected type; otherwise, . + public bool TryGet(string key, [MaybeNullWhen(false)] out T value) + { + if (_data.TryGetValue(key, out var obj) && obj is T typed) + { + value = typed; + return true; + } + + value = default; + return false; + } +} diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs new file mode 100644 index 0000000..94f3849 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs @@ -0,0 +1,22 @@ +namespace MatroskaBatchFlow.Core.Abstractions.Pipeline; + +/// +/// Well-known keys for values stored in . +/// +public static class PipelineContextKeys +{ + /// + /// [] — raw file references to import. + /// + public const string InputFiles = nameof(InputFiles); + + /// + /// — files after MediaInfo scanning. + /// + public const string ScannedFiles = nameof(ScannedFiles); + + /// + /// — files to remove from the batch. + /// + public const string FilesToRemove = nameof(FilesToRemove); +} diff --git a/src/MatroskaBatchFlow.Core/Services/FileScanner.cs b/src/MatroskaBatchFlow.Core/Services/FileScanner.cs index 618c65f..c0830b3 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileScanner.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileScanner.cs @@ -33,13 +33,14 @@ public partial class FileScanner(IOptionsMonitor optionsMonitor, IL /// An array of objects representing the files to scan. /// A collection of . /// Thrown when no files are provided for scanning. - public async Task> ScanAsync(FileInfo[] files) + public async Task> ScanAsync(FileInfo[] files, IProgress<(int current, int total)>? progress = null) { if (files == null || files.Length == 0) throw new ArgumentException("No files provided for scanning.", nameof(files)); LogScanningFiles(files.Length); - var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(files.Select(f => f.FullName)); + var filePaths = files.Select(f => f.FullName).ToList(); + var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(filePaths, progress); _scannedFiles.Clear(); _scannedFiles.AddRange(scannedFiles); @@ -55,8 +56,8 @@ public async Task> ScanWithMediaInfoAsync() { EnsureDirectoryExists(); LogScanningDirectory(_options.DirectoryPath, _options.Recursive); - var files = await Task.Run(() => GetFilteredFiles()); - LogFilesFound(files.Count()); + var files = (await Task.Run(() => GetFilteredFiles().ToList())).ToList(); + LogFilesFound(files.Count); var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(files); _scannedFiles.Clear(); @@ -125,15 +126,19 @@ private static ScannedFileInfo ParseMediaInfoJson(string json, string filePath) /// /// The collection of file paths to analyze. /// A collection of . - private static async Task> AnalyzeFilesWithMediaInfoAsync(IEnumerable files) + private static async Task> AnalyzeFilesWithMediaInfoAsync( + IReadOnlyList files, + IProgress<(int current, int total)>? progress = null) { return await Task.Run(() => { var scannedFiles = new List(); var mediaInfo = new MediaInfo(); + var total = files.Count; - foreach (var file in files) + for (var index = 0; index < total; index++) { + var file = files[index]; mediaInfo.Open(file); mediaInfo.Option("Complete", "1"); mediaInfo.Option("Output", "JSON"); @@ -142,6 +147,7 @@ private static async Task> AnalyzeFilesWithMediaInf // Parse the JSON into a ScannedFileInfo object var scannedFile = ParseMediaInfoJson(info, file); scannedFiles.Add(scannedFile); + progress?.Report((index + 1, total)); } return scannedFiles; diff --git a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.Logging.cs b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.Logging.cs index 9acba48..a7bfd29 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.Logging.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.Logging.cs @@ -7,9 +7,6 @@ namespace MatroskaBatchFlow.Core.Services.FileValidation; /// public sealed partial class ValidationStateService { - [LoggerMessage(Level = LogLevel.Debug, Message = "Re-validation triggered by file list change: {Action}")] - private partial void LogFileListChangeTriggered(string action); - [LoggerMessage(Level = LogLevel.Debug, Message = "Validation skipped: no files in batch")] private partial void LogValidationSkipped(); diff --git a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs index fd1e92f..e37dc47 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs @@ -1,4 +1,3 @@ -using System.Collections.Specialized; using MatroskaBatchFlow.Core.Models; using Microsoft.Extensions.Logging; @@ -49,8 +48,6 @@ public ValidationStateService( _validationSettingsService = validationSettingsService; _userSettings = userSettings; _logger = logger; - - _batchConfiguration.FileList.CollectionChanged += OnFileListChanged; } /// @@ -74,12 +71,6 @@ public void Revalidate() UpdateState(results); } - private void OnFileListChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - LogFileListChangeTriggered(e.Action.ToString()); - Revalidate(); - } - /// /// Updates the internal validation state based on the specified collection of file validation results. /// @@ -132,6 +123,5 @@ private void UpdateState(IReadOnlyList results) public void Dispose() { _disposed = true; - _batchConfiguration.FileList.CollectionChanged -= OnFileListChanged; } } diff --git a/src/MatroskaBatchFlow.Core/Services/IFileScanner.cs b/src/MatroskaBatchFlow.Core/Services/IFileScanner.cs index 50da0d6..b4d8446 100644 --- a/src/MatroskaBatchFlow.Core/Services/IFileScanner.cs +++ b/src/MatroskaBatchFlow.Core/Services/IFileScanner.cs @@ -4,6 +4,6 @@ namespace MatroskaBatchFlow.Core.Services; public interface IFileScanner { - Task> ScanAsync(FileInfo[] files); + Task> ScanAsync(FileInfo[] files, IProgress<(int current, int total)>? progress); Task> ScanWithMediaInfoAsync(); } diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs new file mode 100644 index 0000000..6880bd7 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs @@ -0,0 +1,51 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.Services.FileProcessing; + +namespace MatroskaBatchFlow.Core.Services.Pipeline; + +/// +/// Pipeline stage that initializes per-file track configurations and applies processing rules +/// for all scanned files in the context. +/// +/// +/// Reads from the context. +/// Reports determinate progress as each file is processed. +/// +public sealed class InitializeTrackConfigStage( + IBatchTrackConfigurationInitializer trackConfigInitializer, + IFileProcessingEngine fileProcessingRuleEngine, + IBatchConfiguration batchConfig) : IPipelineStage +{ + /// + public string DisplayName => "Applying track configuration\u2026"; + + /// + public bool IsIndeterminate => false; + + /// + public bool ShowsOverlay => true; + + /// + public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + if (!context.TryGet>(PipelineContextKeys.ScannedFiles, out var scannedFiles) || scannedFiles.Count == 0) + return Task.CompletedTask; + + var totalFiles = scannedFiles.Count; + + for (var index = 0; index < totalFiles; index++) + { + ct.ThrowIfCancellationRequested(); + var file = scannedFiles[index]; + + trackConfigInitializer.Initialize(file, TrackType.Audio, TrackType.Video, TrackType.Text); + fileProcessingRuleEngine.Apply(file, batchConfig); + + progress?.Report((index + 1, totalFiles)); + } + + return Task.CompletedTask; + } +} diff --git a/src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.Logging.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.Logging.cs similarity index 54% rename from src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.Logging.cs rename to src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.Logging.cs index 678d1ae..049e5d0 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.Logging.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.Logging.cs @@ -1,19 +1,12 @@ -namespace MatroskaBatchFlow.Uno.Presentation; +using Microsoft.Extensions.Logging; + +namespace MatroskaBatchFlow.Core.Services.Pipeline; /// -/// LoggerMessage definitions for . +/// LoggerMessage definitions for . /// -public sealed partial class InputViewModel +public sealed partial class RefreshStaleMetadataStage { - [LoggerMessage(Level = LogLevel.Debug, Message = "Importing {FileCount} file(s)")] - private partial void LogImportingFiles(int fileCount); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {DuplicateCount} duplicate file(s)")] - private partial void LogDuplicatesSkipped(int duplicateCount); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Scanned {ScannedCount} file(s)")] - private partial void LogFilesScanned(int scannedCount); - [LoggerMessage(Level = LogLevel.Information, Message = "Re-scanning {Count} file(s) with stale metadata before validation")] private partial void LogRescanningStaleFiles(int count); diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs new file mode 100644 index 0000000..d269971 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs @@ -0,0 +1,74 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using Microsoft.Extensions.Logging; + +namespace MatroskaBatchFlow.Core.Services.Pipeline; + +/// +/// Pipeline stage that re-scans files previously marked as stale, replacing them with fresh metadata. +/// +/// +/// Stale files are files whose metadata may be outdated (e.g., modified externally since the last scan). +/// This stage performs a single batch re-scan operation, migrates existing configurations, and clears stale flags. +/// If a re-scan fails for any file the error is logged and the stale flag is cleared to prevent repeated attempts. +/// +public sealed partial class RefreshStaleMetadataStage( + IFileScanner fileScanner, + IBatchConfiguration batchConfig, + IScannedFileInfoPathComparer pathComparer, + ILogger logger) : IPipelineStage +{ + /// + public string DisplayName => "Refreshing stale file metadata\u2026"; + + /// + public bool IsIndeterminate => true; + + /// + public bool ShowsOverlay => true; + + /// + public async Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + var staleFiles = batchConfig.GetStaleFiles().ToList(); + if (staleFiles.Count == 0) + return; + + ct.ThrowIfCancellationRequested(); + LogRescanningStaleFiles(staleFiles.Count); + + try + { + var fileInfos = staleFiles.Select(f => new FileInfo(f.Path)).ToArray(); + var freshScans = (await fileScanner.ScanAsync(fileInfos, null)).ToList(); + + foreach (var staleFile in staleFiles) + { + var freshScan = freshScans.FirstOrDefault(f => pathComparer.PathEquals(f.Path, staleFile.Path)); + + if (freshScan != null) + { + batchConfig.MigrateFileConfiguration(staleFile.Id, freshScan.Id); + batchConfig.FileList.Remove(staleFile); + batchConfig.FileList.Add(freshScan); + batchConfig.ClearStaleFlag(staleFile.Id); + + LogFileRescanned(staleFile.Path); + } + else + { + batchConfig.ClearStaleFlag(staleFile.Id); + LogRescanFailedNotFound(staleFile.Path); + } + } + } + catch (Exception ex) + { + LogRescanBatchFailed(staleFiles.Count, ex); + + foreach (var staleFile in staleFiles) + { + batchConfig.ClearStaleFlag(staleFile.Id); + } + } + } +} diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.Logging.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.Logging.cs new file mode 100644 index 0000000..6891e85 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.Logging.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +namespace MatroskaBatchFlow.Core.Services.Pipeline; + +/// +/// LoggerMessage definitions for . +/// +public sealed partial class ScanFilesStage +{ + [LoggerMessage(Level = LogLevel.Debug, Message = "Scanning {FileCount} file(s) with MediaInfo")] + private partial void LogScanningFiles(int fileCount); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Scanned {ScannedCount} file(s)")] + private partial void LogFilesScanned(int scannedCount); +} diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs new file mode 100644 index 0000000..93c87d7 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs @@ -0,0 +1,41 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Models; +using Microsoft.Extensions.Logging; + +namespace MatroskaBatchFlow.Core.Services.Pipeline; + +/// +/// Pipeline stage that scans input files with MediaInfo to extract metadata. +/// Reads and writes . +/// +public sealed partial class ScanFilesStage( + IFileScanner fileScanner, + ILogger logger) : IPipelineStage +{ + /// + public string DisplayName => "Scanning files\u2026"; + + /// + public bool IsIndeterminate => false; + + /// + public bool ShowsOverlay => true; + + /// + public async Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + if (!context.TryGet(PipelineContextKeys.InputFiles, out var files) || files.Length == 0) + { + context.Set(PipelineContextKeys.ScannedFiles, new List()); + return; + } + + ct.ThrowIfCancellationRequested(); + + LogScanningFiles(files.Length); + var scannedFiles = (await fileScanner.ScanAsync(files, progress)).ToList(); + LogFilesScanned(scannedFiles.Count); + + context.Set(PipelineContextKeys.ScannedFiles, scannedFiles); + } +} diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs new file mode 100644 index 0000000..2d25c55 --- /dev/null +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs @@ -0,0 +1,27 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.FileValidation; + +namespace MatroskaBatchFlow.Core.Services.Pipeline; + +/// +/// Pipeline stage that triggers a full revalidation of the current batch. +/// +public sealed class ValidateStage(IValidationStateService validationStateService) : IPipelineStage +{ + /// + public string DisplayName => "Validating files\u2026"; + + /// + public bool IsIndeterminate => true; + + /// + public bool ShowsOverlay => true; + + /// + public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + validationStateService.Revalidate(); + return Task.CompletedTask; + } +} diff --git a/src/MatroskaBatchFlow.Uno/App.xaml.cs b/src/MatroskaBatchFlow.Uno/App.xaml.cs index 48b7cc7..4a9b71d 100644 --- a/src/MatroskaBatchFlow.Uno/App.xaml.cs +++ b/src/MatroskaBatchFlow.Uno/App.xaml.cs @@ -155,6 +155,7 @@ private bool TryBuildHost(LoggingLevelSwitch levelSwitch, LoggingViewService log services.AddCoreServices(levelSwitch, loggingViewService); services.AddFileValidationRules(); services.AddFileProcessingRules(); + services.AddPipelineServices(); services.AddViewModels(); services.AddPages(); services.AddUserSettings(); diff --git a/src/MatroskaBatchFlow.Uno/Contracts/Services/IBatchOperationOrchestrator.cs b/src/MatroskaBatchFlow.Uno/Contracts/Services/IBatchOperationOrchestrator.cs new file mode 100644 index 0000000..ab446e4 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Contracts/Services/IBatchOperationOrchestrator.cs @@ -0,0 +1,29 @@ +using MatroskaBatchFlow.Core.Models; + +namespace MatroskaBatchFlow.Uno.Contracts.Services; + +/// +/// Orchestrates batch-mutating file operations (import, remove, revalidate) +/// and manages overlay feedback state throughout each pipeline. +/// +public interface IBatchOperationOrchestrator +{ + /// + /// Imports files by scanning them with MediaInfo, refreshing stale metadata, + /// initializing track configurations, and adding them to the batch. + /// Duplicate files are filtered and reported to the user. + /// + /// The files to import into the current batch. + Task ImportFilesAsync(FileInfo[] files); + + /// + /// Removes the specified files from the current batch. + /// + /// The scanned file info objects to remove. + Task RemoveFilesAsync(IEnumerable files); + + /// + /// Forces a re-validation of the current batch against current validation settings. + /// + Task RevalidateAsync(); +} diff --git a/src/MatroskaBatchFlow.Uno/Contracts/Services/IInputOperationFeedbackService.cs b/src/MatroskaBatchFlow.Uno/Contracts/Services/IInputOperationFeedbackService.cs new file mode 100644 index 0000000..efbfeba --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Contracts/Services/IInputOperationFeedbackService.cs @@ -0,0 +1,34 @@ +using MatroskaBatchFlow.Uno.Models; + +namespace MatroskaBatchFlow.Uno.Contracts.Services; + +/// +/// Provides a mechanism for pipeline stages to communicate overlay feedback state to the UI. +/// +/// +/// The publishes overlay state before and during stage execution. +/// Views subscribe to to show or hide the overlay control. +/// +public interface IInputOperationFeedbackService +{ + /// + /// Gets the most recently published overlay state. + /// + InputOperationOverlayState CurrentState { get; } + + /// + /// Raised whenever the overlay state changes, including when cleared. + /// + event EventHandler? StateChanged; + + /// + /// Publishes a new overlay state and raises . + /// + /// The overlay state to publish. + void Publish(InputOperationOverlayState state); + + /// + /// Resets the overlay state to and raises . + /// + void Clear(); +} diff --git a/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs new file mode 100644 index 0000000..6f7428d --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs @@ -0,0 +1,19 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; + +namespace MatroskaBatchFlow.Uno.Contracts.Services; + +/// +/// Executes an ordered sequence of instances, +/// managing overlay feedback and progress reporting automatically. +/// +public interface IPipelineRunner +{ + /// + /// Runs the given stages sequentially, publishing overlay state for stages that opt in. + /// The overlay is always cleared when execution completes (success or failure). + /// + /// The ordered stages to execute. + /// The shared context bag passed to every stage. + /// Cancellation token for cooperative cancellation. + Task RunAsync(IReadOnlyList stages, PipelineContext context, CancellationToken ct = default); +} diff --git a/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs b/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs index ee227a8..203d465 100644 --- a/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs +++ b/src/MatroskaBatchFlow.Uno/Extensions/ServiceCollectionExtensions.cs @@ -4,11 +4,13 @@ using MatroskaBatchFlow.Core.Services.FileProcessing.Track; using MatroskaBatchFlow.Core.Services.FileProcessing.Track.Name; using MatroskaBatchFlow.Core.Services.FileValidation; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Core.Services.Processing; using MatroskaBatchFlow.Uno.Activation; using MatroskaBatchFlow.Uno.Contracts.Services; using MatroskaBatchFlow.Uno.Presentation.Dialogs; using MatroskaBatchFlow.Uno.Services; +using MatroskaBatchFlow.Uno.Services.Pipeline; using Serilog.Core; namespace MatroskaBatchFlow.Uno.Extensions; @@ -52,6 +54,8 @@ public IServiceCollection AddCoreServices(LoggingLevelSwitch levelSwitch, ILoggi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -104,6 +108,29 @@ public IServiceCollection AddFileProcessingRules() return services; } + /// + /// Registers pipeline stages and the pipeline runner with the dependency injection container. + /// + /// The service collection for chaining. + public IServiceCollection AddPipelineServices() + { + // Core pipeline stages + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Uno pipeline stages + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Pipeline runner + services.AddSingleton(); + + return services; + } + /// /// Registers view models with the dependency injection container. /// diff --git a/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs b/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs new file mode 100644 index 0000000..ab1cfb3 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs @@ -0,0 +1,11 @@ +namespace MatroskaBatchFlow.Uno.Models; + +public sealed record InputOperationOverlayState(string Message, bool IsIndeterminate, int Current, int Total, bool BlocksInput) +{ + /// + /// Gets whether the overlay should be visible. Derived from . + /// + public bool IsActive => !string.IsNullOrEmpty(Message); + + public static InputOperationOverlayState Inactive { get; } = new(string.Empty, true, 0, 0, false); +} diff --git a/src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml b/src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml new file mode 100644 index 0000000..f5a3f52 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml.cs b/src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml.cs new file mode 100644 index 0000000..b60a86e --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Presentation/Controls/InputOperationOverlay.xaml.cs @@ -0,0 +1,63 @@ +namespace MatroskaBatchFlow.Uno.Presentation.Controls; + +public sealed partial class InputOperationOverlay : UserControl +{ + public static readonly DependencyProperty IsActiveProperty = + DependencyProperty.Register(nameof(IsActive), typeof(bool), typeof(InputOperationOverlay), new PropertyMetadata(false)); + + public static readonly DependencyProperty MessageProperty = + DependencyProperty.Register(nameof(Message), typeof(string), typeof(InputOperationOverlay), new PropertyMetadata(string.Empty)); + + public static readonly DependencyProperty IsIndeterminateProperty = + DependencyProperty.Register(nameof(IsIndeterminate), typeof(bool), typeof(InputOperationOverlay), new PropertyMetadata(true)); + + public static readonly DependencyProperty CurrentProperty = + DependencyProperty.Register(nameof(Current), typeof(int), typeof(InputOperationOverlay), new PropertyMetadata(0)); + + public static readonly DependencyProperty TotalProperty = + DependencyProperty.Register(nameof(Total), typeof(int), typeof(InputOperationOverlay), new PropertyMetadata(0)); + + public static readonly DependencyProperty BlocksInputProperty = + DependencyProperty.Register(nameof(BlocksInput), typeof(bool), typeof(InputOperationOverlay), new PropertyMetadata(false)); + + public InputOperationOverlay() + { + this.InitializeComponent(); + } + + public bool IsActive + { + get => (bool)GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + + public string Message + { + get => (string)GetValue(MessageProperty); + set => SetValue(MessageProperty, value); + } + + public bool IsIndeterminate + { + get => (bool)GetValue(IsIndeterminateProperty); + set => SetValue(IsIndeterminateProperty, value); + } + + public int Current + { + get => (int)GetValue(CurrentProperty); + set => SetValue(CurrentProperty, value); + } + + public int Total + { + get => (int)GetValue(TotalProperty); + set => SetValue(TotalProperty, value); + } + + public bool BlocksInput + { + get => (bool)GetValue(BlocksInputProperty); + set => SetValue(BlocksInputProperty, value); + } +} diff --git a/src/MatroskaBatchFlow.Uno/Presentation/InputPage.xaml b/src/MatroskaBatchFlow.Uno/Presentation/InputPage.xaml index df7d7b1..0f63be5 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/InputPage.xaml +++ b/src/MatroskaBatchFlow.Uno/Presentation/InputPage.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:MatroskaBatchFlow.Uno.Presentation" + xmlns:controls="using:MatroskaBatchFlow.Uno.Presentation.Controls" xmlns:converters="using:MatroskaBatchFlow.Uno.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:utu="using:Uno.Toolkit.UI" @@ -107,51 +108,56 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - + + + + + + diff --git a/src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.cs b/src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.cs index 985b080..9c9b543 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/InputViewModel.cs @@ -1,9 +1,7 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using CommunityToolkit.Mvvm.Messaging; -using MatroskaBatchFlow.Core.Enums; using MatroskaBatchFlow.Core.Services; -using MatroskaBatchFlow.Core.Services.FileProcessing; using MatroskaBatchFlow.Core.Services.FileValidation; using MatroskaBatchFlow.Uno.Behavior; using MatroskaBatchFlow.Uno.Contracts.Services; @@ -22,6 +20,9 @@ public sealed partial class InputViewModel : ObservableObject, IFilesDropped, IN [ObservableProperty] private ValidationNotificationState validationNotifications = new(); + [ObservableProperty] + private InputOperationOverlayState overlayState = InputOperationOverlayState.Inactive; + private bool _isValidationInfoBarOpen; /// @@ -46,15 +47,12 @@ public bool IsValidationInfoBarOpen } private readonly IFileListAdapter _fileListAdapter; - private readonly IFileScanner _fileScanner; - private readonly IFileProcessingEngine _fileProcessingRuleEngine; private readonly IBatchConfiguration _batchConfig; - private readonly IBatchTrackConfigurationInitializer _trackConfigInitializer; private readonly IFilePickerDialogService _filePickerDialogService; private readonly IWritableSettings _userSettings; private readonly IValidationStateService _validationStateService; - private readonly IPlatformService _platformService; - private readonly IScannedFileInfoPathComparer _pathComparer; + private readonly IInputOperationFeedbackService _inputOperationFeedbackService; + private readonly IBatchOperationOrchestrator _orchestrator; private readonly ILogger _logger; public bool CanSelectAll => _batchConfig.FileList.Count > SelectedFiles.Count; @@ -91,32 +89,26 @@ public bool IsValidationInfoBarOpen public InputViewModel( IFileListAdapter fileListAdapter, - IFileScanner fileScanner, - IFileProcessingEngine fileProcessingRuleEngine, IBatchConfiguration batchConfig, - IBatchTrackConfigurationInitializer trackConfigInitializer, IFilePickerDialogService filePickerDialogService, IWritableSettings userSettings, IValidationStateService validationStateService, - IPlatformService platformService, - IScannedFileInfoPathComparer pathComparer, + IInputOperationFeedbackService inputOperationFeedbackService, + IBatchOperationOrchestrator orchestrator, ILogger logger ) { _fileListAdapter = fileListAdapter; - _fileScanner = fileScanner; - _fileProcessingRuleEngine = fileProcessingRuleEngine; _batchConfig = batchConfig; - _trackConfigInitializer = trackConfigInitializer; _filePickerDialogService = filePickerDialogService; _userSettings = userSettings; _validationStateService = validationStateService; - _platformService = platformService; - _pathComparer = pathComparer; + _inputOperationFeedbackService = inputOperationFeedbackService; + _orchestrator = orchestrator; _logger = logger; - RemoveSelected = new RelayCommand(RemoveSelectedFiles); - RemoveAll = new RelayCommand(RemoveAllFiles); + RemoveSelected = new AsyncRelayCommand(RemoveSelectedFilesAsync); + RemoveAll = new AsyncRelayCommand(RemoveAllFilesAsync); ClearSelection = new RelayCommand(ClearFileSelection); SelectAll = new RelayCommand(SelectAllFiles); AddFilesCommand = new AsyncRelayCommand(AddFilesAsync); @@ -125,10 +117,13 @@ ILogger logger _batchConfig.FileList.CollectionChanged += BatchConfigFileList_CollectionChanged; SelectedFiles.CollectionChanged += SelectedFiles_CollectionChanged; _validationStateService.StateChanged += OnValidationStateChanged; + _inputOperationFeedbackService.StateChanged += OnInputOperationFeedbackStateChanged; // Store the handler for later unsubscription. _validationNotificationsChangedHandler = (s, e) => NotifyValidationPropertiesChanged(); ValidationNotifications.AllNotifications.CollectionChanged += _validationNotificationsChangedHandler; + + OverlayState = _inputOperationFeedbackService.CurrentState ?? InputOperationOverlayState.Inactive; } /// @@ -159,188 +154,7 @@ public async Task OnFilesDroppedAsync(IStorageItem[] files) } // Import the files for further processing and validation. - await ImportStorageFilesAsync(storageFiles); - } - - /// - /// Asynchronously imports a collection of objects into the batch configuration. - /// Scans, validates, synchronizes track counts, applies processing rules, and adds valid files to the file list. - /// - /// - /// A read-only list of objects to import. - /// Must not be . If empty, the method returns immediately. - /// - /// - /// A representing the asynchronous operation. - /// - private async Task ImportStorageFilesAsync(IReadOnlyList files) - { - if (files is null or not { Count: > 0 }) - return; - - LogImportingFiles(files.Count); - - // Check for duplicates and filter to unique files only - var uniqueFiles = FilterDuplicateFiles(files); - - if (uniqueFiles.Count == 0) - return; - - // Scan the files to get their information. - IEnumerable newFiles = await _fileScanner.ScanAsync(uniqueFiles.ToFileInfo()); - var scannedFiles = newFiles.ToList(); - if (scannedFiles.Count == 0) - return; - - LogFilesScanned(scannedFiles.Count); - - // Re-scan any stale files so metadata is fresh for downstream validation. - await RescanStaleFilesAsync(); - - // Initialize per-file track configurations for all new files - foreach (var file in scannedFiles) - { - _trackConfigInitializer.Initialize( - file, - TrackType.Audio, - TrackType.Video, - TrackType.Text - ); - } - - // Apply processing rules to the new files. - foreach (var file in scannedFiles) - { - _fileProcessingRuleEngine.Apply(file, _batchConfig); - } - - // Add files via the adapter to keep everything in sync. - _fileListAdapter.AddFiles(scannedFiles); - } - - - /// - /// Filters out duplicate files from the provided list and notifies the user if any duplicates are found. - /// - /// The list of files to check for duplicates. - /// A list containing only unique files that are not already in the batch configuration. - private List FilterDuplicateFiles(IReadOnlyList files) - { - // Platform-aware comparison. Not perfect, but should be good enough for our purposes. - var comparer = _platformService.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - - // Build lookup of existing paths with appropriate comparer for O(1) lookups. - // Paths in _batchConfig are assumed to be normalized already. - var existingPaths = new HashSet( - _batchConfig.FileList.Select(f => f.Path), - comparer); - - // Track seen paths in this batch to detect duplicates within the input - var seenPaths = new HashSet(comparer); - var duplicates = new List(); - var uniqueFiles = new List(); - - foreach (var file in files) - { - var normalizedPath = Path.GetFullPath(file.Path); - - // If the file is already in the existing batch configuration, treat as duplicate. - if (existingPaths.Contains(normalizedPath)) - { - duplicates.Add(normalizedPath); - continue; - } - - // Track duplicates within the current set of input files. - var isNewPathInBatch = seenPaths.Add(normalizedPath); - if (!isNewPathInBatch) - { - duplicates.Add(normalizedPath); - } - else - { - uniqueFiles.Add(file); - } - } - - // Show duplicate message if any were found - if (duplicates.Count > 0) - { - LogDuplicatesSkipped(duplicates.Count); - - var duplicateFileNames = string.Join(Environment.NewLine, duplicates.Select(p => Path.GetFileName(p) ?? p)); - var message = duplicates.Count == 1 - ? $"This file is already in the list:{Environment.NewLine}{duplicateFileNames}" - : $"These {duplicates.Count} files are already in the list:{Environment.NewLine}{duplicateFileNames}"; - - WeakReferenceMessenger.Default.Send(new DialogMessage("Duplicate Files", message)); - } - - return uniqueFiles; - } - - /// - /// Re-scans all stale files in the batch configuration and updates them with fresh metadata. - /// - /// - /// Stale files are files whose metadata may be outdated. This method rescans them in a single batch operation, - /// removes the old entries, adds fresh scanned data, and clears the stale flags. - /// If a rescan fails for any file, the error is logged and the stale flag is cleared to prevent repeated failures. - /// - /// A representing the asynchronous operation. - private async Task RescanStaleFilesAsync() - { - var staleFiles = _batchConfig.GetStaleFiles().ToList(); - if (staleFiles.Count == 0) - return; - - LogRescanningStaleFiles(staleFiles.Count); - - try - { - // Batch re-scan all stale files in a single operation for better performance - var fileInfos = staleFiles.Select(f => new FileInfo(f.Path)).ToArray(); - var freshScans = (await _fileScanner.ScanAsync(fileInfos)).ToList(); - - // Match fresh scans back to stale files by path - foreach (var staleFile in staleFiles) - { - var freshScan = freshScans.FirstOrDefault(f => _pathComparer.PathEquals(f.Path, staleFile.Path)); - - if (freshScan != null) - { - // Migrate file configuration to preserve user's settings - // The configuration represents user's intent and should not be reset - _batchConfig.MigrateFileConfiguration(staleFile.Id, freshScan.Id); - - // Replace file with fresh metadata while keeping configuration - // Note: This causes UI re-render but ensures object identity is properly updated - _batchConfig.FileList.Remove(staleFile); - _batchConfig.FileList.Add(freshScan); - _batchConfig.ClearStaleFlag(staleFile.Id); - - LogFileRescanned(staleFile.Path); - } - else - { - // File couldn't be re-scanned (e.g., deleted, moved, or scanner failed) - // Clear stale flag to prevent repeated attempts - _batchConfig.ClearStaleFlag(staleFile.Id); - LogRescanFailedNotFound(staleFile.Path); - } - } - } - catch (Exception ex) - { - // Batch rescan failed entirely - clear all stale flags to prevent repeated failures - LogRescanBatchFailed(staleFiles.Count, ex); - foreach (var staleFile in staleFiles) - { - _batchConfig.ClearStaleFlag(staleFile.Id); - } - } + await _orchestrator.ImportFilesAsync(storageFiles.ToFileInfo()); } /// @@ -348,7 +162,7 @@ private async Task RescanStaleFilesAsync() /// /// This method processes the removal of selected files by iterating over a copy of the collection. - private void RemoveSelectedFiles() + private async Task RemoveSelectedFilesAsync() { var filesToRemove = SelectedFiles .Select(file => file.FileInfo) @@ -357,13 +171,13 @@ private void RemoveSelectedFiles() if (filesToRemove.Count == 0) return; - _fileListAdapter.RemoveFiles(filesToRemove); + await _orchestrator.RemoveFilesAsync(filesToRemove); } /// /// Removes all files from the internal file list. /// - private void RemoveAllFiles() + private async Task RemoveAllFilesAsync() { var filesToRemove = _fileListAdapter.ScannedFileViewModels .Select(file => file.FileInfo) @@ -372,7 +186,7 @@ private void RemoveAllFiles() if (filesToRemove.Count == 0) return; - _fileListAdapter.RemoveFiles(filesToRemove); + await _orchestrator.RemoveFilesAsync(filesToRemove); } /// @@ -408,7 +222,7 @@ private async Task AddFilesAsync() if (files.Count == 0) return; - await ImportStorageFilesAsync(files); + await _orchestrator.ImportFilesAsync(files.ToFileInfo()); } /// @@ -434,6 +248,11 @@ private void OnValidationStateChanged(object? sender, EventArgs e) IsValidationInfoBarOpen = _validationStateService.HasResults; } + private void OnInputOperationFeedbackStateChanged(object? sender, EventArgs e) + { + OverlayState = _inputOperationFeedbackService.CurrentState ?? InputOperationOverlayState.Inactive; + } + /// /// Shows a modal dialog with detailed validation results. /// @@ -496,6 +315,7 @@ public void Dispose() _batchConfig.FileList.CollectionChanged -= BatchConfigFileList_CollectionChanged; SelectedFiles.CollectionChanged -= SelectedFiles_CollectionChanged; _validationStateService.StateChanged -= OnValidationStateChanged; + _inputOperationFeedbackService.StateChanged -= OnInputOperationFeedbackStateChanged; ValidationNotifications.AllNotifications.CollectionChanged -= _validationNotificationsChangedHandler; GC.SuppressFinalize(this); diff --git a/src/MatroskaBatchFlow.Uno/Presentation/SettingsViewModel.cs b/src/MatroskaBatchFlow.Uno/Presentation/SettingsViewModel.cs index 7c03754..e54a56c 100644 --- a/src/MatroskaBatchFlow.Uno/Presentation/SettingsViewModel.cs +++ b/src/MatroskaBatchFlow.Uno/Presentation/SettingsViewModel.cs @@ -14,6 +14,7 @@ public partial class SettingsViewModel : ObservableObject private readonly IWritableSettings _userSettings; private readonly IValidationSettingsService _validationSettingsService; private readonly IValidationStateService _validationStateService; + private readonly IBatchOperationOrchestrator _orchestrator; private readonly IUIPreferencesService _uiPreferences; private readonly ILogger _logger; private readonly ILogLevelService _logLevelService; @@ -86,6 +87,7 @@ public SettingsViewModel( IWritableSettings userSettings, IValidationSettingsService validationSettingsService, IValidationStateService validationStateService, + IBatchOperationOrchestrator orchestrator, IUIPreferencesService uiPreferences, ILogLevelService logLevelService, IOptions loggingOptions, @@ -94,6 +96,7 @@ public SettingsViewModel( _userSettings = userSettings; _validationSettingsService = validationSettingsService; _validationStateService = validationStateService; + _orchestrator = orchestrator; _uiPreferences = uiPreferences; _logLevelService = logLevelService; _loggingOptions = loggingOptions.Value; @@ -190,7 +193,7 @@ await _userSettings.UpdateAsync(settings => LogValidationStrictnessChanged(previousMode, mode); // Trigger re-validation of the current batch - _validationStateService.Revalidate(); + await _orchestrator.RevalidateAsync(); // Notify UI of all changes NotifyAllValidationPropertiesChanged(); @@ -419,7 +422,7 @@ await _userSettings.UpdateAsync(settings => }); // Trigger re-validation of the current batch - _validationStateService.Revalidate(); + await _orchestrator.RevalidateAsync(); } catch (Exception ex) { diff --git a/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.Logging.cs b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.Logging.cs new file mode 100644 index 0000000..3eaaa89 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.Logging.cs @@ -0,0 +1,10 @@ +namespace MatroskaBatchFlow.Uno.Services; + +/// +/// LoggerMessage definitions for . +/// +public sealed partial class BatchOperationOrchestrator +{ + [LoggerMessage(Level = LogLevel.Debug, Message = "Importing {FileCount} file(s)")] + private partial void LogImportingFiles(int fileCount); +} diff --git a/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs new file mode 100644 index 0000000..f79b15c --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs @@ -0,0 +1,81 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; +using MatroskaBatchFlow.Uno.Contracts.Services; +using MatroskaBatchFlow.Uno.Services.Pipeline; + +namespace MatroskaBatchFlow.Uno.Services; + +/// +/// Orchestrates batch-mutating file operations by composing pipeline stages +/// and delegating execution to . +/// +/// The pipeline runner that executes stages sequentially. +/// Stage: filters duplicate files from the input set. +/// Stage: scans files with MediaInfo. +/// Stage: refreshes metadata for stale files. +/// Stage: initializes track configurations and applies processing rules. +/// Stage: adds scanned files to the batch. +/// Stage: removes files from the batch. +/// Stage: triggers revalidation. +/// The logger for recording orchestration operations. +public sealed partial class BatchOperationOrchestrator( + IPipelineRunner runner, + FilterDuplicateFilesStage filterDuplicatesStage, + ScanFilesStage scanFilesStage, + RefreshStaleMetadataStage refreshStaleMetadataStage, + InitializeTrackConfigStage initTrackConfigStage, + AddFilesToBatchStage addFilesStage, + RemoveFilesFromBatchStage removeFilesStage, + ValidateStage validateStage, + ILogger logger) : IBatchOperationOrchestrator +{ + /// + public async Task ImportFilesAsync(FileInfo[] files) + { + if (files is null or { Length: 0 }) + return; + + LogImportingFiles(files.Length); + + var context = new PipelineContext(); + context.Set(PipelineContextKeys.InputFiles, files); + + await runner.RunAsync( + [ + filterDuplicatesStage, + scanFilesStage, + refreshStaleMetadataStage, + initTrackConfigStage, + addFilesStage, + validateStage + ], context); + } + + /// + public async Task RemoveFilesAsync(IEnumerable files) + { + var filesToRemove = files.ToList(); + if (filesToRemove.Count == 0) + return; + + var context = new PipelineContext(); + context.Set(PipelineContextKeys.FilesToRemove, filesToRemove); + + await runner.RunAsync( + [ + removeFilesStage, + validateStage + ], context); + } + + /// + public async Task RevalidateAsync() + { + var context = new PipelineContext(); + + await runner.RunAsync( + [ + validateStage + ], context); + } +} diff --git a/src/MatroskaBatchFlow.Uno/Services/InputOperationFeedbackService.cs b/src/MatroskaBatchFlow.Uno/Services/InputOperationFeedbackService.cs new file mode 100644 index 0000000..140a310 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/InputOperationFeedbackService.cs @@ -0,0 +1,25 @@ +using MatroskaBatchFlow.Uno.Contracts.Services; +using MatroskaBatchFlow.Uno.Models; + +namespace MatroskaBatchFlow.Uno.Services; + +/// +public sealed class InputOperationFeedbackService : IInputOperationFeedbackService +{ + private InputOperationOverlayState _currentState = InputOperationOverlayState.Inactive; + + public InputOperationOverlayState CurrentState => _currentState; + + public event EventHandler? StateChanged; + + public void Publish(InputOperationOverlayState state) + { + _currentState = state; + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void Clear() + { + Publish(InputOperationOverlayState.Inactive); + } +} diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs new file mode 100644 index 0000000..0902061 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs @@ -0,0 +1,32 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Uno.Contracts.Services; + +namespace MatroskaBatchFlow.Uno.Services.Pipeline; + +/// +/// Pipeline stage that adds scanned files to the batch via . +/// Reads from the context. +/// +public sealed class AddFilesToBatchStage(IFileListAdapter fileListAdapter) : IPipelineStage +{ + /// + public string DisplayName => "Adding files to batch\u2026"; + + /// + public bool IsIndeterminate => true; + + /// + public bool ShowsOverlay => false; + + /// + public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + if (!context.TryGet>(PipelineContextKeys.ScannedFiles, out var scannedFiles) || scannedFiles.Count == 0) + return Task.CompletedTask; + + ct.ThrowIfCancellationRequested(); + fileListAdapter.AddFiles(scannedFiles); + + return Task.CompletedTask; + } +} diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.Logging.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.Logging.cs new file mode 100644 index 0000000..e0727fb --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.Logging.cs @@ -0,0 +1,10 @@ +namespace MatroskaBatchFlow.Uno.Services.Pipeline; + +/// +/// LoggerMessage definitions for . +/// +public sealed partial class FilterDuplicateFilesStage +{ + [LoggerMessage(Level = LogLevel.Debug, Message = "Skipped {DuplicateCount} duplicate file(s)")] + private partial void LogDuplicatesSkipped(int duplicateCount); +} diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs new file mode 100644 index 0000000..ff722d6 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs @@ -0,0 +1,81 @@ +using CommunityToolkit.Mvvm.Messaging; +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services; +using MatroskaBatchFlow.Uno.Messages; + +namespace MatroskaBatchFlow.Uno.Services.Pipeline; + +/// +/// Pipeline stage that filters duplicate files from the input set and notifies the user about any duplicates found. +/// Reads and overwrites in the context. +/// +public sealed partial class FilterDuplicateFilesStage( + IBatchConfiguration batchConfig, + IPlatformService platformService, + ILogger logger) : IPipelineStage +{ + /// + public string DisplayName => "Checking for duplicates\u2026"; + + /// + public bool IsIndeterminate => true; + + /// + public bool ShowsOverlay => false; + + /// + public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + if (!context.TryGet(PipelineContextKeys.InputFiles, out var files) || files.Length == 0) + return Task.CompletedTask; + + var comparer = platformService.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + // Build lookup of existing paths with appropriate comparer for O(1) lookups. + var existingPaths = new HashSet(batchConfig.FileList.Select(f => f.Path), comparer); + + var seenPaths = new HashSet(comparer); + var duplicates = new List(); + var uniqueFiles = new List(); + + foreach (var file in files) + { + var normalizedPath = file.FullName; + + if (existingPaths.Contains(normalizedPath)) + { + duplicates.Add(normalizedPath); + continue; + } + + if (!seenPaths.Add(normalizedPath)) + { + duplicates.Add(normalizedPath); + } + else + { + uniqueFiles.Add(file); + } + } + + if (duplicates.Count > 0) + { + LogDuplicatesSkipped(duplicates.Count); + + var duplicateFileNames = string.Join(Environment.NewLine, duplicates.Select(p => Path.GetFileName(p) ?? p)); + + var message = duplicates.Count == 1 + ? $"This file is already in the list:{Environment.NewLine}{duplicateFileNames}" + : $"These {duplicates.Count} files are already in the list:{Environment.NewLine}{duplicateFileNames}"; + + WeakReferenceMessenger.Default.Send(new DialogMessage("Duplicate Files", message)); + } + + // Overwrite context with filtered files + context.Set(PipelineContextKeys.InputFiles, uniqueFiles.ToArray()); + + return Task.CompletedTask; + } +} diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs new file mode 100644 index 0000000..9fd15c1 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs @@ -0,0 +1,13 @@ +namespace MatroskaBatchFlow.Uno.Services.Pipeline; + +/// +/// LoggerMessage definitions for . +/// +public sealed partial class PipelineRunner +{ + [LoggerMessage(Level = LogLevel.Debug, Message = "Pipeline stage '{StageName}' starting ({StageNumber}/{TotalStages})")] + private partial void LogStageStarting(string stageName, int stageNumber, int totalStages); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Pipeline stage '{StageName}' completed")] + private partial void LogStageCompleted(string stageName); +} diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs new file mode 100644 index 0000000..6600742 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Uno.Contracts.Services; +using MatroskaBatchFlow.Uno.Models; + +namespace MatroskaBatchFlow.Uno.Services.Pipeline; + +/// +/// Executes pipeline stages sequentially and manages overlay feedback for stages that opt in. +/// +public sealed partial class PipelineRunner( + IInputOperationFeedbackService feedbackService, + ILogger logger) : IPipelineRunner +{ + /// + /// Minimum time an overlay-enabled stage stays visible, preventing sub-perceptual flicker. + /// + private static readonly TimeSpan MinOverlayDuration = TimeSpan.FromMilliseconds(500); + + /// + public async Task RunAsync(IReadOnlyList stages, PipelineContext context, CancellationToken ct = default) + { + try + { + for (var i = 0; i < stages.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var stage = stages[i]; + LogStageStarting(stage.DisplayName, i + 1, stages.Count); + + IProgress<(int current, int total)>? progress = null; + long overlayStartedTicks = 0; + + if (stage.ShowsOverlay) + { + // Publish initial overlay state for this stage. + feedbackService.Publish(new InputOperationOverlayState( + stage.DisplayName, + stage.IsIndeterminate, + Current: 0, + Total: 0, + BlocksInput: true)); + + // Yield so the UI can render the overlay before the stage runs. + await Task.Yield(); + + overlayStartedTicks = Stopwatch.GetTimestamp(); + + // Create a progress reporter that updates the overlay with determinate progress. + progress = new Progress<(int current, int total)>(p => + feedbackService.Publish(new InputOperationOverlayState( + stage.DisplayName, + IsIndeterminate: false, + p.current, + p.total, + BlocksInput: true))); + } + + await stage.ExecuteAsync(context, progress, ct); + + // Ensure the overlay stays visible long enough for the user to perceive it. + if (stage.ShowsOverlay) + { + var elapsed = Stopwatch.GetElapsedTime(overlayStartedTicks); + var remaining = MinOverlayDuration - elapsed; + if (remaining > TimeSpan.Zero) + await Task.Delay(remaining, ct); + } + + LogStageCompleted(stage.DisplayName); + } + } + finally + { + feedbackService.Clear(); + } + } +} diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs new file mode 100644 index 0000000..75e3c82 --- /dev/null +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs @@ -0,0 +1,32 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Uno.Contracts.Services; + +namespace MatroskaBatchFlow.Uno.Services.Pipeline; + +/// +/// Pipeline stage that removes specified files from the batch via . +/// Reads from the context. +/// +public sealed class RemoveFilesFromBatchStage(IFileListAdapter fileListAdapter) : IPipelineStage +{ + /// + public string DisplayName => "Removing files\u2026"; + + /// + public bool IsIndeterminate => true; + + /// + public bool ShowsOverlay => false; + + /// + public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + { + if (!context.TryGet>(PipelineContextKeys.FilesToRemove, out var filesToRemove) || filesToRemove.Count == 0) + return Task.CompletedTask; + + ct.ThrowIfCancellationRequested(); + fileListAdapter.RemoveFiles(filesToRemove); + + return Task.CompletedTask; + } +} diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileScannerTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileScannerTests.cs index 716be94..4bd3da2 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileScannerTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileScannerTests.cs @@ -72,6 +72,25 @@ public async Task ScanAsync_ScansMultipleFiles() Assert.Equal(2, result.Count()); } + [Fact] + public async Task ScanAsync_ReportsDeterministicProgress() + { + // Arrange + var file1 = CreateTestMkvFile("test1.mkv"); + var file2 = CreateTestMkvFile("test2.mkv"); + var scanner = CreateScanner(); + var files = new[] { new FileInfo(file1), new FileInfo(file2) }; + var reports = new List<(int current, int total)>(); + var progress = new Progress<(int current, int total)>(value => reports.Add(value)); + + // Act + await scanner.ScanAsync(files, progress); + + // Assert + Assert.NotEmpty(reports); + Assert.Equal((2, 2), reports[^1]); + } + [Fact] public async Task ScanAsync_UpdatesInternalScannedFilesList() { diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs index f835dd1..49e59f2 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs @@ -154,12 +154,13 @@ public void Revalidate_ClearsPreviousResults_WhenFileListBecomesEmpty() _sut.Revalidate(); Assert.True(_sut.HasResults); - // Subscribe before clearing so we capture the event + // Clear the file list and subscribe for state change + _fileList.Clear(); var eventFired = false; _sut.StateChanged += (_, _) => eventFired = true; - // Act — clearing the file list triggers CollectionChanged → Revalidate → clears results - _fileList.Clear(); + // Act — explicitly revalidate after file list becomes empty + _sut.Revalidate(); // Assert Assert.Empty(_sut.CurrentResults); @@ -167,47 +168,7 @@ public void Revalidate_ClearsPreviousResults_WhenFileListBecomesEmpty() Assert.True(eventFired); } - [Fact] - public void FileListCollectionChanged_TriggersRevalidation() - { - // Arrange - _validationEngine.Validate(Arg.Any>(), Arg.Any()) - .Returns(new List - { - new(ValidationSeverity.Warning, "file1.mkv", "Test warning") - }); - - // Act — adding a file triggers CollectionChanged - var file = CreateScannedFile("file1.mkv"); - _fileList.Add(file); - - // Assert — the service should have re-validated automatically - Assert.True(_sut.HasResults); - Assert.Single(_sut.CurrentResults); - } - [Fact] - public void FileListCollectionChanged_RemovingFile_TriggersRevalidation() - { - // Arrange - var file = CreateScannedFile("file1.mkv"); - _fileList.Add(file); - - _validationEngine.Validate(Arg.Any>(), Arg.Any()) - .Returns(new List - { - new(ValidationSeverity.Warning, "file1.mkv", "Test warning") - }); - _sut.Revalidate(); // populate results - Assert.True(_sut.HasResults); - - // Act — remove file so list becomes empty - _fileList.Remove(file); - - // Assert — results should be cleared from the CollectionChanged re-validation - Assert.Empty(_sut.CurrentResults); - Assert.False(_sut.HasResults); - } [Fact] public void Revalidate_UsesCurrentEffectiveSettings() @@ -259,53 +220,7 @@ public void Revalidate_WithMixedResults_SetsPropertiesCorrectly() Assert.True(_sut.HasResults); } - [Fact] - public void FileListCollectionChanged_RemovingLastFile_FiresStateChanged_WhenNoValidationResults() - { - // Arrange — add a file but ensure validation returns no results (all valid) - var file = CreateScannedFile("file1.mkv"); - _fileList.Add(file); - - _validationEngine.Validate(Arg.Any>(), Arg.Any()) - .Returns(new List()); // No validation issues - - // Trigger initial validation to ensure results are empty - _sut.Revalidate(); - Assert.False(_sut.HasResults); - - // Subscribe to StateChanged AFTER initial state is established - var eventFired = false; - _sut.StateChanged += (_, _) => eventFired = true; - - // Act — remove the last file, transitioning to empty file list - _fileList.Remove(file); - - // Assert — StateChanged should fire even though both old and new results are empty, - // because the file list state changed (subscribers like MainViewModel.HasFiles need notification) - Assert.True(eventFired); - Assert.Empty(_sut.CurrentResults); - Assert.False(_sut.HasResults); - } - [Fact] - public void Dispose_UnsubscribesFromFileListChanges() - { - // Arrange - _validationEngine.Validate(Arg.Any>(), Arg.Any()) - .Returns(new List - { - new(ValidationSeverity.Warning, "file1.mkv", "Test warning") - }); - - // Act — dispose, then add a file - _sut.Dispose(); - var file = CreateScannedFile("file1.mkv"); - _fileList.Add(file); - - // Assert — validation should NOT have run since we disposed (unsubscribed) - Assert.Empty(_sut.CurrentResults); - Assert.False(_sut.HasResults); - } [Fact] public void Revalidate_AfterDispose_ThrowsObjectDisposedException() diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/InputViewModelTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/InputViewModelTests.cs index 177a7ba..04ca602 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/InputViewModelTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/InputViewModelTests.cs @@ -1,13 +1,12 @@ using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.Input; using MatroskaBatchFlow.Core.Enums; using MatroskaBatchFlow.Core.Models; using MatroskaBatchFlow.Core.Services; -using MatroskaBatchFlow.Core.Services.FileProcessing; using MatroskaBatchFlow.Core.Services.FileValidation; using MatroskaBatchFlow.Core.UnitTests.Builders; using MatroskaBatchFlow.Core.Utilities; using MatroskaBatchFlow.Uno.Contracts.Services; -using MatroskaBatchFlow.Uno.Messages; using MatroskaBatchFlow.Uno.Models; using MatroskaBatchFlow.Uno.Presentation; using Microsoft.Extensions.Logging; @@ -21,37 +20,30 @@ namespace MatroskaBatchFlow.Uno.UnitTests.Presentation; public class InputViewModelTests : IDisposable { private readonly IFileListAdapter _fileListAdapter; - private readonly IFileScanner _fileScanner; - private readonly IFileProcessingEngine _fileProcessingEngine; private readonly IBatchConfiguration _batchConfiguration; - private readonly IBatchTrackConfigurationInitializer _trackConfigInitializer; private readonly IFilePickerDialogService _filePickerDialogService; private readonly IWritableSettings _userSettings; private readonly IValidationStateService _validationStateService; - private readonly IPlatformService _platformService; - private readonly IScannedFileInfoPathComparer _pathComparer; + private readonly IInputOperationFeedbackService _inputOperationFeedbackService; + private readonly IBatchOperationOrchestrator _orchestrator; private readonly ILogger _logger; private readonly UniqueObservableCollection _fileList; public InputViewModelTests() { _fileListAdapter = Substitute.For(); - _fileScanner = Substitute.For(); - _fileProcessingEngine = Substitute.For(); _batchConfiguration = Substitute.For(); - _trackConfigInitializer = Substitute.For(); _filePickerDialogService = Substitute.For(); _userSettings = Substitute.For>(); _validationStateService = Substitute.For(); - _platformService = Substitute.For(); - _pathComparer = Substitute.For(); + _inputOperationFeedbackService = Substitute.For(); + _orchestrator = Substitute.For(); _logger = Substitute.For>(); _fileList = []; _batchConfiguration.FileList.Returns(_fileList); _userSettings.Value.Returns(new UserSettings()); - _platformService.IsWindows().Returns(true); } [Fact] @@ -151,7 +143,7 @@ public void ValidationProperties_ReflectNotificationState_WithWarnings() } [Fact] - public void RemoveSelected_WithSelectedFiles_DelegatesRemovalToFileListAdapter() + public async Task RemoveSelected_WithSelectedFiles_DelegatesToOrchestrator() { // Arrange var viewModel = CreateViewModel(); @@ -160,10 +152,10 @@ public void RemoveSelected_WithSelectedFiles_DelegatesRemovalToFileListAdapter() viewModel.SelectedFiles.Add(fileVm); // Act - viewModel.RemoveSelected.Execute(null); + await ((IAsyncRelayCommand)viewModel.RemoveSelected).ExecuteAsync(null); // Assert - _fileListAdapter.Received(1).RemoveFiles(Arg.Is>( + await _orchestrator.Received(1).RemoveFilesAsync(Arg.Is>( files => files.Contains(file))); } @@ -195,15 +187,12 @@ private InputViewModel CreateViewModel() { return new InputViewModel( _fileListAdapter, - _fileScanner, - _fileProcessingEngine, _batchConfiguration, - _trackConfigInitializer, _filePickerDialogService, _userSettings, _validationStateService, - _platformService, - _pathComparer, + _inputOperationFeedbackService, + _orchestrator, _logger); } diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SettingsViewModelTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SettingsViewModelTests.cs index 99a518a..3c3cde6 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SettingsViewModelTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Presentation/SettingsViewModelTests.cs @@ -20,6 +20,7 @@ public class SettingsViewModelTests private readonly IWritableSettings _userSettings; private readonly IValidationSettingsService _validationSettingsService; private readonly IValidationStateService _validationStateService; + private readonly IBatchOperationOrchestrator _orchestrator; private readonly IUIPreferencesService _uiPreferences; private readonly ILogLevelService _logLevelService; private readonly IOptions _loggingOptions; @@ -31,6 +32,7 @@ public SettingsViewModelTests() _userSettings = Substitute.For>(); _validationSettingsService = Substitute.For(); _validationStateService = Substitute.For(); + _orchestrator = Substitute.For(); _uiPreferences = Substitute.For(); _logLevelService = Substitute.For(); _loggingOptions = Substitute.For>(); @@ -204,12 +206,27 @@ public void OnSelectedThemeIndexChanged_UpdatesAppTheme() _uiPreferences.Received().AppTheme = AppThemePreference.Dark; } + [Fact] + public void ChangingStrictnessMode_DelegatesToOrchestrator() + { + _userSettings.UpdateAsync(Arg.Any>()).Returns(Task.CompletedTask); + _orchestrator.RevalidateAsync().Returns(Task.CompletedTask); + + _userSettingsValue.BatchValidation.Mode = StrictnessMode.Custom; + var viewModel = CreateViewModel(); + + viewModel.SelectedStrictnessModeIndex = (int)StrictnessMode.Strict; + + _orchestrator.Received(1).RevalidateAsync(); + } + private SettingsViewModel CreateViewModel() { return new SettingsViewModel( _userSettings, _validationSettingsService, _validationStateService, + _orchestrator, _uiPreferences, _logLevelService, _loggingOptions, diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs new file mode 100644 index 0000000..377d02c --- /dev/null +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs @@ -0,0 +1,228 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Enums; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.Services; +using MatroskaBatchFlow.Core.Services.FileProcessing; +using MatroskaBatchFlow.Core.Services.FileValidation; +using MatroskaBatchFlow.Core.Services.Pipeline; +using MatroskaBatchFlow.Core.UnitTests.Builders; +using MatroskaBatchFlow.Uno.Contracts.Services; +using MatroskaBatchFlow.Uno.Services; +using MatroskaBatchFlow.Uno.Services.Pipeline; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace MatroskaBatchFlow.Uno.UnitTests.Services; + +/// +/// Contains unit tests for the class. +/// +public class BatchOperationOrchestratorTests +{ + private readonly IPipelineRunner _pipelineRunner; + private readonly FilterDuplicateFilesStage _filterDuplicatesStage; + private readonly ScanFilesStage _scanFilesStage; + private readonly RefreshStaleMetadataStage _refreshStaleMetadataStage; + private readonly InitializeTrackConfigStage _initTrackConfigStage; + private readonly AddFilesToBatchStage _addFilesStage; + private readonly RemoveFilesFromBatchStage _removeFilesStage; + private readonly ValidateStage _validateStage; + private readonly ILogger _logger; + + public BatchOperationOrchestratorTests() + { + _pipelineRunner = Substitute.For(); + _logger = Substitute.For>(); + + // Create real stage instances with mocked dependencies. + // The runner is mocked so stage methods are never called — only identity matters. + _filterDuplicatesStage = new FilterDuplicateFilesStage( + Substitute.For(), + Substitute.For(), + Substitute.For>()); + + _scanFilesStage = new ScanFilesStage( + Substitute.For(), + Substitute.For>()); + + _refreshStaleMetadataStage = new RefreshStaleMetadataStage( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For>()); + + _initTrackConfigStage = new InitializeTrackConfigStage( + Substitute.For(), + Substitute.For(), + Substitute.For()); + + _addFilesStage = new AddFilesToBatchStage(Substitute.For()); + _removeFilesStage = new RemoveFilesFromBatchStage(Substitute.For()); + _validateStage = new ValidateStage(Substitute.For()); + } + + [Fact] + public async Task ImportFilesAsync_WithNullOrEmpty_DoesNotCallRunner() + { + var orchestrator = CreateOrchestrator(); + + await orchestrator.ImportFilesAsync(null!); + await orchestrator.ImportFilesAsync([]); + + await _pipelineRunner.DidNotReceive().RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ImportFilesAsync_ComposesCorrectStageSequence() + { + IReadOnlyList? capturedStages = null; + await _pipelineRunner.RunAsync( + Arg.Do>(s => capturedStages = s), + Arg.Any(), + Arg.Any()); + + var testFile = Path.GetTempFileName(); + try + { + var orchestrator = CreateOrchestrator(); + await orchestrator.ImportFilesAsync([new FileInfo(testFile)]); + + Assert.NotNull(capturedStages); + Assert.Equal(6, capturedStages.Count); + Assert.Same(_filterDuplicatesStage, capturedStages[0]); + Assert.Same(_scanFilesStage, capturedStages[1]); + Assert.Same(_refreshStaleMetadataStage, capturedStages[2]); + Assert.Same(_initTrackConfigStage, capturedStages[3]); + Assert.Same(_addFilesStage, capturedStages[4]); + Assert.Same(_validateStage, capturedStages[5]); + } + finally + { + File.Delete(testFile); + } + } + + [Fact] + public async Task ImportFilesAsync_SetsInputFilesInContext() + { + PipelineContext? capturedContext = null; + await _pipelineRunner.RunAsync( + Arg.Any>(), + Arg.Do(c => capturedContext = c), + Arg.Any()); + + var testFile = Path.GetTempFileName(); + try + { + var fileInfo = new FileInfo(testFile); + var orchestrator = CreateOrchestrator(); + + await orchestrator.ImportFilesAsync([fileInfo]); + + Assert.NotNull(capturedContext); + var inputFiles = capturedContext.Get(PipelineContextKeys.InputFiles); + Assert.Single(inputFiles); + Assert.Equal(fileInfo.FullName, inputFiles[0].FullName); + } + finally + { + File.Delete(testFile); + } + } + + [Fact] + public async Task RemoveFilesAsync_WithEmptyList_DoesNotCallRunner() + { + var orchestrator = CreateOrchestrator(); + + await orchestrator.RemoveFilesAsync([]); + + await _pipelineRunner.DidNotReceive().RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task RemoveFilesAsync_ComposesCorrectStageSequence() + { + IReadOnlyList? capturedStages = null; + await _pipelineRunner.RunAsync( + Arg.Do>(s => capturedStages = s), + Arg.Any(), + Arg.Any()); + + var file = CreateScannedFile("file1.mkv"); + var orchestrator = CreateOrchestrator(); + + await orchestrator.RemoveFilesAsync([file]); + + Assert.NotNull(capturedStages); + Assert.Equal(2, capturedStages.Count); + Assert.Same(_removeFilesStage, capturedStages[0]); + Assert.Same(_validateStage, capturedStages[1]); + } + + [Fact] + public async Task RemoveFilesAsync_SetsFilesToRemoveInContext() + { + PipelineContext? capturedContext = null; + await _pipelineRunner.RunAsync( + Arg.Any>(), + Arg.Do(c => capturedContext = c), + Arg.Any()); + + var file = CreateScannedFile("file1.mkv"); + var orchestrator = CreateOrchestrator(); + + await orchestrator.RemoveFilesAsync([file]); + + Assert.NotNull(capturedContext); + var filesToRemove = capturedContext.Get>(PipelineContextKeys.FilesToRemove); + Assert.Single(filesToRemove); + Assert.Same(file, filesToRemove[0]); + } + + [Fact] + public async Task RevalidateAsync_ComposesCorrectStageSequence() + { + IReadOnlyList? capturedStages = null; + await _pipelineRunner.RunAsync( + Arg.Do>(s => capturedStages = s), + Arg.Any(), + Arg.Any()); + + var orchestrator = CreateOrchestrator(); + + await orchestrator.RevalidateAsync(); + + Assert.NotNull(capturedStages); + Assert.Single(capturedStages); + Assert.Same(_validateStage, capturedStages[0]); + } + + private BatchOperationOrchestrator CreateOrchestrator() + { + return new BatchOperationOrchestrator( + _pipelineRunner, + _filterDuplicatesStage, + _scanFilesStage, + _refreshStaleMetadataStage, + _initTrackConfigStage, + _addFilesStage, + _removeFilesStage, + _validateStage, + _logger); + } + + private static ScannedFileInfo CreateScannedFile(string path) + { + var builder = new MediaInfoResultBuilder() + .AddTrackOfType(TrackType.Video) + .AddTrackOfType(TrackType.Audio); + return new ScannedFileInfo(builder.Build(), path); + } +} diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/InputOperationFeedbackServiceTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/InputOperationFeedbackServiceTests.cs new file mode 100644 index 0000000..4167fb5 --- /dev/null +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/InputOperationFeedbackServiceTests.cs @@ -0,0 +1,64 @@ +using MatroskaBatchFlow.Uno.Models; +using MatroskaBatchFlow.Uno.Services; + +namespace MatroskaBatchFlow.Uno.UnitTests.Services; + +public class InputOperationFeedbackServiceTests +{ + [Fact] + public void Publish_UpdatesCurrentSnapshotAndRaisesStateChanged() + { + var service = new InputOperationFeedbackService(); + var raised = false; + service.StateChanged += (_, _) => raised = true; + + var state = new InputOperationOverlayState( + "Revalidating files\u2026", + true, + 0, + 0, + true); + + service.Publish(state); + + Assert.True(raised); + Assert.Equal(state, service.CurrentState); + } + + [Fact] + public void Clear_ResetsToInactiveState() + { + var service = new InputOperationFeedbackService(); + + service.Publish(new InputOperationOverlayState( + "Removing files\u2026", + true, + 0, + 0, + true)); + + service.Clear(); + + Assert.Equal(InputOperationOverlayState.Inactive, service.CurrentState); + } + + [Fact] + public void LateSubscriber_CanHydrateFromCurrentSnapshot() + { + var service = new InputOperationFeedbackService(); + var expected = new InputOperationOverlayState( + "Applying track configuration\u2026", + false, + 2, + 5, + true); + + service.Publish(expected); + + InputOperationOverlayState? observed = null; + service.StateChanged += (_, _) => observed = service.CurrentState; + + Assert.Equal(expected, service.CurrentState); + Assert.Null(observed); + } +} \ No newline at end of file From 9e8a4a84d4e49b96cfea68fbf8aea6380a25c943 Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Tue, 24 Feb 2026 00:38:27 +0100 Subject: [PATCH 02/12] feat(core,ui): add pipeline abort signal to skip stages on empty input - Add IsAborted property to PipelineContext for early pipeline termination - PipelineRunner checks IsAborted after each stage and breaks the loop - FilterDuplicateFilesStage sets IsAborted when all files are duplicates - Add PipelineRunnerTests covering abort, normal flow, and feedback cleanup - Add FilterDuplicateFilesStageTests covering abort and non-abort scenarios --- .../Abstractions/Pipeline/PipelineContext.cs | 7 ++ .../Pipeline/FilterDuplicateFilesStage.cs | 8 +- .../Pipeline/PipelineRunner.Logging.cs | 3 + .../Services/Pipeline/PipelineRunner.cs | 6 + .../FilterDuplicateFilesStageTests.cs | 89 ++++++++++++++ .../Services/Pipeline/PipelineRunnerTests.cs | 113 ++++++++++++++++++ 6 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs create mode 100644 tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs index 2768646..bfdbf0a 100644 --- a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs +++ b/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs @@ -15,6 +15,13 @@ public sealed class PipelineContext { private readonly Dictionary _data = []; + /// + /// When set to , signals the + /// to stop executing subsequent stages. A stage sets this when continuing + /// the pipeline would be pointless (e.g., all input was filtered out). + /// + public bool IsAborted { get; set; } + /// /// Stores a value in the context under the specified key. /// diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs index ff722d6..d919f3f 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs @@ -73,9 +73,15 @@ public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int to WeakReferenceMessenger.Default.Send(new DialogMessage("Duplicate Files", message)); } - // Overwrite context with filtered files + // Overwrite context with filtered files. context.Set(PipelineContextKeys.InputFiles, uniqueFiles.ToArray()); + // If every file was a duplicate, abort the pipeline — no work remains. + if (uniqueFiles.Count == 0) + { + context.IsAborted = true; + } + return Task.CompletedTask; } } diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs index 9fd15c1..fffd4f0 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs @@ -10,4 +10,7 @@ public sealed partial class PipelineRunner [LoggerMessage(Level = LogLevel.Debug, Message = "Pipeline stage '{StageName}' completed")] private partial void LogStageCompleted(string stageName); + + [LoggerMessage(Level = LogLevel.Information, Message = "Pipeline aborted after stage '{StageName}' — no further stages will run")] + private partial void LogPipelineAborted(string stageName); } diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs index 6600742..76e1966 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs @@ -69,6 +69,12 @@ public async Task RunAsync(IReadOnlyList stages, PipelineContext } LogStageCompleted(stage.DisplayName); + + if (context.IsAborted) + { + LogPipelineAborted(stage.DisplayName); + break; + } } } finally diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs new file mode 100644 index 0000000..a90f84e --- /dev/null +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs @@ -0,0 +1,89 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Models; +using MatroskaBatchFlow.Core.Services; +using MatroskaBatchFlow.Uno.Services.Pipeline; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace MatroskaBatchFlow.Uno.UnitTests.Services.Pipeline; + +/// +/// Contains unit tests for the class. +/// +public class FilterDuplicateFilesStageTests +{ + private readonly IBatchConfiguration _batchConfig; + private readonly IPlatformService _platformService; + private readonly FilterDuplicateFilesStage _stage; + + public FilterDuplicateFilesStageTests() + { + _batchConfig = Substitute.For(); + _batchConfig.FileList.Returns([]); + _platformService = Substitute.For(); + _platformService.IsWindows().Returns(true); + + _stage = new FilterDuplicateFilesStage( + _batchConfig, + _platformService, + Substitute.For>()); + } + + [Fact] + public async Task ExecuteAsync_AbortsContext_WhenAllFilesAreDuplicates() + { + var existingFile = Path.GetTempFileName(); + try + { + _batchConfig.FileList.Returns([new ScannedFileInfo(null!, existingFile)]); + + var context = new PipelineContext(); + context.Set(PipelineContextKeys.InputFiles, new[] { new FileInfo(existingFile) }); + + await _stage.ExecuteAsync(context, null, CancellationToken.None); + + Assert.True(context.IsAborted); + var remaining = context.Get(PipelineContextKeys.InputFiles); + Assert.Empty(remaining); + } + finally + { + File.Delete(existingFile); + } + } + + [Fact] + public async Task ExecuteAsync_DoesNotAbort_WhenSomeFilesAreUnique() + { + var existingFile = Path.GetTempFileName(); + var newFile = Path.GetTempFileName(); + try + { + _batchConfig.FileList.Returns([new ScannedFileInfo(null!, existingFile)]); + + var context = new PipelineContext(); + context.Set(PipelineContextKeys.InputFiles, new[] { new FileInfo(existingFile), new FileInfo(newFile) }); + + await _stage.ExecuteAsync(context, null, CancellationToken.None); + + Assert.False(context.IsAborted); + var remaining = context.Get(PipelineContextKeys.InputFiles); + Assert.Single(remaining); + } + finally + { + File.Delete(existingFile); + File.Delete(newFile); + } + } + + [Fact] + public async Task ExecuteAsync_DoesNotAbort_WhenNoInputFiles() + { + var context = new PipelineContext(); + + await _stage.ExecuteAsync(context, null, CancellationToken.None); + + Assert.False(context.IsAborted); + } +} diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs new file mode 100644 index 0000000..0361a72 --- /dev/null +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs @@ -0,0 +1,113 @@ +using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Uno.Contracts.Services; +using MatroskaBatchFlow.Uno.Services.Pipeline; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace MatroskaBatchFlow.Uno.UnitTests.Services.Pipeline; + +/// +/// Contains unit tests for the class. +/// +public class PipelineRunnerTests +{ + private readonly IInputOperationFeedbackService _feedbackService; + private readonly PipelineRunner _runner; + + public PipelineRunnerTests() + { + _feedbackService = Substitute.For(); + _runner = new PipelineRunner( + _feedbackService, + Substitute.For>()); + } + + [Fact] + public async Task RunAsync_StopsExecutingStages_WhenContextIsAborted() + { + var executedStages = new List(); + + var abortingStage = CreateStage("Aborting", (ctx, _, _) => + { + executedStages.Add("Aborting"); + ctx.IsAborted = true; + return Task.CompletedTask; + }, showsOverlay: false); + + var subsequentStage = CreateStage("Should Not Run", (_, _, _) => + { + executedStages.Add("Should Not Run"); + return Task.CompletedTask; + }, showsOverlay: false); + + var context = new PipelineContext(); + + await _runner.RunAsync([abortingStage, subsequentStage], context); + + Assert.Single(executedStages); + Assert.Equal("Aborting", executedStages[0]); + Assert.True(context.IsAborted); + } + + [Fact] + public async Task RunAsync_ExecutesAllStages_WhenNotAborted() + { + var executedStages = new List(); + + var stage1 = CreateStage("Stage 1", (_, _, _) => + { + executedStages.Add("Stage 1"); + return Task.CompletedTask; + }, showsOverlay: false); + + var stage2 = CreateStage("Stage 2", (_, _, _) => + { + executedStages.Add("Stage 2"); + return Task.CompletedTask; + }, showsOverlay: false); + + var context = new PipelineContext(); + + await _runner.RunAsync([stage1, stage2], context); + + Assert.Equal(2, executedStages.Count); + Assert.Equal("Stage 1", executedStages[0]); + Assert.Equal("Stage 2", executedStages[1]); + } + + [Fact] + public async Task RunAsync_ClearsFeedback_EvenWhenAborted() + { + var abortingStage = CreateStage("Aborting", (ctx, _, _) => + { + ctx.IsAborted = true; + return Task.CompletedTask; + }, showsOverlay: false); + + var context = new PipelineContext(); + + await _runner.RunAsync([abortingStage], context); + + _feedbackService.Received(1).Clear(); + } + + private static IPipelineStage CreateStage( + string displayName, + Func?, CancellationToken, Task> execute, + bool showsOverlay = true) + { + var stage = Substitute.For(); + stage.DisplayName.Returns(displayName); + stage.IsIndeterminate.Returns(true); + stage.ShowsOverlay.Returns(showsOverlay); + stage.ExecuteAsync( + Arg.Any(), + Arg.Any?>(), + Arg.Any()) + .Returns(ci => execute( + ci.Arg(), + ci.Arg?>(), + ci.Arg())); + return stage; + } +} From 7a35e288505daeef1aaacb523ac22810b462560a Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 14:34:40 +0100 Subject: [PATCH 03/12] fix(processing): serialize overlapping stage runner calls and yield between file iterations - Add SemaphoreSlim to PipelineRunner to serialize concurrent RunAsync calls, preventing interleaved batch mutations and premature overlay clearing - Make InitializeTrackConfigStage.ExecuteAsync truly async by yielding after each file so the overlay and progress bar stay responsive during large batches - Add LogRunQueued warning for diagnostics when a caller has to wait --- .../Pipeline/InitializeTrackConfigStage.cs | 11 +- .../Pipeline/PipelineRunner.Logging.cs | 3 + .../Services/Pipeline/PipelineRunner.cs | 106 +++++++++++------- 3 files changed, 74 insertions(+), 46 deletions(-) diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs index 6880bd7..1985ae8 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs @@ -28,10 +28,10 @@ public sealed class InitializeTrackConfigStage( public bool ShowsOverlay => true; /// - public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + public async Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) { if (!context.TryGet>(PipelineContextKeys.ScannedFiles, out var scannedFiles) || scannedFiles.Count == 0) - return Task.CompletedTask; + return; var totalFiles = scannedFiles.Count; @@ -44,8 +44,11 @@ public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int to fileProcessingRuleEngine.Apply(file, batchConfig); progress?.Report((index + 1, totalFiles)); - } - return Task.CompletedTask; + // Yield to the UI thread's message loop after each file so that progress + // callbacks and overlay repaints can render between iterations, preventing + // the UI from freezing during a large batch. + await Task.Yield(); + } } } diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs index fffd4f0..cb52950 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.Logging.cs @@ -13,4 +13,7 @@ public sealed partial class PipelineRunner [LoggerMessage(Level = LogLevel.Information, Message = "Pipeline aborted after stage '{StageName}' — no further stages will run")] private partial void LogPipelineAborted(string stageName); + + [LoggerMessage(Level = LogLevel.Warning, Message = "A pipeline run is already in progress; the new run will wait until it completes")] + private partial void LogRunQueued(); } diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs index 76e1966..e9a18b3 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs @@ -8,6 +8,12 @@ namespace MatroskaBatchFlow.Uno.Services.Pipeline; /// /// Executes pipeline stages sequentially and manages overlay feedback for stages that opt in. /// +/// +/// Concurrent calls to are serialized: the second caller waits until the +/// first run completes (or is cancelled) before it begins. This prevents interleaved mutations +/// on shared state (batch configuration, validation) and avoids one run clearing the overlay +/// while another is still in progress. +/// public sealed partial class PipelineRunner( IInputOperationFeedbackService feedbackService, ILogger logger) : IPipelineRunner @@ -17,69 +23,85 @@ public sealed partial class PipelineRunner( /// private static readonly TimeSpan MinOverlayDuration = TimeSpan.FromMilliseconds(500); + /// + /// Ensures that at most one pipeline run executes at a time. + /// + private readonly SemaphoreSlim _runLock = new(1, 1); + /// public async Task RunAsync(IReadOnlyList stages, PipelineContext context, CancellationToken ct = default) { + if (_runLock.CurrentCount == 0) + LogRunQueued(); + + await _runLock.WaitAsync(ct); try { - for (var i = 0; i < stages.Count; i++) + try { - ct.ThrowIfCancellationRequested(); - - var stage = stages[i]; - LogStageStarting(stage.DisplayName, i + 1, stages.Count); - - IProgress<(int current, int total)>? progress = null; - long overlayStartedTicks = 0; - - if (stage.ShowsOverlay) + for (var i = 0; i < stages.Count; i++) { - // Publish initial overlay state for this stage. - feedbackService.Publish(new InputOperationOverlayState( - stage.DisplayName, - stage.IsIndeterminate, - Current: 0, - Total: 0, - BlocksInput: true)); + ct.ThrowIfCancellationRequested(); - // Yield so the UI can render the overlay before the stage runs. - await Task.Yield(); + var stage = stages[i]; + LogStageStarting(stage.DisplayName, i + 1, stages.Count); - overlayStartedTicks = Stopwatch.GetTimestamp(); + IProgress<(int current, int total)>? progress = null; + long overlayStartedTicks = 0; - // Create a progress reporter that updates the overlay with determinate progress. - progress = new Progress<(int current, int total)>(p => + if (stage.ShowsOverlay) + { + // Publish initial overlay state for this stage. feedbackService.Publish(new InputOperationOverlayState( stage.DisplayName, - IsIndeterminate: false, - p.current, - p.total, - BlocksInput: true))); - } + stage.IsIndeterminate, + Current: 0, + Total: 0, + BlocksInput: true)); - await stage.ExecuteAsync(context, progress, ct); + // Yield so the UI can render the overlay before the stage runs. + await Task.Yield(); - // Ensure the overlay stays visible long enough for the user to perceive it. - if (stage.ShowsOverlay) - { - var elapsed = Stopwatch.GetElapsedTime(overlayStartedTicks); - var remaining = MinOverlayDuration - elapsed; - if (remaining > TimeSpan.Zero) - await Task.Delay(remaining, ct); - } + overlayStartedTicks = Stopwatch.GetTimestamp(); - LogStageCompleted(stage.DisplayName); + // Create a progress reporter that updates the overlay with determinate progress. + progress = new Progress<(int current, int total)>(p => + feedbackService.Publish(new InputOperationOverlayState( + stage.DisplayName, + IsIndeterminate: false, + p.current, + p.total, + BlocksInput: true))); + } - if (context.IsAborted) - { - LogPipelineAborted(stage.DisplayName); - break; + await stage.ExecuteAsync(context, progress, ct); + + // Ensure the overlay stays visible long enough for the user to perceive it. + if (stage.ShowsOverlay) + { + var elapsed = Stopwatch.GetElapsedTime(overlayStartedTicks); + var remaining = MinOverlayDuration - elapsed; + if (remaining > TimeSpan.Zero) + await Task.Delay(remaining, ct); + } + + LogStageCompleted(stage.DisplayName); + + if (context.IsAborted) + { + LogPipelineAborted(stage.DisplayName); + break; + } } } + finally + { + feedbackService.Clear(); + } } finally { - feedbackService.Clear(); + _runLock.Release(); } } } From b504f6c9f81922f45c978e6388f16a8a25fd5d8c Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 14:35:37 +0100 Subject: [PATCH 04/12] refactor(FileScanner): simplify file retrieval by removing redundant ToList call --- src/MatroskaBatchFlow.Core/Services/FileScanner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MatroskaBatchFlow.Core/Services/FileScanner.cs b/src/MatroskaBatchFlow.Core/Services/FileScanner.cs index c0830b3..5f7b09e 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileScanner.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileScanner.cs @@ -56,7 +56,7 @@ public async Task> ScanWithMediaInfoAsync() { EnsureDirectoryExists(); LogScanningDirectory(_options.DirectoryPath, _options.Recursive); - var files = (await Task.Run(() => GetFilteredFiles().ToList())).ToList(); + var files = await Task.Run(() => GetFilteredFiles().ToList()); LogFilesFound(files.Count); var scannedFiles = await AnalyzeFilesWithMediaInfoAsync(files); From 738ce0411e2dd9fc1247d130aff2d7837f3914a7 Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 14:37:08 +0100 Subject: [PATCH 05/12] chore(config): add Serena language backend setting for project configuration --- .serena/project.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.serena/project.yml b/.serena/project.yml index 2473364..8821de0 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -107,3 +107,10 @@ fixed_tools: [] # override of the corresponding setting in serena_config.yml, see the documentation there. # If null or missing, the value from the global config is used. symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: From 69fc90e23ba9469d6b00be498d0ebd8d9ba590f0 Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 15:20:06 +0100 Subject: [PATCH 06/12] perf(validation): run validation off the UI thread to keep overlay responsive - Add RevalidateAsync to IValidationStateService and ValidationStateService - Snapshot file list and settings on the calling (UI) thread before going off-thread, since ObservableCollection is not thread-safe - Run CPU-bound validation work in Task.Run; continuation resumes on the original synchronization context so UpdateState and StateChanged remain safe for UI-bound subscribers - Update ValidateStage.ExecuteAsync to await RevalidateAsync --- .../FileValidation/IValidationStateService.cs | 7 +++++ .../FileValidation/ValidationStateService.cs | 28 +++++++++++++++++++ .../Services/Pipeline/ValidateStage.cs | 5 ++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/MatroskaBatchFlow.Core/Services/FileValidation/IValidationStateService.cs b/src/MatroskaBatchFlow.Core/Services/FileValidation/IValidationStateService.cs index db9dcf4..4531765 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileValidation/IValidationStateService.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileValidation/IValidationStateService.cs @@ -35,4 +35,11 @@ public interface IValidationStateService : IDisposable /// Forces a re-validation of all files in the current batch against the current validation settings. /// void Revalidate(); + + /// + /// Asynchronously forces a re-validation of all files in the current batch. + /// The expensive validation work runs on a background thread; state is updated + /// on the calling synchronization context so UI-bound subscribers remain safe. + /// + Task RevalidateAsync(); } diff --git a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs index e37dc47..bab4c55 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs @@ -71,6 +71,34 @@ public void Revalidate() UpdateState(results); } + /// + /// Thrown if the service has been disposed. + public async Task RevalidateAsync() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Snapshot both collections on the calling (UI) thread before going off-thread, + // since ObservableCollection and the settings graph are not thread-safe. + List fileSnapshot = [.. _batchConfiguration.FileList]; + + if (fileSnapshot.Count == 0) + { + LogValidationSkipped(); + UpdateState([]); // Always clear results when there are no files, to reset any previous state + return; + } + + var settings = _validationSettingsService.GetEffectiveSettings(_userSettings.Value); + + // Run the CPU-bound validation work off the UI thread. + // The continuation (and therefore UpdateState + StateChanged) resumes on the + // original synchronization context, keeping UI-bound subscribers safe. + List results = await Task.Run( + () => _validationEngine.Validate(fileSnapshot, settings).ToList()); + + UpdateState(results); + } + /// /// Updates the internal validation state based on the specified collection of file validation results. /// diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs index 2d25c55..99eca77 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs @@ -18,10 +18,9 @@ public sealed class ValidateStage(IValidationStateService validationStateService public bool ShowsOverlay => true; /// - public Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) + public async Task ExecuteAsync(PipelineContext context, IProgress<(int current, int total)>? progress, CancellationToken ct) { ct.ThrowIfCancellationRequested(); - validationStateService.Revalidate(); - return Task.CompletedTask; + await validationStateService.RevalidateAsync(); } } From af32c8e94e4b96e9b4fa1017e21f74c9387a452e Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 15:32:37 +0100 Subject: [PATCH 07/12] test(validation): add RevalidateAsync tests mirroring existing Revalidate coverage --- .../ValidationStateServiceTests.cs | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs index 49e59f2..7c6fe90 100644 --- a/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs +++ b/tests/MatroskaBatchFlow.Core.UnitTests/Services/FileValidation/ValidationStateServiceTests.cs @@ -232,6 +232,188 @@ public void Revalidate_AfterDispose_ThrowsObjectDisposedException() Assert.Throws(() => _sut.Revalidate()); } + [Fact] + public async Task RevalidateAsync_WithEmptyFileList_HasNoResults() + { + // Act + await _sut.RevalidateAsync(); + + // Assert + Assert.Empty(_sut.CurrentResults); + Assert.False(_sut.HasBlockingErrors); + Assert.False(_sut.HasWarnings); + Assert.False(_sut.HasResults); + } + + [Fact] + public async Task RevalidateAsync_WithFiles_RunsValidationEngine() + { + // Arrange + var file = CreateScannedFile("file1.mkv"); + _fileList.Add(file); + + var expectedResults = new List + { + new(ValidationSeverity.Warning, "file1.mkv", "Test warning") + }; + _validationEngine.Validate(Arg.Any>(), Arg.Any()) + .Returns(expectedResults); + + // Act + await _sut.RevalidateAsync(); + + // Assert + Assert.Single(_sut.CurrentResults); + Assert.False(_sut.HasBlockingErrors); + Assert.True(_sut.HasWarnings); + Assert.True(_sut.HasResults); + } + + [Fact] + public async Task RevalidateAsync_WithBlockingErrors_SetsHasBlockingErrors() + { + // Arrange + var file = CreateScannedFile("file1.mkv"); + _fileList.Add(file); + + var expectedResults = new List + { + new(ValidationSeverity.Error, "file1.mkv", "Track count mismatch") + }; + _validationEngine.Validate(Arg.Any>(), Arg.Any()) + .Returns(expectedResults); + + // Act + await _sut.RevalidateAsync(); + + // Assert + Assert.True(_sut.HasBlockingErrors); + Assert.True(_sut.HasResults); + } + + [Fact] + public async Task RevalidateAsync_FiresStateChangedEvent() + { + // Arrange + var file = CreateScannedFile("file1.mkv"); + _fileList.Add(file); + + _validationEngine.Validate(Arg.Any>(), Arg.Any()) + .Returns(new List()); + + var eventFired = false; + _sut.StateChanged += (_, _) => eventFired = true; + + // Act + await _sut.RevalidateAsync(); + + // Assert + Assert.True(eventFired); + } + + [Fact] + public async Task RevalidateAsync_WithEmptyFileList_FiresStateChanged() + { + // Arrange — results are already empty (default state) + var eventFired = false; + _sut.StateChanged += (_, _) => eventFired = true; + + // Act + await _sut.RevalidateAsync(); + + // Assert — StateChanged always fires to ensure consistent behavior for file-list changes + Assert.True(eventFired); + } + + [Fact] + public async Task RevalidateAsync_ClearsPreviousResults_WhenFileListBecomesEmpty() + { + // Arrange — first populate with results + var file = CreateScannedFile("file1.mkv"); + _fileList.Add(file); + + _validationEngine.Validate(Arg.Any>(), Arg.Any()) + .Returns(new List + { + new(ValidationSeverity.Warning, "file1.mkv", "Test warning") + }); + await _sut.RevalidateAsync(); + Assert.True(_sut.HasResults); + + // Clear the file list and subscribe for state change + _fileList.Clear(); + var eventFired = false; + _sut.StateChanged += (_, _) => eventFired = true; + + // Act — explicitly revalidate after file list becomes empty + await _sut.RevalidateAsync(); + + // Assert + Assert.Empty(_sut.CurrentResults); + Assert.False(_sut.HasResults); + Assert.True(eventFired); + } + + [Fact] + public async Task RevalidateAsync_UsesCurrentEffectiveSettings() + { + // Arrange + var file = CreateScannedFile("file1.mkv"); + _fileList.Add(file); + + var customSettings = new BatchValidationSettings + { + Mode = StrictnessMode.Custom + }; + _validationSettingsService.GetEffectiveSettings(Arg.Any()) + .Returns(customSettings); + _validationEngine.Validate(Arg.Any>(), Arg.Any()) + .Returns(new List()); + + // Act + await _sut.RevalidateAsync(); + + // Assert — verify the correct settings were passed + _validationEngine.Received().Validate( + Arg.Any>(), + Arg.Is(s => s.Mode == StrictnessMode.Custom)); + } + + [Fact] + public async Task RevalidateAsync_WithMixedResults_SetsPropertiesCorrectly() + { + // Arrange + var file = CreateScannedFile("file1.mkv"); + _fileList.Add(file); + + _validationEngine.Validate(Arg.Any>(), Arg.Any()) + .Returns(new List + { + new(ValidationSeverity.Error, "file1.mkv", "Track count mismatch"), + new(ValidationSeverity.Warning, "file1.mkv", "Language inconsistency"), + new(ValidationSeverity.Info, "file1.mkv", "Info message") + }); + + // Act + await _sut.RevalidateAsync(); + + // Assert + Assert.Equal(3, _sut.CurrentResults.Count); + Assert.True(_sut.HasBlockingErrors); + Assert.True(_sut.HasWarnings); + Assert.True(_sut.HasResults); + } + + [Fact] + public async Task RevalidateAsync_AfterDispose_ThrowsObjectDisposedException() + { + // Arrange + _sut.Dispose(); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.RevalidateAsync()); + } + private static ScannedFileInfo CreateScannedFile(string path) { var builder = new MediaInfoResultBuilder() From 1a11f4859928ab33faa42599da5eefa396028767 Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 16:12:33 +0100 Subject: [PATCH 08/12] test(processing): add PipelineRunner tests - Add tests covering: overlay publish/clear semantics, determinate progress updates, non-overlay stages, per-stage overlay ordering, cancellation, and concurrent run serialization via SemaphoreSlim --- .../Services/Pipeline/PipelineRunnerTests.cs | 184 +++++++++++++++++- 1 file changed, 179 insertions(+), 5 deletions(-) diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs index 0361a72..bbaac81 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs @@ -1,5 +1,6 @@ using MatroskaBatchFlow.Core.Abstractions.Pipeline; using MatroskaBatchFlow.Uno.Contracts.Services; +using MatroskaBatchFlow.Uno.Models; using MatroskaBatchFlow.Uno.Services.Pipeline; using Microsoft.Extensions.Logging; using NSubstitute; @@ -42,7 +43,7 @@ public async Task RunAsync_StopsExecutingStages_WhenContextIsAborted() var context = new PipelineContext(); - await _runner.RunAsync([abortingStage, subsequentStage], context); + await _runner.RunAsync([abortingStage, subsequentStage], context, TestContext.Current.CancellationToken); Assert.Single(executedStages); Assert.Equal("Aborting", executedStages[0]); @@ -68,7 +69,7 @@ public async Task RunAsync_ExecutesAllStages_WhenNotAborted() var context = new PipelineContext(); - await _runner.RunAsync([stage1, stage2], context); + await _runner.RunAsync([stage1, stage2], context, TestContext.Current.CancellationToken); Assert.Equal(2, executedStages.Count); Assert.Equal("Stage 1", executedStages[0]); @@ -86,19 +87,192 @@ public async Task RunAsync_ClearsFeedback_EvenWhenAborted() var context = new PipelineContext(); - await _runner.RunAsync([abortingStage], context); + await _runner.RunAsync([abortingStage], context, TestContext.Current.CancellationToken); _feedbackService.Received(1).Clear(); } + [Fact] + public async Task RunAsync_ClearsFeedback_WhenStageThrows() + { + var throwingStage = CreateStage("Throwing", (_, _, _) => + throw new InvalidOperationException("Stage failed")); + + var context = new PipelineContext(); + + await Assert.ThrowsAsync( + () => _runner.RunAsync([throwingStage], context, TestContext.Current.CancellationToken)); + + _feedbackService.Received(1).Clear(); + } + + [Fact] + public async Task RunAsync_ClearsFeedback_WhenCancelled() + { + using var cts = new CancellationTokenSource(); + + var blockingStage = CreateStage("Blocking", async (_, _, token) => + { + await cts.CancelAsync(); + token.ThrowIfCancellationRequested(); + }, showsOverlay: false); + + var context = new PipelineContext(); + + await Assert.ThrowsAnyAsync( + () => _runner.RunAsync([blockingStage], context, cts.Token)); + + _feedbackService.Received(1).Clear(); + } + + [Fact] + public async Task RunAsync_PublishesInitialOverlayState_ForOverlayStage() + { + var stage = CreateStage("Scanning files…", (_, _, _) => Task.CompletedTask, showsOverlay: true, isIndeterminate: true); + + await _runner.RunAsync([stage], new PipelineContext(), TestContext.Current.CancellationToken); + + _feedbackService.Received().Publish(Arg.Is(s => + s.Message == "Scanning files…" && + s.IsIndeterminate == true && + s.Current == 0 && + s.Total == 0 && + s.BlocksInput == true)); + } + + [Fact] + public async Task RunAsync_DoesNotPublishOverlay_ForNonOverlayStage() + { + var stage = CreateStage("Silent Stage", (_, _, _) => Task.CompletedTask, showsOverlay: false); + + await _runner.RunAsync([stage], new PipelineContext(), TestContext.Current.CancellationToken); + + _feedbackService.DidNotReceive().Publish(Arg.Any()); + } + + [Fact] + public async Task RunAsync_PublishesDeterminateProgress_WhenProgressReported() + { + var stage = CreateStage("Processing…", (_, progress, _) => + { + progress?.Report((3, 10)); + return Task.CompletedTask; + }, showsOverlay: true, isIndeterminate: false); + + await _runner.RunAsync([stage], new PipelineContext(), TestContext.Current.CancellationToken); + + _feedbackService.Received().Publish(Arg.Is(s => + s.Current == 3 && + s.Total == 10 && + s.IsIndeterminate == false)); + } + + [Fact] + public async Task RunAsync_PublishesOverlayForEachOverlayStage_InOrder() + { + var publishedMessages = new List(); + _feedbackService + .When(f => f.Publish(Arg.Any())) + .Do(ci => publishedMessages.Add(ci.Arg().Message)); + + var stage1 = CreateStage("Stage A", (_, _, _) => Task.CompletedTask, showsOverlay: true); + var stage2 = CreateStage("Stage B", (_, _, _) => Task.CompletedTask, showsOverlay: true); + + await _runner.RunAsync([stage1, stage2], new PipelineContext(), TestContext.Current.CancellationToken); + + Assert.Contains("Stage A", publishedMessages); + Assert.Contains("Stage B", publishedMessages); + Assert.True(publishedMessages.IndexOf("Stage A") < publishedMessages.IndexOf("Stage B")); + } + + [Fact] + public async Task RunAsync_SerializesConcurrentCalls() + { + // First run holds a gate open so it blocks until we release it. + var firstRunStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstRunGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRunStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var executionOrder = new List(); + + var slowStage = CreateStage("Slow", async (_, _, _) => + { + executionOrder.Add("first-start"); + firstRunStarted.SetResult(); + await firstRunGate.Task; + executionOrder.Add("first-end"); + }, showsOverlay: false); + + var fastStage = CreateStage("Fast", (_, _, _) => + { + executionOrder.Add("second-start"); + secondRunStarted.SetResult(); + return Task.CompletedTask; + }, showsOverlay: false); + + var ct = TestContext.Current.CancellationToken; + var firstRun = Task.Run(() => _runner.RunAsync([slowStage], new PipelineContext(), ct), ct); + + // Wait for first run to actually start before launching the second. + await firstRunStarted.Task.WaitAsync(ct); + var secondRun = Task.Run(() => _runner.RunAsync([fastStage], new PipelineContext(), ct), ct); + + // Give the second run enough time to attempt to acquire the lock. + await Task.Delay(50, ct); + + // Second run should not have started while first holds the lock. + Assert.DoesNotContain("second-start", executionOrder); + + // Release the first run. + firstRunGate.SetResult(); + await Task.WhenAll(firstRun, secondRun); + + // Both ran, and first completed entirely before second started. + Assert.Equal(["first-start", "first-end", "second-start"], executionOrder); + } + + [Fact] + public async Task RunAsync_DoesNotClearFeedback_WhenCancelledBeforeAcquiringLock() + { + // Block the lock with a long-running first call. + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lockHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var holdingStage = CreateStage("Holding", async (_, _, _) => + { + lockHeld.SetResult(); + await gate.Task; + }, showsOverlay: false); + + var holdingCt = TestContext.Current.CancellationToken; + _ = Task.Run(() => _runner.RunAsync([holdingStage], new PipelineContext(), holdingCt), holdingCt); + await lockHeld.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Now try a second run on the same runner, but cancel it before it can acquire the lock. + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Inject pre-cancelled token — WaitAsync on _runner's held lock should throw immediately. + await Assert.ThrowsAnyAsync( + () => _runner.RunAsync([CreateStage("Never", (_, _, _) => Task.CompletedTask)], new PipelineContext(), cts.Token)); + + // This test only verifies the second runner's behavior: because it never acquires the lock + // (the token is already cancelled), it must not call Clear on the feedback service. Any Clear + // invocation from the first, long-running runner is intentionally not asserted here. + _feedbackService.DidNotReceive().Clear(); + + gate.SetResult(); // Let the first run finish cleanly. + } + private static IPipelineStage CreateStage( string displayName, Func?, CancellationToken, Task> execute, - bool showsOverlay = true) + bool showsOverlay = true, + bool isIndeterminate = true) { var stage = Substitute.For(); stage.DisplayName.Returns(displayName); - stage.IsIndeterminate.Returns(true); + stage.IsIndeterminate.Returns(isIndeterminate); stage.ShowsOverlay.Returns(showsOverlay); stage.ExecuteAsync( Arg.Any(), From 196267cac11ab4ef5da747acfa678dc16ff1984e Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 16:17:16 +0100 Subject: [PATCH 09/12] docs(validation): update summary to clarify revalidation triggers --- .../Services/FileValidation/ValidationStateService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs index bab4c55..d77cc9e 100644 --- a/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs +++ b/src/MatroskaBatchFlow.Core/Services/FileValidation/ValidationStateService.cs @@ -5,8 +5,8 @@ namespace MatroskaBatchFlow.Core.Services.FileValidation; /// /// Manages ongoing validation state for the current batch of files. -/// Automatically re-validates when the file list changes, and exposes a -/// method for explicit triggers (e.g., after validation settings change). +/// Exposes and for explicit triggers +/// (e.g., after the file list or validation settings change). /// public sealed partial class ValidationStateService : IValidationStateService { From 90a353bc2ceba5e2fac956a9ccdb3006504e2a9b Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 17:20:17 +0100 Subject: [PATCH 10/12] refactor(core): move pipeline abstractions into Services/Pipeline namespace - Move IPipelineStage, PipelineContext, PipelineContextKeys from Abstractions/Pipeline/ to Services/Pipeline/ - Update namespace from MatroskaBatchFlow.Core.Abstractions.Pipeline to MatroskaBatchFlow.Core.Services.Pipeline - Update all using directives across core, ui, and test projects --- .../{Abstractions => Services}/Pipeline/IPipelineStage.cs | 2 +- .../Services/Pipeline/InitializeTrackConfigStage.cs | 1 - .../{Abstractions => Services}/Pipeline/PipelineContext.cs | 2 +- .../{Abstractions => Services}/Pipeline/PipelineContextKeys.cs | 2 +- .../Services/Pipeline/RefreshStaleMetadataStage.cs | 1 - src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs | 1 - src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs | 1 - src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs | 2 +- .../Services/BatchOperationOrchestrator.cs | 1 - .../Services/Pipeline/AddFilesToBatchStage.cs | 2 +- .../Services/Pipeline/FilterDuplicateFilesStage.cs | 2 +- src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs | 2 +- .../Services/Pipeline/RemoveFilesFromBatchStage.cs | 2 +- .../Services/BatchOperationOrchestratorTests.cs | 1 - .../Services/Pipeline/FilterDuplicateFilesStageTests.cs | 2 +- .../Services/Pipeline/PipelineRunnerTests.cs | 2 +- 16 files changed, 10 insertions(+), 16 deletions(-) rename src/MatroskaBatchFlow.Core/{Abstractions => Services}/Pipeline/IPipelineStage.cs (96%) rename src/MatroskaBatchFlow.Core/{Abstractions => Services}/Pipeline/PipelineContext.cs (97%) rename src/MatroskaBatchFlow.Core/{Abstractions => Services}/Pipeline/PipelineContextKeys.cs (92%) diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/IPipelineStage.cs similarity index 96% rename from src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs rename to src/MatroskaBatchFlow.Core/Services/Pipeline/IPipelineStage.cs index bc04939..a98b7e5 100644 --- a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/IPipelineStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/IPipelineStage.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace MatroskaBatchFlow.Core.Abstractions.Pipeline; +namespace MatroskaBatchFlow.Core.Services.Pipeline; /// /// Represents a single stage in a composable pipeline. diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs index 1985ae8..d8e638c 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/InitializeTrackConfigStage.cs @@ -1,4 +1,3 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; using MatroskaBatchFlow.Core.Enums; using MatroskaBatchFlow.Core.Models; using MatroskaBatchFlow.Core.Services.FileProcessing; diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContext.cs similarity index 97% rename from src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs rename to src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContext.cs index bfdbf0a..96f3380 100644 --- a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContext.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContext.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace MatroskaBatchFlow.Core.Abstractions.Pipeline; +namespace MatroskaBatchFlow.Core.Services.Pipeline; /// /// Shared data bag passed between pipeline stages. diff --git a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContextKeys.cs similarity index 92% rename from src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs rename to src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContextKeys.cs index 94f3849..95ed801 100644 --- a/src/MatroskaBatchFlow.Core/Abstractions/Pipeline/PipelineContextKeys.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/PipelineContextKeys.cs @@ -1,4 +1,4 @@ -namespace MatroskaBatchFlow.Core.Abstractions.Pipeline; +namespace MatroskaBatchFlow.Core.Services.Pipeline; /// /// Well-known keys for values stored in . diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs index d269971..c61e421 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/RefreshStaleMetadataStage.cs @@ -1,4 +1,3 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; using Microsoft.Extensions.Logging; namespace MatroskaBatchFlow.Core.Services.Pipeline; diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs index 93c87d7..1aedf7b 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/ScanFilesStage.cs @@ -1,4 +1,3 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; using MatroskaBatchFlow.Core.Models; using Microsoft.Extensions.Logging; diff --git a/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs b/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs index 99eca77..832d455 100644 --- a/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs +++ b/src/MatroskaBatchFlow.Core/Services/Pipeline/ValidateStage.cs @@ -1,4 +1,3 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; using MatroskaBatchFlow.Core.Services.FileValidation; namespace MatroskaBatchFlow.Core.Services.Pipeline; diff --git a/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs index 6f7428d..52eeee2 100644 --- a/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs +++ b/src/MatroskaBatchFlow.Uno/Contracts/Services/IPipelineRunner.cs @@ -1,4 +1,4 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; namespace MatroskaBatchFlow.Uno.Contracts.Services; diff --git a/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs index f79b15c..01c7441 100644 --- a/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs +++ b/src/MatroskaBatchFlow.Uno/Services/BatchOperationOrchestrator.cs @@ -1,4 +1,3 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Uno.Contracts.Services; using MatroskaBatchFlow.Uno.Services.Pipeline; diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs index 0902061..59b57ef 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/AddFilesToBatchStage.cs @@ -1,4 +1,4 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Uno.Contracts.Services; namespace MatroskaBatchFlow.Uno.Services.Pipeline; diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs index d919f3f..cd6c763 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/FilterDuplicateFilesStage.cs @@ -1,5 +1,5 @@ using CommunityToolkit.Mvvm.Messaging; -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Core.Services; using MatroskaBatchFlow.Uno.Messages; diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs index e9a18b3..4b67210 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs @@ -1,5 +1,5 @@ using System.Diagnostics; -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Uno.Contracts.Services; using MatroskaBatchFlow.Uno.Models; diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs index 75e3c82..62bf442 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/RemoveFilesFromBatchStage.cs @@ -1,4 +1,4 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Uno.Contracts.Services; namespace MatroskaBatchFlow.Uno.Services.Pipeline; diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs index 377d02c..731c563 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/BatchOperationOrchestratorTests.cs @@ -1,4 +1,3 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; using MatroskaBatchFlow.Core.Enums; using MatroskaBatchFlow.Core.Models; using MatroskaBatchFlow.Core.Services; diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs index a90f84e..ac48c6a 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/FilterDuplicateFilesStageTests.cs @@ -1,4 +1,4 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Core.Models; using MatroskaBatchFlow.Core.Services; using MatroskaBatchFlow.Uno.Services.Pipeline; diff --git a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs index bbaac81..c24d944 100644 --- a/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs +++ b/tests/MatroskaBatchFlow.Uno.UnitTests/Services/Pipeline/PipelineRunnerTests.cs @@ -1,4 +1,4 @@ -using MatroskaBatchFlow.Core.Abstractions.Pipeline; +using MatroskaBatchFlow.Core.Services.Pipeline; using MatroskaBatchFlow.Uno.Contracts.Services; using MatroskaBatchFlow.Uno.Models; using MatroskaBatchFlow.Uno.Services.Pipeline; From d551dd0ea68d14159b5f924e5e1016d9727f2341 Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 19:09:42 +0100 Subject: [PATCH 11/12] docs(InputOperationOverlayState): add XML documentation for Inactive InputOperationOverlayState property --- src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs b/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs index ab1cfb3..642ea8c 100644 --- a/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs +++ b/src/MatroskaBatchFlow.Uno/Models/InputOperationOverlayState.cs @@ -7,5 +7,8 @@ public sealed record InputOperationOverlayState(string Message, bool IsIndetermi /// public bool IsActive => !string.IsNullOrEmpty(Message); + /// + /// A sentinel value representing an inactive (hidden) overlay with no message or progress. + /// public static InputOperationOverlayState Inactive { get; } = new(string.Empty, true, 0, 0, false); } From 5096f891729de73f2836de616d1f2db1f20f3dd7 Mon Sep 17 00:00:00 2001 From: Tim Gels Date: Fri, 27 Feb 2026 21:54:55 +0100 Subject: [PATCH 12/12] refactor(PipelineRunner): change minimum overlay duration into constant --- .../Services/Pipeline/PipelineRunner.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs index 4b67210..c98d98f 100644 --- a/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs +++ b/src/MatroskaBatchFlow.Uno/Services/Pipeline/PipelineRunner.cs @@ -14,14 +14,17 @@ namespace MatroskaBatchFlow.Uno.Services.Pipeline; /// on shared state (batch configuration, validation) and avoids one run clearing the overlay /// while another is still in progress. /// -public sealed partial class PipelineRunner( - IInputOperationFeedbackService feedbackService, - ILogger logger) : IPipelineRunner +public sealed partial class PipelineRunner(IInputOperationFeedbackService feedbackService, ILogger logger) : IPipelineRunner { + /// + /// Minimum overlay visibility duration in milliseconds. + /// + private const int MinOverlayDurationMs = 500; + /// /// Minimum time an overlay-enabled stage stays visible, preventing sub-perceptual flicker. /// - private static readonly TimeSpan MinOverlayDuration = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan MinOverlayDuration = TimeSpan.FromMilliseconds(MinOverlayDurationMs); /// /// Ensures that at most one pipeline run executes at a time.