namespace HSchool.People.Tests;
///
/// 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.
///
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(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));
}
///
/// 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.
///
[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));
}
/// Sorting only reads the nominative, so the other five cases can be the same word.
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(),
Choices = new Dictionary(),
Skills = new Dictionary(),
Traits = [],
Needs = new Dictionary(),
};
}