Enhance family and roster management in school simulation by implementing support for incomplete families, ensuring proper handling of absent parents while maintaining surname consistency. Update family creation logic to account for single-parent households and revise child identification to prevent ID conflicts. Introduce seat shuffling to prevent siblings from being placed in the same class, improving the realism of student distribution. Update related tests to validate these new functionalities and ensure robustness in family and roster handling.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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 StaffTopUp_LeavesNobodyWithoutARole()
|
||||
{
|
||||
// More posts than the pupils' parents can fill, and an odd deficit: the old top-up added
|
||||
// two adults per opening and the spare one ended up neither staff, parent nor pupil.
|
||||
var roster = Fixtures.Generate(Fixtures.PostHeavyMap());
|
||||
|
||||
Assert.All(
|
||||
roster.People,
|
||||
person => Assert.True(
|
||||
person.IsStudent || person.IsStaff || 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, "Slavic", 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,
|
||||
"Slavic",
|
||||
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.NameSets["Slavic"];
|
||||
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.NameSets["Slavic"];
|
||||
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);
|
||||
|
||||
// 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>(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user