Conversation
- Create SubModule models (DO, DTO, Mapper) - Implement SubModuleLoader with async support - Support reading and writing SubModule.xml files - Add comprehensive unit tests - Support creating default SubModule.xml Closes #10
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for Bannerlord SubModule.xml by introducing domain/DTO models, mapping, an XML loader with async APIs, and unit tests validating load/save/default behaviors.
Changes:
- Added SubModule DO/DTO model graphs and a DO↔DTO mapper.
- Implemented
SubModuleLoaderto load/saveSubModule.xmland create a default template. - Added unit tests covering load/save (sync+async) and default creation.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| BannerlordModEditor.Common/Models/SubModule/SubModuleMapper.cs | Maps between DO and DTO representations used by serialization. |
| BannerlordModEditor.Common/Models/SubModule/SubModuleLoader.cs | Implements XML load/save + async wrappers + default template creation. |
| BannerlordModEditor.Common/Models/SubModule/SubModuleDTO.cs | Defines XML-serializable DTO object model for SubModule.xml. |
| BannerlordModEditor.Common/Models/SubModule/SubModuleDO.cs | Defines domain object model mirroring the DTO structure. |
| BannerlordModEditor.Common.Tests/Models/SubModule/SubModuleLoaderTests.cs | Validates loader behavior with temp files and basic XML samples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public async Task<SubModuleDO?> LoadAsync(string filePath) | ||
| { | ||
| return await Task.Run(() => Load(filePath)); | ||
| } |
There was a problem hiding this comment.
Task.Run around synchronous file IO/serialization can cause unnecessary thread-pool usage and contention under load. Prefer implementing true async IO (e.g., async file read/write with useAsync: true, deserialize/serialize from/to an in-memory buffer) or remove the async methods and let callers decide whether to offload to the thread pool.
| public async Task SaveAsync(SubModuleDO data, string filePath) | ||
| { | ||
| await Task.Run(() => Save(data, filePath)); | ||
| } |
There was a problem hiding this comment.
Task.Run around synchronous file IO/serialization can cause unnecessary thread-pool usage and contention under load. Prefer implementing true async IO (e.g., async file read/write with useAsync: true, deserialize/serialize from/to an in-memory buffer) or remove the async methods and let callers decide whether to offload to the thread pool.
|
|
||
| try | ||
| { | ||
| using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); |
There was a problem hiding this comment.
This overload defaults to FileShare.None, which can unnecessarily lock SubModule.xml while reading and break scenarios where another process/thread needs to read the file concurrently. Consider using a FileStream overload that specifies an explicit FileShare (commonly FileShare.Read or FileShare.ReadWrite) for the read path.
| using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); | |
| using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); |
| [XmlRoot("Module")] | ||
| public class SubModuleDO | ||
| { | ||
| [XmlElement("Name")] | ||
| public string Name { get; set; } = string.Empty; | ||
|
|
||
| [XmlElement("Id")] | ||
| public string Id { get; set; } = string.Empty; | ||
|
|
||
| [XmlElement("Version")] | ||
| public string Version { get; set; } = string.Empty; | ||
|
|
||
| [XmlElement("SingleplayerModule")] | ||
| public bool SingleplayerModule { get; set; } = true; | ||
|
|
||
| [XmlElement("MultiplayerModule")] | ||
| public bool MultiplayerModule { get; set; } | ||
|
|
||
| [XmlArray("DependedModules")] | ||
| [XmlArrayItem("Module")] | ||
| public List<DependedModuleDO> DependedModules { get; set; } = new(); | ||
|
|
||
| [XmlArray("SubModules")] | ||
| [XmlArrayItem("SubModule")] | ||
| public List<SubModuleItemDO> SubModules { get; set; } = new(); | ||
|
|
||
| [XmlArray("Xmls")] | ||
| [XmlArrayItem("XmlNode")] | ||
| public List<XmlNodeDO> Xmls { get; set; } = new(); | ||
|
|
||
| [XmlArray("OptionalDependedModules")] | ||
| [XmlArrayItem("Module")] | ||
| public List<OptionalDependedModuleDO> OptionalDependedModules { get; set; } = new(); | ||
| } | ||
|
|
||
| public class DependedModuleDO | ||
| { | ||
| [XmlAttribute("Id")] | ||
| public string Id { get; set; } = string.Empty; | ||
| } | ||
|
|
||
| public class OptionalDependedModuleDO | ||
| { | ||
| [XmlAttribute("Id")] | ||
| public string Id { get; set; } = string.Empty; | ||
| } | ||
|
|
||
| public class SubModuleItemDO | ||
| { | ||
| [XmlElement("Name")] | ||
| public string Name { get; set; } = string.Empty; | ||
|
|
||
| [XmlElement("DLLName")] | ||
| public string DLLName { get; set; } = string.Empty; | ||
|
|
||
| [XmlElement("SubModuleClassType")] | ||
| public string SubModuleClassType { get; set; } = string.Empty; | ||
|
|
||
| [XmlElement("IsOptional")] | ||
| public bool IsOptional { get; set; } | ||
|
|
||
| [XmlElement("IsTicked")] | ||
| public bool IsTicked { get; set; } = true; | ||
|
|
||
| [XmlArray("Tags")] | ||
| [XmlArrayItem("Tag")] | ||
| public List<SubModuleTagDO> Tags { get; set; } = new(); | ||
| } | ||
|
|
||
| public class SubModuleTagDO | ||
| { | ||
| [XmlAttribute("Key")] | ||
| public string Key { get; set; } = string.Empty; | ||
|
|
||
| [XmlAttribute("Value")] | ||
| public string Value { get; set; } = string.Empty; | ||
| } | ||
|
|
||
| public class XmlNodeDO | ||
| { | ||
| [XmlAttribute("Id")] | ||
| public string Id { get; set; } = string.Empty; | ||
|
|
||
| [XmlAttribute("Type")] | ||
| public string Type { get; set; } = string.Empty; | ||
|
|
||
| [XmlText] |
There was a problem hiding this comment.
The DO types are decorated with XML serialization attributes even though serialization is performed via SubModuleDTO + XmlSerializer(typeof(SubModuleDTO)). This duplicates the schema across DO/DTO and increases the risk of them drifting out of sync. Consider either (a) removing XML attributes from the DO classes (keep DTO as the serialization contract), or (b) consolidating to a single XML model to eliminate the mapper if separation isn’t needed.
| [XmlRoot("Module")] | |
| public class SubModuleDO | |
| { | |
| [XmlElement("Name")] | |
| public string Name { get; set; } = string.Empty; | |
| [XmlElement("Id")] | |
| public string Id { get; set; } = string.Empty; | |
| [XmlElement("Version")] | |
| public string Version { get; set; } = string.Empty; | |
| [XmlElement("SingleplayerModule")] | |
| public bool SingleplayerModule { get; set; } = true; | |
| [XmlElement("MultiplayerModule")] | |
| public bool MultiplayerModule { get; set; } | |
| [XmlArray("DependedModules")] | |
| [XmlArrayItem("Module")] | |
| public List<DependedModuleDO> DependedModules { get; set; } = new(); | |
| [XmlArray("SubModules")] | |
| [XmlArrayItem("SubModule")] | |
| public List<SubModuleItemDO> SubModules { get; set; } = new(); | |
| [XmlArray("Xmls")] | |
| [XmlArrayItem("XmlNode")] | |
| public List<XmlNodeDO> Xmls { get; set; } = new(); | |
| [XmlArray("OptionalDependedModules")] | |
| [XmlArrayItem("Module")] | |
| public List<OptionalDependedModuleDO> OptionalDependedModules { get; set; } = new(); | |
| } | |
| public class DependedModuleDO | |
| { | |
| [XmlAttribute("Id")] | |
| public string Id { get; set; } = string.Empty; | |
| } | |
| public class OptionalDependedModuleDO | |
| { | |
| [XmlAttribute("Id")] | |
| public string Id { get; set; } = string.Empty; | |
| } | |
| public class SubModuleItemDO | |
| { | |
| [XmlElement("Name")] | |
| public string Name { get; set; } = string.Empty; | |
| [XmlElement("DLLName")] | |
| public string DLLName { get; set; } = string.Empty; | |
| [XmlElement("SubModuleClassType")] | |
| public string SubModuleClassType { get; set; } = string.Empty; | |
| [XmlElement("IsOptional")] | |
| public bool IsOptional { get; set; } | |
| [XmlElement("IsTicked")] | |
| public bool IsTicked { get; set; } = true; | |
| [XmlArray("Tags")] | |
| [XmlArrayItem("Tag")] | |
| public List<SubModuleTagDO> Tags { get; set; } = new(); | |
| } | |
| public class SubModuleTagDO | |
| { | |
| [XmlAttribute("Key")] | |
| public string Key { get; set; } = string.Empty; | |
| [XmlAttribute("Value")] | |
| public string Value { get; set; } = string.Empty; | |
| } | |
| public class XmlNodeDO | |
| { | |
| [XmlAttribute("Id")] | |
| public string Id { get; set; } = string.Empty; | |
| [XmlAttribute("Type")] | |
| public string Type { get; set; } = string.Empty; | |
| [XmlText] | |
| public class SubModuleDO | |
| { | |
| public string Name { get; set; } = string.Empty; | |
| public string Id { get; set; } = string.Empty; | |
| public string Version { get; set; } = string.Empty; | |
| public bool SingleplayerModule { get; set; } = true; | |
| public bool MultiplayerModule { get; set; } | |
| public List<DependedModuleDO> DependedModules { get; set; } = new(); | |
| public List<SubModuleItemDO> SubModules { get; set; } = new(); | |
| public List<XmlNodeDO> Xmls { get; set; } = new(); | |
| public List<OptionalDependedModuleDO> OptionalDependedModules { get; set; } = new(); | |
| } | |
| public class DependedModuleDO | |
| { | |
| public string Id { get; set; } = string.Empty; | |
| } | |
| public class OptionalDependedModuleDO | |
| { | |
| public string Id { get; set; } = string.Empty; | |
| } | |
| public class SubModuleItemDO | |
| { | |
| public string Name { get; set; } = string.Empty; | |
| public string DLLName { get; set; } = string.Empty; | |
| public string SubModuleClassType { get; set; } = string.Empty; | |
| public bool IsOptional { get; set; } | |
| public bool IsTicked { get; set; } = true; | |
| public List<SubModuleTagDO> Tags { get; set; } = new(); | |
| } | |
| public class SubModuleTagDO | |
| { | |
| public string Key { get; set; } = string.Empty; | |
| public string Value { get; set; } = string.Empty; | |
| } | |
| public class XmlNodeDO | |
| { | |
| public string Id { get; set; } = string.Empty; | |
| public string Type { get; set; } = string.Empty; |
| var result = _loader.Load("/nonexistent/path/SubModule.xml"); | ||
| Assert.Null(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task LoadAsync_ReturnsNullForNonExistentFile() | ||
| { | ||
| var result = await _loader.LoadAsync("/nonexistent/path/SubModule.xml"); |
There was a problem hiding this comment.
Using a hard-coded absolute path like /nonexistent/path/... can be brittle across OSes/environments (and in rare cases could exist or be accessible). Prefer generating a guaranteed-nonexistent path under the temp directory (e.g., Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "SubModule.xml")) so the test remains deterministic.
| var result = _loader.Load("/nonexistent/path/SubModule.xml"); | |
| Assert.Null(result); | |
| } | |
| [Fact] | |
| public async Task LoadAsync_ReturnsNullForNonExistentFile() | |
| { | |
| var result = await _loader.LoadAsync("/nonexistent/path/SubModule.xml"); | |
| var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "SubModule.xml"); | |
| var result = _loader.Load(nonExistentPath); | |
| Assert.Null(result); | |
| } | |
| [Fact] | |
| public async Task LoadAsync_ReturnsNullForNonExistentFile() | |
| { | |
| var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "SubModule.xml"); | |
| var result = await _loader.LoadAsync(nonExistentPath); |
| var result = _loader.Load("/nonexistent/path/SubModule.xml"); | ||
| Assert.Null(result); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task LoadAsync_ReturnsNullForNonExistentFile() | ||
| { | ||
| var result = await _loader.LoadAsync("/nonexistent/path/SubModule.xml"); |
There was a problem hiding this comment.
Using a hard-coded absolute path like /nonexistent/path/... can be brittle across OSes/environments (and in rare cases could exist or be accessible). Prefer generating a guaranteed-nonexistent path under the temp directory (e.g., Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "SubModule.xml")) so the test remains deterministic.
| var result = _loader.Load("/nonexistent/path/SubModule.xml"); | |
| Assert.Null(result); | |
| } | |
| [Fact] | |
| public async Task LoadAsync_ReturnsNullForNonExistentFile() | |
| { | |
| var result = await _loader.LoadAsync("/nonexistent/path/SubModule.xml"); | |
| var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "SubModule.xml"); | |
| var result = _loader.Load(nonExistentPath); | |
| Assert.Null(result); | |
| } | |
| [Fact] | |
| public async Task LoadAsync_ReturnsNullForNonExistentFile() | |
| { | |
| var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "SubModule.xml"); | |
| var result = await _loader.LoadAsync(nonExistentPath); |
Summary
Changes
BannerlordModEditor.Common/Models/SubModule/SubModuleDO.cs- Domain objectsBannerlordModEditor.Common/Models/SubModule/SubModuleDTO.cs- Data transfer objectsBannerlordModEditor.Common/Models/SubModule/SubModuleMapper.cs- DO/DTO mapperBannerlordModEditor.Common/Models/SubModule/SubModuleLoader.cs- XML loaderBannerlordModEditor.Common.Tests/Models/SubModule/SubModuleLoaderTests.cs- Unit testsCloses #10