Create picks a country; climate is rolled from the school seed and stored. Old Slavic saves lift as Russia. Co-authored-by: Cursor <cursoragent@cursor.com>
316 lines
16 KiB
C#
316 lines
16 KiB
C#
namespace HSchool.People.Tests;
|
||
|
||
public class StaffingTests
|
||
{
|
||
private const float Cap = 10_000f;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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 };
|
||
}
|