Ship a disk example pack so last-wins, patches and create-with-a-mod are tested for real.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 01:06:12 +03:00
co-authored by Cursor
parent edd3d35c79
commit 505b1429ab
20 changed files with 405 additions and 14 deletions
@@ -28,6 +28,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,165 @@
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
/// <summary>
/// The shipped <c>mods/example</c> folder: catalog, create, reload and a map that uses its room.
/// Temp packs in other tests stay disposable; this one has to survive a restart.
/// </summary>
[Collection(AppHostCollection.Name)]
public class ExamplePackTests(AppHostFixture fixture)
{
private static readonly DateTime ExpectedDefaultStart = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Catalog_WithExample_AddsPackTypesAndLabelsOnTopOfCore()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<CatalogResponse>(
"/api/catalog?lang=ru&mods=example",
TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<CatalogResponse>(
"/api/catalog?lang=en&mods=example",
TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Кладовая", Assert.Single(ru.Rooms, room => room.DefName == "ExampleStore").Label);
Assert.Equal("Storeroom", Assert.Single(en.Rooms, room => room.DefName == "ExampleStore").Label);
Assert.Equal("Примерные имена", Assert.Single(ru.NameSets, set => set.DefName == "ExampleNames").Label);
Assert.Equal("Example names", Assert.Single(en.NameSets, set => set.DefName == "ExampleNames").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
}
[Fact]
public async Task Catalog_PackPatch_IsVisibleOnlyWhenThePackIsSelected()
{
using var client = fixture.App.CreateHttpClient("server");
var vanilla = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var withPack = await client.GetFromJsonAsync<CatalogResponse>(
"/api/catalog?lang=ru&mods=example",
TestContext.Current.CancellationToken);
Assert.NotNull(vanilla);
Assert.NotNull(withPack);
Assert.DoesNotContain(
Assert.Single(vanilla.Rooms, room => room.DefName == "PrincipalsOffice").Slots,
slot => slot.Key == "exampleLocker");
Assert.Contains(
Assert.Single(withPack.Rooms, room => room.DefName == "PrincipalsOffice").Slots,
slot => slot.Key == "exampleLocker");
Assert.DoesNotContain(vanilla.Rooms, room => room.DefName == "ExampleStore");
}
[Fact]
public async Task CreateSchool_WithExample_SurvivesReload()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var created = await CreateWithModsAsync(client, "С примером", ["example"]);
Assert.Equal(["core", "example"], created.ModIds);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = (await SchoolApiTests.GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
Assert.Equal(created.Name, restored.Name);
Assert.Equal(["core", "example"], restored.ModIds);
Assert.True(restored.Running);
}
[Fact]
public async Task CreateSchool_WithThePackRoomOnTheMap_Succeeds()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Кладовая во дворе",
startDate = ExpectedDefaultStart,
modIds = new[] { "example" },
map = PackRoomMap,
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolApiTests.SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
Assert.Equal(["core", "example"], created.ModIds);
}
[Fact]
public async Task CreateSchool_WithoutThePack_StaysVanilla()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var created = await SchoolApiTests.CreateAsync(client, "Без примера", ExpectedDefaultStart);
Assert.Equal(["core"], created.ModIds);
}
private static async Task<SchoolApiTests.SchoolResponse> CreateWithModsAsync(
HttpClient client,
string name,
string[] modIds)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate = ExpectedDefaultStart, modIds },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolApiTests.SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
private static readonly object PackRoomMap = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = new[] { new { id = "main", def = "MainBuilding" } },
floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } },
rooms = new[]
{
new { id = "store", def = "ExampleStore", building = "main", floor = "floor-1", slots = Array.Empty<object>() },
},
links = new[] { new { a = "yard", b = "store" } },
};
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<NameSetInfoResponse> NameSets,
DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record NameSetInfoResponse(string DefName, string Label);
private sealed record RoomInfoResponse(
string DefName,
string Label,
IReadOnlyList<RoomSlotResponse> Slots);
private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record DayFrameResponse(string DefName, string Label);
private sealed record HolidayInfoResponse(string DefName, string Label);
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def);
}
@@ -0,0 +1,97 @@
namespace HSchool.Content.Tests;
/// <summary>
/// The shipped <c>mods/example</c> folder, copied next to vanilla core. Last-wins, patches and a
/// placeable room have to work from disk — in-memory documents already have their own tests.
/// </summary>
public class ExamplePackTests
{
private const string ExamplePackId = "example";
private readonly CatalogLoader _loader = new();
[Fact]
public void DuplicateChair_LastWinsAndWarns()
{
var log = new RecordingLog();
var catalog = LoadCoreAndExample(log);
Assert.Equal(["Sit", "Chat"], catalog.Things["Chair"].Actions);
Assert.Contains(
log.Warnings,
warning => warning.Contains("Chair", StringComparison.Ordinal)
&& warning.Contains(ExamplePackId, StringComparison.Ordinal));
}
[Fact]
public void PatchAddsASlot_OnlyWhenThePackIsLoaded()
{
var vanilla = LoadCore();
Assert.DoesNotContain(vanilla.Rooms["PrincipalsOffice"].Slots, slot => slot.Key == "exampleLocker");
var withPack = LoadCoreAndExample();
Assert.Contains(withPack.Rooms["PrincipalsOffice"].Slots, slot => slot.Key == "exampleLocker" && slot.Thing == "Locker");
}
[Fact]
public void PackTypes_LoadWithLocalizedLabels()
{
var catalog = LoadCoreAndExample();
Assert.True(catalog.Traits.ContainsKey("ExampleEarlyRiser"));
Assert.True(catalog.Traits.ContainsKey("ExampleNightOwl"));
Assert.True(catalog.NameSets.ContainsKey("ExampleNames"));
Assert.True(catalog.Rooms.ContainsKey("ExampleStore"));
Assert.Equal("Кладовая", catalog.Label("ru", catalog.Rooms["ExampleStore"]));
Assert.Equal("Storeroom", catalog.Label("en", catalog.Rooms["ExampleStore"]));
Assert.Equal("Примерные имена", catalog.Label("ru", catalog.NameSets["ExampleNames"]));
Assert.Equal(["RussianLanguage"], catalog.NameSets["ExampleNames"].Spoken.ToArray());
Assert.True(catalog.Rooms.ContainsKey("PrincipalsOffice"));
Assert.Equal("Кабинет директора", catalog.Label("ru", catalog.Rooms["PrincipalsOffice"]));
}
[Fact]
public void MapUsingThePackRoom_PassesValidation()
{
var catalog = LoadCoreAndExample();
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "SchoolYard" },
Buildings = [new BuildingNode { Id = "main", Def = "MainBuilding" }],
Floors = [new FloorNode { Id = "floor-1", Def = "StandardFloor", Building = "main" }],
Rooms =
[
new RoomNode { Id = "store", Def = "ExampleStore", Building = "main", Floor = "floor-1" },
],
Links = [new MapLink { A = "yard", B = "store" }],
};
MapValidator.Validate(map, catalog);
var defaults = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId, ExamplePackId], Documents());
Assert.NotNull(defaults);
Assert.Equal("SchoolYard", defaults.Territory?.Def);
}
private DefCatalog LoadCore() =>
_loader.Load(
[CatalogLoader.CorePackId],
PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()));
private DefCatalog LoadCoreAndExample(IContentLog? log = null) =>
_loader.Load([CatalogLoader.CorePackId, ExamplePackId], Documents(), log);
private static IReadOnlyList<ContentDocument> Documents()
{
var example = ExampleRoot();
Assert.True(Directory.Exists(example), $"Example pack was not copied to {example}.");
return
[
.. PackDocuments.FromDirectory(CatalogLoader.CorePackId, CoreRoot()),
.. PackDocuments.FromDirectory(ExamplePackId, example),
];
}
private static string CoreRoot() => Path.Combine(AppContext.BaseDirectory, "vanilla");
private static string ExampleRoot() => Path.Combine(AppContext.BaseDirectory, "example");
}
@@ -25,6 +25,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -26,6 +26,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="golden\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@@ -26,6 +26,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -29,6 +29,10 @@
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\src\HSchool.Server\mods\example\**\*">
<Link>example\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>