From 7e212ccba5ef9dc32f1a85413447d8234d639374 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 14:55:04 +0200 Subject: [PATCH 1/7] feat(validation): integrate CSV pipeline and language detection with GameInstallationValidator Integrate CsvContentProvider and LanguageDetector into GameInstallationValidator to enable manifest-driven multi-language game installation validation. - Enhance ValidationResult with detailed missing, corrupted, and extra file counts - Add ValidateInstallationAsync and language-aware ValidateAsync overloads to IGameInstallationValidator - Refactor LanguageDetector to use cross-platform paths and centralized constants - Register ILanguageDetector in GameInstallationModule and CsvContentProvider in ContentPipelineModule - Add comprehensive unit tests in LanguageDetectorTests and GameInstallationValidatorTests - Add CSV validation pipeline architecture documentation Closes #145, closes #146, closes #147 --- .../GameInstallations/LanguageDetector.cs | 159 ++++--- .../Validation/IGameInstallationValidator.cs | 32 +- .../Results/Validation/ValidationResult.cs | 14 +- .../LanguageDetectorTests.cs | 179 ++++++++ .../GameInstallationValidatorTests.cs | 403 ++++++++++++++++++ .../Validation/GameInstallationValidator.cs | 306 +++++++++++-- .../ContentPipelineModule.cs | 3 +- .../GameInstallationModule.cs | 2 + docs/features/content/csv-validation.md | 129 ++++++ docs/features/content/index.md | 1 + 10 files changed, 1123 insertions(+), 105 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs create mode 100644 docs/features/content/csv-validation.md diff --git a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs index dcc7557ed..28e4b1652 100644 --- a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs +++ b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Content; namespace GenHub.Core.Features.GameInstallations; @@ -19,125 +21,148 @@ public class LanguageDetector : ILanguageDetector /// The detected language code in uppercase (e.g., "EN", "DE"), or "EN" as fallback. public Task DetectAsync(string installationPath, CancellationToken cancellationToken = default) { - if (!Directory.Exists(installationPath)) + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(installationPath) || !Directory.Exists(installationPath)) { - return Task.FromResult("EN"); // Fallback + return Task.FromResult(CsvConstants.LanguageEn); } - // Check for language-specific directories and files - var languageMappings = new[] + // Check for language-specific directories + var directoryMappings = new (string RelativeDir, string Language)[] { - new { Pattern = "Data\\english", Language = "EN" }, - new { Pattern = "Data\\English", Language = "EN" }, - new { Pattern = "Data\\german", Language = "DE" }, - new { Pattern = "Data\\deutsch", Language = "DE" }, - new { Pattern = "Data\\french", Language = "FR" }, - new { Pattern = "Data\\spanish", Language = "ES" }, - new { Pattern = "Data\\italian", Language = "IT" }, - new { Pattern = "Data\\korean", Language = "KO" }, - new { Pattern = "Data\\polish", Language = "PL" }, - new { Pattern = "Data\\portuguese", Language = "PT-BR" }, - new { Pattern = "Data\\chinese", Language = "ZH-CN" }, - new { Pattern = "Data\\chinese-traditional", Language = "ZH-TW" }, + (LanguageDirectoryNames.DataEnglish, CsvConstants.LanguageEn), + (LanguageDirectoryNames.DataEnglishUppercase, CsvConstants.LanguageEn), + (LanguageDirectoryNames.DataGerman, CsvConstants.LanguageDe), + (LanguageDirectoryNames.DataDeutsch, CsvConstants.LanguageDe), + (LanguageDirectoryNames.DataFrench, CsvConstants.LanguageFr), + (LanguageDirectoryNames.DataSpanish, CsvConstants.LanguageEs), + (LanguageDirectoryNames.DataItalian, CsvConstants.LanguageIt), + (LanguageDirectoryNames.DataKorean, CsvConstants.LanguageKo), + (LanguageDirectoryNames.DataPolish, CsvConstants.LanguagePl), + (LanguageDirectoryNames.DataPortuguese, CsvConstants.LanguagePtBr), + (LanguageDirectoryNames.DataChinese, CsvConstants.LanguageZhCn), + (LanguageDirectoryNames.DataChineseTraditional, CsvConstants.LanguageZhTw), }; - foreach (var mapping in languageMappings) + foreach (var (relativeDir, language) in directoryMappings) { - if (Directory.Exists(Path.Combine(installationPath, mapping.Pattern))) + var dirPath = CombineRelativePath(installationPath, relativeDir); + if (Directory.Exists(dirPath)) { - return Task.FromResult(mapping.Language); + return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language)); } } // Check for language-specific files - var fileMappings = new[] + var fileMappings = new (string FileName, string Language)[] { // English - new { Pattern = "English.big", Language = "EN" }, - new { Pattern = "AudioEnglish.big", Language = "EN" }, - new { Pattern = "SpeechEnglish.big", Language = "EN" }, + (LanguageFilePatterns.EnglishBig, CsvConstants.LanguageEn), + (LanguageFilePatterns.AudioEnglishBig, CsvConstants.LanguageEn), + (LanguageFilePatterns.SpeechEnglishBig, CsvConstants.LanguageEn), // German - new { Pattern = "German.big", Language = "DE" }, - new { Pattern = "AudioGerman.big", Language = "DE" }, + (LanguageFilePatterns.GermanBig, CsvConstants.LanguageDe), + (LanguageFilePatterns.AudioGermanBig, CsvConstants.LanguageDe), // French - new { Pattern = "French.big", Language = "FR" }, - new { Pattern = "AudioFrench.big", Language = "FR" }, + (LanguageFilePatterns.FrenchBig, CsvConstants.LanguageFr), + (LanguageFilePatterns.AudioFrenchBig, CsvConstants.LanguageFr), // Spanish - new { Pattern = "Spanish.big", Language = "ES" }, - new { Pattern = "AudioSpanish.big", Language = "ES" }, + (LanguageFilePatterns.SpanishBig, CsvConstants.LanguageEs), + (LanguageFilePatterns.AudioSpanishBig, CsvConstants.LanguageEs), // Italian - new { Pattern = "Italian.big", Language = "IT" }, - new { Pattern = "AudioItalian.big", Language = "IT" }, + (LanguageFilePatterns.ItalianBig, CsvConstants.LanguageIt), + (LanguageFilePatterns.AudioItalianBig, CsvConstants.LanguageIt), // Korean - new { Pattern = "Korean.big", Language = "KO" }, - new { Pattern = "AudioKorean.big", Language = "KO" }, + (LanguageFilePatterns.KoreanBig, CsvConstants.LanguageKo), + (LanguageFilePatterns.AudioKoreanBig, CsvConstants.LanguageKo), // Polish - new { Pattern = "Polish.big", Language = "PL" }, - new { Pattern = "AudioPolish.big", Language = "PL" }, + (LanguageFilePatterns.PolishBig, CsvConstants.LanguagePl), + (LanguageFilePatterns.AudioPolishBig, CsvConstants.LanguagePl), // Portuguese-Brazil - new { Pattern = "PortugueseBrazil.big", Language = "PT-BR" }, - new { Pattern = "AudioPortugueseBrazil.big", Language = "PT-BR" }, + (LanguageFilePatterns.PortugueseBrazilBig, CsvConstants.LanguagePtBr), + (LanguageFilePatterns.AudioPortugueseBrazilBig, CsvConstants.LanguagePtBr), // Chinese Simplified - new { Pattern = "Chinese.big", Language = "ZH-CN" }, - new { Pattern = "AudioChinese.big", Language = "ZH-CN" }, + (LanguageFilePatterns.ChineseBig, CsvConstants.LanguageZhCn), + (LanguageFilePatterns.AudioChineseBig, CsvConstants.LanguageZhCn), // Chinese Traditional - new { Pattern = "ChineseTraditional.big", Language = "ZH-TW" }, - new { Pattern = "AudioChineseTraditional.big", Language = "ZH-TW" }, + (LanguageFilePatterns.ChineseTraditionalBig, CsvConstants.LanguageZhTw), + (LanguageFilePatterns.AudioChineseTraditionalBig, CsvConstants.LanguageZhTw), }; - foreach (var mapping in fileMappings) + foreach (var (fileName, language) in fileMappings) { - if (File.Exists(Path.Combine(installationPath, mapping.Pattern))) + var filePath = Path.Combine(installationPath, fileName); + if (File.Exists(filePath)) { - return Task.FromResult(mapping.Language); + return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language)); } } // Check for Zero Hour specific patterns - var zhPatterns = new[] + var zhPatterns = new (string Pattern, string Language)[] { - new { Pattern = "EnglishZH.big", Language = "EN" }, - new { Pattern = "AudioZH.big", Language = "EN" }, - new { Pattern = "INIZH.big", Language = "EN" }, - new { Pattern = "*ZH.big", Language = "EN" }, // Generic ZH files - new { Pattern = "GeneralsOnlineZH", Language = "EN" }, // Executables - new { Pattern = "GermanZH.big", Language = "DE" }, - new { Pattern = "FrenchZH.big", Language = "FR" }, - new { Pattern = "SpanishZH.big", Language = "ES" }, - new { Pattern = "ItalianZH.big", Language = "IT" }, - new { Pattern = "KoreanZH.big", Language = "KO" }, - new { Pattern = "PolishZH.big", Language = "PL" }, - new { Pattern = "PortugueseZH.big", Language = "PT-BR" }, - new { Pattern = "ChineseZH.big", Language = "ZH-CN" }, + (LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn), + ("AudioZH.big", CsvConstants.LanguageEn), + ("INIZH.big", CsvConstants.LanguageEn), + (LanguageFilePatterns.GermanZHBig, CsvConstants.LanguageDe), + (LanguageFilePatterns.FrenchZHBig, CsvConstants.LanguageFr), + (LanguageFilePatterns.SpanishZHBig, CsvConstants.LanguageEs), + (LanguageFilePatterns.ItalianZHBig, CsvConstants.LanguageIt), + (LanguageFilePatterns.KoreanZHBig, CsvConstants.LanguageKo), + (LanguageFilePatterns.PolishZHBig, CsvConstants.LanguagePl), + (LanguageFilePatterns.PortugueseZHBig, CsvConstants.LanguagePtBr), + (LanguageFilePatterns.ChineseZHBig, CsvConstants.LanguageZhCn), + ("*ZH.big", CsvConstants.LanguageEn), }; - foreach (var mapping in zhPatterns) + foreach (var (pattern, language) in zhPatterns) { - if (mapping.Pattern.Contains("*")) + if (pattern.Contains('*')) { - // Handle wildcard - var files = Directory.GetFiles(installationPath, mapping.Pattern, SearchOption.AllDirectories); - if (files.Length > 0) + try + { + var files = Directory.GetFiles(installationPath, pattern, SearchOption.AllDirectories); + if (files.Length > 0) + { + return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language)); + } + } + catch (IOException) + { + // Fall through on IO issues + } + catch (UnauthorizedAccessException) { - return Task.FromResult(mapping.Language); + // Fall through on permission issues } } - else if (File.Exists(Path.Combine(installationPath, mapping.Pattern))) + else { - return Task.FromResult(mapping.Language); + var filePath = Path.Combine(installationPath, pattern); + if (File.Exists(filePath)) + { + return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language)); + } } } // Fallback to English - return Task.FromResult("EN"); + return Task.FromResult(CsvConstants.LanguageEn); + } + + private static string CombineRelativePath(string basePath, string relativePath) + { + var segments = relativePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + return Path.Combine(segments.Prepend(basePath).ToArray()); } } diff --git a/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs b/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs index 47d9f14f7..6bfe6beeb 100644 --- a/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs +++ b/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs @@ -1,3 +1,7 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; @@ -24,5 +28,31 @@ public interface IGameInstallationValidator /// Progress reporter for MVVM integration. /// A cancellation token. /// A representing the outcome of the validation. - Task ValidateAsync(GameInstallation installation, IProgress? progress = null, CancellationToken cancellationToken = default); + Task ValidateAsync(GameInstallation installation, IProgress? progress, CancellationToken cancellationToken = default); + + /// + /// Validates a game installation with an explicit language and progress reporting. + /// + /// The game installation to validate. + /// Optional explicit language code (e.g., "EN", "DE"). If null, language is auto-detected. + /// Progress reporter for MVVM integration. + /// A cancellation token. + /// A representing the outcome of the validation. + Task ValidateAsync(GameInstallation installation, string? language, IProgress? progress = null, CancellationToken cancellationToken = default); + + /// + /// Validates a specific game installation directory by path, game type, and optional language. + /// + /// The path to the game directory. + /// The target game type (Generals or ZeroHour). + /// Optional explicit language code. If null, language is auto-detected. + /// Progress reporter for MVVM integration. + /// A cancellation token. + /// A representing the outcome of the validation. + Task ValidateInstallationAsync( + string installationPath, + GameType gameType, + string? language = null, + IProgress? progress = null, + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs b/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs index 910df9aaa..ff0cf6611 100644 --- a/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs +++ b/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs @@ -5,7 +5,7 @@ namespace GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; /// Encapsulates the result of a validation operation for a game version or installation. -public class ValidationResult(string validatedTargetId, List? issues, TimeSpan elapsed = default) +public class ValidationResult(string validatedTargetId, List? issues, TimeSpan elapsed = default, int totalFilesValidated = 0) : ResultBase(DetermineSuccess(issues), ExtractErrorMessages(issues), elapsed) { /// Gets the unique ID of the target that was validated (e.g., a GameClient ID or a GameInstallation ID). @@ -17,6 +17,18 @@ public class ValidationResult(string validatedTargetId, List? i /// Gets a value indicating whether the target is considered valid. public bool IsValid => Success; + /// Gets the total number of files validated. + public int TotalFilesValidated { get; init; } = totalFilesValidated; + + /// Gets the count of missing files. + public int MissingFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.MissingFile); + + /// Gets the count of corrupted or size-mismatched files. + public int CorruptedFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.CorruptedFile || i.IssueType == ValidationIssueType.MismatchedFileSize); + + /// Gets the count of extra or unexpected files. + public int ExtraFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.UnexpectedFile); + /// Gets the count of critical issues that prevent the target from being considered valid. public int CriticalIssueCount => Issues.Count(i => i.Severity == ValidationSeverity.Error || i.Severity == ValidationSeverity.Critical); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs new file mode 100644 index 000000000..b62e474a5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.GameInstallations; +using Xunit; + +namespace GenHub.Tests.Features.GameInstallations; + +/// +/// Unit tests for LanguageDetector. +/// +public class LanguageDetectorTests +{ + private readonly LanguageDetector _detector = new(); + + /// + /// Tests that invalid or non-existent paths return English fallback. + /// + /// The invalid path to test. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("non_existent_directory_xyz_123")] + public async Task DetectAsync_WithInvalidPath_ReturnsEnglishFallback(string? path) + { + var result = await _detector.DetectAsync(path!); + Assert.Equal(CsvConstants.LanguageEn, result); + } + + /// + /// Tests that a cancelled token throws OperationCanceledException. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task DetectAsync_WithCancelledToken_ThrowsOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => _detector.DetectAsync("some_path", cts.Token)); + } + + /// + /// Tests that language directory presence detects the corresponding language code. + /// + /// The relative directory name. + /// The expected detected language code. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(LanguageDirectoryNames.DataEnglish, CsvConstants.LanguageEn)] + [InlineData(LanguageDirectoryNames.DataEnglishUppercase, CsvConstants.LanguageEn)] + [InlineData(LanguageDirectoryNames.DataGerman, CsvConstants.LanguageDe)] + [InlineData(LanguageDirectoryNames.DataDeutsch, CsvConstants.LanguageDe)] + [InlineData(LanguageDirectoryNames.DataFrench, CsvConstants.LanguageFr)] + [InlineData(LanguageDirectoryNames.DataSpanish, CsvConstants.LanguageEs)] + [InlineData(LanguageDirectoryNames.DataItalian, CsvConstants.LanguageIt)] + [InlineData(LanguageDirectoryNames.DataKorean, CsvConstants.LanguageKo)] + [InlineData(LanguageDirectoryNames.DataPolish, CsvConstants.LanguagePl)] + [InlineData(LanguageDirectoryNames.DataPortuguese, CsvConstants.LanguagePtBr)] + [InlineData(LanguageDirectoryNames.DataChinese, CsvConstants.LanguageZhCn)] + [InlineData(LanguageDirectoryNames.DataChineseTraditional, CsvConstants.LanguageZhTw)] + public async Task DetectAsync_WithLanguageDirectory_DetectsCorrectLanguage(string relativeDir, string expectedLanguage) + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var segments = relativeDir.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + var dirPath = Path.Combine(segments.Prepend(tempDir.FullName).ToArray()); + Directory.CreateDirectory(dirPath); + + var result = await _detector.DetectAsync(tempDir.FullName); + Assert.Equal(expectedLanguage, result); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that language-specific BIG files detect the corresponding language code. + /// + /// The BIG file name. + /// The expected detected language code. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(LanguageFilePatterns.GermanBig, CsvConstants.LanguageDe)] + [InlineData(LanguageFilePatterns.AudioGermanBig, CsvConstants.LanguageDe)] + [InlineData(LanguageFilePatterns.FrenchBig, CsvConstants.LanguageFr)] + [InlineData(LanguageFilePatterns.AudioFrenchBig, CsvConstants.LanguageFr)] + [InlineData(LanguageFilePatterns.SpanishBig, CsvConstants.LanguageEs)] + [InlineData(LanguageFilePatterns.AudioSpanishBig, CsvConstants.LanguageEs)] + [InlineData(LanguageFilePatterns.ItalianBig, CsvConstants.LanguageIt)] + [InlineData(LanguageFilePatterns.AudioItalianBig, CsvConstants.LanguageIt)] + [InlineData(LanguageFilePatterns.KoreanBig, CsvConstants.LanguageKo)] + [InlineData(LanguageFilePatterns.AudioKoreanBig, CsvConstants.LanguageKo)] + [InlineData(LanguageFilePatterns.PolishBig, CsvConstants.LanguagePl)] + [InlineData(LanguageFilePatterns.AudioPolishBig, CsvConstants.LanguagePl)] + [InlineData(LanguageFilePatterns.PortugueseBrazilBig, CsvConstants.LanguagePtBr)] + [InlineData(LanguageFilePatterns.AudioPortugueseBrazilBig, CsvConstants.LanguagePtBr)] + [InlineData(LanguageFilePatterns.ChineseBig, CsvConstants.LanguageZhCn)] + [InlineData(LanguageFilePatterns.AudioChineseBig, CsvConstants.LanguageZhCn)] + [InlineData(LanguageFilePatterns.ChineseTraditionalBig, CsvConstants.LanguageZhTw)] + [InlineData(LanguageFilePatterns.AudioChineseTraditionalBig, CsvConstants.LanguageZhTw)] + public async Task DetectAsync_WithLanguageBigFile_DetectsCorrectLanguage(string fileName, string expectedLanguage) + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var filePath = Path.Combine(tempDir.FullName, fileName); + await File.WriteAllTextAsync(filePath, "dummy big content"); + + var result = await _detector.DetectAsync(tempDir.FullName); + Assert.Equal(expectedLanguage, result); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that Zero Hour specific language BIG files detect the corresponding language code. + /// + /// The Zero Hour BIG file name. + /// The expected detected language code. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(LanguageFilePatterns.GermanZHBig, CsvConstants.LanguageDe)] + [InlineData(LanguageFilePatterns.FrenchZHBig, CsvConstants.LanguageFr)] + [InlineData(LanguageFilePatterns.SpanishZHBig, CsvConstants.LanguageEs)] + [InlineData(LanguageFilePatterns.ItalianZHBig, CsvConstants.LanguageIt)] + [InlineData(LanguageFilePatterns.KoreanZHBig, CsvConstants.LanguageKo)] + [InlineData(LanguageFilePatterns.PolishZHBig, CsvConstants.LanguagePl)] + [InlineData(LanguageFilePatterns.PortugueseZHBig, CsvConstants.LanguagePtBr)] + [InlineData(LanguageFilePatterns.ChineseZHBig, CsvConstants.LanguageZhCn)] + [InlineData(LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn)] + public async Task DetectAsync_WithZeroHourPatterns_DetectsCorrectLanguage(string fileName, string expectedLanguage) + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var filePath = Path.Combine(tempDir.FullName, fileName); + await File.WriteAllTextAsync(filePath, "dummy zh big content"); + + var result = await _detector.DetectAsync(tempDir.FullName); + Assert.Equal(expectedLanguage, result); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that directory containing only unknown files falls back to English. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task DetectAsync_WithUnknownFiles_FallsBackToEnglish() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + await File.WriteAllTextAsync(Path.Combine(tempDir.FullName, "random_mod_file.big"), "data"); + + var result = await _detector.DetectAsync(tempDir.FullName); + Assert.Equal(CsvConstants.LanguageEn, result); + } + finally + { + tempDir.Delete(true); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs index bdb971a03..527e1a7e9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs @@ -1,14 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.GameInstallations; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; using GenHub.Core.Models.Validation; using GenHub.Features.Validation; using Microsoft.Extensions.Logging; using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; +using GameType = GenHub.Core.Models.Enums.GameType; namespace GenHub.Tests.Features.Validation; @@ -388,6 +401,396 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefullyAsync } } + /// + /// Tests that ValidateAsync validates a multi-language installation using CsvContentProvider. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_WithCsvContentProvider_ValidatesMultiLanguageInstallationSuccessfully() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var manifest = new ContentManifest + { + Id = new ManifestId("csv-generals-1.08-de"), + Name = "Generals 1.08 (DE)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = new List + { + new() { RelativePath = "generals.exe", Size = 100, Hash = "abc", SourceType = ContentSourceType.GameInstallation, IsRequired = true }, + new() { RelativePath = "German.big", Size = 200, Hash = "def", SourceType = ContentSourceType.GameInstallation, IsRequired = true }, + }, + }; + + var searchResult = new ContentSearchResult + { + Id = "csv-generals-1.08-de", + Name = "Generals 1.08 (DE)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + }; + searchResult.SetData(manifest); + + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.Generals && q.Language == CsvConstants.LanguageDe), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([searchResult])); + + var mockLanguageDetector = new Mock(); + mockLanguageDetector + .Setup(d => d.DetectAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CsvConstants.LanguageDe); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + null, + _contentValidatorMock.Object, + _hashProviderMock.Object, + mockLanguageDetector.Object, + null, + [mockContentProvider.Object]); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation, CancellationToken.None); + + Assert.True(result.IsValid); + Assert.Equal(2, result.TotalFilesValidated); + Assert.Empty(result.Issues); + mockContentProvider.Verify( + p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.Generals && q.Language == CsvConstants.LanguageDe), It.IsAny()), + Times.Once); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that ValidateAsync with an explicit language overrides auto-detection. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_WithExplicitLanguage_OverridesAutoDetection() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var manifest = new ContentManifest + { + Id = new ManifestId("csv-generals-1.08-fr"), + Name = "Generals 1.08 (FR)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "French.big", Size = 100, Hash = "abc", SourceType = ContentSourceType.GameInstallation }], + }; + + var searchResult = new ContentSearchResult { Id = "csv-generals-1.08-fr" }; + searchResult.SetData(manifest); + + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.Is(q => q.Language == CsvConstants.LanguageFr), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([searchResult])); + + var mockLanguageDetector = new Mock(); + mockLanguageDetector + .Setup(d => d.DetectAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CsvConstants.LanguageDe); // Auto-detect would say DE, but explicit is FR + + var validator = new GameInstallationValidator( + _loggerMock.Object, + null, + _contentValidatorMock.Object, + _hashProviderMock.Object, + mockLanguageDetector.Object, + null, + [mockContentProvider.Object]); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation, "fr"); + + Assert.True(result.IsValid); + Assert.Equal(1, result.TotalFilesValidated); + mockContentProvider.Verify( + p => p.SearchAsync(It.Is(q => q.Language == CsvConstants.LanguageFr), It.IsAny()), + Times.Once); + mockLanguageDetector.Verify(d => d.DetectAsync(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that ValidateInstallationAsync validates direct path and game type with language normalization. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateInstallationAsync_DirectPathAndGameType_ResolvesAndValidates() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var manifest = new ContentManifest + { + Id = new ManifestId("csv-zerohour-1.04-zh-cn"), + Name = "Zero Hour 1.04 (ZH-CN)", + Version = "1.04", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Files = [new ManifestFile { RelativePath = "ChineseZH.big", Size = 50, Hash = "xyz", SourceType = ContentSourceType.GameInstallation }], + }; + + var searchResult = new ContentSearchResult { Id = "csv-zerohour-1.04-zh-cn" }; + searchResult.SetData(manifest); + + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.ZeroHour && q.Language == CsvConstants.LanguageZhCn), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([searchResult])); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + null, + _contentValidatorMock.Object, + _hashProviderMock.Object, + new LanguageDetector(), + null, + [mockContentProvider.Object]); + + var result = await validator.ValidateInstallationAsync(tempDir.FullName, GameType.ZeroHour, "zh-cn"); + + Assert.True(result.IsValid); + Assert.Equal(1, result.TotalFilesValidated); + mockContentProvider.Verify( + p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.ZeroHour && q.Language == CsvConstants.LanguageZhCn), It.IsAny()), + Times.Once); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that ValidateAsync reports detailed issue counts on ValidationResult. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_DetailedCounts_ReportsCorrectMissingCorruptedAndExtraCounts() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var manifest = new ContentManifest + { + Id = new ManifestId("csv-generals-1.08-en"), + Files = + [ + new ManifestFile { RelativePath = "missing1.txt", Size = 10, Hash = "h1" }, + new ManifestFile { RelativePath = "corrupted1.txt", Size = 20, Hash = "h2" }, + new ManifestFile { RelativePath = "valid1.txt", Size = 30, Hash = "h3" }, + ], + }; + + var searchResult = new ContentSearchResult { Id = "csv-generals-1.08-en" }; + searchResult.SetData(manifest); + + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([searchResult])); + + var mockContentValidator = new Mock(); + mockContentValidator + .Setup(c => c.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult("test", [])); + + mockContentValidator + .Setup(c => c.ValidateAllAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new ValidationResult( + "test", + [ + new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Message = "Missing file 1", Severity = ValidationSeverity.Error }, + new ValidationIssue { IssueType = ValidationIssueType.CorruptedFile, Message = "Corrupted file 1", Severity = ValidationSeverity.Error }, + new ValidationIssue { IssueType = ValidationIssueType.MismatchedFileSize, Message = "Size mismatch", Severity = ValidationSeverity.Warning }, + new ValidationIssue { IssueType = ValidationIssueType.UnexpectedFile, Message = "Extra file 1", Severity = ValidationSeverity.Warning }, + new ValidationIssue { IssueType = ValidationIssueType.UnexpectedFile, Message = "Extra file 2", Severity = ValidationSeverity.Warning }, + ])); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + null, + mockContentValidator.Object, + _hashProviderMock.Object, + null, + null, + [mockContentProvider.Object]); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation, CancellationToken.None); + + Assert.False(result.IsValid); + Assert.Equal(3, result.TotalFilesValidated); + Assert.Equal(1, result.MissingFilesCount); + Assert.Equal(2, result.CorruptedFilesCount); // CorruptedFile + MismatchedFileSize + Assert.Equal(2, result.ExtraFilesCount); + Assert.Equal(2, result.CriticalIssueCount); + Assert.Equal(3, result.WarningIssueCount); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that ValidateAsync returns a language-specific error message when CSV provider search fails. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_WithCsvProviderFailure_ReturnsLanguageSpecificErrorMessage() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateFailure("Network timeout")); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + null, + _contentValidatorMock.Object, + _hashProviderMock.Object, + null, + null, + [mockContentProvider.Object]); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation, "PL"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => i.Message.Contains("PL") && i.Message.Contains("Network timeout")); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests multi-language normalization and support for all supported language codes. + /// + /// The raw input language code. + /// The expected normalized uppercase language code. + /// A representing the asynchronous unit test. + [Theory] + [InlineData("en", CsvConstants.LanguageEn)] + [InlineData("de", CsvConstants.LanguageDe)] + [InlineData("fr", CsvConstants.LanguageFr)] + [InlineData("es", CsvConstants.LanguageEs)] + [InlineData("it", CsvConstants.LanguageIt)] + [InlineData("ko", CsvConstants.LanguageKo)] + [InlineData("pl", CsvConstants.LanguagePl)] + [InlineData("pt-br", CsvConstants.LanguagePtBr)] + [InlineData("zh-cn", CsvConstants.LanguageZhCn)] + [InlineData("zh-tw", CsvConstants.LanguageZhTw)] + public async Task ValidateAsync_MultiLanguageSupport_NormalizesLanguageAndValidates(string inputLanguage, string expectedNormalized) + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var manifest = new ContentManifest + { + Id = new ManifestId($"csv-generals-1.08-{inputLanguage}"), + Files = [new ManifestFile { RelativePath = "test.txt", Size = 10, Hash = "h" }], + }; + + var searchResult = new ContentSearchResult { Id = $"csv-generals-1.08-{inputLanguage}" }; + searchResult.SetData(manifest); + + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.Is(q => q.Language == expectedNormalized), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([searchResult])); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + null, + _contentValidatorMock.Object, + _hashProviderMock.Object, + null, + null, + [mockContentProvider.Object]); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation, inputLanguage); + + Assert.True(result.IsValid); + mockContentProvider.Verify( + p => p.SearchAsync(It.Is(q => q.Language == expectedNormalized), It.IsAny()), + Times.Once); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that ValidateAsync throws ArgumentNullException when installation is null. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_NullInstallation_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => _validator.ValidateAsync(null!, CancellationToken.None)); + } + /// /// Custom progress implementation that captures reports synchronously. /// diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 7288e74e6..75dbd40eb 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -1,40 +1,56 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.GameInstallations; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Validation; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; +using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; namespace GenHub.Features.Validation; /// /// Validates the integrity of a game installation directory (e.g., from Steam, EA App). -/// Focuses on installation-specific validation concerns. +/// Integrates with the CSV content pipeline for manifest-driven multi-language validation. /// public class GameInstallationValidator( ILogger logger, - IManifestProvider manifestProvider, + IManifestProvider? manifestProvider, IContentValidator contentValidator, - IFileHashProvider hashProvider) + IFileHashProvider hashProvider, + ILanguageDetector? languageDetector = null, + CsvContentProvider? csvContentProvider = null, + IEnumerable? contentProviders = null) : FileSystemValidator(logger, hashProvider), IGameInstallationValidator, IValidator { + private readonly ILanguageDetector _languageDetector = languageDetector ?? new LanguageDetector(); + private readonly IContentProvider? _resolvedCsvProvider = csvContentProvider ?? + contentProviders?.OfType().FirstOrDefault() ?? + contentProviders?.FirstOrDefault(p => string.Equals(p.SourceName, PublisherTypeConstants.CsvRegistry, StringComparison.OrdinalIgnoreCase)); + /// /// Validates the specified game installation. /// /// The game installation to validate. /// A cancellation token. /// A representing the validation outcome. - public async Task ValidateAsync(GameInstallation installation, CancellationToken cancellationToken = default) + public Task ValidateAsync(GameInstallation installation, CancellationToken cancellationToken = default) { - return await ValidateAsync(installation, null, cancellationToken); + return ValidateAsync(installation, (string?)null, null, cancellationToken); } /// @@ -44,76 +60,296 @@ public async Task ValidateAsync(GameInstallation installation, /// Progress reporter for MVVM integration. /// A cancellation token. /// A representing the validation outcome. - public async Task ValidateAsync(GameInstallation installation, IProgress? progress = null, CancellationToken cancellationToken = default) + public Task ValidateAsync(GameInstallation installation, IProgress? progress, CancellationToken cancellationToken = default) { + return ValidateAsync(installation, (string?)null, progress, cancellationToken); + } + + /// + /// Validates the specified game installation with explicit language and progress reporting. + /// + /// The game installation to validate. + /// Optional explicit language code. If null, language is auto-detected. + /// Progress reporter for MVVM integration. + /// A cancellation token. + /// A representing the validation outcome. + public async Task ValidateAsync( + GameInstallation installation, + string? language, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(installation); cancellationToken.ThrowIfCancellationRequested(); + logger.LogInformation("Starting validation for installation '{Path}'", installation.InstallationPath); + var stopwatch = Stopwatch.StartNew(); var issues = new List(); + int totalFiles = 0; - // Calculate total steps dynamically based on installation - int totalSteps = 4; // Base steps: manifest fetch, manifest validation, integrity, extraneous files - if (installation.HasGenerals) totalSteps++; - if (installation.HasZeroHour) totalSteps++; + var targets = new List<(string Path, GameType GameType)>(); - int currentStep = 0; + if (installation.HasGenerals && !string.IsNullOrWhiteSpace(installation.GeneralsPath)) + { + targets.Add((installation.GeneralsPath, GameType.Generals)); + } + + if (installation.HasZeroHour && !string.IsNullOrWhiteSpace(installation.ZeroHourPath)) + { + targets.Add((installation.ZeroHourPath, GameType.ZeroHour)); + } + + if (targets.Count == 0) + { + var fallbackGameType = installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals; + targets.Add((installation.InstallationPath, fallbackGameType)); + } + + int targetIndex = 0; + foreach (var (targetPath, targetGame) in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + targetIndex++; - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Fetching manifest")); + logger.LogDebug("Validating target {Index}/{Total}: {GameType} at '{Path}'", targetIndex, targets.Count, targetGame, targetPath); - // Fetch manifest for this installation type - var manifest = await manifestProvider.GetManifestAsync(installation, cancellationToken); + var result = await ValidateInstallationCoreAsync( + targetPath, + targetGame, + language, + installation, + progress, + cancellationToken); + + issues.AddRange(result.Issues); + totalFiles += result.TotalFilesValidated; + } + + stopwatch.Stop(); + logger.LogInformation( + "Installation validation for '{Path}' completed with {IssueCount} issues ({CriticalCount} critical, {TotalFiles} files validated).", + installation.InstallationPath, + issues.Count, + issues.Count(i => i.Severity == ValidationSeverity.Error || i.Severity == ValidationSeverity.Critical), + totalFiles); + + return new ValidationResult(installation.InstallationPath, issues, stopwatch.Elapsed, totalFiles); + } + + /// + /// Validates a specific game installation directory by path, game type, and optional language. + /// + /// The path to the game directory. + /// The target game type (Generals or ZeroHour). + /// Optional explicit language code. If null, language is auto-detected. + /// Progress reporter for MVVM integration. + /// A cancellation token. + /// A representing the outcome of the validation. + public Task ValidateInstallationAsync( + string installationPath, + GameType gameType, + string? language = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(installationPath)) + { + throw new ArgumentException("Installation path cannot be null or empty.", nameof(installationPath)); + } + + return ValidateInstallationCoreAsync( + installationPath, + gameType, + language, + installation: null, + progress: progress, + cancellationToken: cancellationToken); + } + + private async Task ValidateInstallationCoreAsync( + string installationPath, + GameType gameType, + string? language, + GameInstallation? installation, + IProgress? progress, + CancellationToken cancellationToken) + { cancellationToken.ThrowIfCancellationRequested(); + var stopwatch = Stopwatch.StartNew(); + var issues = new List(); + + string detectedLanguage; + if (string.IsNullOrWhiteSpace(language)) + { + detectedLanguage = await _languageDetector.DetectAsync(installationPath, cancellationToken); + } + else + { + detectedLanguage = language; + } + + var normalizedLanguage = ContentSearchQuery.NormalizeLanguage(detectedLanguage); + logger.LogInformation( + "Validating installation at '{Path}' for game {GameType} in language {Language}", + installationPath, + gameType, + normalizedLanguage); + + int totalSteps = 4; + int currentStep = 0; + progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Resolving manifest")); + + ContentManifest? manifest = null; + if (_resolvedCsvProvider != null) + { + manifest = await ResolveManifestFromCsvProviderAsync( + installationPath, + gameType, + normalizedLanguage, + issues, + cancellationToken); + } + + if (manifest == null && manifestProvider != null) + { + logger.LogDebug("Attempting fallback manifest lookup via IManifestProvider for '{Path}'", installationPath); + var targetInstall = installation ?? new GameInstallation(installationPath, GameInstallationType.Unknown, null); + manifest = await manifestProvider.GetManifestAsync(targetInstall, cancellationToken); + } + if (manifest == null) { - issues.Add(new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Path = installation.InstallationPath, Message = "Manifest not found for installation." }); + if (issues.Count == 0) + { + issues.Add(new ValidationIssue + { + IssueType = ValidationIssueType.MissingFile, + Path = installationPath, + Message = $"Manifest not found for {gameType} ({normalizedLanguage}) installation at '{installationPath}'.", + Severity = ValidationSeverity.Error, + }); + } + progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); - return new ValidationResult(installation.InstallationPath, issues); + stopwatch.Stop(); + return new ValidationResult(installationPath, issues, stopwatch.Elapsed, 0); } progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Core manifest validation")); - var manifestValidationResult = await contentValidator.ValidateManifestAsync(manifest, cancellationToken); issues.AddRange(manifestValidationResult.Issues); progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating content files")); - - // Use ContentValidator for full content validation (integrity + extraneous files) try { - var fullValidation = await contentValidator.ValidateAllAsync(installation.InstallationPath, manifest, progress, cancellationToken); + var fullValidation = await contentValidator.ValidateAllAsync( + installationPath, + manifest, + progress, + cancellationToken); issues.AddRange(fullValidation.Issues); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { - logger.LogError(ex, "Content validation failed for installation '{Path}'", installation.InstallationPath); + logger.LogError(ex, "Content validation failed for installation '{Path}' ({GameType}, {Language})", installationPath, gameType, normalizedLanguage); issues.Add(new ValidationIssue { IssueType = ValidationIssueType.CorruptedFile, - Path = installation.InstallationPath, - Message = $"Content validation failed: {ex.Message}", + Path = installationPath, + Message = $"Content validation failed for {gameType} ({normalizedLanguage}): {ex.Message}", Severity = ValidationSeverity.Error, }); } - // Installation-specific validations (directories, etc.) var requiredDirs = manifest.RequiredDirectories ?? Enumerable.Empty(); if (requiredDirs.Any()) { - if (installation.HasGenerals) + var dirIssues = await ValidateDirectoriesAsync(installationPath, requiredDirs, cancellationToken); + issues.AddRange(dirIssues); + } + + progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); + + stopwatch.Stop(); + var totalFiles = manifest.Files?.Count ?? 0; + return new ValidationResult(installationPath, issues, stopwatch.Elapsed, totalFiles); + } + + private async Task ResolveManifestFromCsvProviderAsync( + string installationPath, + GameType gameType, + string language, + List issues, + CancellationToken cancellationToken) + { + try + { + var query = new ContentSearchQuery { - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Generals directories")); - issues.AddRange(await ValidateDirectoriesAsync(installation.GeneralsPath, requiredDirs, cancellationToken)); - } + TargetGame = gameType, + Language = language, + ContentType = ContentType.GameInstallation, + }; - if (installation.HasZeroHour) + var searchResult = await _resolvedCsvProvider!.SearchAsync(query, cancellationToken); + if (!searchResult.Success || searchResult.Data == null || !searchResult.Data.Any()) { - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Zero Hour directories")); - issues.AddRange(await ValidateDirectoriesAsync(installation.ZeroHourPath, requiredDirs, cancellationToken)); + logger.LogWarning( + "CSV provider search returned no results for {GameType} ({Language}): {Error}", + gameType, + language, + searchResult.FirstError ?? "No matching items"); + + issues.Add(new ValidationIssue + { + IssueType = ValidationIssueType.MissingFile, + Path = installationPath, + Message = $"No CSV manifest found for {gameType} ({language}): {searchResult.FirstError ?? "No matching catalog entries"}", + Severity = ValidationSeverity.Error, + }); + return null; } - } - progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); + var matchingItem = searchResult.Data.FirstOrDefault(); + var manifest = matchingItem?.GetData(); + if (manifest == null) + { + logger.LogWarning( + "CSV provider search result did not contain valid manifest data for {GameType} ({Language})", + gameType, + language); + + issues.Add(new ValidationIssue + { + IssueType = ValidationIssueType.MissingFile, + Path = installationPath, + Message = $"Failed to parse CSV manifest for {gameType} ({language}).", + Severity = ValidationSeverity.Error, + }); + return null; + } - logger.LogInformation("Installation validation for '{Path}' completed with {Count} issues.", installation.InstallationPath, issues.Count); - return new ValidationResult(installation.InstallationPath, issues); + return manifest; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to resolve CSV manifest for {GameType} ({Language})", gameType, language); + issues.Add(new ValidationIssue + { + IssueType = ValidationIssueType.MissingFile, + Path = installationPath, + Message = $"Error retrieving CSV manifest for {gameType} ({language}): {ex.Message}", + Severity = ValidationSeverity.Error, + }); + return null; + } } } \ No newline at end of file diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index acd40541f..bed3b02a9 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -351,7 +351,8 @@ private static void AddLocalFileSystemPipeline(IServiceCollection services) private static void AddCsvPipeline(IServiceCollection services) { // Register CSV content provider - services.AddTransient(); + services.AddTransient(); + services.AddTransient(sp => sp.GetRequiredService()); // Register CSV discoverer (concrete and interface) services.AddTransient(); diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs index fb6e18f93..94e3769fd 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Features.GameInstallations; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Features.GameInstallations; using Microsoft.Extensions.DependencyInjection; @@ -16,6 +17,7 @@ public static class GameInstallationModule /// The updated service collection. public static IServiceCollection AddGameInstallation(this IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/docs/features/content/csv-validation.md b/docs/features/content/csv-validation.md new file mode 100644 index 000000000..5e0d5e04d --- /dev/null +++ b/docs/features/content/csv-validation.md @@ -0,0 +1,129 @@ +--- +title: CSV Validation Pipeline & Game Installation Validator Integration +description: Architecture, multi-language validation, and manifest generation using CSV catalogs for Command & Conquer Generals and Zero Hour installations +--- + +# CSV Validation Pipeline + +The **CSV Validation Pipeline** provides a high-performance, manifest-driven mechanism for validating vanilla *Command & Conquer: Generals* (v1.08) and *Zero Hour* (v1.04) game installations against unified remote or cached CSV catalogs across all 10 official game language editions. + +--- + +## Architecture Overview + +The pipeline integrates with GenHub's modular content provider architecture and validation engine: + +```mermaid +flowchart TD + A[GameInstallationValidator] -->|Language Detection| B[LanguageDetector] + A -->|ContentSearchQuery| C[CsvContentProvider] + C -->|DiscoverAsync| D[CsvDiscoverer] + D -->|index.json / Catalogs| E[Catalog URLs] + C -->|ResolveAsync| F[CsvResolver] + F -->|Streaming Parse & Filter| G[ContentManifest] + A -->|ValidateManifestAsync & ValidateAllAsync| H[ContentValidator] + H -->|Detailed Issue Aggregation| I[ValidationResult] +``` + +--- + +## Core Components + +### 1. `LanguageDetector` (`ILanguageDetector`) +Located in `GenHub.Core.Features.GameInstallations`: +- Analyzes game directory layout and file patterns to determine the installed language. +- Checks language directories: `Data\english\`, `Data\german\`, `Data\deutsch\`, `Data\french\`, `Data\spanish\`, `Data\italian\`, `Data\korean\`, `Data\polish\`, `Data\PortugueseBrazil\`, `Data\chinese\`, `Data\chinesetraditional\`. +- Checks BIG archive patterns: `German.big`, `French.big`, `Spanish.big`, `Italian.big`, `Korean.big`, `Polish.big`, `PortugueseBrazil.big`, `Chinese.big`, `ChineseTraditional.big`, and their Zero Hour counterparts (`GermanZH.big`, `FrenchZH.big`, etc.). +- Normalizes language codes to uppercase (`EN`, `DE`, `FR`, `ES`, `IT`, `KO`, `PL`, `PT-BR`, `ZH-CN`, `ZH-TW`) with fallback to `EN`. + +### 2. `CsvDiscoverer` (`IContentDiscoverer`) +Located in `GenHub.Features.Content.Services.ContentDiscoverers`: +- Discovers remote CSV catalogs matching the requested game type (`Generals` or `ZeroHour`) and language. +- First queries remote `index.json` metadata if available, falling back to configuration catalog URLs (`CsvConstants.DefaultGeneralsCsvUrl` / `DefaultZeroHourCsvUrl`). +- Generates language-specific manifest IDs (e.g., `csv-generals-1.08-de`). + +### 3. `CsvResolver` (`IContentResolver`) +Located in `GenHub.Features.Content.Services.ContentResolvers`: +- Streams and parses RFC-4180 compliant CSV catalogs using `CsvHelper`. +- Filters rows matching the requested `TargetGame` and `Language`. +- Always includes shared files tagged with language `All` (such as `game.dat` or shared executables) alongside the language-specific assets. +- Produces a strongly typed `ContentManifest`. + +### 4. `CsvContentProvider` (`IContentProvider`) +Located in `GenHub.Features.Content.Services.ContentProviders`: +- Facade registered under source name `csv-registry` (`PublisherTypeConstants.CsvRegistry`). +- Exposes `SearchAsync(ContentSearchQuery)` returning `ContentSearchResult` objects populated with `ContentManifest`. + +### 5. `GameInstallationValidator` (`IGameInstallationValidator`) +Located in `GenHub.Features.Validation`: +- Orchestrates multi-target validation across both Generals and Zero Hour directories within an installation. +- Auto-detects language if not specified explicitly, or accepts an explicit language code. +- Normalizes input language parameters regardless of casing. +- Performs manifest validation, hash verification, file size checks, and directory structure validation. +- Aggregates issues and calculates detailed counts in `ValidationResult`. + +--- + +## Detailed Validation Result Metrics + +`ValidationResult` includes comprehensive metrics for diagnosing game installation health: + +```csharp +public sealed record ValidationResult( + string Path, + IReadOnlyList Issues, + TimeSpan Elapsed = default, + int TotalFilesValidated = 0) +{ + public bool IsValid => Issues.All(i => i.Severity != ValidationSeverity.Error && i.Severity != ValidationSeverity.Critical); + public int CriticalIssueCount => Issues.Count(i => i.Severity == ValidationSeverity.Critical || i.Severity == ValidationSeverity.Error); + public int WarningIssueCount => Issues.Count(i => i.Severity == ValidationSeverity.Warning); + + public int MissingFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.MissingFile); + public int CorruptedFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.CorruptedFile || i.IssueType == ValidationIssueType.MismatchedFileSize); + public int ExtraFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.UnexpectedFile); +} +``` + +--- + +## Supported Language Matrix + +| Language Code | Display Name | Directory Marker | Primary BIG File Marker | +| :--- | :--- | :--- | :--- | +| `EN` | English | `Data\English\` | `English.big` / `EnglishZH.big` | +| `DE` | German | `Data\German\`, `Data\Deutsch\` | `German.big` / `GermanZH.big` | +| `FR` | French | `Data\French\` | `French.big` / `FrenchZH.big` | +| `ES` | Spanish | `Data\Spanish\` | `Spanish.big` / `SpanishZH.big` | +| `IT` | Italian | `Data\Italian\` | `Italian.big` / `ItalianZH.big` | +| `KO` | Korean | `Data\Korean\` | `Korean.big` / `KoreanZH.big` | +| `PL` | Polish | `Data\Polish\` | `Polish.big` / `PolishZH.big` | +| `PT-BR` | Portuguese (Brazil) | `Data\PortugueseBrazil\` | `PortugueseBrazil.big` / `PortugueseZH.big` | +| `ZH-CN` | Chinese (Simplified) | `Data\Chinese\` | `Chinese.big` / `ChineseZH.big` | +| `ZH-TW` | Chinese (Traditional) | `Data\ChineseTraditional\` | `ChineseTraditional.big` | + +--- + +## Dependency Injection Setup + +In `ValidationModule.cs`: +```csharp +services.AddTransient(); +services.AddTransient(); +``` + +In `GameInstallationModule.cs`: +```csharp +services.AddSingleton(); +services.AddSingleton(); +``` + +In `ContentPipelineModule.cs`: +```csharp +services.AddTransient(); +services.AddTransient(sp => sp.GetRequiredService()); +services.AddTransient(); +services.AddTransient(); +services.AddTransient(); +services.AddTransient(); +``` diff --git a/docs/features/content/index.md b/docs/features/content/index.md index d07e0e7eb..8a6871222 100644 --- a/docs/features/content/index.md +++ b/docs/features/content/index.md @@ -9,6 +9,7 @@ The GenHub content system provides a flexible, extensible architecture for disco ## Core Documentation +- [CSV Validation Pipeline](./csv-validation.md) - Multi-language game installation validation via unified CSV catalogs - [Publisher Configuration](./publisher-configuration.md) - Data-driven publisher configuration for flexible content pipeline customization - [Publisher Infrastructure](./publisher-infrastructure.md) - Extensible architecture for publisher-specific content handling From 0bf39eab54d1ce87d517ad335a96e7e904a1ebc2 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 15:01:36 +0200 Subject: [PATCH 2/7] fix(validation): simplify language detection expression and add async suffixes to test methods --- .../Validation/GameInstallationValidatorTests.cs | 14 +++++++------- .../Validation/GameInstallationValidator.cs | 12 +++--------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs index 527e1a7e9..bb0d1e65e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs @@ -406,7 +406,7 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefullyAsync /// /// A representing the asynchronous unit test. [Fact] - public async Task ValidateAsync_WithCsvContentProvider_ValidatesMultiLanguageInstallationSuccessfully() + public async Task ValidateAsync_WithCsvContentProvider_ValidatesMultiLanguageInstallationSuccessfullyAsync() { var tempDir = Directory.CreateTempSubdirectory(); try @@ -481,7 +481,7 @@ public async Task ValidateAsync_WithCsvContentProvider_ValidatesMultiLanguageIns /// /// A representing the asynchronous unit test. [Fact] - public async Task ValidateAsync_WithExplicitLanguage_OverridesAutoDetection() + public async Task ValidateAsync_WithExplicitLanguage_OverridesAutoDetectionAsync() { var tempDir = Directory.CreateTempSubdirectory(); try @@ -545,7 +545,7 @@ public async Task ValidateAsync_WithExplicitLanguage_OverridesAutoDetection() /// /// A representing the asynchronous unit test. [Fact] - public async Task ValidateInstallationAsync_DirectPathAndGameType_ResolvesAndValidates() + public async Task ValidateInstallationAsync_DirectPathAndGameType_ResolvesAndValidatesAsync() { var tempDir = Directory.CreateTempSubdirectory(); try @@ -597,7 +597,7 @@ public async Task ValidateInstallationAsync_DirectPathAndGameType_ResolvesAndVal /// /// A representing the asynchronous unit test. [Fact] - public async Task ValidateAsync_DetailedCounts_ReportsCorrectMissingCorruptedAndExtraCounts() + public async Task ValidateAsync_DetailedCounts_ReportsCorrectMissingCorruptedAndExtraCountsAsync() { var tempDir = Directory.CreateTempSubdirectory(); try @@ -679,7 +679,7 @@ public async Task ValidateAsync_DetailedCounts_ReportsCorrectMissingCorruptedAnd /// /// A representing the asynchronous unit test. [Fact] - public async Task ValidateAsync_WithCsvProviderFailure_ReturnsLanguageSpecificErrorMessage() + public async Task ValidateAsync_WithCsvProviderFailure_ReturnsLanguageSpecificErrorMessageAsync() { var tempDir = Directory.CreateTempSubdirectory(); try @@ -733,7 +733,7 @@ public async Task ValidateAsync_WithCsvProviderFailure_ReturnsLanguageSpecificEr [InlineData("pt-br", CsvConstants.LanguagePtBr)] [InlineData("zh-cn", CsvConstants.LanguageZhCn)] [InlineData("zh-tw", CsvConstants.LanguageZhTw)] - public async Task ValidateAsync_MultiLanguageSupport_NormalizesLanguageAndValidates(string inputLanguage, string expectedNormalized) + public async Task ValidateAsync_MultiLanguageSupport_NormalizesLanguageAndValidatesAsync(string inputLanguage, string expectedNormalized) { var tempDir = Directory.CreateTempSubdirectory(); try @@ -786,7 +786,7 @@ public async Task ValidateAsync_MultiLanguageSupport_NormalizesLanguageAndValida /// /// A representing the asynchronous unit test. [Fact] - public async Task ValidateAsync_NullInstallation_ThrowsArgumentNullException() + public async Task ValidateAsync_NullInstallation_ThrowsArgumentNullExceptionAsync() { await Assert.ThrowsAsync(() => _validator.ValidateAsync(null!, CancellationToken.None)); } diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 75dbd40eb..800c0785e 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -178,15 +178,9 @@ private async Task ValidateInstallationCoreAsync( var stopwatch = Stopwatch.StartNew(); var issues = new List(); - string detectedLanguage; - if (string.IsNullOrWhiteSpace(language)) - { - detectedLanguage = await _languageDetector.DetectAsync(installationPath, cancellationToken); - } - else - { - detectedLanguage = language; - } + var detectedLanguage = string.IsNullOrWhiteSpace(language) + ? await _languageDetector.DetectAsync(installationPath, cancellationToken) + : language; var normalizedLanguage = ContentSearchQuery.NormalizeLanguage(detectedLanguage); logger.LogInformation( From 841717060cd8c30ed82fd19b317ae33bcafe474c Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 15:13:31 +0200 Subject: [PATCH 3/7] fix(validation): disambiguate validation overloads, isolate CSV fallback diagnostics, and correct validation metrics --- .../Validation/IGameInstallationValidator.cs | 6 +- .../GameInstallationValidatorTests.cs | 117 ++++++++++++++++++ .../Validation/GameInstallationValidator.cs | 109 +++++++++------- 3 files changed, 187 insertions(+), 45 deletions(-) diff --git a/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs b/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs index 6bfe6beeb..60f2a6648 100644 --- a/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs +++ b/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs @@ -31,14 +31,14 @@ public interface IGameInstallationValidator Task ValidateAsync(GameInstallation installation, IProgress? progress, CancellationToken cancellationToken = default); /// - /// Validates a game installation with an explicit language and progress reporting. + /// Validates a game installation with an explicit language and optional progress reporting. /// /// The game installation to validate. - /// Optional explicit language code (e.g., "EN", "DE"). If null, language is auto-detected. + /// The explicit language code (e.g., "EN", "DE"). /// Progress reporter for MVVM integration. /// A cancellation token. /// A representing the outcome of the validation. - Task ValidateAsync(GameInstallation installation, string? language, IProgress? progress = null, CancellationToken cancellationToken = default); + Task ValidateAsync(GameInstallation installation, string language, IProgress? progress = null, CancellationToken cancellationToken = default); /// /// Validates a specific game installation directory by path, game type, and optional language. diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs index bb0d1e65e..483e9bb2d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs @@ -791,6 +791,123 @@ public async Task ValidateAsync_NullInstallation_ThrowsArgumentNullExceptionAsyn await Assert.ThrowsAsync(() => _validator.ValidateAsync(null!, CancellationToken.None)); } + /// + /// Tests that when CSV provider fails to find a manifest, fallback to IManifestProvider succeeds without retaining CSV failure issues. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_CsvFails_FallbackManifestProviderSucceeds_DoesNotPreserveCsvFailureAsync() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var fallbackManifest = new ContentManifest + { + Id = new ManifestId("fallback-manifest"), + Name = "Fallback Manifest", + Version = "1.0", + Files = [new ManifestFile { RelativePath = "test.big", Size = 50, Hash = "abc" }], + }; + + var mockManifestProvider = new Mock(); + mockManifestProvider + .Setup(m => m.GetManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(fallbackManifest); + + var mockContentProvider = new Mock(); + mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry); + mockContentProvider + .Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateFailure("Catalog not found")); + + _contentValidatorMock + .Setup(c => c.ValidateAllAsync(It.IsAny(), fallbackManifest, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ValidationResult(tempDir.FullName, [], TimeSpan.FromSeconds(1), 1)); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + mockManifestProvider.Object, + _contentValidatorMock.Object, + _hashProviderMock.Object, + null, + null, + [mockContentProvider.Object]); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation); + + Assert.True(result.IsValid); + Assert.Empty(result.Issues); + Assert.Equal(1, result.TotalFilesValidated); + } + finally + { + tempDir.Delete(true); + } + } + + /// + /// Tests that when content validator throws an exception, TotalFilesValidated reports 0 rather than full manifest count. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ValidateAsync_ContentValidatorThrows_ReportsZeroTotalFilesValidatedAsync() + { + var tempDir = Directory.CreateTempSubdirectory(); + try + { + var manifest = new ContentManifest + { + Id = new ManifestId("test-manifest"), + Name = "Test Manifest", + Files = + [ + new ManifestFile { RelativePath = "file1.big", Size = 10, Hash = "h1" }, + new ManifestFile { RelativePath = "file2.big", Size = 20, Hash = "h2" }, + ], + }; + + var mockManifestProvider = new Mock(); + mockManifestProvider + .Setup(m => m.GetManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(manifest); + + _contentValidatorMock + .Setup(c => c.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny())) + .ThrowsAsync(new IOException("Disk read error")); + + var validator = new GameInstallationValidator( + _loggerMock.Object, + mockManifestProvider.Object, + _contentValidatorMock.Object, + _hashProviderMock.Object, + null, + null, + null); + + var installation = new GameInstallation( + tempDir.FullName, + GameInstallationType.Steam, + new Mock>().Object); + installation.SetPaths(tempDir.FullName, null); + + var result = await validator.ValidateAsync(installation); + + Assert.False(result.IsValid); + Assert.Equal(0, result.TotalFilesValidated); + Assert.Contains(result.Issues, i => i.Message.Contains("Disk read error")); + } + finally + { + tempDir.Delete(true); + } + } + /// /// Custom progress implementation that captures reports synchronously. /// diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 800c0785e..9115def97 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -43,14 +43,14 @@ public class GameInstallationValidator( contentProviders?.FirstOrDefault(p => string.Equals(p.SourceName, PublisherTypeConstants.CsvRegistry, StringComparison.OrdinalIgnoreCase)); /// - /// Validates the specified game installation. + /// Validates the specified game installation against expected files and checksums. /// /// The game installation to validate. /// A cancellation token. /// A representing the validation outcome. public Task ValidateAsync(GameInstallation installation, CancellationToken cancellationToken = default) { - return ValidateAsync(installation, (string?)null, null, cancellationToken); + return ValidateInternalAsync(installation, null, null, cancellationToken); } /// @@ -62,22 +62,61 @@ public Task ValidateAsync(GameInstallation installation, Cance /// A representing the validation outcome. public Task ValidateAsync(GameInstallation installation, IProgress? progress, CancellationToken cancellationToken = default) { - return ValidateAsync(installation, (string?)null, progress, cancellationToken); + return ValidateInternalAsync(installation, null, progress, cancellationToken); } /// - /// Validates the specified game installation with explicit language and progress reporting. + /// Validates the specified game installation with explicit language and optional progress reporting. /// /// The game installation to validate. - /// Optional explicit language code. If null, language is auto-detected. + /// The explicit language code (e.g. "EN", "DE"). /// Progress reporter for MVVM integration. /// A cancellation token. /// A representing the validation outcome. - public async Task ValidateAsync( + public Task ValidateAsync( GameInstallation installation, - string? language, + string language, IProgress? progress = null, CancellationToken cancellationToken = default) + { + return ValidateInternalAsync(installation, language, progress, cancellationToken); + } + + /// + /// Validates a specific game installation directory by path, game type, and optional language. + /// + /// The path to the game directory. + /// The target game type (Generals or ZeroHour). + /// Optional explicit language code. If null, language is auto-detected. + /// Progress reporter for MVVM integration. + /// A cancellation token. + /// A representing the outcome of the validation. + public Task ValidateInstallationAsync( + string installationPath, + GameType gameType, + string? language = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(installationPath)) + { + throw new ArgumentException("Installation path cannot be null or empty.", nameof(installationPath)); + } + + return ValidateInstallationCoreAsync( + installationPath, + gameType, + language, + installation: null, + progress: progress, + cancellationToken: cancellationToken); + } + + private async Task ValidateInternalAsync( + GameInstallation installation, + string? language, + IProgress? progress, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(installation); cancellationToken.ThrowIfCancellationRequested(); @@ -136,36 +175,6 @@ public async Task ValidateAsync( return new ValidationResult(installation.InstallationPath, issues, stopwatch.Elapsed, totalFiles); } - /// - /// Validates a specific game installation directory by path, game type, and optional language. - /// - /// The path to the game directory. - /// The target game type (Generals or ZeroHour). - /// Optional explicit language code. If null, language is auto-detected. - /// Progress reporter for MVVM integration. - /// A cancellation token. - /// A representing the outcome of the validation. - public Task ValidateInstallationAsync( - string installationPath, - GameType gameType, - string? language = null, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(installationPath)) - { - throw new ArgumentException("Installation path cannot be null or empty.", nameof(installationPath)); - } - - return ValidateInstallationCoreAsync( - installationPath, - gameType, - language, - installation: null, - progress: progress, - cancellationToken: cancellationToken); - } - private async Task ValidateInstallationCoreAsync( string installationPath, GameType gameType, @@ -194,26 +203,40 @@ private async Task ValidateInstallationCoreAsync( progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Resolving manifest")); ContentManifest? manifest = null; + var csvIssues = new List(); if (_resolvedCsvProvider != null) { manifest = await ResolveManifestFromCsvProviderAsync( installationPath, gameType, normalizedLanguage, - issues, + csvIssues, cancellationToken); } if (manifest == null && manifestProvider != null) { - logger.LogDebug("Attempting fallback manifest lookup via IManifestProvider for '{Path}'", installationPath); - var targetInstall = installation ?? new GameInstallation(installationPath, GameInstallationType.Unknown, null); + logger.LogDebug("Attempting fallback manifest lookup via IManifestProvider for '{Path}' ({GameType})", installationPath, gameType); + var targetInstall = new GameInstallation(installationPath, installation?.InstallationType ?? GameInstallationType.Unknown, null); + if (gameType == GameType.ZeroHour) + { + targetInstall.SetPaths(generalsPath: null, zeroHourPath: installationPath); + } + else + { + targetInstall.SetPaths(generalsPath: installationPath, zeroHourPath: null); + } + manifest = await manifestProvider.GetManifestAsync(targetInstall, cancellationToken); } if (manifest == null) { - if (issues.Count == 0) + if (csvIssues.Count > 0) + { + issues.AddRange(csvIssues); + } + else { issues.Add(new ValidationIssue { @@ -234,6 +257,7 @@ private async Task ValidateInstallationCoreAsync( issues.AddRange(manifestValidationResult.Issues); progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating content files")); + int totalFiles = 0; try { var fullValidation = await contentValidator.ValidateAllAsync( @@ -242,6 +266,7 @@ private async Task ValidateInstallationCoreAsync( progress, cancellationToken); issues.AddRange(fullValidation.Issues); + totalFiles = manifest.Files?.Count ?? 0; } catch (OperationCanceledException) { @@ -257,6 +282,7 @@ private async Task ValidateInstallationCoreAsync( Message = $"Content validation failed for {gameType} ({normalizedLanguage}): {ex.Message}", Severity = ValidationSeverity.Error, }); + totalFiles = 0; } var requiredDirs = manifest.RequiredDirectories ?? Enumerable.Empty(); @@ -269,7 +295,6 @@ private async Task ValidateInstallationCoreAsync( progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); stopwatch.Stop(); - var totalFiles = manifest.Files?.Count ?? 0; return new ValidationResult(installationPath, issues, stopwatch.Elapsed, totalFiles); } From c8ccd7f14201d3ccfb6558e2a28cf9dbf767419a Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 15:18:03 +0200 Subject: [PATCH 4/7] style(validation): alphabetize using directives ordinally --- .../Features/GameInstallations/LanguageDetector.cs | 4 ++-- .../Validation/GameInstallationValidator.cs | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs index 28e4b1652..fde274ba3 100644 --- a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs +++ b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs @@ -1,10 +1,10 @@ +using GenHub.Core.Constants; +using GenHub.Core.Models.Content; using System; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using GenHub.Core.Constants; -using GenHub.Core.Models.Content; namespace GenHub.Core.Features.GameInstallations; diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 9115def97..3d52b9fc4 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -1,10 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Features.GameInstallations; using GenHub.Core.Interfaces.Common; @@ -19,6 +12,13 @@ using GenHub.Core.Models.Validation; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace GenHub.Features.Validation; From 5dbc67a451eee327a62bd2b5ee06d10df31892b3 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 15:27:44 +0200 Subject: [PATCH 5/7] fix(validation): address SonarCloud and DeepSource findings on async naming and nullability --- .../LanguageDetectorTests.cs | 12 ++++++------ .../Validation/GameInstallationValidator.cs | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs index b62e474a5..cb06e2c3b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs @@ -25,7 +25,7 @@ public class LanguageDetectorTests [InlineData("")] [InlineData(" ")] [InlineData("non_existent_directory_xyz_123")] - public async Task DetectAsync_WithInvalidPath_ReturnsEnglishFallback(string? path) + public async Task DetectAsync_WithInvalidPath_ReturnsEnglishFallbackAsync(string? path) { var result = await _detector.DetectAsync(path!); Assert.Equal(CsvConstants.LanguageEn, result); @@ -36,7 +36,7 @@ public async Task DetectAsync_WithInvalidPath_ReturnsEnglishFallback(string? pat /// /// A representing the asynchronous unit test. [Fact] - public async Task DetectAsync_WithCancelledToken_ThrowsOperationCanceledException() + public async Task DetectAsync_WithCancelledToken_ThrowsOperationCanceledExceptionAsync() { using var cts = new CancellationTokenSource(); cts.Cancel(); @@ -63,7 +63,7 @@ public async Task DetectAsync_WithCancelledToken_ThrowsOperationCanceledExceptio [InlineData(LanguageDirectoryNames.DataPortuguese, CsvConstants.LanguagePtBr)] [InlineData(LanguageDirectoryNames.DataChinese, CsvConstants.LanguageZhCn)] [InlineData(LanguageDirectoryNames.DataChineseTraditional, CsvConstants.LanguageZhTw)] - public async Task DetectAsync_WithLanguageDirectory_DetectsCorrectLanguage(string relativeDir, string expectedLanguage) + public async Task DetectAsync_WithLanguageDirectory_DetectsCorrectLanguageAsync(string relativeDir, string expectedLanguage) { var tempDir = Directory.CreateTempSubdirectory(); try @@ -106,7 +106,7 @@ public async Task DetectAsync_WithLanguageDirectory_DetectsCorrectLanguage(strin [InlineData(LanguageFilePatterns.AudioChineseBig, CsvConstants.LanguageZhCn)] [InlineData(LanguageFilePatterns.ChineseTraditionalBig, CsvConstants.LanguageZhTw)] [InlineData(LanguageFilePatterns.AudioChineseTraditionalBig, CsvConstants.LanguageZhTw)] - public async Task DetectAsync_WithLanguageBigFile_DetectsCorrectLanguage(string fileName, string expectedLanguage) + public async Task DetectAsync_WithLanguageBigFile_DetectsCorrectLanguageAsync(string fileName, string expectedLanguage) { var tempDir = Directory.CreateTempSubdirectory(); try @@ -139,7 +139,7 @@ public async Task DetectAsync_WithLanguageBigFile_DetectsCorrectLanguage(string [InlineData(LanguageFilePatterns.PortugueseZHBig, CsvConstants.LanguagePtBr)] [InlineData(LanguageFilePatterns.ChineseZHBig, CsvConstants.LanguageZhCn)] [InlineData(LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn)] - public async Task DetectAsync_WithZeroHourPatterns_DetectsCorrectLanguage(string fileName, string expectedLanguage) + public async Task DetectAsync_WithZeroHourPatterns_DetectsCorrectLanguageAsync(string fileName, string expectedLanguage) { var tempDir = Directory.CreateTempSubdirectory(); try @@ -161,7 +161,7 @@ public async Task DetectAsync_WithZeroHourPatterns_DetectsCorrectLanguage(string /// /// A representing the asynchronous unit test. [Fact] - public async Task DetectAsync_WithUnknownFiles_FallsBackToEnglish() + public async Task DetectAsync_WithUnknownFiles_FallsBackToEnglishAsync() { var tempDir = Directory.CreateTempSubdirectory(); try diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 3d52b9fc4..85d0ae0b4 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -198,9 +198,7 @@ private async Task ValidateInstallationCoreAsync( gameType, normalizedLanguage); - int totalSteps = 4; - int currentStep = 0; - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Resolving manifest")); + progress?.Report(new ValidationProgress(1, 4, "Resolving manifest")); ContentManifest? manifest = null; var csvIssues = new List(); @@ -247,16 +245,16 @@ private async Task ValidateInstallationCoreAsync( }); } - progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); + progress?.Report(new ValidationProgress(4, 4, "Validation complete")); stopwatch.Stop(); return new ValidationResult(installationPath, issues, stopwatch.Elapsed, 0); } - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Core manifest validation")); + progress?.Report(new ValidationProgress(2, 4, "Core manifest validation")); var manifestValidationResult = await contentValidator.ValidateManifestAsync(manifest, cancellationToken); issues.AddRange(manifestValidationResult.Issues); - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating content files")); + progress?.Report(new ValidationProgress(3, 4, "Validating content files")); int totalFiles = 0; try { @@ -292,7 +290,7 @@ private async Task ValidateInstallationCoreAsync( issues.AddRange(dirIssues); } - progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); + progress?.Report(new ValidationProgress(4, 4, "Validation complete")); stopwatch.Stop(); return new ValidationResult(installationPath, issues, stopwatch.Elapsed, totalFiles); @@ -305,6 +303,11 @@ private async Task ValidateInstallationCoreAsync( List issues, CancellationToken cancellationToken) { + if (_resolvedCsvProvider == null) + { + return null; + } + try { var query = new ContentSearchQuery @@ -314,7 +317,7 @@ private async Task ValidateInstallationCoreAsync( ContentType = ContentType.GameInstallation, }; - var searchResult = await _resolvedCsvProvider!.SearchAsync(query, cancellationToken); + var searchResult = await _resolvedCsvProvider.SearchAsync(query, cancellationToken); if (!searchResult.Success || searchResult.Data == null || !searchResult.Data.Any()) { logger.LogWarning( From a444b85b445cf39819619cca1d028870eb9bcfd5 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 15:59:43 +0200 Subject: [PATCH 6/7] fix(validation): address code review comments on patterns, fallback logger, and total files count --- .../GenHub.Core/Constants/LanguageFilePatterns.cs | 15 +++++++++++++++ .../GameInstallations/LanguageDetector.cs | 6 +++--- .../Validation/GameInstallationValidator.cs | 7 +++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs index 7e8b42629..d15f8d581 100644 --- a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs +++ b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs @@ -219,4 +219,19 @@ public static class LanguageFilePatterns /// File pattern for Portuguese ZH BIG files: "PortugueseZH.big". /// public const string PortugueseZHBig = "PortugueseZH.big"; + + /// + /// File pattern for Zero Hour audio BIG files: "AudioZH.big". + /// + public const string AudioZHBig = "AudioZH.big"; + + /// + /// File pattern for Zero Hour INI BIG files: "INIZH.big". + /// + public const string IniZHBig = "INIZH.big"; + + /// + /// Wildcard file pattern for any Zero Hour BIG files: "*ZH.big". + /// + public const string AnyZeroHourBig = "*ZH.big"; } diff --git a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs index fde274ba3..53c3445ec 100644 --- a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs +++ b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs @@ -112,8 +112,8 @@ public Task DetectAsync(string installationPath, CancellationToken cance var zhPatterns = new (string Pattern, string Language)[] { (LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn), - ("AudioZH.big", CsvConstants.LanguageEn), - ("INIZH.big", CsvConstants.LanguageEn), + (LanguageFilePatterns.AudioZHBig, CsvConstants.LanguageEn), + (LanguageFilePatterns.IniZHBig, CsvConstants.LanguageEn), (LanguageFilePatterns.GermanZHBig, CsvConstants.LanguageDe), (LanguageFilePatterns.FrenchZHBig, CsvConstants.LanguageFr), (LanguageFilePatterns.SpanishZHBig, CsvConstants.LanguageEs), @@ -122,7 +122,7 @@ public Task DetectAsync(string installationPath, CancellationToken cance (LanguageFilePatterns.PolishZHBig, CsvConstants.LanguagePl), (LanguageFilePatterns.PortugueseZHBig, CsvConstants.LanguagePtBr), (LanguageFilePatterns.ChineseZHBig, CsvConstants.LanguageZhCn), - ("*ZH.big", CsvConstants.LanguageEn), + (LanguageFilePatterns.AnyZeroHourBig, CsvConstants.LanguageEn), }; foreach (var (pattern, language) in zhPatterns) diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 85d0ae0b4..42a02a41f 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -12,6 +12,7 @@ using GenHub.Core.Models.Validation; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System; using System.Collections.Generic; using System.Diagnostics; @@ -215,7 +216,7 @@ private async Task ValidateInstallationCoreAsync( if (manifest == null && manifestProvider != null) { logger.LogDebug("Attempting fallback manifest lookup via IManifestProvider for '{Path}' ({GameType})", installationPath, gameType); - var targetInstall = new GameInstallation(installationPath, installation?.InstallationType ?? GameInstallationType.Unknown, null); + var targetInstall = new GameInstallation(installationPath, installation?.InstallationType ?? GameInstallationType.Unknown, NullLogger.Instance); if (gameType == GameType.ZeroHour) { targetInstall.SetPaths(generalsPath: null, zeroHourPath: installationPath); @@ -264,7 +265,9 @@ private async Task ValidateInstallationCoreAsync( progress, cancellationToken); issues.AddRange(fullValidation.Issues); - totalFiles = manifest.Files?.Count ?? 0; + totalFiles = fullValidation.TotalFilesValidated > 0 + ? fullValidation.TotalFilesValidated + : manifest.Files?.Count ?? 0; } catch (OperationCanceledException) { From ae90cba1e8b11e975d0bd88a6d7ddc5a2afe931f Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 30 Aug 2026 16:32:59 +0200 Subject: [PATCH 7/7] fix(validation): populate TotalFilesValidated in ContentValidator and consolidate ZeroHourIniBig --- GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs | 5 ----- .../Features/GameInstallations/LanguageDetector.cs | 2 +- GenHub/GenHub/Features/Content/Services/ContentValidator.cs | 4 ++-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs index d15f8d581..3685583a4 100644 --- a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs +++ b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs @@ -225,11 +225,6 @@ public static class LanguageFilePatterns /// public const string AudioZHBig = "AudioZH.big"; - /// - /// File pattern for Zero Hour INI BIG files: "INIZH.big". - /// - public const string IniZHBig = "INIZH.big"; - /// /// Wildcard file pattern for any Zero Hour BIG files: "*ZH.big". /// diff --git a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs index 53c3445ec..e8c84637b 100644 --- a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs +++ b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs @@ -113,7 +113,7 @@ public Task DetectAsync(string installationPath, CancellationToken cance { (LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn), (LanguageFilePatterns.AudioZHBig, CsvConstants.LanguageEn), - (LanguageFilePatterns.IniZHBig, CsvConstants.LanguageEn), + (GameClientConstants.ZeroHourIniBig, CsvConstants.LanguageEn), (LanguageFilePatterns.GermanZHBig, CsvConstants.LanguageDe), (LanguageFilePatterns.FrenchZHBig, CsvConstants.LanguageFr), (LanguageFilePatterns.SpanishZHBig, CsvConstants.LanguageEs), diff --git a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs index 92711ffcc..481a8fcb3 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs @@ -84,7 +84,7 @@ public async Task ValidateAllAsync(string contentPath, Content progress?.Report(new ValidationProgress(3, 3, "Validation Complete")); _logger.LogDebug("Full content validation for {ManifestId} completed with {IssueCount} issues.", manifest.Id, issues.Count); - return new ValidationResult(manifest.Id, issues); + return new ValidationResult(manifest.Id, issues, totalFilesValidated: integrityResult.TotalFilesValidated); } /// @@ -175,7 +175,7 @@ public async Task ValidateContentIntegrityAsync(string content } _logger.LogDebug("Content integrity validation for {ManifestId} completed with {IssueCount} issues.", manifest.Id, issues.Count); - return new ValidationResult(manifest.Id, issues); + return new ValidationResult(manifest.Id, issues, totalFilesValidated: totalFiles); } ///