Enhance school creation functionality by introducing support for name sets in the API and UI. Update the catalog to include skills, traits, body attributes, needs, and name sets, improving character generation capabilities. Revise localization strings for better user guidance and update tests to validate the new name set functionality and ensure robustness in school creation processes.
ci / server (push) Failing after 3m45s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 18:57:01 +03:00
parent 38afbcad36
commit e6182e0e45
56 changed files with 3706 additions and 9 deletions
@@ -157,6 +157,8 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label);
}
[Fact]
@@ -206,6 +208,26 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownNameSet_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужие имена",
startDate = ExpectedDefaultStart,
nameSetId = "NoSuchNames",
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-name-set", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
{
@@ -335,6 +357,7 @@ public class SchoolApiTests(AppHostFixture fixture)
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
@@ -0,0 +1,260 @@
namespace HSchool.Content.Tests;
public class PeopleDefTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void VanillaCore_LoadsPeopleDefs()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var catalog = _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
Assert.True(catalog.Skills.Count >= 10);
Assert.Equal(10, catalog.Traits.Count);
Assert.Equal(4, catalog.Needs.Count);
Assert.True(catalog.BodyAttributes.ContainsKey("Height"));
Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind);
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind);
Assert.All(catalog.Needs.Values, need => Assert.Equal(0, need.DecayPerHour));
Assert.Contains(catalog.Skills["Agility"].BodyLimits, limit => limit.Attribute == "Build" && limit.Value == "Obese");
Assert.True(catalog.NameSets.ContainsKey("Slavic"));
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"]));
Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"]));
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
}
[Fact]
public void Skill_InheritsRangeFromParent()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"base",
"""{ "defName": "AcademicBase", "abstract": true, "range": { "min": 0, "max": 100 }, "distribution": { "mean": 40, "stdDev": 10 } }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"math",
"""{ "defName": "Mathematics", "parent": "AcademicBase", "distribution": { "mean": 55, "stdDev": 12 } }"""),
]);
Assert.Equal(0, catalog.Skills["Mathematics"].Range.Min);
Assert.Equal(100, catalog.Skills["Mathematics"].Range.Max);
Assert.Equal(55, catalog.Skills["Mathematics"].Distribution?.Mean);
Assert.False(catalog.Skills["Mathematics"].Abstract);
Assert.True(catalog.Skills["AcademicBase"].Abstract);
}
[Fact]
public void TraitPatch_AddsAnIncompatibility()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "skills", "math", """{ "defName": "Mathematics" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"traits",
"kind",
"""{ "defName": "Kind", "weight": 1, "incompatible": [] }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"traits",
"bully",
"""{ "defName": "Bully", "weight": 1 }"""),
PackDocuments.Patch(
"addon",
"kind",
"""{ "target": "Kind", "ops": [ { "op": "add", "path": "/incompatible/-", "value": "Bully" } ] }"""),
]);
Assert.Equal(["Bully"], catalog.Traits["Kind"].Incompatible);
Assert.Contains("Bully", catalog.TraitIncompatibilities("Kind"));
Assert.Contains("Kind", catalog.TraitIncompatibilities("Bully"));
}
[Fact]
public void UnknownBodyLimitAttribute_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"agility",
"""{ "defName": "Agility", "bodyLimits": [ { "attribute": "Missing", "value": "X", "max": 10 } ] }"""),
]));
Assert.Contains("Missing", ex.Message);
}
[Fact]
public void UnknownBuildValue_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"agility",
"""{ "defName": "Agility", "bodyLimits": [ { "attribute": "Build", "value": "Huge", "max": 10 } ] }"""),
]));
Assert.Contains("Huge", ex.Message);
}
[Fact]
public void UnknownTraitIncompatibility_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"traits",
"kind",
"""{ "defName": "Kind", "incompatible": ["Ghost"] }"""),
]));
Assert.Contains("Ghost", ex.Message);
}
[Fact]
public void OneWayIncompatibility_IsMutual()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "a", """{ "defName": "Diligent", "weight": 1, "incompatible": ["Lazy"] }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "b", """{ "defName": "Lazy", "weight": 1 }"""),
]);
Assert.Equal(["Lazy"], catalog.Traits["Diligent"].Incompatible);
Assert.Empty(catalog.Traits["Lazy"].Incompatible);
Assert.Contains("Lazy", catalog.TraitIncompatibilities("Diligent"));
Assert.Contains("Diligent", catalog.TraitIncompatibilities("Lazy"));
}
[Fact]
public void ExtraPack_AddsANameSetAndTrait()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "kind", """{ "defName": "Kind", "weight": 1 }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"namesets",
"slavic",
"""
{
"defName": "Slavic",
"maleGiven": [{ "form": "Иван" }],
"femaleGiven": [{ "form": "Анна", "declension": "a" }],
"surnames": [{ "male": "Иванов", "female": "Иванова" }]
}
"""),
PackDocuments.Def("addon", "traits", "stoic", """{ "defName": "Stoic", "weight": 3 }"""),
PackDocuments.Def("addon", "traits", "cheerful", """{ "defName": "Cheerful", "weight": 2 }"""),
PackDocuments.Def(
"addon",
"namesets",
"nordic",
"""
{
"defName": "Nordic",
"maleGiven": [{ "form": "Lars", "declension": "indeclinable" }],
"femaleGiven": [{ "form": "Ingrid", "declension": "indeclinable" }],
"surnames": [{ "male": "Berg", "female": "Berg", "declension": "indeclinable" }]
}
"""),
PackDocuments.Locale("addon", "ru", """{ "Nordic": "Северный", "Stoic": "Стоик", "Cheerful": "Жизнерадостный" }"""),
]);
Assert.True(catalog.NameSets.ContainsKey("Nordic"));
Assert.True(catalog.Traits.ContainsKey("Stoic"));
Assert.True(catalog.Traits.ContainsKey("Cheerful"));
Assert.Equal("Северный", catalog.Label("ru", catalog.NameSets["Nordic"]));
Assert.Equal("Стоик", catalog.Label("ru", catalog.Traits["Stoic"]));
}
}
public class NameGrammarTests
{
[Fact]
public void HardGiven_FollowsTheConsonantModel()
{
var entry = new GivenNameEntry { Form = "Иван", Declension = NameGrammar.Hard };
Assert.Equal("Иван", NameGrammar.InflectGiven(entry, GrammaticalCase.Nominative, NameGrammar.Hard));
Assert.Equal("Ивана", NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, NameGrammar.Hard));
Assert.Equal("Ивану", NameGrammar.InflectGiven(entry, GrammaticalCase.Dative, NameGrammar.Hard));
Assert.Equal("Иваном", NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, NameGrammar.Hard));
}
[Fact]
public void ExplicitTable_OverridesTheModel()
{
var entry = new GivenNameEntry
{
Form = "Любовь",
Declension = NameGrammar.Soft,
Cases = new CaseTable
{
Nom = "Любовь",
Gen = "Любови",
Dat = "Любови",
Acc = "Любовь",
Ins = "Любовью",
Pre = "Любови",
},
};
Assert.Equal("Любови", NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, NameGrammar.Soft));
Assert.Equal("Любовью", NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, NameGrammar.Soft));
Assert.Equal("Любовя", NameGrammar.InflectGivenForm("Любовь", NameGrammar.Soft, GrammaticalCase.Genitive));
}
[Fact]
public void OvSurname_UsesGenderedEndings()
{
var entry = new SurnameEntry { Male = "Иванов", Female = "Иванова", Declension = NameGrammar.Ov };
Assert.Equal("Иванова", NameGrammar.InflectSurname(entry, female: false, GrammaticalCase.Genitive, NameGrammar.Ov));
Assert.Equal("Ивановой", NameGrammar.InflectSurname(entry, female: true, GrammaticalCase.Genitive, NameGrammar.Ov));
Assert.Equal("Иванову", NameGrammar.InflectSurname(entry, female: true, GrammaticalCase.Accusative, NameGrammar.Ov));
}
[Fact]
public void SlavicPatronymic_FromTheFathersName()
{
Assert.Equal("Иванович", NameGrammar.Patronymic("Иван", female: false, NameGrammar.SlavicPatronymic));
Assert.Equal("Ивановна", NameGrammar.Patronymic("Иван", female: true, NameGrammar.SlavicPatronymic));
Assert.Equal("Андреевич", NameGrammar.Patronymic("Андрей", female: false, NameGrammar.SlavicPatronymic));
Assert.Equal("Дмитриевич", NameGrammar.Patronymic("Дмитрий", female: false, NameGrammar.SlavicPatronymic));
Assert.Equal("Ильинична", NameGrammar.Patronymic("Илья", female: true, NameGrammar.SlavicPatronymic));
}
[Fact]
public void BodyBuild_ComesFromBmiBands()
{
Assert.Equal(BodyBuilds.Skinny, BodyBuilds.FromHeightAndWeight(170, 50));
Assert.Equal(BodyBuilds.Athletic, BodyBuilds.FromHeightAndWeight(170, 68));
Assert.Equal(BodyBuilds.Obese, BodyBuilds.FromHeightAndWeight(170, 90));
}
[Fact]
public void Patronymic_InflectsLikeAHardOrAStem()
{
Assert.Equal("Ивановича", NameGrammar.InflectPatronymic("Иванович", female: false, GrammaticalCase.Genitive));
Assert.Equal("Ивановны", NameGrammar.InflectPatronymic("Ивановна", female: true, GrammaticalCase.Genitive));
Assert.Equal("Ивановну", NameGrammar.InflectPatronymic("Ивановна", female: true, GrammaticalCase.Accusative));
}
}
+78
View File
@@ -0,0 +1,78 @@
namespace HSchool.People.Tests;
internal static class PackDocuments
{
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
{
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
}
return documents;
}
}
internal static class Fixtures
{
public static readonly DateTime AsOf = RosterGenerator.DefaultAsOf;
public const int SchoolSeed = 20260818;
public static DefCatalog Catalog()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
return new CatalogLoader().Load(
[CatalogLoader.CorePackId],
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
}
public static MapLayout Classrooms(int count, int desks = 16)
{
var rooms = new RoomNode[count];
for (var i = 0; i < count; i++)
{
rooms[i] = new RoomNode
{
Id = $"classroom-{i:00}",
Def = "Classroom",
Building = "main",
Floor = "floor-1",
Label = $"{101 + i}",
Slots =
[
new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks },
],
};
}
return new MapLayout { Rooms = rooms };
}
public static Roster Generate(MapLayout map, int seed = SchoolSeed) =>
RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf);
public static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "h-school.sln")))
{
dir = dir.Parent;
}
if (dir is null)
{
throw new InvalidOperationException("Could not find h-school.sln from the test output directory.");
}
return dir.FullName;
}
}
@@ -0,0 +1,2 @@
global using HSchool.Content;
global using HSchool.People;
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.People.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,153 @@
namespace HSchool.People.Tests;
public class RosterGeneratorTests
{
[Fact]
public void SameSeedMapAndNameSet_YieldTheSameRoster()
{
var map = Fixtures.Classrooms(4);
var a = Fixtures.Generate(map);
var b = Fixtures.Generate(map);
Assert.Equal(Snapshot(a), Snapshot(b));
}
[Fact]
public void ThirteenthFamily_DoesNotChangeTheFirstTwelve()
{
var twelve = FamilySnapshots(Fixtures.Generate(Fixtures.Classrooms(4)), take: 12);
var thirteen = FamilySnapshots(Fixtures.Generate(Fixtures.Classrooms(5)), take: 12);
Assert.Equal(12, twelve.Count);
Assert.Equal(twelve, thirteen);
}
[Fact]
public void FamilyNames_ShareSurnameAndPatronymicFromTheFather()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(4));
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
foreach (var family in roster.Families)
{
if (family.ChildIds.Count == 0)
{
continue;
}
var father = people[family.ParentIds[0]];
var mother = people[family.ParentIds[1]];
Assert.False(father.Female);
Assert.True(mother.Female);
foreach (var childId in family.ChildIds)
{
var child = people[childId];
Assert.Equal(
NameGrammar.Patronymic(father.Name.Given, child.Female, NameGrammar.SlavicPatronymic),
child.Name.Patronymic);
Assert.Equal(child.Female ? mother.Name.Surname : father.Name.Surname, child.Name.Surname);
Assert.Equal(father.Name.SurnameCases.Nom, father.Name.Surname);
Assert.Equal(mother.Name.SurnameCases.Nom, mother.Name.Surname);
}
}
}
[Fact]
public void ObesePupil_CannotHaveHighAgility()
{
var agility = Fixtures.Catalog().Skills["Agility"];
var clamped = PersonSampler.ApplyBodyLimits(
80,
agility,
new Dictionary<string, string> { [BodyBuilds.Attribute] = BodyBuilds.Obese });
Assert.Equal(25, clamped);
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var obesePupils = roster.People.Where(person =>
person.IsStudent
&& person.Choices.TryGetValue(BodyBuilds.Attribute, out var build)
&& build == BodyBuilds.Obese);
Assert.All(
obesePupils,
person => Assert.True(
person.Skills["Agility"] <= 25,
$"{person.Name.Surname} {person.Name.Given} is obese with agility {person.Skills["Agility"]}."));
}
[Fact]
public void OneClassroomAndEleven_BothFillSeatsAndJobs()
{
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(1)), classrooms: 1);
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(11)), classrooms: 11);
}
[Fact]
public void SomeStaffAreAlsoParents()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(4));
Assert.Contains(roster.People, person => person.IsStaff && person.IsParent);
}
[Fact]
public void Assembly_DoesNotReferenceArchAspNetOrSockets()
{
var names = typeof(RosterGenerator).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!);
Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Sources_DoNotUseWallClock()
{
var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.People");
foreach (var path in Directory.EnumerateFiles(root, "*.cs"))
{
var text = File.ReadAllText(path);
Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal);
Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal);
}
}
private static void AssertFilled(Roster roster, int classrooms)
{
Assert.Equal(classrooms, roster.Classes.Count);
Assert.Equal(classrooms * 16, roster.People.Count(person => person.IsStudent));
Assert.All(roster.Classes, schoolClass =>
{
Assert.Equal(schoolClass.Capacity, schoolClass.PupilIds.Count);
Assert.InRange(schoolClass.Year, 1, 11);
});
var demand = SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(classrooms));
Assert.Equal(demand.Staff.Count, roster.People.Count(person => person.IsStaff));
Assert.All(demand.Staff, opening =>
Assert.Contains(
roster.People,
person => person.IsStaff && person.Position == opening.Position && person.WorkplaceRoomId == opening.RoomId));
}
private static string Snapshot(Roster roster) =>
string.Join('\n', roster.People.Select(person =>
$"{person.Id}|{person.FamilyId}|{person.Female}|{person.BirthDate:O}|{person.Name.Given}|{person.Name.Surname}|{person.Name.Patronymic}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Position}|{person.Choices[BodyBuilds.Attribute]}|{Skills(person)}|{string.Join(',', person.Traits)}"));
private static string Skills(Person person) =>
string.Join(',', person.Skills.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
private static List<string> FamilySnapshots(Roster roster, int take)
{
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
return roster.Families
.OrderBy(family => family.Id, StringComparer.Ordinal)
.Take(take)
.Select(family =>
{
var members = family.ParentIds.Concat(family.ChildIds).Select(id => people[id]);
return string.Join(';', members.Select(person =>
$"{person.Id}:{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}:{person.BirthDate:O}:{person.Female}:{Skills(person)}"));
})
.ToList();
}
}