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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# CFPA-specifics
# CFPA-specifics
Minecraft-Mod-Language-Package-*.zip
*.md5
build/
Expand Down Expand Up @@ -389,3 +389,4 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd

/.reasonix/
12 changes: 12 additions & 0 deletions Minecraft-Mod-Language-Package.sln
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uploader", "src\Uploader\Up
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Language.Core", "src\Language.Core\Language.Core.csproj", "{D59EE21C-875E-4B31-8A97-813DC4C2DB68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Packer.Core", "src\Packer.Core\Packer.Core.csproj", "{0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Packer.Core.Tests", "src\Packer.Core.Tests\Packer.Core.Tests.csproj", "{C1362B4E-DC54-1918-71D7-10198B04FA50}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -27,6 +31,14 @@ Global
{D59EE21C-875E-4B31-8A97-813DC4C2DB68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D59EE21C-875E-4B31-8A97-813DC4C2DB68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D59EE21C-875E-4B31-8A97-813DC4C2DB68}.Release|Any CPU.Build.0 = Release|Any CPU
{0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Release|Any CPU.Build.0 = Release|Any CPU
{C1362B4E-DC54-1918-71D7-10198B04FA50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1362B4E-DC54-1918-71D7-10198B04FA50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1362B4E-DC54-1918-71D7-10198B04FA50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1362B4E-DC54-1918-71D7-10198B04FA50}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
4 changes: 4 additions & 0 deletions diag_mc.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Target dir exists: True
Has font/: True
Policy types: [DirectPolicy, IndirectPolicy]
Providers: 0
178 changes: 178 additions & 0 deletions src/Packer.Core.Tests/ConfigurationTests/DeserializationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using Packer.Core.Model.Configuration;
using Packer.Core.Model.PackerPolicys;
using Packer.Core.Model.ResourceFile;
using System.Text.Json;

namespace Packer.Core.Tests.ConfigurationTests;

/// <summary>测试 1/2/3:配置与策略的解析与组合行为</summary>
public class DeserializationTests
{
static readonly FloatingConfig EmptyConfig = new([], [], [], [],
new Dictionary<string, string>(), new Dictionary<string, string>());
static readonly Config EmptyGlobal = new(
new BaseConfig("", ["zh_cn"], "", [], "", [], [], []), EmptyConfig);

static readonly JsonSerializerOptions Opts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
};

// ── 测试 1:浮动配置和打包策略在命名空间文件夹里时可以解析 ──

[Fact]
public void NamespaceFolder_Loads_LocalConfig_And_Policy()
{
using var tmp = new TempDir();
// 造一个命名空间目录,带 local-config.json 和 packer-policy.json
tmp.Write("local-config.json", """{ "inclusionDomains": ["font"] }""");
tmp.Write("packer-policy.json", """[{ "type": "singleton", "source": "a.json", "relativePath": "lang/zh_cn.json" }]""");

var ns = new AssetsNamespaceResource(tmp.Path, "myns", "mymod", "1.21");

// 验证 local-config 被加载
Assert.Contains("font", ns.LocalConfig.InclusionDomains);

// 验证策略被加载(不会是 Shared)
Assert.NotSame(PackerPolicy.Shared, ns.PackerPolicies);

// 验证策略类型正确
var policy = Assert.Single(ns.PackerPolicies);
Assert.IsType<SingletonPolicy>(policy);
}

// ── 测试 2:全局配置和浮动配置组合正确 ──

[Fact]
public void GlobalConfig_Merges_With_LocalConfig()
{
var global = new FloatingConfig(
["font"], [], [], ["README.md"],
new Dictionary<string, string> { ["旧"] = "新" },
new Dictionary<string, string>());

var local = new FloatingConfig(
["gui"], [], [], ["packer-policy.json"],
new Dictionary<string, string>(),
new Dictionary<string, string>());

var merged = global.Merge(local);

// domain 取并集
Assert.Contains("font", merged.InclusionDomains);
Assert.Contains("gui", merged.InclusionDomains);

// exclusionPaths 取并集
Assert.Contains("README.md", merged.ExclusionPaths);
Assert.Contains("packer-policy.json", merged.ExclusionPaths);

// 全局的 replacement 保留
Assert.Equal("新", merged.CharacterReplacement["旧"]);
}

// ── 测试 3:没有浮动配置和打包策略时结果正确 ──

[Fact]
public void NoLocalConfig_Returns_Shared()
{
using var tmp = new TempDir();
// 空目录,没有 local-config.json
var ns = new AssetsNamespaceResource(tmp.Path, "ns", "mod", "1.21");

Assert.Same(FloatingConfig.Shared, ns.LocalConfig);
Assert.Same(PackerPolicy.Shared, ns.PackerPolicies);
}

// ── 测试 8:TryAdd 和 ModifyOnly ──

[Fact]
public void JsonFile_Merge_TryAdd_FirstWins()
{
var a = new JsonFile(new() { ["a"] = "1", ["b"] = "2" }, "x.json");
var b = new JsonFile(new() { ["a"] = "x", ["c"] = "3" }, "x.json");

var r = a.Merge(b);
Assert.Equal("1", r.Entries["a"]); // 第一个赢
Assert.Equal("2", r.Entries["b"]);
Assert.Equal("3", r.Entries["c"]);
}

[Fact]
public void JsonFile_Merge_ModifyOnly_OverwritesExisting()
{
var a = new JsonFile(new() { ["a"] = "1", ["b"] = "2" }, "x.json");
var b = new JsonFile(new() { ["a"] = "x", ["c"] = "3" }, "x.json")
{
PolicyItem = new DirectPolicy { ModifyOnly = true }
};

var r = a.Merge(b);
Assert.Equal("x", r.Entries["a"]); // 覆盖已有
Assert.Equal("2", r.Entries["b"]); // 不动
Assert.False(r.Entries.ContainsKey("c")); // 不添加新键
}

[Fact]
public void LangFile_Merge_TryAdd_FirstWins()
{
var a = new LangFile(new() { ["k1"] = "v1", ["k2"] = "v2" }, "x.lang");
var b = new LangFile(new() { ["k1"] = "x", ["k3"] = "v3" }, "x.lang");

var r = a.Merge(b);
Assert.Equal("v1", r.Entries["k1"]);
Assert.Equal("v3", r.Entries["k3"]);
}

// ── 测试 11:全局配置的过滤 ──
// 此测试需要在真实目录中放置文件,用 AssetsNamespaceResource + DirectPolicy
// 验证 inclusionDomains / exclusionDomains / exclusionPaths 生效

[Fact]
public void GlobalFilter_InclusionDomain_ForceIncludes()
{
using var dir = new TempDir();
dir.Write("font/extra.txt", "should be included via domain");
dir.Write("sounds/ambient.ogg", "should NOT be included");

var ns = new AssetsNamespaceResource(dir.Path, "testns", "testmod", "1.21");
var config = new FloatingConfig(
InclusionDomains: ["font"], // font 之下无条件进包
ExclusionDomains: [],
InclusionPaths: [],
ExclusionPaths: ["packer-policy.json", "local-config.json"],
CharacterReplacement: new Dictionary<string, string>(),
DestinationReplacement: new Dictionary<string, string>());

// DirectPolicy 默认共享
var providers = new DirectPolicy().CreateProviders(ns, EmptyGlobal, config).ToList();

Assert.Contains(providers, p => p.Destination.EndsWith("font/extra.txt"));
Assert.DoesNotContain(providers, p => p.Destination.EndsWith("sounds/ambient.ogg"));
}
}

/// <summary>临时目录辅助类</summary>
public class TempDir : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString());

public TempDir()
{
Directory.CreateDirectory(Path);
}

/// <summary>在临时目录下创建一个文件(自动创建父目录)</summary>
public void Write(string relativePath, string content)
{
var full = System.IO.Path.Combine(Path, relativePath);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!);
File.WriteAllText(full, content);
}

