Files
h-school/tests/HSchool.People.Tests/StaffingTests.cs
T
Leonid Pershin e5a0ae5b9d
ci / server (push) Failing after 3m43s
ci / client (push) Successful in 14s
Enhance staffing management with uncovered teacher tracking
- Updated `protocol.md` to clarify the concept of uncovered subjects and the number of additional teachers required.
- Introduced `teachersShort` property in the `StaffingSubject` interface to indicate how many more teachers are needed for a subject.
- Enhanced localization strings to reflect the new uncovered teacher information in both English and Russian.
- Updated the management panel UI to display the number of teachers short for each uncovered subject.
- Revised the `Uncovered` method in the staffing logic to return detailed information about uncovered subjects, including the shortfall of teachers.
- Added tests to validate the new uncovered teacher tracking functionality and ensure accurate reporting in the staffing API.
- Marked related tasks as complete in the documentation for the golden fixtures phase.
2026-08-19 23:53:37 +03:00

316 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "Slavic", 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, "Slavic", 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 };
}