253 lines
10 KiB
C#
253 lines
10 KiB
C#
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, "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>(),
|
||
};
|
||
}
|