Skip to content
Merged
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
66 changes: 66 additions & 0 deletions Ra3.BattleNet.Metadata.Tests/MetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,5 +228,71 @@ public void GetIncludeTree_ReturnsTreeStructure()
tree.Should().NotBeNullOrEmpty();
tree.Should().Contain("Metadata");
}

[Fact]
public void ToNodeTree_ShouldKeepLeafValue()
{
// Arrange
var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
var metadata = Metadata.LoadFromFile(filePath);

// Act
var root = metadata.ToNodeTree();
var app = root.Children.Single(c => c.Name == "Application");
var appName = app.Children.Single(c => c.Name == "Name");

// Assert
appName.Value.Should().Be("Test Application");
}

[Fact]
public void GetBusinessEntities_ShouldReturnTypedEntities()
{
// Arrange
var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml");
var metadata = Metadata.LoadFromFile(filePath);

// Act
var entities = metadata.GetBusinessEntities();

// Assert
entities.Should().Contain(e => e.EntityType == "Application" && e.Id == "RA3BattleNet");
entities.Should().Contain(e => e.EntityType == "Mod" && e.Id == "Corona");
entities.Should().Contain(e => e.EntityType == "Markdown");
entities.Should().Contain(e => e.EntityType == "Manifest");
}


[Fact]
public void Mods_ShouldExposeVersionAndPackages()
{
// Arrange
var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml");
var metadata = Metadata.LoadFromFile(filePath);

// Act
var corona = metadata.Mods().Single(m => m.Id == "Corona");

// Assert
corona.Version.Should().Be("3.229");
corona.Packages.Should().NotBeEmpty();
}

[Fact]
public void Catalog_ShouldProvideConvenientLookup()
{
// Arrange
var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml");
var metadata = Metadata.LoadFromFile(filePath);

// Act
var catalog = metadata.Catalog();
var app = catalog.Application("RA3BattleNet");

// Assert
app.Should().NotBeNull();
app!.Version.Should().Be("1.5.2.0");
}

}
}
102 changes: 102 additions & 0 deletions Ra3.BattleNet.Metadata/Metadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class Metadata
private readonly Dictionary<string, string> _envVars = new();
private readonly Dictionary<string, string> _fileHashes = new();
private string _currentFilePath = string.Empty;
private string? _value = null;
private Metadata? _parent = null;
private string _includeType = "public"; // "public" 或 "private"

Expand All @@ -24,6 +25,10 @@ public class Metadata
public IReadOnlyList<Metadata> DefineChildren => _defineChildren;
public Metadata? Parent => _parent;
public string IncludeType => _includeType;
/// <summary>
/// 节点文本值(仅叶子节点有效)。
/// </summary>
public string? Value => _value;

public Metadata()
{
Expand Down Expand Up @@ -224,6 +229,12 @@ private XElement ToXElement()
{
element.SetAttributeValue(var.Key, var.Value);
}

if (!string.IsNullOrWhiteSpace(_value) && !_children.Any())
{
element.Value = _value;
}

foreach (var child in _children)
{
element.Add(child.ToXElement());
Expand Down Expand Up @@ -298,6 +309,97 @@ private void ParseElement(XElement? element, string currentFilePath, HashSet<str
_children.Add(childMetadata);
}
}

if (!element.HasElements)
{
_value = element.Value;
}
}

/// <summary>
/// 将当前元数据转换为可反序列化使用的树状结构。
/// </summary>
public MetadataNode ToNodeTree()
{
return BuildNode(this);
}

/// <summary>
/// 获取所有业务实体(Application/Mod/Markdown/Image/Manifest)节点。
/// </summary>
public IReadOnlyList<BusinessEntity> GetBusinessEntities()
{
var supported = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Application", "Mod", "Markdown", "Image", "Manifest"
};

var allNodes = GetAllNodes();
var entities = new List<BusinessEntity>();

foreach (var node in allNodes.Where(n => supported.Contains(n.Name)))
{
var entity = new BusinessEntity
{
EntityType = node.Name,
Id = node.Get("ID"),
Path = node.GetElementPath(),
Attributes = new Dictionary<string, string>(node._variables, StringComparer.OrdinalIgnoreCase),
Properties = CollectEntityProperties(node)
};

entities.Add(entity);
}

return entities;
}

private static Dictionary<string, string> CollectEntityProperties(Metadata node)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

foreach (var child in node._children)
{
if (!string.IsNullOrWhiteSpace(child._value) && !result.ContainsKey(child.Name))
{
result[child.Name] = child._value;
}
}

var packageCount = node._children.FirstOrDefault(c => c.Name == "Packages")
?._children.Count(c => c.Name == "Package");
if (packageCount.HasValue)
{
result["PackageCount"] = packageCount.Value.ToString();
}

var fileCount = node._children.Count(c => c.Name == "File");
if (fileCount > 0)
{
result["FileCount"] = fileCount.ToString();
}

return result;
}

private MetadataNode BuildNode(Metadata metadata)
{
return new MetadataNode(
metadata.Name,
metadata.Value,
new Dictionary<string, string>(metadata._variables, StringComparer.OrdinalIgnoreCase),
metadata._children.Select(BuildNode).ToList());
}

private List<Metadata> GetAllNodes()
{
var nodes = new List<Metadata> { this };
foreach (var child in _children)
{
nodes.AddRange(child.GetAllNodes());
}

return nodes;
}

