Conversation
- Create ITpacParserService interface - Implement TpacParserService to parse .tpac files - Extract model IDs, names, and types from tpac files - Add comprehensive unit tests Closes #9
There was a problem hiding this comment.
Pull request overview
Introduces a .tpac file parsing service with an interface and DTOs, plus xUnit tests to validate parsing behavior.
Changes:
- Added
ITpacParserServiceplusTpacParserServiceimplementation to parse.tpacfiles and extract model metadata. - Added DTOs (
TpacFileInfo,TpacModelInfo) to return parsed results and errors. - Added xUnit tests covering extension detection and basic parsing flows.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| BannerlordModEditor.Common/Services/TpacParserService.cs | Adds .tpac parser service, DTOs, and interface for parsing/extracting models. |
| BannerlordModEditor.Common.Tests/Services/TpacParserServiceTests.cs | Adds xUnit tests for .tpac extension checks and model extraction. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var tempFile = Path.GetTempFileName(); | ||
| var content = @"model_id: 12345 | ||
| model_name: TestModel | ||
| model_type: Character | ||
| property1: value1 | ||
| property2: value2 | ||
| model_id: 67890 | ||
| model_name: AnotherModel | ||
| model_type: Item"; |
There was a problem hiding this comment.
Path.GetTempFileName() creates a file with a .tmp extension, but ParseTpacFile rejects non-.tpac extensions via IsTpacFile. This test will fail by returning IsValid = false with "Not a valid .tpac file". Create the temp path with a .tpac extension (e.g., Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".tpac")) and write to that path instead.
| var tempFile = Path.GetTempFileName(); | ||
| var content = @"model_id: 99999 | ||
| model_name: AsyncModel | ||
| model_type: Weapon"; |
There was a problem hiding this comment.
Same issue as the sync test: the temp file will have a .tmp extension, so ParseTpacFileAsync will return invalid due to IsTpacFile. Use a temp file path ending in .tpac to align the test with the service’s validation logic.
| try | ||
| { | ||
| var content = File.ReadAllText(tpacFilePath); | ||
|
|
||
| var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); | ||
| var currentModel = new TpacModelInfo(); | ||
|
|
||
| foreach (var line in lines) | ||
| { | ||
| var trimmed = line.Trim(); | ||
|
|
||
| if (trimmed.StartsWith("model_id:", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| if (!string.IsNullOrEmpty(currentModel.ModelId)) | ||
| { | ||
| models.Add(currentModel); | ||
| } | ||
| currentModel = new TpacModelInfo | ||
| { | ||
| ModelId = trimmed["model_id:".Length..].Trim() | ||
| }; | ||
| } | ||
| else if (trimmed.StartsWith("model_name:", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| currentModel.ModelName = trimmed["model_name:".Length..].Trim(); | ||
| } | ||
| else if (trimmed.StartsWith("model_type:", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| currentModel.ModelType = trimmed["model_type:".Length..].Trim(); | ||
| } | ||
| else if (trimmed.Contains(':')) | ||
| { | ||
| var parts = trimmed.Split(':', 2); | ||
| if (parts.Length == 2) | ||
| { | ||
| currentModel.Properties[parts[0].Trim()] = parts[1].Trim(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!string.IsNullOrEmpty(currentModel.ModelId)) | ||
| { | ||
| models.Add(currentModel); | ||
| } | ||
| } | ||
| catch | ||
| { |
There was a problem hiding this comment.
ExtractModels swallows all exceptions and returns whatever it has collected (often an empty list). Because ParseTpacFile treats “no exception” as success, an IO/parsing failure can incorrectly produce IsValid = true with Models = []. Remove the empty catch and let exceptions propagate to ParseTpacFile (so it can set IsValid = false), or catch and rethrow a more specific exception that preserves the original as an inner exception.
| try | |
| { | |
| var content = File.ReadAllText(tpacFilePath); | |
| var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); | |
| var currentModel = new TpacModelInfo(); | |
| foreach (var line in lines) | |
| { | |
| var trimmed = line.Trim(); | |
| if (trimmed.StartsWith("model_id:", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| if (!string.IsNullOrEmpty(currentModel.ModelId)) | |
| { | |
| models.Add(currentModel); | |
| } | |
| currentModel = new TpacModelInfo | |
| { | |
| ModelId = trimmed["model_id:".Length..].Trim() | |
| }; | |
| } | |
| else if (trimmed.StartsWith("model_name:", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| currentModel.ModelName = trimmed["model_name:".Length..].Trim(); | |
| } | |
| else if (trimmed.StartsWith("model_type:", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| currentModel.ModelType = trimmed["model_type:".Length..].Trim(); | |
| } | |
| else if (trimmed.Contains(':')) | |
| { | |
| var parts = trimmed.Split(':', 2); | |
| if (parts.Length == 2) | |
| { | |
| currentModel.Properties[parts[0].Trim()] = parts[1].Trim(); | |
| } | |
| } | |
| } | |
| if (!string.IsNullOrEmpty(currentModel.ModelId)) | |
| { | |
| models.Add(currentModel); | |
| } | |
| } | |
| catch | |
| { | |
| var content = File.ReadAllText(tpacFilePath); | |
| var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries); | |
| var currentModel = new TpacModelInfo(); | |
| foreach (var line in lines) | |
| { | |
| var trimmed = line.Trim(); | |
| if (trimmed.StartsWith("model_id:", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| if (!string.IsNullOrEmpty(currentModel.ModelId)) | |
| { | |
| models.Add(currentModel); | |
| } | |
| currentModel = new TpacModelInfo | |
| { | |
| ModelId = trimmed["model_id:".Length..].Trim() | |
| }; | |
| } | |
| else if (trimmed.StartsWith("model_name:", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| currentModel.ModelName = trimmed["model_name:".Length..].Trim(); | |
| } | |
| else if (trimmed.StartsWith("model_type:", StringComparison.OrdinalIgnoreCase)) | |
| { | |
| currentModel.ModelType = trimmed["model_type:".Length..].Trim(); | |
| } | |
| else if (trimmed.Contains(':')) | |
| { | |
| var parts = trimmed.Split(':', 2); | |
| if (parts.Length == 2) | |
| { | |
| currentModel.Properties[parts[0].Trim()] = parts[1].Trim(); | |
| } | |
| } | |
| } | |
| if (!string.IsNullOrEmpty(currentModel.ModelId)) | |
| { | |
| models.Add(currentModel); |
| public async Task<TpacFileInfo> ParseTpacFileAsync(string filePath) | ||
| { | ||
| return await Task.Run(() => ParseTpacFile(filePath)); |
There was a problem hiding this comment.
ParseTpacFileAsync uses Task.Run to offload synchronous IO, which consumes a thread-pool thread and doesn’t provide true async IO. Prefer an actually-async pipeline (e.g., File.ReadAllTextAsync or streaming with StreamReader.ReadLineAsync) and parse from the async-read content; this scales better under concurrency and avoids unnecessary thread-pool usage.
| public async Task<TpacFileInfo> ParseTpacFileAsync(string filePath) | |
| { | |
| return await Task.Run(() => ParseTpacFile(filePath)); | |
| public Task<TpacFileInfo> ParseTpacFileAsync(string filePath) | |
| { | |
| return Task.FromResult(ParseTpacFile(filePath)); |
| if (!IsTpacFile(filePath)) | ||
| { | ||
| info.IsValid = false; | ||
| info.ErrorMessage = "Not a valid .tpac file"; | ||
| return info; | ||
| } |
There was a problem hiding this comment.
The behavior for invalid extensions (setting IsValid = false and the specific error message) isn’t covered by tests. Add a unit test that writes valid .tpac-formatted content to a non-.tpac filename (or passes such a path) and asserts IsValid is false and ErrorMessage equals "Not a valid .tpac file".
| else if (trimmed.Contains(':')) | ||
| { | ||
| var parts = trimmed.Split(':', 2); | ||
| if (parts.Length == 2) | ||
| { | ||
| currentModel.Properties[parts[0].Trim()] = parts[1].Trim(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Property extraction into TpacModelInfo.Properties is a core part of the parser, but the tests currently don’t assert that properties are captured correctly. Add assertions in ParseTpacFile_ExtractsModelsFromValidFile to verify property1/property2 exist with expected values on the first model.
Summary
ITpacParserServiceinterfaceTpacParserServiceto parse .tpac filesChanges
BannerlordModEditor.Common/Services/TpacParserService.cs- ImplementationBannerlordModEditor.Common.Tests/Services/TpacParserServiceTests.cs- Unit testsCloses #9