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 };
}