/// <summary>
Expand Down
45 changes: 45 additions & 0 deletions Ra3.BattleNet.Metadata/MetadataModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace Ra3.BattleNet.Metadata;

/// <summary>
/// 通用元数据树节点(适合反序列化后继续加工)。
/// </summary>
/// <param name="Name">节点名(即 XML 标签名)。</param>
/// <param name="Value">叶子节点文本值;若包含子节点则通常为 <c>null</c> 或空字符串。</param>
/// <param name="Attributes">节点属性字典(如 <c>ID</c>、<c>Source</c>)。</param>
/// <param name="Children">子节点集合。</param>
public sealed record MetadataNode(
string Name,
string? Value,
IReadOnlyDictionary<string, string> Attributes,
IReadOnlyList<MetadataNode> Children);

/// <summary>
/// 业务实体快照,便于直接查询核心字段。
/// </summary>
public sealed class BusinessEntity
{
/// <summary>
/// 实体类型(如 <c>Application</c>、<c>Mod</c>、<c>Markdown</c>)。
/// </summary>
public string EntityType { get; init; } = string.Empty;

/// <summary>
/// 实体 ID(来自节点 <c>ID</c> 属性)。
/// </summary>
public string? Id { get; init; }

/// <summary>
/// 实体在元数据树中的路径(<c>A -&gt; B -&gt; C</c>)。
/// </summary>
public string Path { get; init; } = string.Empty;

/// <summary>
/// 原始属性字典。
/// </summary>
public IReadOnlyDictionary<string, string> Attributes { get; init; } = new Dictionary<string, string>();

/// <summary>
/// 归一化后的常用属性(如 <c>PackageCount</c>、<c>FileCount</c>)。
/// </summary>
public IReadOnlyDictionary<string, string> Properties { get; init; } = new Dictionary<string, string>();
}
119 changes: 119 additions & 0 deletions Ra3.BattleNet.Metadata/MetadataQueryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
namespace Ra3.BattleNet.Metadata;

/// <summary>
/// <see cref="Metadata"/> 的业务查询扩展方法。
/// </summary>
public static class MetadataQueryExtensions
{
/// <summary>
/// 构建目录式查询入口。
/// </summary>
/// <param name="root">元数据根对象。</param>
/// <returns>可按实体类型查询的目录对象。</returns>
public static MetadataCatalog Catalog(this Metadata root)
{
return new MetadataCatalog(root);
}

/// <summary>
/// 获取所有 Mod 实体。
/// </summary>
/// <param name="root">元数据根对象。</param>
/// <returns>Mod 实体列表。</returns>
public static IReadOnlyList<ModEntry> Mods(this Metadata root)
{
return root.GetAllElements("Mod")
.Select(ToMod)
.ToList();
}

/// <summary>
/// 获取所有 Application 实体。
/// </summary>
/// <param name="root">元数据根对象。</param>
/// <returns>Application 实体列表。</returns>
public static IReadOnlyList<ApplicationEntry> Applications(this Metadata root)
{
return root.GetAllElements("Application")
.Select(ToApplication)
.ToList();
}

/// <summary>
/// 获取所有 Markdown 资源。
/// </summary>
/// <param name="root">元数据根对象。</param>
/// <returns>Markdown 资源列表。</returns>
public static IReadOnlyList<MarkdownEntry> Markdowns(this Metadata root)
{
return root.GetAllElements("Markdown")
.Select(node => new MarkdownEntry(
Id: node.Get("ID") ?? string.Empty,
Source: node.Get("Source"),
Hash: node.Get("Hash"),
Raw: node))
.ToList();
}

/// <summary>
/// 获取所有图片资源。
/// </summary>
/// <param name="root">元数据根对象。</param>
/// <returns>图片资源列表。</returns>
public static IReadOnlyList<ImageEntry> Images(this Metadata root)
{
return root.GetAllElements("Image")
.Select(node => new ImageEntry(
Id: node.Get("ID") ?? string.Empty,
Source: node.Get("Source"),
Url: node.Get("Url"),
Raw: node))
.ToList();
}

/// <summary>
/// 将 <c>Mod</c> 节点映射为 <see cref="ModEntry"/>。
/// </summary>
private static ModEntry ToMod(Metadata node)
{
return new ModEntry(
Id: node.Get("ID") ?? string.Empty,
Version: node.Find("CurrentVersion")?.Value,
Icon: node.Find("Icon")?.Value,
Packages: ReadPackages(node),
Raw: node);
}

/// <summary>
/// 将 <c>Application</c> 节点映射为 <see cref="ApplicationEntry"/>。
/// </summary>
private static ApplicationEntry ToApplication(Metadata node)
{
return new ApplicationEntry(
Id: node.Get("ID") ?? string.Empty,
Version: node.Find("Version")?.Value,
Packages: ReadPackages(node),
Raw: node);
}

/// <summary>
/// 读取一个业务实体下的全部版本包。
/// </summary>
private static IReadOnlyList<PackageEntry> ReadPackages(Metadata node)
{
var packagesNode = node.Find("Packages");
if (packagesNode == null)
{
return [];
}

return packagesNode.Children
.Where(c => c.Name == "Package")
.Select(package => new PackageEntry(
Version: package.Get("Version") ?? string.Empty,
ReleaseDate: package.Find("ReleaseDate")?.Value,
ManifestId: package.Find("Manifest")?.Value,
Raw: package))
.ToList();
}
}
Loading