public void Dispose()
{
try { Directory.Delete(Path, recursive: true); }
catch { /* 清理失败忽略 */ }
}
}
8 changes: 8 additions & 0 deletions src/Packer.Core.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
global using Packer.Core.Model;
global using Packer.Core.Model.Abstract;
global using Packer.Core.Model.Configuration;
global using Packer.Core.Model.ModProvider;
global using Packer.Core.Model.ResourceFile;
global using System.Text.Json;
global using System.Text.Json.Serialization.Metadata;
global using Packer.Core.Tests.ConfigurationTests;
81 changes: 81 additions & 0 deletions src/Packer.Core.Tests/InMemoryFileProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;

namespace Packer.Core.Tests;

/// <summary>
/// 内存虚拟文件系统,实现 <see cref="IFileProvider"/>。
/// 用于不依赖真实磁盘的单元测试。
/// </summary>
public class InMemoryFileProvider : IFileProvider
{
private readonly Dictionary<string, byte[]> _files = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _dirs = new(StringComparer.OrdinalIgnoreCase);

/// <summary>添加一个虚拟文件</summary>
public void AddFile(string path, string content)
{
path = Normalize(path);
_files[path] = System.Text.Encoding.UTF8.GetBytes(content);
// 注册所有父目录
var parts = path.Split('/');
for (int i = 1; i < parts.Length; i++)
{
var dir = string.Join("/", parts, 0, i);
_dirs.Add(dir);
}
}

public IFileInfo GetFileInfo(string subpath)
{
subpath = Normalize(subpath);
if (_files.TryGetValue(subpath, out var data))
return new InMemoryFile(Path.GetFileName(subpath), data, false);
return new InMemoryFile(Path.GetFileName(subpath), null, true);
}

public IDirectoryContents GetDirectoryContents(string subpath)
{
subpath = Normalize(subpath);
var prefix = string.IsNullOrEmpty(subpath) ? "" : subpath + "/";
var files = _files.Keys
.Where(k => k.StartsWith(prefix) && k.LastIndexOf('/') == prefix.Length - 1)
.Select(k => new InMemoryFile(Path.GetFileName(k), _files[k], false));
var dirs = _dirs
.Where(d => d.StartsWith(prefix) && d.Length > prefix.Length && !d[prefix.Length..].Contains('/'))
.Select(d => new InMemoryFile(d[(prefix.Length)..], null, false) { IsDirectory = true });
return new InMemoryDirectoryContents(files.Concat(dirs));
}

public IChangeToken Watch(string filter) => NullChangeToken.Singleton;

private static string Normalize(string path)
=> path.Replace('\\', '/').TrimStart('/');
}

internal class InMemoryFile : IFileInfo
{
private readonly byte[]? _data;
public bool Exists => _data is not null;
public long Length => _data?.Length ?? -1;
public string? PhysicalPath => null;
public string Name { get; }
public DateTimeOffset LastModified => DateTimeOffset.MinValue;
public bool IsDirectory { get; init; }

public InMemoryFile(string name, byte[]? data, bool notExists)
{
Name = name;
_data = notExists ? null : data;
}

public Stream CreateReadStream()
=> _data is not null ? new MemoryStream(_data) : Stream.Null;
}

internal class InMemoryDirectoryContents(IEnumerable<IFileInfo> contents) : IDirectoryContents
{
public bool Exists => true;
public IEnumerator<IFileInfo> GetEnumerator() => contents.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
Loading
Loading