- Updated protocol documentation to include `nativeLanguages` in `nameSets` and added `nativeLanguage` to school creation options. - Enhanced the UI for school creation to allow selection of native languages, improving user experience. - Revised API interfaces to accommodate new native language features, ensuring proper data handling. - Improved localization strings to support new native language functionalities in both English and Russian. - Updated tests to validate the new native language features and ensure robust functionality in staffing scenarios.
401 lines
17 KiB
C#
401 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);
|
|
|
|
using var over = await client.PostAsJsonAsync(
|
|
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects",
|
|
new { subject = "Literature" },
|
|
TestContext.Current.CancellationToken);
|
|
// Cheap asks may still fit; only the numbers matter when the cap actually bites.
|
|
if (over.StatusCode == HttpStatusCode.Conflict)
|
|
{
|
|
var problem = await over.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
|
Assert.Equal("payroll-exceeded", problem?.Code);
|
|
Assert.True(problem?.Attempted > problem?.Allocated);
|
|
Assert.Equal(assigned.Payroll, problem?.Payroll);
|
|
}
|
|
else
|
|
{
|
|
over.EnsureSuccessStatusCode();
|
|
assigned = await over.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
|
|
Assert.NotNull(assigned);
|
|
}
|
|
|
|
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);
|
|
}
|