From 26beaaff6f1df1fa9f952fd298b5a0fc5d8f4391 Mon Sep 17 00:00:00 2001 From: OpenCode Bot Date: Fri, 20 Mar 2026 09:07:52 +0800 Subject: [PATCH] feat(#7): Add XSD validation from game directory - Create IXsdSchemaProvider interface - Implement XsdSchemaProvider for loading XSD schemas - Support finding XSD files in game directory - Validate XML files against XSD schemas - Add comprehensive unit tests Closes #7 --- .../Services/XsdSchemaProviderTests.cs | 144 +++++++++++++++++ .../Services/XsdSchemaProvider.cs | 148 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 BannerlordModEditor.Common.Tests/Services/XsdSchemaProviderTests.cs create mode 100644 BannerlordModEditor.Common/Services/XsdSchemaProvider.cs diff --git a/BannerlordModEditor.Common.Tests/Services/XsdSchemaProviderTests.cs b/BannerlordModEditor.Common.Tests/Services/XsdSchemaProviderTests.cs new file mode 100644 index 00000000..905a7da9 --- /dev/null +++ b/BannerlordModEditor.Common.Tests/Services/XsdSchemaProviderTests.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; +using BannerlordModEditor.Common.Services; + +namespace BannerlordModEditor.Common.Tests.Services +{ + public class XsdSchemaProviderTests + { + private readonly XsdSchemaProvider _provider; + + public XsdSchemaProviderTests() + { + _provider = new XsdSchemaProvider(); + } + + [Fact] + public void GetXsdPathForXml_ReturnsNullForNonExistentGameDirectory() + { + var result = _provider.GetXsdPathForXml("test.xml", "/nonexistent/path"); + Assert.Null(result); + } + + [Fact] + public void GetXsdPathForXml_ReturnsNullForEmptyPaths() + { + Assert.Null(_provider.GetXsdPathForXml("", "")); + Assert.Null(_provider.GetXsdPathForXml(null!, null!)); + } + + [Fact] + public void GetXsdPathForXml_FindsXsdInXmlSchemasDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var xsdDir = Path.Combine(tempDir, "XmlSchemas"); + Directory.CreateDirectory(xsdDir); + + try + { + var xsdPath = Path.Combine(xsdDir, "attributes.xsd"); + File.WriteAllText(xsdPath, ""); + + var result = _provider.GetXsdPathForXml("attributes.xml", tempDir); + Assert.Equal(xsdPath, result); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void GetAllXsdFiles_ReturnsEmptyForNonExistentDirectory() + { + var result = _provider.GetAllXsdFiles("/nonexistent/path"); + Assert.Empty(result); + } + + [Fact] + public void GetAllXsdFiles_FindsAllXsdFiles() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var xsdDir = Path.Combine(tempDir, "XmlSchemas"); + Directory.CreateDirectory(xsdDir); + + try + { + File.WriteAllText(Path.Combine(xsdDir, "file1.xsd"), ""); + File.WriteAllText(Path.Combine(xsdDir, "file2.xsd"), ""); + + var result = _provider.GetAllXsdFiles(tempDir); + Assert.Equal(2, result.Count); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void LoadSchemaSet_ReturnsEmptyForNonExistentFile() + { + var result = _provider.LoadSchemaSet("/nonexistent/schema.xsd"); + Assert.NotNull(result); + Assert.Equal(0, result.Count); + } + + [Fact] + public void LoadSchemaSet_LoadsValidXsd() + { + var tempFile = Path.GetTempFileName(); + var xsd = @" + + + + + + + + +"; + + try + { + File.WriteAllText(tempFile, xsd); + + var result = _provider.LoadSchemaSet(tempFile); + Assert.NotNull(result); + Assert.Equal(1, result.Count); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void ValidateXmlAgainstXsd_ReturnsFalseForNonExistentXml() + { + var result = _provider.ValidateXmlAgainstXsd("/nonexistent.xml", "/schema.xsd", out var errors); + Assert.False(result); + Assert.Contains("XML file not found", errors[0]); + } + + [Fact] + public void ValidateXmlAgainstXsd_ReturnsFalseForNonExistentXsd() + { + var tempXml = Path.GetTempFileName(); + File.WriteAllText(tempXml, ""); + + try + { + var result = _provider.ValidateXmlAgainstXsd(tempXml, "/nonexistent.xsd", out var errors); + Assert.False(result); + Assert.Contains("XSD schema not found", errors[0]); + } + finally + { + File.Delete(tempXml); + } + } + } +} diff --git a/BannerlordModEditor.Common/Services/XsdSchemaProvider.cs b/BannerlordModEditor.Common/Services/XsdSchemaProvider.cs new file mode 100644 index 00000000..b39ec54d --- /dev/null +++ b/BannerlordModEditor.Common/Services/XsdSchemaProvider.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Schema; + +namespace BannerlordModEditor.Common.Services +{ + public interface IXsdSchemaProvider + { + string? GetXsdPathForXml(string xmlFilePath, string gameDirectory); + List GetAllXsdFiles(string gameDirectory); + XmlSchemaSet LoadSchemaSet(string xsdPath); + bool ValidateXmlAgainstXsd(string xmlFilePath, string xsdPath, out List errors); + } + + public class XsdSchemaProvider : IXsdSchemaProvider + { + private const string XsdDirectoryRelativePath = "XmlSchemas"; + private const string ModuleDataRelativePath = "Modules"; + private const string NativeModuleRelativePath = "Modules\\Native\\ModuleData"; + + public string? GetXsdPathForXml(string xmlFilePath, string gameDirectory) + { + if (string.IsNullOrEmpty(xmlFilePath) || string.IsNullOrEmpty(gameDirectory)) + return null; + + var xmlFileName = Path.GetFileNameWithoutExtension(xmlFilePath); + + var xsdPaths = new[] + { + Path.Combine(gameDirectory, XsdDirectoryRelativePath, $"{xmlFileName}.xsd"), + Path.Combine(gameDirectory, XsdDirectoryRelativePath, "Native", $"{xmlFileName}.xsd"), + Path.Combine(gameDirectory, NativeModuleRelativePath, $"{xmlFileName}.xsd") + }; + + foreach (var xsdPath in xsdPaths) + { + if (File.Exists(xsdPath)) + return xsdPath; + } + + return null; + } + + public List GetAllXsdFiles(string gameDirectory) + { + var xsdFiles = new List(); + + if (string.IsNullOrEmpty(gameDirectory) || !Directory.Exists(gameDirectory)) + return xsdFiles; + + var searchPaths = new[] + { + Path.Combine(gameDirectory, XsdDirectoryRelativePath), + Path.Combine(gameDirectory, NativeModuleRelativePath) + }; + + foreach (var searchPath in searchPaths) + { + if (Directory.Exists(searchPath)) + { + try + { + xsdFiles.AddRange(Directory.GetFiles(searchPath, "*.xsd", SearchOption.AllDirectories)); + } + catch + { + } + } + } + + return xsdFiles.Distinct().ToList(); + } + + public XmlSchemaSet LoadSchemaSet(string xsdPath) + { + var schemaSet = new XmlSchemaSet(); + + if (!File.Exists(xsdPath)) + return schemaSet; + + try + { + using var stream = new FileStream(xsdPath, FileMode.Open, FileAccess.Read); + var schema = XmlSchema.Read(stream, null); + if (schema != null) + { + schemaSet.Add(schema); + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to load XSD schema from {xsdPath}: {ex.Message}", ex); + } + + return schemaSet; + } + + public bool ValidateXmlAgainstXsd(string xmlFilePath, string xsdPath, out List errors) + { + errors = new List(); + + if (!File.Exists(xmlFilePath)) + { + errors.Add($"XML file not found: {xmlFilePath}"); + return false; + } + + if (!File.Exists(xsdPath)) + { + errors.Add($"XSD schema not found: {xsdPath}"); + return false; + } + + try + { + var schemaSet = LoadSchemaSet(xsdPath); + var validationErrors = new List(); + var settings = new XmlReaderSettings + { + ValidationType = ValidationType.Schema, + Schemas = schemaSet + }; + + settings.ValidationEventHandler += (sender, e) => + { + var severity = e.Severity == XmlSeverityType.Error ? "Error" : "Warning"; + validationErrors.Add($"{severity}: {e.Message}"); + }; + + using var reader = XmlReader.Create(xmlFilePath, settings); + while (reader.Read()) + { + } + + errors = validationErrors; + return errors.Count == 0; + } + catch (Exception ex) + { + errors.Add($"Validation error: {ex.Message}"); + return false; + } + } + } +}