Files
h-school/tests/HSchool.People.Tests/StaffingTests.cs
T
Leonid Pershin 25ce26064d
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 14s
Update staffing management to enhance payroll and hiring functionalities
- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments.
- Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate.
- Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments.
- Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects.
- Improved localization strings to reflect changes in staffing and payroll terminology.
2026-08-19 12:51:38 +03:00

278 lines
14 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, Cap);
var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
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 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, Cap);
var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
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 Applicant WithAsk(Applicant applicant, float hourlyAsk) =>
applicant with { HourlyWageAsk = hourlyAsk };
}