diff --git a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs index 7e8b42629..3685583a4 100644 --- a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs +++ b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs @@ -219,4 +219,14 @@ 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"; + + /// + /// 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 dcc7557ed..e8c84637b 100644 --- a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs +++ b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs @@ -1,3 +1,5 @@ +using GenHub.Core.Constants; +using GenHub.Core.Models.Content; using System; using System.IO; using System.Linq; @@ -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), + (LanguageFilePatterns.AudioZHBig, CsvConstants.LanguageEn), + (GameClientConstants.ZeroHourIniBig, 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), + (LanguageFilePatterns.AnyZeroHourBig, 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..60f2a6648 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 optional progress reporting. + /// + /// The game installation to validate. + /// 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); + + /// + /// 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..cb06e2c3b --- /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_ReturnsEnglishFallbackAsync(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_ThrowsOperationCanceledExceptionAsync() + { + 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_DetectsCorrectLanguageAsync(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_DetectsCorrectLanguageAsync(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_DetectsCorrectLanguageAsync(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_FallsBackToEnglishAsync() + { + 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..483e9bb2d 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,513 @@ 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_ValidatesMultiLanguageInstallationSuccessfullyAsync() + { + 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_OverridesAutoDetectionAsync() + { + 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_ResolvesAndValidatesAsync() + { + 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_ReportsCorrectMissingCorruptedAndExtraCountsAsync() + { + 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_ReturnsLanguageSpecificErrorMessageAsync() + { + 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_NormalizesLanguageAndValidatesAsync(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_ThrowsArgumentNullExceptionAsync() + { + 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/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); } /// diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index 7288e74e6..42a02a41f 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -1,40 +1,57 @@ -using System; -using System.Collections.Generic; -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; +using Microsoft.Extensions.Logging.Abstractions; +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; /// /// 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. + /// Validates the specified game installation against expected files and checksums. /// /// 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 ValidateInternalAsync(installation, null, null, cancellationToken); } /// @@ -44,76 +61,320 @@ 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 ValidateInternalAsync(installation, null, progress, cancellationToken); + } + + /// + /// Validates the specified game installation with explicit language and optional progress reporting. + /// + /// The game installation to validate. + /// The explicit language code (e.g. "EN", "DE"). + /// Progress reporter for MVVM integration. + /// A cancellation token. + /// A representing the validation outcome. + public Task ValidateAsync( + GameInstallation installation, + 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(); + logger.LogInformation("Starting validation for installation '{Path}'", installation.InstallationPath); + var stopwatch = Stopwatch.StartNew(); var issues = new List(); + int totalFiles = 0; + + var targets = new List<(string Path, GameType GameType)>(); + + 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)); + } - // 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++; + if (targets.Count == 0) + { + var fallbackGameType = installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals; + targets.Add((installation.InstallationPath, fallbackGameType)); + } - int currentStep = 0; + int targetIndex = 0; + foreach (var (targetPath, targetGame) in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + targetIndex++; + + logger.LogDebug("Validating target {Index}/{Total}: {GameType} at '{Path}'", targetIndex, targets.Count, targetGame, targetPath); - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Fetching manifest")); + var result = await ValidateInstallationCoreAsync( + targetPath, + targetGame, + language, + installation, + progress, + cancellationToken); - // Fetch manifest for this installation type - var manifest = await manifestProvider.GetManifestAsync(installation, 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); + } + + private async Task ValidateInstallationCoreAsync( + string installationPath, + GameType gameType, + string? language, + GameInstallation? installation, + IProgress? progress, + CancellationToken cancellationToken) + { cancellationToken.ThrowIfCancellationRequested(); - if (manifest == null) + var stopwatch = Stopwatch.StartNew(); + var issues = new List(); + + var detectedLanguage = string.IsNullOrWhiteSpace(language) + ? await _languageDetector.DetectAsync(installationPath, cancellationToken) + : language; + + var normalizedLanguage = ContentSearchQuery.NormalizeLanguage(detectedLanguage); + logger.LogInformation( + "Validating installation at '{Path}' for game {GameType} in language {Language}", + installationPath, + gameType, + normalizedLanguage); + + progress?.Report(new ValidationProgress(1, 4, "Resolving manifest")); + + ContentManifest? manifest = null; + var csvIssues = new List(); + if (_resolvedCsvProvider != null) { - issues.Add(new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Path = installation.InstallationPath, Message = "Manifest not found for installation." }); - progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete")); - return new ValidationResult(installation.InstallationPath, issues); + manifest = await ResolveManifestFromCsvProviderAsync( + installationPath, + gameType, + normalizedLanguage, + csvIssues, + cancellationToken); + } + + 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, NullLogger.Instance); + if (gameType == GameType.ZeroHour) + { + targetInstall.SetPaths(generalsPath: null, zeroHourPath: installationPath); + } + else + { + targetInstall.SetPaths(generalsPath: installationPath, zeroHourPath: null); + } + + manifest = await manifestProvider.GetManifestAsync(targetInstall, cancellationToken); } - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Core manifest validation")); + if (manifest == null) + { + if (csvIssues.Count > 0) + { + issues.AddRange(csvIssues); + } + else + { + 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(4, 4, "Validation complete")); + stopwatch.Stop(); + return new ValidationResult(installationPath, issues, stopwatch.Elapsed, 0); + } + + 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")); - - // Use ContentValidator for full content validation (integrity + extraneous files) + progress?.Report(new ValidationProgress(3, 4, "Validating content files")); + int totalFiles = 0; try { - var fullValidation = await contentValidator.ValidateAllAsync(installation.InstallationPath, manifest, progress, cancellationToken); + var fullValidation = await contentValidator.ValidateAllAsync( + installationPath, + manifest, + progress, + cancellationToken); issues.AddRange(fullValidation.Issues); + totalFiles = fullValidation.TotalFilesValidated > 0 + ? fullValidation.TotalFilesValidated + : manifest.Files?.Count ?? 0; + } + 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, }); + totalFiles = 0; } - // 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(4, 4, "Validation complete")); + + stopwatch.Stop(); + return new ValidationResult(installationPath, issues, stopwatch.Elapsed, totalFiles); + } + + private async Task ResolveManifestFromCsvProviderAsync( + string installationPath, + GameType gameType, + string language, + List issues, + CancellationToken cancellationToken) + { + if (_resolvedCsvProvider == null) + { + return null; + } + + 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); - logger.LogInformation("Installation validation for '{Path}' completed with {Count} issues.", installation.InstallationPath, issues.Count); - return new ValidationResult(installation.InstallationPath, issues); + issues.Add(new ValidationIssue + { + IssueType = ValidationIssueType.MissingFile, + Path = installationPath, + Message = $"Failed to parse CSV manifest for {gameType} ({language}).", + Severity = ValidationSeverity.Error, + }); + return null; + } + + 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