From 21d79cb49b92198bfe88af763c2f21204adfde24 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 21:31:01 +0300 Subject: [PATCH] Enhance people management tests and fix related issues - 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. --- docs/phases/reviewed.md | 81 ++++++++++++ tests/HSchool.AppHost.Tests/PeopleApiTests.cs | 119 ++++++++++++++++-- .../HSchool.AppHost.Tests/StaffingApiTests.cs | 63 ++++++++++ .../ApplicantPoolTests.cs | 20 +++ 4 files changed, 275 insertions(+), 8 deletions(-) diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 83f1360..b7d2731 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -94,3 +94,84 @@ `LessonLearningSystem.cs`, `PresenceSystem.cs`, `gameScreen.ts`), и какое-то время решение не компилировалось. Выводам по срезу 1 это не мешает: его файлы правка не трогает, а дописанные тесты прогнаны и на чистом `b135a9c`, и на дереве после починки сборки. + +## Срез 2. Люди + +- **Фазы:** 5–9 +- **Проверен на:** `5cd5a6d`, 2026-08-19 +- **Пути:** `src/HSchool.People`, `src/HSchool.Content/PeopleDefs.cs`, + `src/HSchool.Simulation/RosterSpawner.cs`, `src/HSchool.Simulation/School.cs`, + `src/HSchool.Server/Api/SchoolEndpoints.cs`, `src/HSchool.Server/Game/PersonCardReader.cs`, + `src/HSchool.Client/src/ui/peoplePanel.ts`, `src/HSchool.Client/src/ui/personCard.ts`, + `tests/HSchool.People.Tests`, `tests/HSchool.Content.Tests/PeopleDefTests.cs`, + `tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs`, + `tests/HSchool.AppHost.Tests/PeopleApiTests.cs` +- **Итог:** исправлен один тест, который падал на чистом клоне, укреплён второй, дописан один; + расхождение с дизайном одно и то же, что в срезе 1 (раскладка оболочки менеджера) + +Что подтверждено: + +- **Фаза 5.** Все шесть пунктов списка тестов на месте в `PeopleDefTests` (разбор каждого вида, + наследование навыка, патч черты, неизвестная ссылка — ошибка каталога, склонение по модели и + перекрытие явной таблицей), плюс `ExtraPack_AddsANameSetAndTrait` — критерий «мод со своим + набором имён виден в каталоге». Наборы имён в `GET /api/catalog` проверены в + `SchoolApiTests.Catalog_LabelsDefsInTheRequestedLanguage`. +- **Фаза 6.** Все шесть тестов из списка фазы существуют и проходят. Критерий «в проекте нет + сокетов, ECS и `DateTime.Now`» закреплён самими тестами — + `Assembly_DoesNotReferenceArchAspNetOrSockets` и `Sources_DoNotUseWallClock`. +- **Фаза 7.** Одиннадцать кабинетов (`VanillaMap_HasElevenHomeroomsNotTheComputerLab`), нужды + тикают со школой, ростер публикуется снимком, `PersistPeople` зовётся только когда состав + изменился — файл не переписывается по таймеру. Ростер, не сходящийся с картой, оставляет школу + незапущенной (`RosterFit.Matches` → `SchoolContentUnavailableException`). +- **Фаза 8.** Список читает опубликованный снимок (`loop.FindPeople`), карточка идёт через + очередь команд с `TaskCompletionSource` — инвариант 3 цел. Все пять требуемых фазой тестов API + на месте. Клиент: фильтры, сортируемые колонки, пейджер, семейные ссылки, всё через `t(...)`; + в строке списка только лёгкие поля. +- **Фаза 9.** Выпуск старшей имеющейся параллели, сохранение вместимости, семьи через поколения и + уход родителей выпускников покрыты в `YearlyIntakeTests` и `PeopleInSchoolTests`. +- **Дизайн.** `design/people.md` сверен построчно: неполные семьи ≈8% (тест требует 5–10%), + фамилия и отчество отцовские даже у одинокой матери, идентификатор ребёнка — счётчик, места + раздаются вперемешку. Расхождений нет. + +Исправлено: + +- **`PeopleApiTests.Card_IncludesFamilyAndLiveNeeds` падал на чистом клоне.** Тест брал первого + ученика страницы и требовал у него ровно двух родителей. Состав семьи выводится из сида, а сид — + это id школы (`SchoolWorker.InstallPeople`: `seed = school.Id`), то есть зависит от того, + сколько школ создали тесты до него и что лежит в `saves/index.json`. Замер по сидам 1–30: у 28 + первый пятиклассник с двумя родителями, у 2 — с одним, и полный прогон на свежем checkout + `b135a9c` в отдельном worktree ловил как раз такой сид. Одинокие родители — сознательное + решение дизайна, поэтому неправ был тест: теперь он ищет ученика, у которого есть мать, а + количество родителей проверяет диапазоном. Проверено тем же способом, каким ловилось: полный + прогон на чистом `b135a9c` трижды, 60/60. +- **`PeopleApiTests.List_SortsBySurname` мерил не тем.** Тест требовал ordinal-порядка, а сервер + ставит Ё на место Е (`RosterBrowser.NameOrder`, тест `SurnameSort_FollowsTheAlphabetNotCodePoints`). + Совпадали они только потому, что в `core` нет фамилий на Ё — мод с такой фамилией уронил бы + тест, а не код. Теперь тест складывает Ё в Е так же, как сервер. + +Дописано: + +- `PeopleApiTests.ReloadAfterTheYearlyIntake_RestoresTheNewComposition` — критерии фаз 7 и 9 + «перезапуск поднимает тех же людей» и «перезапуск после перехода поднимает **новый** состав» не + проверялись сквозь сервер. Просто сравнить людей до и после reload недостаточно: генерация + детерминирована от id школы, поэтому забывший файл сервер собрал бы тот же ростер и тест бы + ничего не поймал. Тест поэтому ждёт 1 сентября (школа создаётся за 15 игровых минут до + полуночи, это около трёх секунд реального времени), а после reload требует состав **после** + набора и явно отличный от исходного. + +Открыто: + +- **Раскладка оболочки менеджера разошлась с дизайном** — то же расхождение, что записано в срезе + 1. Фаза 8 обещает панель «Люди» нижним рядом во всю ширину, `design/people.md` фиксирует + «панель в оболочке менеджера, нижний ряд; не отдельный экран», а на экране она вкладкой рядом с + картой. Тот же коммит `d8f8db6`, что убрал панель событий. Решается одним решением на оба + пункта: либо вернуть нижний ряд, либо переписать обе строки дизайна. + +Замечено рядом (за границами среза): + +- `design/people.md` и задача фазы 5 говорят «скорость убывания нужд в `core` — ноль», а в + `defs/needs` теперь `decayPerHour` от 0.05 до 0.15. Это сознательная работа фазы 20, не + регрессия; строка дизайна просто устарела и относится к срезу 5. +- Срез 1 успел сдвинуться: коммит `5cd5a6d` тронул `gameScreen.ts` и `strings.ts` (подпись + идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и + относится к срезу 5, но следующий проход это увидит. diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs index 18ce108..5a930ee 100644 --- a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -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( - $"/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( + $"/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); } + /// + /// 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. + /// + [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"; + + /// Polls the published roster until the intake has replaced the graduating year. + private static async Task WaitForIntakeAsync( + HttpClient client, + int schoolId, + IReadOnlyList 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 GetCardAsync(HttpClient client, int schoolId, string personId) + { + var card = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(card); + return card; + } + + /// Mirrors the server's name order: Ё sorts as Е, everything else stays ordinal. + private static string Alphabetised(string name) => name.Replace('Ё', 'Е').Replace('ё', 'е'); + private static async Task GetPeopleAsync(HttpClient client, int schoolId, string query) { var page = await client.GetFromJsonAsync( diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs index 48a9f85..b11beb2 100644 --- a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs +++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs @@ -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)); } + /// + /// 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. + /// + [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(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 GetStaffingAsync(HttpClient client, int schoolId) { var staffing = await client.GetFromJsonAsync( diff --git a/tests/HSchool.People.Tests/ApplicantPoolTests.cs b/tests/HSchool.People.Tests/ApplicantPoolTests.cs index dd11a0f..bfa2945 100644 --- a/tests/HSchool.People.Tests/ApplicantPoolTests.cs +++ b/tests/HSchool.People.Tests/ApplicantPoolTests.cs @@ -66,6 +66,26 @@ public class ApplicantPoolTests Assert.True(ApplicantPool.HourlyAsk(catalog, strong) > ApplicantPool.HourlyAsk(catalog, weak)); } + /// + /// 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). + /// + [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() {