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 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(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)); } /// /// 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.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)); } /// 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(), }; }