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
+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();
}
}