Files
h-school/tests/HSchool.AppHost.Tests/StaffingApiTests.cs
T
Leonid Pershin 5eabc90d53
ci / server (push) Failing after 3m43s
ci / client (push) Successful in 15s
Enhance school day structure and decision-making for lunch breaks
- Updated `ai.md` to clarify the mechanics of hunger restoration and the importance of lunch breaks in the school schedule.
- Revised `schedule.md` to detail the new lunch break structure, allowing for separate sittings for different grade levels.
- Enhanced `Decision.cs` and `DecisionPlanner.cs` to incorporate logic for lunch breaks, ensuring that students only leave lessons during their designated lunch windows.
- Updated `DayFrameDef` and related classes to support multiple lunch breaks and validate their configurations.
- Adjusted tests to validate the new decision-making logic regarding lunch breaks and hunger management, ensuring robust functionality.
- Improved localization strings to reflect changes in the school day structure and lunch functionalities.
2026-08-19 23:17:16 +03:00

386 lines
17 KiB
C#

using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class StaffingApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task GetStaffing_UnknownSchool_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.GetAsync("/api/schools/999999/staffing", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("unknown-school", await ProblemCodeAsync(response));
}
[Fact]
public async Task GetStaffing_EmptySchool_HasPoolAndNoPayroll()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат пустой", Start);
var staffing = await GetStaffingAsync(client, school.Id);
Assert.Equal(100_000f, staffing.Allocated);
Assert.Equal(0f, staffing.Payroll);
Assert.Equal(100_000f, staffing.Remaining);
Assert.Equal(32, staffing.Applicants.Count);
Assert.Empty(staffing.Staff);
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
Assert.NotEmpty(staffing.Positions);
Assert.Contains(staffing.Positions, position => position.DefName == "Teacher");
Assert.Contains(staffing.Subjects, subject => subject.DefName == "Mathematics");
Assert.All(
staffing.Applicants,
applicant =>
{
Assert.Contains(applicant.Skills, skill => skill.Id == "Communication");
Assert.Contains(
applicant.Skills,
skill => skill.Id is "RussianLanguage" or "BelarusianLanguage" or "UkrainianLanguage");
Assert.All(applicant.Skills, skill => Assert.False(string.IsNullOrWhiteSpace(skill.Value)));
});
}
[Fact]
public async Task Card_OpensAGeneratedApplicant()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат карточка пула", Start);
var staffing = await GetStaffingAsync(client, school.Id);
var generated = staffing.Applicants.First(applicant => !applicant.IsParent);
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(generated.Id)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
Assert.Equal(generated.Id, card.Id);
Assert.NotEmpty(card.Skills);
}
[Fact]
public async Task Hire_ParentKeepsBothRolesOnTheCard()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат родитель", Start);
var before = await GetStaffingAsync(client, school.Id);
var parent = before.Applicants.First(applicant => applicant.IsParent);
await HireAsync(client, school.Id, parent.Id, "Teacher");
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(parent.Id)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
Assert.Contains("parent", card.Roles);
Assert.Contains("staff", card.Roles);
}
[Fact]
public async Task Hire_AddsStaff_RemovesApplicant_AndRejectsASecondHireOfTheSamePerson()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат наём", Start);
var before = await GetStaffingAsync(client, school.Id);
var applicant = before.Applicants[0];
var after = await HireAsync(client, school.Id, applicant.Id, "Teacher");
Assert.Contains(after.Staff, member => member.Id == applicant.Id && member.Position == "Teacher");
Assert.DoesNotContain(after.Applicants, candidate => candidate.Id == applicant.Id);
Assert.True(after.Payroll > 0f);
Assert.True(after.Payroll <= after.Allocated);
using var again = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = applicant.Id, position = "Teacher" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
Assert.Equal("already-hired", await ProblemCodeAsync(again));
var people = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{school.Id}/people?role=staff&pageSize=10",
TestContext.Current.CancellationToken);
Assert.NotNull(people);
Assert.Contains(people.People, person => person.Id == applicant.Id);
}
[Fact]
public async Task Hire_UnknownApplicant_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат неизвестный", Start);
using var response = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = "nobody", position = "Teacher" },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("unknown-applicant", await ProblemCodeAsync(response));
}
[Fact]
public async Task Hire_IdlePool_FitsUnderTheCap()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат предел", Start);
var staffing = await GetStaffingAsync(client, school.Id);
while (staffing.Applicants.Count > 0)
{
var applicant = staffing.Applicants[0];
using var response = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = applicant.Id, position = "Teacher" },
TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
Assert.Equal("payroll-exceeded", await ProblemCodeAsync(response));
break;
}
response.EnsureSuccessStatusCode();
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
}
Assert.Equal(100_000f, staffing.Allocated);
Assert.True(staffing.Payroll > 0f);
Assert.True(staffing.Payroll <= staffing.Allocated);
Assert.NotEmpty(staffing.Staff);
}
[Fact]
public async Task AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат предметы", Start);
var before = await GetStaffingAsync(client, school.Id);
var hired = await HireAsync(client, school.Id, before.Applicants[0].Id, "Teacher");
var teacher = hired.Staff.Single();
var assigned = await AssignAsync(client, school.Id, teacher.Id, "Mathematics");
// The first subject already costs: 35 curriculum hours is well past one full rate.
Assert.True(assigned.Payroll > hired.Payroll);
assigned = await AssignAsync(client, school.Id, teacher.Id, "RussianLanguage");
Assert.True(assigned.Payroll > hired.Payroll);
Assert.Equal(2, assigned.Staff.Single().Subjects.Count);
// A third subject used to be tried here with an if/else around the cap, which meant the
// test passed whichever way it went. The refusal has its own test now
// (AssigningPastTheCap_IsRejectedWithTheNumbers); this one is about assign, unassign and
// what survives a restart.
var payrollWithSubjects = assigned.Payroll;
using var unassign = await client.DeleteAsync(
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage",
TestContext.Current.CancellationToken);
unassign.EnsureSuccessStatusCode();
var afterDrop = await unassign.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(afterDrop);
Assert.True(afterDrop.Payroll < payrollWithSubjects);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await GetStaffingAsync(client, school.Id);
Assert.Equal(afterDrop.Payroll, restored.Payroll);
// Phase 11: the pool lives in the same file and has to come back with it, asks included.
Assert.Equal(
afterDrop.Applicants.Select(applicant => (applicant.Id, applicant.HourlyWageAsk)),
restored.Applicants.Select(applicant => (applicant.Id, applicant.HourlyWageAsk)));
Assert.Equal(teacher.Id, restored.Staff.Single().Id);
Assert.Equal(
afterDrop.Staff.Single().Subjects.Select(subject => subject.DefName),
restored.Staff.Single().Subjects.Select(subject => subject.DefName));
}
/// <summary>
/// Phase 12 wants the cap refused at the moment of the action, with the numbers in the answer.
/// Reaching it means loading teachers up, not hiring more: a subject splits its hours between
/// everyone who teaches it, so the payroll peaks while few teachers carry many hours and falls
/// again once the load is spread. Measured over thirty seeds, this sweep always crosses.
/// </summary>
[Fact]
public async Task AssigningPastTheCap_IsRejectedWithTheNumbers()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Штат перебор", Start);
var staffing = await GetStaffingAsync(client, school.Id);
while (staffing.Applicants.Count > 0)
{
var applicant = staffing.Applicants[0];
using var response = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = applicant.Id, position = "Teacher" },
TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
break;
}
response.EnsureSuccessStatusCode();
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
}
Assert.NotEmpty(staffing.Staff);
var subjects = staffing.Subjects.Select(subject => subject.DefName).ToArray();
Assert.NotEmpty(subjects);
foreach (var member in staffing.Staff)
{
foreach (var subject in subjects)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(member.Id)}/subjects",
new { subject },
TestContext.Current.CancellationToken);
if (response.StatusCode != HttpStatusCode.Conflict)
{
response.EnsureSuccessStatusCode();
continue;
}
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
Assert.Equal("payroll-exceeded", problem?.Code);
Assert.Equal(100_000f, problem?.Allocated);
Assert.True(problem?.Attempted > problem?.Allocated);
Assert.True(problem?.Payroll <= problem?.Allocated);
// Float arithmetic: the server computes remaining once, so compare within a cent.
Assert.Equal(problem!.Allocated!.Value - problem.Payroll!.Value, problem.Remaining!.Value, 0.01f);
// Refused means refused: the assignment must not have landed anyway.
var after = await GetStaffingAsync(client, school.Id);
Assert.Equal(problem.Payroll!.Value, after.Payroll, 0.01f);
Assert.DoesNotContain(
after.Staff.Single(person => person.Id == member.Id).Subjects,
assigned => assigned.DefName == subject);
return;
}
}
Assert.Fail("Loading every teacher with every subject never reached the payroll cap.");
}
private static async Task<StaffingResponse> GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
$"/api/schools/{schoolId}/staffing?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<StaffingResponse> HireAsync(HttpClient client, int schoolId, string personId, string position)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/hire",
new { personId, position },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<StaffingResponse> AssignAsync(HttpClient client, int schoolId, string personId, string subject)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(personId)}/subjects",
new { subject },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
private sealed record ProblemResponse(string? Code, float? Allocated, float? Payroll, float? Remaining, float? Attempted);
private sealed record StaffingResponse(
float Allocated,
float Payroll,
float Remaining,
IReadOnlyList<UncoveredSubjectResponse> Uncovered,
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff,
IReadOnlyList<DefLabelResponse> Positions,
IReadOnlyList<UncoveredSubjectResponse> Subjects);
private sealed record UncoveredSubjectResponse(string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek);
private sealed record ApplicantResponse(
string Id,
string FullName,
bool Female,
int Age,
bool IsParent,
float HourlyWageAsk,
float MonthlyBase,
IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits);
private sealed record LabeledStatResponse(string Id, string Label, string Value);
private sealed record StaffMemberResponse(
string Id,
string FullName,
bool Female,
int Age,
bool IsParent,
string Position,
string PositionLabel,
float HourlyWageAsk,
float MonthlyPay,
IReadOnlyList<DefLabelResponse> Subjects);
private sealed record DefLabelResponse(string DefName, string Label);
private sealed record PersonCardResponse(string Id, IReadOnlyList<string> Roles, IReadOnlyList<LabeledStatResponse> Skills);
private sealed record PeopleListResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonListItemResponse> People,
object Filters);
private sealed record PersonListItemResponse(
string Id,
string FullName,
string Surname,
string Given,
string Patronymic,
bool Female,
int Age,
IReadOnlyList<string> Roles,
int? ClassYear,
string? ClassLetter,
string? Position,
string? PositionLabel);
}