Files
h-school/tests/HSchool.People.Tests/ReviewFixTests.cs
T
Leonid PershinandCursor 37501c506a Dress people at generation from a separate apparel stream.
Wardrobes live on the person in people.json: everyday layers, textbooks per parallel, and chance-based bags from catalog data. Hauling and the carry cap are defs, not constants. The golden roster gains an items column; later family members shift because Hauling consumes extra appearance rolls. Old saves without items are dressed on load.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 03:24:54 +03:00

254 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace HSchool.People.Tests;
/// <summary>
/// Regressions for the defects the slice review turned up. Each one was measured on the vanilla
/// catalog before it was fixed, so the numbers below are thresholds, not guesses.
/// </summary>
public class ReviewFixTests
{
[Fact]
public void Siblings_AreNotAllInTheSameClass()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var classOf = roster.People
.Where(person => person.IsStudent && person.ClassId is not null)
.ToDictionary(person => person.Id, person => person.ClassId!, StringComparer.Ordinal);
var multi = roster.Families.Where(family => family.ChildIds.Count > 1).ToArray();
Assert.NotEmpty(multi);
// Seats used to leave SchoolDemand grouped by classroom and families took a consecutive
// run of them, so 44 of 47 sibling groups shared one class, one year and one birth window.
var spread = multi.Count(family =>
family.ChildIds.Select(id => classOf[id]).Distinct(StringComparer.Ordinal).Count() > 1);
Assert.True(
spread * 2 > multi.Length,
$"only {spread} of {multi.Length} sibling groups span more than one class");
}
[Fact]
public void TraitModifiers_CannotReopenWhatTheBodyClosed()
{
var catalog = Fixtures.Catalog();
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
foreach (var person in roster.People)
{
foreach (var (skillName, value) in person.Skills)
{
var skill = catalog.Skills[skillName];
var capped = PersonSampler.ApplyBodyLimits(value, skill, person.Choices);
Assert.Equal(capped, value);
}
}
}
[Fact]
public void RosterWithoutStaff_LeavesNobodyWithoutARole()
{
var roster = Fixtures.Generate(Fixtures.PostHeavyMap());
Assert.DoesNotContain(roster.People, person => person.IsStaff);
Assert.All(
roster.People,
person => Assert.True(
person.IsStudent || person.IsParent,
$"{person.Id} ({person.Name.Full}) has no role at all"));
}
[Fact]
public void Graduation_DoesNotHandTheGraduatesIdToANewcomer()
{
var catalog = Fixtures.Catalog();
var map = Fixtures.Classrooms(4);
var roster = Fixtures.Generate(map);
var seen = new HashSet<string>(roster.People.Select(person => person.Id), StringComparer.Ordinal);
var date = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc);
for (var year = 0; year < 4; year++)
{
var before = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
roster = YearlyIntake.Apply(catalog, roster, Fixtures.SchoolSeed, "Russia", date);
date = date.AddYears(1);
foreach (var person in roster.People)
{
if (before.TryGetValue(person.Id, out var earlier))
{
// Same id must still be the same human being.
Assert.Equal(earlier.Name.Full, person.Name.Full);
Assert.Equal(earlier.BirthDate, person.BirthDate);
continue;
}
Assert.True(seen.Add(person.Id), $"{person.Id} was reused for a different person");
}
}
}
[Fact]
public void IntakeFamilies_DoNotArriveAsTriplets()
{
var catalog = Fixtures.Catalog();
var roster = Fixtures.Generate(Fixtures.Classrooms(4));
var known = roster.Families.Select(family => family.Id).ToHashSet(StringComparer.Ordinal);
var after = YearlyIntake.Apply(
catalog,
roster,
Fixtures.SchoolSeed,
"Russia",
new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc));
// Every seat an intake fills is a first-year seat, so a brand-new family that took two or
// three of them would be handing the school same-age triplets.
var arrived = after.Families.Where(family => !known.Contains(family.Id)).ToArray();
Assert.NotEmpty(arrived);
Assert.All(arrived, family => Assert.Single(family.ChildIds));
}
/// <summary>
/// Twelve schools rather than one: a single roster gives around forty incomplete families,
/// and a share measured on forty samples swings far enough to make the test a coin toss.
/// </summary>
[Fact]
public void IncompleteFamilies_AreAroundTheIntendedShareAndSplitBetweenBothParents()
{
var withChildren = 0;
var single = 0;
var motherOnly = 0;
for (var seed = 1; seed <= 12; seed++)
{
var roster = Fixtures.Generate(Fixtures.Classrooms(11), seed);
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
foreach (var family in roster.Families.Where(family => family.ChildIds.Count > 0))
{
withChildren++;
Assert.InRange(family.ParentIds.Count, 1, 2);
if (family.ParentIds.Count != 1)
{
continue;
}
single++;
if (people[family.ParentIds[0]].Female)
{
motherOnly++;
}
}
}
var share = single * 100d / withChildren;
Assert.InRange(share, 5d, 10d);
// Which parent stays is drawn from its own stream; sharing one with the appearance rolls
// produced mother-only households every single time.
Assert.InRange(motherOnly * 100d / single, 30d, 70d);
}
[Fact]
public void AChildOfASingleMother_StillCarriesTheFathersSurnameAndPatronymic()
{
var catalog = Fixtures.Catalog();
var names = catalog.Countries["Russia"].Names;
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var motherOnly = roster.Families
.Where(family => family.ChildIds.Count > 0 && family.ParentIds.Count == 1)
.Where(family => people[family.ParentIds[0]].Female)
.ToArray();
Assert.NotEmpty(motherOnly);
foreach (var family in motherOnly)
{
var entry = names.Surnames.Single(candidate => candidate.Male == family.Surname);
foreach (var child in family.ChildIds.Select(id => people[id]))
{
Assert.Equal(child.Female ? entry.Female : entry.Male, child.Name.Surname);
Assert.Equal(
NameGrammar.Patronymic(family.FatherGiven, child.Female, NameGrammar.SlavicPatronymic),
child.Name.Patronymic);
}
}
}
[Fact]
public void ASingleMotherFamily_CanStillTakeInAYoungerSibling()
{
var catalog = Fixtures.Catalog();
var names = catalog.Countries["Russia"].Names;
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var motherOnly = roster.Families
.Where(family => family.ChildIds.Count > 0 && family.ParentIds.Count == 1)
.First(family => people[family.ParentIds[0]].Female);
var members = motherOnly.ParentIds.Concat(motherOnly.ChildIds).Select(id => people[id]).ToArray();
var seat = new PupilSeat("class-classroom-00", "classroom-00", Year: 1, "А");
var child = FamilyFactory.AddChild(
catalog,
names,
new Random(1),
motherOnly,
members,
seat,
new DateTime(2011, 9, 1, 0, 0, 0, DateTimeKind.Utc),
Fixtures.AsOf,
motherOnly.NextChildIndex,
Fixtures.SchoolSeed);
// Boys need the male form of a surname no living member of this household carries.
var entry = names.Surnames.Single(candidate => candidate.Male == motherOnly.Surname);
Assert.Equal(child.Female ? entry.Female : entry.Male, child.Name.Surname);
Assert.Equal(child.Name.Surname, child.Name.SurnameCases.Nom);
Assert.Equal(
NameGrammar.Patronymic(motherOnly.FatherGiven, child.Female, NameGrammar.SlavicPatronymic),
child.Name.Patronymic);
}
[Fact]
public void SurnameSort_FollowsTheAlphabetNotCodePoints()
{
var roster = new Roster(
[Pupil("a", "Ёлкина"), Pupil("b", "Абрамова"), Pupil("c", "Яковлева"), Pupil("d", "Егорова")],
[],
[]);
var page = RosterBrowser.Apply(
roster,
Fixtures.AsOf,
new RosterQuery(null, null, null, null, null, null, null, PersonSort.Surname, false, 1, 10));
// Ordinal put «Ёлкина» (U+0401) ahead of «Абрамова» (U+0410); the alphabet puts it after «Егорова».
Assert.Equal(
["Абрамова", "Егорова", "Ёлкина", "Яковлева"],
page.People.Select(person => person.Name.Surname));
}
/// <summary>Sorting only reads the nominative, so the other five cases can be the same word.</summary>
private static CaseTable Flat(string word) =>
new() { Nom = word, Gen = word, Dat = word, Acc = word, Ins = word, Pre = word };
private static Person Pupil(string id, string surname) =>
new()
{
Id = id,
FamilyId = id,
Female = true,
BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Name = new PersonName("Мария", surname, "Петровна", Flat("Мария"), Flat(surname), Flat("Петровна")),
IsStudent = true,
IsStaff = false,
IsParent = false,
Numbers = new Dictionary<string, int>(),
Choices = new Dictionary<string, string>(),
Skills = new Dictionary<string, int>(),
Traits = [],
Needs = new Dictionary<string, float>(),
};
}