Enhance people management tests and fix related issues
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 15s

- Updated `PeopleApiTests` to correctly handle family structures, ensuring tests account for single-parent scenarios and sorting behavior for surnames.
- Added a new test to validate roster integrity after the yearly intake, confirming that the composition reflects changes post-intake.
- Revised assertions to ensure accurate comparisons of student and family data, improving test reliability.
- Enhanced documentation within tests to clarify the purpose and expected outcomes of new functionalities.
- Addressed discrepancies between design and implementation regarding the layout of the people management interface.
This commit is contained in:
Leonid Pershin
2026-08-19 21:31:01 +03:00
parent 5cd5a6d258
commit 21d79cb49b
4 changed files with 275 additions and 8 deletions
+111 -8
View File
@@ -35,7 +35,10 @@ public class PeopleApiTests(AppHostFixture fixture)
var page = await GetPeopleAsync(client, school.Id, "sort=surname&pageSize=20");
var surnames = page.People.Select(person => person.Surname).ToArray();
Assert.Equal(surnames.OrderBy(name => name, StringComparer.Ordinal), surnames);
// The server alphabetises Ё with Е, so plain ordinal order is the wrong yardstick: it only
// agrees because no vanilla surname starts with Ё. Fold the same way the server does.
Assert.Equal(surnames.OrderBy(Alphabetised, StringComparer.Ordinal), surnames);
}
[Fact]
@@ -139,14 +142,29 @@ public class PeopleApiTests(AppHostFixture fixture)
var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start);
var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=10");
var pupil = page.People[0];
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil.Id)}?lang=ru",
TestContext.Current.CancellationToken);
// Every fifth-to-tenth family is single-parent on purpose (design/people.md), and the
// roster seed is the school id — which depends on how many schools ran before this test.
// Demanding two parents from whoever happens to sort first therefore fails on some runs;
// look for a pupil who has a mother instead of assuming the first one does.
PersonCardResponse? card = null;
foreach (var candidate in page.People)
{
var candidateCard = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(candidate.Id)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(candidateCard);
Assert.Equal(candidate.Id, candidateCard.Id);
Assert.InRange(candidateCard.Family.Parents.Count, 1, 2);
if (candidateCard.Family.Parents.Any(parent => parent.Female))
{
card = candidateCard;
break;
}
}
Assert.NotNull(card);
Assert.Equal(pupil.Id, card.Id);
Assert.Equal(2, card.Family.Parents.Count);
Assert.Contains(card.Needs, need => need.Id == "Sleep" && need.Value == 1f);
Assert.NotEmpty(card.Body);
Assert.NotEmpty(card.Skills);
@@ -156,9 +174,94 @@ public class PeopleApiTests(AppHostFixture fixture)
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(mother.Id)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(motherCard);
Assert.Contains(motherCard.Family.Children, child => child.Id == pupil.Id);
Assert.Contains(motherCard.Family.Children, child => child.Id == card.Id);
}
/// <summary>
/// Phases 7 and 9 together: the roster survives a restart, and what comes back is the
/// composition *after* the first-September intake. Generating from the seed again would
/// rebuild the pre-intake roster, so comparing against it is what makes this test bite.
/// </summary>
[Fact]
public async Task ReloadAfterTheYearlyIntake_RestoresTheNewComposition()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
// Fifteen game minutes short of 1 September: the intake fires at midnight, and five game
// minutes per real second gets the clock there in about three seconds.
var school = await SchoolApiTests.CreateAsync(
client,
"Набор и рестарт",
new DateTime(2012, 8, 31, 23, 45, 0, DateTimeKind.Utc));
var before = await GetPeopleAsync(client, school.Id, Everyone);
var beforeIds = before.People.Select(person => person.Id).ToArray();
Assert.NotEmpty(beforeIds);
var after = await WaitForIntakeAsync(client, school.Id, beforeIds);
var afterIds = after.People.Select(person => person.Id).ToArray();
var pupil = after.People.First(person => person.Roles.Contains("student"));
var cardBefore = await GetCardAsync(client, school.Id, pupil.Id);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await GetPeopleAsync(client, school.Id, Everyone);
Assert.Equal(after.Total, restored.Total);
Assert.Equal(
after.People.Select(person => (person.Id, person.FullName, person.ClassYear)),
restored.People.Select(person => (person.Id, person.FullName, person.ClassYear)));
Assert.NotEqual(beforeIds, restored.People.Select(person => person.Id).ToArray());
var cardAfter = await GetCardAsync(client, school.Id, pupil.Id);
Assert.Equal(cardBefore.BirthDate, cardAfter.BirthDate);
Assert.Equal(
cardBefore.Body.Select(stat => (stat.Id, stat.Value)),
cardAfter.Body.Select(stat => (stat.Id, stat.Value)));
Assert.Equal(
cardBefore.Skills.Select(stat => (stat.Id, stat.Value)),
cardAfter.Skills.Select(stat => (stat.Id, stat.Value)));
Assert.Equal(
cardBefore.Traits.Select(trait => trait.DefName),
cardAfter.Traits.Select(trait => trait.DefName));
}
private const string Everyone = "sort=surname&pageSize=100";
/// <summary>Polls the published roster until the intake has replaced the graduating year.</summary>
private static async Task<PeopleListResponse> WaitForIntakeAsync(
HttpClient client,
int schoolId,
IReadOnlyList<string> beforeIds)
{
for (var attempt = 0; attempt < 60; attempt++)
{
var page = await GetPeopleAsync(client, schoolId, Everyone);
if (!page.People.Select(person => person.Id).SequenceEqual(beforeIds))
{
return page;
}
await Task.Delay(250, TestContext.Current.CancellationToken);
}
throw new InvalidOperationException("The yearly intake never happened.");
}
private static async Task<PersonCardResponse> GetCardAsync(HttpClient client, int schoolId, string personId)
{
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
return card;
}
/// <summary>Mirrors the server's name order: Ё sorts as Е, everything else stays ordinal.</summary>
private static string Alphabetised(string name) => name.Replace('Ё', 'Е').Replace('ё', 'е');
private static async Task<PeopleListResponse> GetPeopleAsync(HttpClient client, int schoolId, string query)
{
var page = await client.GetFromJsonAsync<PeopleListResponse>(
@@ -197,12 +197,75 @@ public class StaffingApiTests(AppHostFixture fixture)
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)
{
staffing = await HireAsync(client, school.Id, staffing.Applicants[0].Id, "Teacher");
}
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>(
@@ -66,6 +66,26 @@ public class ApplicantPoolTests
Assert.True(ApplicantPool.HourlyAsk(catalog, strong) > ApplicantPool.HourlyAsk(catalog, weak));
}
/// <summary>
/// design/staffing.md prices an hour from skills *and* traits — the self-assured ask for more.
/// Skills are covered above; this pins the trait half, which core sets on Leader (+10),
/// HotTempered (+6) and Quiet (-8).
/// </summary>
[Fact]
public void ConfidentTraits_AskForMoreAndQuietOnesForLess()
{
var catalog = Fixtures.Catalog();
var person = Fixtures.Generate(Fixtures.Classrooms(1)).People.First(candidate => candidate.IsParent);
var plain = person with { Traits = [] };
var leader = person with { Traits = ["Leader"] };
var quiet = person with { Traits = ["Quiet"] };
var plainAsk = ApplicantPool.HourlyAsk(catalog, plain);
Assert.Equal(plainAsk + 10f, ApplicantPool.HourlyAsk(catalog, leader), 0.01f);
Assert.Equal(plainAsk - 8f, ApplicantPool.HourlyAsk(catalog, quiet), 0.01f);
}
[Fact]
public void GeneratedApplicants_AreNotOnTheRoster_ParentsKeepTheirId()
{