Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions BannerlordModEditor.Common.Tests/Services/TpacParserServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Xunit;
using BannerlordModEditor.Common.Services;

namespace BannerlordModEditor.Common.Tests.Services
{
public class TpacParserServiceTests
{
private readonly TpacParserService _service;

public TpacParserServiceTests()
{
_service = new TpacParserService();
}

[Fact]
public void IsTpacFile_ReturnsTrueForTpacExtension()
{
Assert.True(_service.IsTpacFile("test.tpac"));
Assert.True(_service.IsTpacFile("path/to/file.TPAC"));
}

[Fact]
public void IsTpacFile_ReturnsFalseForNonTpacExtension()
{
Assert.False(_service.IsTpacFile("test.xml"));
Assert.False(_service.IsTpacFile("test.txt"));
}

[Fact]
public void ParseTpacFile_ReturnsInvalidForNonExistentFile()
{
var result = _service.ParseTpacFile("/nonexistent/file.tpac");
Assert.False(result.IsValid);
Assert.Equal("File not found", result.ErrorMessage);
}

[Fact]
public async Task ParseTpacFileAsync_ReturnsInvalidForNonExistentFile()
{
var result = await _service.ParseTpacFileAsync("/nonexistent/file.tpac");
Assert.False(result.IsValid);
}

[Fact]
public void ParseTpacFile_ExtractsModelsFromValidFile()
{
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";
Comment on lines +50 to +58
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

try
{
File.WriteAllText(tempFile, content);

var result = _service.ParseTpacFile(tempFile);

Assert.True(result.IsValid);
Assert.Equal(2, result.Models.Count);
Assert.Equal("12345", result.Models[0].ModelId);
Assert.Equal("TestModel", result.Models[0].ModelName);
Assert.Equal("Character", result.Models[0].ModelType);
Assert.Equal("67890", result.Models[1].ModelId);
}
finally
{
File.Delete(tempFile);
}
}

[Fact]
public async Task ParseTpacFileAsync_ExtractsModelsFromValidFile()
{
var tempFile = Path.GetTempFileName();
var content = @"model_id: 99999
model_name: AsyncModel
model_type: Weapon";
Comment on lines +82 to +85
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

try
{
File.WriteAllText(tempFile, content);

var result = await _service.ParseTpacFileAsync(tempFile);

Assert.True(result.IsValid);
Assert.Single(result.Models);
Assert.Equal("99999", result.Models[0].ModelId);
Assert.Equal("AsyncModel", result.Models[0].ModelName);
}
finally
{
File.Delete(tempFile);
}
}

[Fact]
public void ExtractModels_ReturnsEmptyForNonExistentFile()
{
var result = _service.ExtractModels("/nonexistent/file.tpac");
Assert.Empty(result);
}
}
}
143 changes: 143 additions & 0 deletions BannerlordModEditor.Common/Services/TpacParserService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace BannerlordModEditor.Common.Services
{
public class TpacModelInfo
{
public string ModelId { get; set; } = string.Empty;
public string ModelName { get; set; } = string.Empty;
public string ModelType { get; set; } = string.Empty;
public Dictionary<string, string> Properties { get; set; } = new();
}

public class TpacFileInfo
{
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public List<TpacModelInfo> Models { get; set; } = new();
public bool IsValid { get; set; }
public string? ErrorMessage { get; set; }
}

public interface ITpacParserService
{
Task<TpacFileInfo> ParseTpacFileAsync(string filePath);
TpacFileInfo ParseTpacFile(string filePath);
List<TpacModelInfo> ExtractModels(string tpacFilePath);
bool IsTpacFile(string filePath);
}

public class TpacParserService : ITpacParserService
{
private const string TpacExtension = ".tpac";

public async Task<TpacFileInfo> ParseTpacFileAsync(string filePath)
{
return await Task.Run(() => ParseTpacFile(filePath));
Comment on lines +38 to +40
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
public async Task<TpacFileInfo> ParseTpacFileAsync(string filePath)
{
return await Task.Run(() => ParseTpacFile(filePath));
public Task<TpacFileInfo> ParseTpacFileAsync(string filePath)
{
return Task.FromResult(ParseTpacFile(filePath));

Copilot uses AI. Check for mistakes.
}

public TpacFileInfo ParseTpacFile(string filePath)
{
var info = new TpacFileInfo
{
FilePath = filePath,
FileName = Path.GetFileName(filePath)
};

if (!File.Exists(filePath))
{
info.IsValid = false;
info.ErrorMessage = "File not found";
return info;
}

if (!IsTpacFile(filePath))
{
info.IsValid = false;
info.ErrorMessage = "Not a valid .tpac file";
return info;
}
Comment on lines +58 to +63
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Copilot uses AI. Check for mistakes.

try
{
info.Models = ExtractModels(filePath);
info.IsValid = true;
}
catch (Exception ex)
{
info.IsValid = false;
info.ErrorMessage = ex.Message;
}

return info;
}

public List<TpacModelInfo> ExtractModels(string tpacFilePath)
{
var models = new List<TpacModelInfo>();

if (!File.Exists(tpacFilePath))
return models;

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();
}
}
Comment on lines +116 to +123
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}

if (!string.IsNullOrEmpty(currentModel.ModelId))
{
models.Add(currentModel);
}
}
catch
{
Comment on lines +86 to +132
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
}

return models;
}

public bool IsTpacFile(string filePath)
{
return Path.GetExtension(filePath).Equals(TpacExtension, StringComparison.OrdinalIgnoreCase);
}
}
}
Loading