namespace HSchool.People.Tests; public class StaffingTests { private const float Cap = 10_000f; /// /// Price of an hour times the hours carried, with one full rate as the floor: a hired person /// costs something even idle, and loading them past the rate costs more. The number of /// subjects does not enter into it — twenty hours are twenty hours. /// [Fact] public void MonthlyPay_IsHoursTimesTheHourlyAsk_WithAFullRateAsTheFloor() { var rules = Fixtures.Catalog().StaffingRules!; Assert.Equal(4_000f, Staffing.MonthlyBase(rules, 50f)); Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 0f)); Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 12f)); Assert.Equal(4_000f, Staffing.MonthlyPay(rules, 50f, 20f)); Assert.Equal(6_000f, Staffing.MonthlyPay(rules, 50f, 30f)); Assert.Equal(7_200f, Staffing.MonthlyPay(rules, 50f, 36f)); } [Fact] public void Hire_TwoPeopleAtThreeAndSevenThousand_RejectsTheThird() { var (catalog, map, roster, pool) = Fresh(); var cheap = WithAsk(pool.Applicants[0], 37.5f); var expensive = WithAsk(pool.Applicants[1], 87.5f); var extra = WithAsk(pool.Applicants[2], 40f); pool = pool with { Applicants = [cheap, expensive, extra] }; var first = Staffing.Hire(catalog, map, roster, pool, cheap.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.None, first.Error); Assert.Equal(3_000f, first.Payroll); var second = Staffing.Hire(catalog, map, first.Roster, first.Pool, expensive.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.None, second.Error); Assert.Equal(10_000f, second.Payroll); Assert.Equal(0f, second.Remaining); var third = Staffing.Hire(catalog, map, second.Roster, second.Pool, extra.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.PayrollExceeded, third.Error); Assert.Equal(10_000f, third.Payroll); Assert.True(third.Attempted > Cap); Assert.Equal(2, second.Roster.People.Count(person => person.IsStaff)); Assert.DoesNotContain(third.Roster.People, person => person.Id == extra.Person.Id && person.IsStaff); } [Fact] public void UnassignSubject_LowersPayrollSoAnotherHireFits() { var (catalog, map, roster, pool) = Fresh(); var first = WithAsk(pool.Applicants[0], 37.5f); var second = WithAsk(pool.Applicants[1], 50f); pool = pool with { Applicants = [first, second, .. pool.Applicants.Skip(2)] }; var hired = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.None, hired.Error); // Mathematics is 35 curriculum hours a week, Literature 21. At 37.5 an hour that is // 37.5 × 56 × 4 = 8 400 a month for one person — and no room left for a second. var loaded = hired; foreach (var subject in new[] { "Mathematics", "Literature" }) { loaded = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, subject, Cap); Assert.Equal(StaffingError.None, loaded.Error); } Assert.Equal(8_400f, loaded.Payroll); var blocked = Staffing.Hire(catalog, map, loaded.Roster, loaded.Pool, second.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.PayrollExceeded, blocked.Error); var dropped = Staffing.UnassignSubject(catalog, loaded.Roster, loaded.Pool, first.Person.Id, "Literature", Cap); Assert.Equal(StaffingError.None, dropped.Error); Assert.Equal(5_250f, dropped.Payroll); var fitted = Staffing.Hire(catalog, map, dropped.Roster, dropped.Pool, second.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(StaffingError.None, fitted.Error); Assert.Equal(9_250f, fitted.Payroll); } /// /// Two teachers of one subject split its hours, so the second one costs a full rate and makes /// the first cheaper. That is the whole reason hiring another is not simply worse than loading /// the one you have. /// [Fact] public void TwoTeachersOfASubject_ShareItsHours() { var (catalog, map, roster, pool) = Fresh(); var first = WithAsk(pool.Applicants[0], 50f); var second = WithAsk(pool.Applicants[1], 50f); pool = pool with { Applicants = [first, second, .. pool.Applicants.Skip(2)] }; var one = Staffing.Hire(catalog, map, roster, pool, first.Person.Id, Staffing.TeacherPosition, 50_000f); one = Staffing.AssignSubject(catalog, one.Roster, one.Pool, first.Person.Id, "Mathematics", 50_000f); // All 35 hours on one person: 50 × 35 × 4. Assert.Equal(7_000f, one.Payroll); var two = Staffing.Hire(catalog, map, one.Roster, one.Pool, second.Person.Id, Staffing.TeacherPosition, 50_000f); two = Staffing.AssignSubject(catalog, two.Roster, two.Pool, second.Person.Id, "Mathematics", 50_000f); // 17.5 hours each, which is under a full rate, so both are paid the rate: 4 000 × 2. Assert.Equal(8_000f, two.Payroll); Assert.All( two.Roster.People.Where(person => person.IsStaff), person => Assert.Equal(17.5f, Staffing.WeeklyHours(catalog, two.Roster, person))); } /// /// Primary school is twenty hours a week for each of four classes. One teacher cannot carry /// eighty hours whatever the budget says, so the subject stays on the uncovered list until /// enough people share it. /// [Fact] public void SubjectNeedingMoreHoursThanOnePersonCarries_StaysUncovered() { var (catalog, map, roster, pool) = Fresh(); var rules = catalog.StaffingRules!; Assert.Equal(80f, Staffing.SubjectHours(catalog, roster, "PrimarySchool")); Assert.True(80f > rules.MaxWeeklyHours); var outcome = new StaffingOutcome(StaffingError.None, roster, pool, 0, 0, 0, 0); for (var i = 0; i < 3; i++) { var applicant = outcome.Pool.Applicants[0]; outcome = Staffing.Hire(catalog, map, outcome.Roster, outcome.Pool, applicant.Person.Id, Staffing.TeacherPosition, 500_000f); Assert.Equal(StaffingError.None, outcome.Error); outcome = Staffing.AssignSubject(catalog, outcome.Roster, outcome.Pool, applicant.Person.Id, "PrimarySchool", 500_000f); Assert.Equal(StaffingError.None, outcome.Error); var stillShort = 80f > (i + 1) * rules.MaxWeeklyHours; Assert.Equal( stillShort, Staffing.Uncovered(catalog, outcome.Roster).Any(subject => subject.DefName == "PrimarySchool")); } } [Fact] public void AssignSubject_ThatWouldExceedTheCap_IsRejected() { var (catalog, map, roster, pool) = Fresh(); var applicant = WithAsk(pool.Applicants[0], 50f); pool = pool with { Applicants = [applicant, .. pool.Applicants.Skip(1)] }; var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); Assert.Equal(4_000f, hired.Payroll); // Mathematics is 35 hours: 50 × 35 × 4 = 7 000. var loaded = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap); Assert.Equal(StaffingError.None, loaded.Error); Assert.Equal(7_000f, loaded.Payroll); // Russian adds 28 more. 63 hours at 50 is 12 600 a month — past the 10 000 allocation. var rejected = Staffing.AssignSubject(catalog, loaded.Roster, loaded.Pool, applicant.Person.Id, "RussianLanguage", Cap); Assert.Equal(StaffingError.PayrollExceeded, rejected.Error); Assert.Equal(7_000f, rejected.Payroll); Assert.Equal(12_600f, rejected.Attempted); Assert.Equal(3_000f, rejected.Remaining); Assert.DoesNotContain( rejected.Roster.People.Single(person => person.Id == applicant.Person.Id).Subjects, name => name == "RussianLanguage"); } [Fact] public void Hire_ParentKeepsTheSameId_GeneratedJoinsTheRoster() { var catalog = Fixtures.Catalog(); var map = Fixtures.VanillaMap(); Roster? roster = null; Applicant? parent = null; Applicant? generated = null; ApplicantPool? pool = null; for (var seed = 1; seed <= 30 && pool is null; seed++) { var candidate = Fixtures.Generate(map, seed); var created = ApplicantPool.Create(catalog, candidate, seed, "Russia", Fixtures.AsOf); var rosterIds = candidate.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); var parentHere = created.Applicants.FirstOrDefault(applicant => rosterIds.Contains(applicant.Person.Id)); var generatedHere = created.Applicants.FirstOrDefault(applicant => applicant.Person.Id.StartsWith('a')); if (parentHere is not null && generatedHere is not null) { roster = candidate; pool = created; parent = parentHere; generated = generatedHere; } } Assert.NotNull(roster); Assert.NotNull(pool); Assert.NotNull(parent); Assert.NotNull(generated); var afterParent = Staffing.Hire(catalog, map, roster, pool, parent.Person.Id, Staffing.TeacherPosition, 50_000f); Assert.Equal(StaffingError.None, afterParent.Error); var staffParent = afterParent.Roster.People.Single(person => person.Id == parent.Person.Id); Assert.True(staffParent.IsParent); Assert.True(staffParent.IsStaff); Assert.Equal(1, afterParent.Roster.People.Count(person => person.Id == parent.Person.Id)); Assert.DoesNotContain(afterParent.Pool.Applicants, applicant => applicant.Person.Id == parent.Person.Id); var afterGenerated = Staffing.Hire(catalog, map, afterParent.Roster, afterParent.Pool, generated.Person.Id, Staffing.TeacherPosition, 50_000f); Assert.Equal(StaffingError.None, afterGenerated.Error); Assert.Contains(afterGenerated.Roster.People, person => person.Id == generated.Person.Id && person.IsStaff && !person.IsParent); Assert.Contains(afterGenerated.Roster.Families, family => family.Id == generated.Person.FamilyId); var again = Staffing.Hire(catalog, map, afterGenerated.Roster, afterGenerated.Pool, parent.Person.Id, Staffing.TeacherPosition, 50_000f); Assert.Equal(StaffingError.AlreadyHired, again.Error); } [Fact] public void Uncovered_ListsSubjectsTaughtInExistingYearsWithoutATeacher() { var (catalog, map, roster, pool) = Fresh(); var uncovered = Staffing.Uncovered(catalog, roster); Assert.Contains(uncovered, subject => subject.DefName == "PrimarySchool"); Assert.Contains(uncovered, subject => subject.DefName == "Mathematics"); Assert.Contains(uncovered, subject => subject.DefName == "PhysicalEducation"); Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count); var applicant = pool.Applicants[0]; var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 100_000f); var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", 100_000f); Assert.Equal(StaffingError.None, assigned.Error); Assert.DoesNotContain(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "Mathematics"); Assert.Contains(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "PrimarySchool"); } [Fact] public void PrimarySchool_OnVanillaMap_NeedsThreeTeachers_AndASecondDoesNotCloseIt() { var (catalog, map, roster, pool) = Fresh(); var rules = catalog.StaffingRules!; Assert.Equal(80f, Staffing.SubjectHours(catalog, roster, "PrimarySchool")); Assert.Equal(3, Staffing.TeachersToCover(80f, rules.MaxWeeklyHours)); var empty = Staffing.Uncovered(catalog, roster).Single(subject => subject.DefName == "PrimarySchool"); Assert.Equal(3, empty.TeachersShort); var outcome = new StaffingOutcome(StaffingError.None, roster, pool, 0, 0, 0, 0); outcome = HireAndAssign(catalog, map, outcome, "PrimarySchool"); Assert.Equal(2, Staffing.Uncovered(catalog, outcome.Roster).Single(subject => subject.DefName == "PrimarySchool").TeachersShort); outcome = HireAndAssign(catalog, map, outcome, "PrimarySchool"); Assert.Equal(1, Staffing.Uncovered(catalog, outcome.Roster).Single(subject => subject.DefName == "PrimarySchool").TeachersShort); outcome = HireAndAssign(catalog, map, outcome, "PrimarySchool"); Assert.DoesNotContain(Staffing.Uncovered(catalog, outcome.Roster), subject => subject.DefName == "PrimarySchool"); } [Fact] public void PeopleJson_RoundTripsHiredStaffAndSubjects() { var (catalog, map, roster, pool) = Fresh(); var applicant = pool.Applicants[0]; var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 100_000f); var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", 100_000f); Assert.Equal(StaffingError.None, assigned.Error); var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, assigned.Pool)); var loaded = RosterJson.Parse(json); var person = loaded.People.Single(member => member.Id == applicant.Person.Id); Assert.True(person.IsStaff); Assert.Equal(Staffing.TeacherPosition, person.Position); Assert.Equal(applicant.HourlyWageAsk, person.HourlyWageAsk); Assert.Equal(["Mathematics"], person.Subjects); Assert.DoesNotContain(loaded.Applicants!.Applicants, candidate => candidate.Person.Id == applicant.Person.Id); } [Fact] public void Hire_UnknownApplicantAndUnknownPosition() { var (catalog, map, roster, pool) = Fresh(); Assert.Equal( StaffingError.UnknownApplicant, Staffing.Hire(catalog, map, roster, pool, "nobody", Staffing.TeacherPosition, Cap).Error); Assert.Equal( StaffingError.UnknownPosition, Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, "Janitor", Cap).Error); } private static (DefCatalog Catalog, MapLayout Map, Roster Roster, ApplicantPool Pool) Fresh() { var catalog = Fixtures.Catalog(); var map = Fixtures.VanillaMap(); var roster = Fixtures.Generate(map); var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf); return (catalog, map, roster, pool); } private static StaffingOutcome HireAndAssign( DefCatalog catalog, MapLayout map, StaffingOutcome current, string subject) { var applicant = current.Pool.Applicants[0]; var hired = Staffing.Hire(catalog, map, current.Roster, current.Pool, applicant.Person.Id, Staffing.TeacherPosition, 500_000f); Assert.Equal(StaffingError.None, hired.Error); var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, subject, 500_000f); Assert.Equal(StaffingError.None, assigned.Error); return assigned; } private static Applicant WithAsk(Applicant applicant, float hourlyAsk) => applicant with { HourlyWageAsk = hourlyAsk }; }