Give packs an identity so create can refuse missing deps and load in a stable order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 00:20:36 +03:00
co-authored by Cursor
parent e5a0ae5b9d
commit 263c94c55d
28 changed files with 851 additions and 56 deletions
+174 -3
View File
@@ -143,6 +143,126 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required);
Assert.Equal("Базовая игра", core.Label);
Assert.False(string.IsNullOrWhiteSpace(core.Version));
Assert.Empty(core.Requires);
}
[Fact]
public async Task Mods_LabelsCoreInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<ModsResponse>("/api/mods?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<ModsResponse>("/api/mods?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Базовая игра", Assert.Single(ru.Mods, pack => pack.Id == "core").Label);
Assert.Equal("Core", Assert.Single(en.Mods, pack => pack.Id == "core").Label);
}
[Fact]
public async Task Mods_PackWithoutManifest_UsesIdAsLabel()
{
using var client = fixture.App.CreateHttpClient("server");
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22plain"))
{
var response = await client.GetFromJsonAsync<ModsResponse>("/api/mods?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var pack = Assert.Single(response.Mods, candidate => candidate.Id == "t22plain");
Assert.False(pack.Required);
Assert.Equal("t22plain", pack.Label);
Assert.Equal(string.Empty, pack.Version);
Assert.Empty(pack.Requires);
}
}
[Fact]
public async Task CreateSchool_ReturnsResolvedPackOrder()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "С ванилью", ExpectedDefaultStart);
Assert.Equal(["core"], created.ModIds);
}
[Fact]
public async Task CreateSchool_WithUnselectedDependency_NamesTheMissingPack()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22need", """{ "version": "1", "requires": ["t22ghost"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Без базы",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22need" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
Assert.Equal("missing-mod", problem?.Code);
Assert.Equal("t22ghost", problem?.Missing);
}
}
[Fact]
public async Task CreateSchool_LoadsRequiredPackBeforeDependentEvenIfListedAfter()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22base", """{ "version": "1", "requires": [] }"""))
using (new TempPack(root, "t22addon", """{ "version": "1", "requires": ["t22base"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Сначала зависимость",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22addon", "t22base" },
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.Equal(["core", "t22base", "t22addon"], created?.ModIds);
}
}
[Fact]
public async Task CreateSchool_WithCyclicMods_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22left", """{ "requires": ["t22right"] }"""))
using (new TempPack(root, "t22right", """{ "requires": ["t22left"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Цикл",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22left", "t22right" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("mod-cycle", await ProblemCodeAsync(response));
}
}
[Fact]
@@ -418,7 +538,13 @@ public class SchoolApiTests(AppHostFixture fixture)
return problem?.Code;
}
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
internal sealed record SchoolResponse(
int Id,
string Name,
DateTime GameTime,
bool Running,
byte SpeedIndex,
IReadOnlyList<string>? ModIds = null);
internal sealed record SchoolsResponse(
int MaxSchools,
@@ -429,13 +555,58 @@ public class SchoolApiTests(AppHostFixture fixture)
private sealed record RandomNameResponse(string Name);
private sealed record ProblemResponse(string? Code);
private sealed record ProblemResponse(string? Code, string? Missing = null);
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
private sealed record ModInfoResponse(string Id, bool Required);
private sealed record ModInfoResponse(
string Id,
bool Required,
string Label,
string Version,
IReadOnlyList<string> Requires);
private sealed record PathResponse(string Path);
private static async Task<string> ModsDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/api/dev/mods-directory",
TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
return payload.Path;
}
private sealed class TempPack : IDisposable
{
public TempPack(string modsRoot, string id, string? packJsonc = null)
{
Path = System.IO.Path.Combine(modsRoot, id);
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
Directory.CreateDirectory(Path);
if (packJsonc is not null)
{
File.WriteAllText(System.IO.Path.Combine(Path, "pack.jsonc"), packJsonc);
}
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
@@ -57,6 +57,20 @@ public class CatalogLoaderTests
Assert.Contains(log.Warnings, warning => warning.Contains("Sit") && warning.Contains("addon"));
}
[Fact]
public void UnlabelledDef_WarnsButLoads()
{
var log = new RecordingLog();
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[PackDocuments.Def(CatalogLoader.CorePackId, "things", "lamp", """{ "defName": "Lamp", "actions": [] }""")],
log);
Assert.True(catalog.Things.ContainsKey("Lamp"));
Assert.Equal("Lamp", catalog.Label("ru", catalog.Things["Lamp"]));
Assert.Contains(log.Warnings, warning => warning.Contains("Lamp", StringComparison.Ordinal));
}
[Fact]
public void Text_FallsBackToTheKeyWhenMissing()
{
@@ -21,6 +21,9 @@ internal static class PackDocuments
public static ContentDocument Map(string packId, string jsonc) =>
new(packId, "maps/default.jsonc", jsonc);
public static ContentDocument Manifest(string packId, string jsonc) =>
new(packId, "pack.jsonc", jsonc);
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
{
var documents = new List<ContentDocument>();
@@ -0,0 +1,114 @@
namespace HSchool.Content.Tests;
public class PackIdentityTests
{
[Fact]
public void PackWithoutManifest_UsesIdAsLabelAndHasNoRequires()
{
var documents = new[]
{
PackDocuments.Def("addon", "traits", "shy", """{ "defName": "Shy", "abstract": true }"""),
};
var manifest = PackManifest.FromDocuments("addon", documents);
Assert.Equal(string.Empty, manifest.Version);
Assert.Empty(manifest.Requires);
Assert.Equal("addon", PackManifest.Label("addon", "ru", documents));
Assert.Equal("addon", PackManifest.Label("addon", "en", documents));
}
[Fact]
public void PackLabel_ComesFromTheRequestedLocale()
{
var documents = new[]
{
PackDocuments.Manifest(CatalogLoader.CorePackId, """{ "version": "1.0", "requires": [] }"""),
PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "core": "Базовая игра" }"""),
PackDocuments.Locale(CatalogLoader.CorePackId, "en", """{ "core": "Core" }"""),
};
Assert.Equal("Базовая игра", PackManifest.Label(CatalogLoader.CorePackId, "ru", documents));
Assert.Equal("Core", PackManifest.Label(CatalogLoader.CorePackId, "en", documents));
Assert.Equal("1.0", PackManifest.FromDocuments(CatalogLoader.CorePackId, documents).Version);
}
[Fact]
public void VanillaCore_HasManifestAndLocalizedName()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root);
var manifest = PackManifest.FromDocuments(CatalogLoader.CorePackId, documents);
Assert.False(string.IsNullOrWhiteSpace(manifest.Version));
Assert.Empty(manifest.Requires);
Assert.Equal("Базовая игра", PackManifest.Label(CatalogLoader.CorePackId, "ru", documents));
Assert.Equal("Core", PackManifest.Label(CatalogLoader.CorePackId, "en", documents));
}
}
public class PackLoadOrderTests
{
[Fact]
public void MissingRequirement_NamesTheMissingPack()
{
var selected = CatalogLoader.NormalizePackOrder(["furniture"]);
var manifests = new Dictionary<string, PackManifest>(StringComparer.OrdinalIgnoreCase)
{
[CatalogLoader.CorePackId] = PackManifest.Empty,
["furniture"] = PackManifest.Parse("furniture", """{ "version": "1", "requires": ["base"] }"""),
};
var ex = Assert.Throws<PackDependencyException>(() => PackLoadOrder.Resolve(selected, manifests));
Assert.Equal(PackDependencyException.MissingCode, ex.Code);
Assert.Equal("base", ex.MissingPackId);
Assert.Contains("base", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void DependencyListedAfterDependent_StillLoadsFirst()
{
var selected = CatalogLoader.NormalizePackOrder(["furniture", "base"]);
Assert.Equal([CatalogLoader.CorePackId, "furniture", "base"], selected);
var manifests = new Dictionary<string, PackManifest>(StringComparer.OrdinalIgnoreCase)
{
[CatalogLoader.CorePackId] = PackManifest.Empty,
["furniture"] = PackManifest.Parse("furniture", """{ "requires": ["base"] }"""),
["base"] = PackManifest.Empty,
};
var order = PackLoadOrder.Resolve(selected, manifests);
Assert.Equal([CatalogLoader.CorePackId, "base", "furniture"], order);
}
[Fact]
public void Cycle_IsRejected()
{
var selected = CatalogLoader.NormalizePackOrder(["left", "right"]);
var manifests = new Dictionary<string, PackManifest>(StringComparer.OrdinalIgnoreCase)
{
[CatalogLoader.CorePackId] = PackManifest.Empty,
["left"] = PackManifest.Parse("left", """{ "requires": ["right"] }"""),
["right"] = PackManifest.Parse("right", """{ "requires": ["left"] }"""),
};
var ex = Assert.Throws<PackDependencyException>(() => PackLoadOrder.Resolve(selected, manifests));
Assert.Equal(PackDependencyException.CycleCode, ex.Code);
Assert.Null(ex.MissingPackId);
}
[Fact]
public void PlayerOrder_IsKeptWhenRequiresDoNotConstrainIt()
{
var selected = CatalogLoader.NormalizePackOrder(["zebra", "apple"]);
var manifests = PackLoadOrder.ManifestsFrom(selected, []);
var order = PackLoadOrder.Resolve(selected, manifests);
Assert.Equal([CatalogLoader.CorePackId, "zebra", "apple"], order);
}
}
@@ -115,6 +115,7 @@ public class VanillaCoreTests
keys.AddRange(Names(catalog.DayFrames.Values));
keys.AddRange(Names(catalog.Holidays.Values));
keys.AddRange(Names(catalog.Behavior.Values));
keys.AddRange(catalog.PackIds);
// Derived in code, so no def carries them.
keys.Add(BodyBuilds.Attribute);