diff --git a/docs/design/people.md b/docs/design/people.md index 3f3a55f..cf4a750 100644 --- a/docs/design/people.md +++ b/docs/design/people.md @@ -261,10 +261,14 @@ Protocol ← Server → Simulation → People → Content ### Детерминизм -Генерация детерминирована от **сида**, сид лежит в файле школы. Один сид + одна карта + один набор +Генерация детерминирована от **сида**. Сид — своё поле школы, не её идентификатор: при создании +его бросает сервер, либо его передают в `POST /api/schools`. Он лежит в файле людей и виден в +списке школ и на экране школы, чтобы им можно было поделиться. Один сид + одна карта + один набор имён + один родной язык = одна и та же школа, всегда. Если родной язык при создании не указан, его выбирает тот же сид — повтор без поля даёт тот же язык. +Существующие школы не перебрасываются: они продолжают жить с числом, уже записанным в файле людей. + Сид у каждой семьи свой, выведенный из школьного: добавление тринадцатой семьи не должно менять первые двенадцать. Иначе тест на генератор ломается от любой правки порядка. @@ -389,7 +393,7 @@ Protocol ← Server → Simulation → People → Content | Падежи | Правило по умолчанию (код), явная таблица как исключение (данные) | | Имена и язык UI | Независимы | | Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET | -| Детерминизм | Сид в сейве, свой сид на семью; родной язык — часть входа | +| Детерминизм | Свой сид школы (не id), в сейве и в API; свой сид на семью; родной язык — часть входа | | Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран | | Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля | | Транспорт списка | HTTP по опубликованному снимку ростера | diff --git a/docs/phases/26-school-seed.md b/docs/phases/26-school-seed.md index 6c8379a..bc287be 100644 --- a/docs/phases/26-school-seed.md +++ b/docs/phases/26-school-seed.md @@ -15,20 +15,20 @@ ## Задачи -- [ ] При создании школы сид берётся из генератора случайных чисел сервера, а не из id -- [ ] `POST /api/schools` принимает необязательный `seed`; передали — берётся он, нет — бросается -- [ ] Сид виден: в ответе `GET /api/schools` и на экране школы, чтобы его можно было переслать -- [ ] Существующие школы не меняются: сид читается из файла людей, как и сейчас -- [ ] Тесты хоста, которым нужен предсказуемый состав, передают сид явно, а не полагаются на +- [x] При создании школы сид берётся из генератора случайных чисел сервера, а не из id +- [x] `POST /api/schools` принимает необязательный `seed`; передали — берётся он, нет — бросается +- [x] Сид виден: в ответе `GET /api/schools` и на экране школы, чтобы его можно было переслать +- [x] Существующие школы не меняются: сид читается из файла людей, как и сейчас +- [x] Тесты хоста, которым нужен предсказуемый состав, передают сид явно, а не полагаются на порядок создания -- [ ] `docs/protocol.md` и [`../design/people.md`](../design/people.md) правятся тем же коммитом +- [x] `docs/protocol.md` и [`../design/people.md`](../design/people.md) правятся тем же коммитом ## Тесты, без которых фаза не закрыта -- [ ] Две школы, созданные с одним сидом, населены одинаково; с разными — по-разному -- [ ] Школа, созданная без сида, после перезапуска поднимает тот же состав -- [ ] Старый сейв, где сид совпадал с id, грузится и состав не меняется -- [ ] Переданный сид виден в списке школ +- [x] Две школы, созданные с одним сидом, населены одинаково; с разными — по-разному +- [x] Школа, созданная без сида, после перезапуска поднимает тот же состав +- [x] Старый сейв, где сид совпадал с id, грузится и состав не меняется +- [x] Переданный сид виден в списке школ ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 9603020..84db4bb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -32,6 +32,8 @@ the wire format is unambiguous, and the client formats it back in UTC. Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a catalog def. +`seed` is the roster generator seed: it is not the school id. Share it to recreate the same +people; it does not change on a living school. ```json { @@ -40,7 +42,7 @@ from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a c "gameMinutesPerRealSecond": 5, "schoolWeekDays": 5, "schools": [ - { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 } + { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "seed": 1847291 } ] } ``` @@ -110,7 +112,8 @@ Body: "modIds": [], "map": null, "nameSetId": "Slavic", - "nativeLanguage": null + "nativeLanguage": null, + "seed": null } ``` @@ -120,6 +123,8 @@ pack set's default layout. A supplied map is validated as a connected yard-and-r `Slavic`). Unknown ids return `400` `unknown-name-set`. `nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick one from the school seed. An id that is not in the set returns `400` `unknown-native-language`. +`seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and +the server rolls one. Existing saves keep the seed already stored in the people file. | Status | Meaning | | --- | --- | diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index a9edc19..a362468 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -55,6 +55,10 @@ const ru = { editMap: 'Редактировать карту', mapDefaultHint: 'Будет использована карта по умолчанию.', mapEditedHint: 'Карта изменена.', + schoolSeed: 'Сид {seed}', + schoolSeedLabel: 'Сид', + schoolSeedHint: 'Необязательно. Чужой сид даёт ту же школу; пустое поле бросает свой.', + schoolSeedInvalid: 'Сид должен быть целым числом.', resetMap: 'Сбросить к умолчанию', done: 'Готово', editorStructure: 'Структура', @@ -266,6 +270,10 @@ const en: Messages = { editMap: 'Edit map', mapDefaultHint: 'The default map will be used.', mapEditedHint: 'The map has been edited.', + schoolSeed: 'Seed {seed}', + schoolSeedLabel: 'Seed', + schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.', + schoolSeedInvalid: 'The seed must be a whole number.', resetMap: 'Reset to default', done: 'Done', editorStructure: 'Structure', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 6710575..82ffceb 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -10,6 +10,8 @@ export interface School { readonly gameTime: string; readonly running: boolean; readonly speedIndex: number; + /** Roster generator seed. Independent of `id`; share it to recreate the same people. */ + readonly seed: number; } export interface SchoolsResponse { @@ -51,6 +53,7 @@ export interface CreateSchoolOptions { readonly map?: MapLayout; readonly nameSetId?: string; readonly nativeLanguage?: string; + readonly seed?: number; } export async function createSchool( @@ -68,6 +71,7 @@ export async function createSchool( map: extras.map ?? null, nameSetId: extras.nameSetId ?? null, nativeLanguage: extras.nativeLanguage ?? null, + seed: extras.seed ?? null, }), }); } diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 36f33db..828ac2a 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -125,6 +125,21 @@ body { font-weight: 600; } +.screen__heading { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.screen__seed { + margin: 0; + color: var(--text-muted); + font-size: 13px; + font-weight: 400; + user-select: all; +} + .screen__actions { margin-left: auto; } diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 6ff892b..00c7115 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -64,6 +64,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { @@ -238,6 +246,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(school)) .catch((reason: unknown) => { @@ -343,6 +361,24 @@ function mapsEqual(left: MapLayout, right: MapLayout): boolean { return JSON.stringify(left) === JSON.stringify(right); } +function parseSeed(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === '') { + return null; + } + + if (!/^-?\d+$/.test(trimmed)) { + return null; + } + + const parsed = Number(trimmed); + if (!Number.isSafeInteger(parsed) || parsed < -2147483648 || parsed > 2147483647) { + return null; + } + + return parsed; +} + function describe(reason: unknown): string { if (!(reason instanceof ApiError)) { return t('serverUnavailable'); diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 5a0f5c2..7f726ec 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -34,6 +34,7 @@ export class GameScreen { private readonly root = el('section', { class: 'screen game' }); private readonly backButton = el('button', { class: 'button', type: 'button' }); private readonly schoolName = el('h1', { class: 'screen__title' }); + private readonly schoolSeed = el('p', { class: 'screen__seed' }); private readonly time = el('p', { class: 'clock__time', text: '--:--' }); private readonly date = el('p', { class: 'clock__date' }); private readonly weekday = el('p', { class: 'clock__weekday' }); @@ -79,6 +80,7 @@ export class GameScreen { private directoryToken = 0; private selectedId: string | null = null; private schoolId: number | null = null; + private peopleSeed: number | null = null; private running = false; private lastGameTime: Date | null = null; private lastSpeedIndex = 0; @@ -102,7 +104,7 @@ export class GameScreen { this.skipButton.hidden = true; this.root.append( - el('header', { class: 'screen__header' }, this.backButton, this.schoolName), + el('header', { class: 'screen__header' }, this.backButton, el('div', { class: 'screen__heading' }, this.schoolName, this.schoolSeed)), el( 'div', { class: 'clockbar' }, @@ -188,6 +190,7 @@ export class GameScreen { } this.paintSkip(); + this.paintSeed(); this.people.setLocate((id) => this.placeOf(id)); this.management.setLocate((id) => this.placeOf(id)); } @@ -195,7 +198,9 @@ export class GameScreen { /** Called when the screen opens, before the first clock frame and snapshot arrive. */ show(school: School): void { this.schoolId = school.id; + this.peopleSeed = school.seed; this.schoolName.textContent = school.name; + this.paintSeed(); this.nodes = []; this.presence = null; this.directory = new Map(); @@ -381,6 +386,10 @@ export class GameScreen { : ''; } + private paintSeed(): void { + this.schoolSeed.textContent = this.peopleSeed === null ? '' : t('schoolSeed', { seed: this.peopleSeed }); + } + private paintTreeLabels(): void { for (const [id, button] of this.treeButtons) { const node = this.nodes.find((candidate) => candidate.id === id); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 3083437..3307e07 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -55,6 +55,7 @@ internal static class SchoolEndpoints request.Map, request.NameSetId, request.NativeLanguage, + request.Seed, NewCompletion()); commands.Enqueue(command); @@ -490,12 +491,13 @@ internal sealed record CreateSchoolRequest( IReadOnlyList? ModIds, MapLayout? Map, string? NameSetId, - string? NativeLanguage); + string? NativeLanguage, + int? Seed); -internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex) +internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed) { public static SchoolResponse From(SchoolState school) => - new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex); + new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.Seed); } /// Everything the main menu needs in one request. diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 9917e5e..7dd4c34 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -18,6 +18,7 @@ internal abstract record GameCommand MapLayout? Map, string? NameSetId, string? NativeLanguage, + int? Seed, TaskCompletionSource Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index a5e41bb..c0c2900 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -342,9 +342,10 @@ internal sealed class GameLoopService( var id = _nextId++; store.WriteNextId(_nextId); - var nativeLanguage = NativeLanguages.Pick(names, id, command.NativeLanguage, rollIfOmitted: true); + var seed = command.Seed ?? Random.Shared.Next(); + var nativeLanguage = NativeLanguages.Pick(names, seed, command.NativeLanguage, rollIfOmitted: true); - var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage); + var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage, seed); Track(worker); worker.Start(); @@ -366,7 +367,7 @@ internal sealed class GameLoopService( throw; } - logger.LogInformation("School {SchoolId} \"{Name}\" created.", id, normalized); + logger.LogInformation("School {SchoolId} \"{Name}\" created with seed {Seed}.", id, normalized, seed); command.Result.TrySetResult(new SchoolCreationOutcome(worker.Snapshot, SchoolCreationError.None)); } catch (Exception ex) @@ -513,6 +514,7 @@ internal sealed class GameLoopService( save.Map, save.NameSetId, save.NativeLanguage, + createSeed: null, save.Presence); worker.Start(); @@ -562,6 +564,7 @@ internal sealed class GameLoopService( MapLayout? map, string? nameSetId, string? nativeLanguage, + int? createSeed = null, IReadOnlyList? presence = null) => new( id, @@ -574,6 +577,7 @@ internal sealed class GameLoopService( map, nameSetId, nativeLanguage, + createSeed, presence, _options, clients, diff --git a/src/HSchool.Server/Game/SchoolState.cs b/src/HSchool.Server/Game/SchoolState.cs index ae68a4a..b1125ec 100644 --- a/src/HSchool.Server/Game/SchoolState.cs +++ b/src/HSchool.Server/Game/SchoolState.cs @@ -4,7 +4,7 @@ namespace HSchool.Server.Game; /// Immutable copy of a school, safe to hand to request threads. The live School object /// never leaves its worker thread. /// -internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex); +internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed); /// Everything the main menu needs in one read. internal sealed record SchoolsState(int MaxSchools, IReadOnlyList Schools); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 972b94e..6c35638 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -34,6 +34,7 @@ internal sealed class SchoolWorker private readonly MapLayout? _savedMap; private readonly string? _nameSetId; private string? _nativeLanguage; + private readonly int? _createSeed; private readonly IReadOnlyList? _savedPresence; private readonly Action _onFailed; @@ -67,6 +68,7 @@ internal sealed class SchoolWorker MapLayout? savedMap, string? nameSetId, string? nativeLanguage, + int? createSeed, IReadOnlyList? savedPresence, SimulationOptions options, ClientRegistry clients, @@ -86,6 +88,7 @@ internal sealed class SchoolWorker _savedMap = savedMap; _nameSetId = nameSetId; _nativeLanguage = nativeLanguage; + _createSeed = createSeed; _savedPresence = savedPresence; _options = options; _clients = clients; @@ -94,7 +97,7 @@ internal sealed class SchoolWorker _mods = mods; _onFailed = onFailed; _logger = logger; - _snapshot = new SchoolState(id, name, time, running, (byte)speedIndex); + _snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, createSeed ?? 0); } public int Id => _id; @@ -577,7 +580,8 @@ internal sealed class SchoolWorker school.Name, school.Clock.Time, school.Clock.IsRunning, - (byte)school.Clock.SpeedIndex)); + (byte)school.Clock.SpeedIndex, + school.PeopleSeed)); Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _timetableSnapshot, school.Timetable); @@ -856,7 +860,12 @@ internal sealed class SchoolWorker if (_isNew) { - seed = school.Id; + if (_createSeed is not int createSeed) + { + throw new InvalidOperationException($"School {_id} was created without a people seed."); + } + + seed = createSeed; native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); _nativeLanguage = native; roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); diff --git a/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs b/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs index d9ca6f7..f1b6efd 100644 --- a/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs +++ b/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs @@ -16,6 +16,7 @@ public class GoldenSaveTests(AppHostFixture fixture) var state = await SchoolApiTests.GetSchoolsAsync(client); var school = Assert.Single(state.Schools); Assert.Equal(1, school.Id); + Assert.Equal(1, school.Seed); Assert.Equal("Золотая", school.Name); Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime); Assert.False(school.Running); @@ -34,6 +35,8 @@ public class GoldenSaveTests(AppHostFixture fixture) var state = await SchoolApiTests.GetSchoolsAsync(client); var school = Assert.Single(state.Schools); + Assert.Equal(1, school.Id); + Assert.Equal(1, school.Seed); Assert.Equal("Золотая", school.Name); var people = await PeopleAsync(client, school.Id); diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs index 5a930ee..511f474 100644 --- a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -139,14 +139,14 @@ public class PeopleApiTests(AppHostFixture fixture) { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); - var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start); + var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start, seed: 1); var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=10"); - // 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. + // Every fifth-to-tenth family is single-parent on purpose (design/people.md). The roster + // seed is no longer the school id, but a given page still mixes complete and single-parent + // houses, so demanding two parents from whoever sorts first fails on some seeds. Look for + // a pupil who has a mother instead of assuming the first one does. PersonCardResponse? card = null; foreach (var candidate in page.People) { diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 9c4ae01..d126fa5 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -371,9 +371,13 @@ public class SchoolApiTests(AppHostFixture fixture) return state; } - internal static async Task CreateAsync(HttpClient client, string name, DateTime startDate) + internal static async Task CreateAsync( + HttpClient client, + string name, + DateTime startDate, + int? seed = null) { - using var response = await PostAsync(client, name, startDate); + using var response = await PostAsync(client, name, startDate, seed); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); @@ -406,10 +410,14 @@ public class SchoolApiTests(AppHostFixture fixture) links = new[] { new { a = "yard", b = "office" } }, }; - private static Task PostAsync(HttpClient client, string name, DateTime startDate) => + private static Task PostAsync( + HttpClient client, + string name, + DateTime startDate, + int? seed = null) => client.PostAsJsonAsync( "/api/schools", - new { name, startDate }, + seed is null ? (object)new { name, startDate } : new { name, startDate, seed }, TestContext.Current.CancellationToken); private static async Task ProblemCodeAsync(HttpResponseMessage response) @@ -418,7 +426,7 @@ public class SchoolApiTests(AppHostFixture fixture) return problem?.Code; } - internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex); + internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex, int Seed); internal sealed record SchoolsResponse( int MaxSchools, diff --git a/tests/HSchool.AppHost.Tests/SchoolSeedTests.cs b/tests/HSchool.AppHost.Tests/SchoolSeedTests.cs new file mode 100644 index 0000000..1ded5b3 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/SchoolSeedTests.cs @@ -0,0 +1,90 @@ +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +/// +/// The people seed is a school field of its own, not the school id. These tests share the +/// AppHost, so each one starts by clearing the list. +/// +[Collection(AppHostCollection.Name)] +public class SchoolSeedTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task SameSeed_PopulatesTheSamePeople_DifferentSeedsDoNot() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + + var first = await SchoolApiTests.CreateAsync(client, "Сид одинаковый А", Start, seed: 42); + var second = await SchoolApiTests.CreateAsync(client, "Сид одинаковый Б", Start, seed: 42); + var other = await SchoolApiTests.CreateAsync(client, "Сид другой", Start, seed: 7); + + var firstYearFive = await YearFiveAsync(client, first.Id); + var secondYearFive = await YearFiveAsync(client, second.Id); + var otherYearFive = await YearFiveAsync(client, other.Id); + + Assert.Equal(firstYearFive, secondYearFive); + Assert.NotEqual(firstYearFive, otherYearFive); + } + + [Fact] + public async Task UnseededSchool_KeepsItsPeopleAfterReload() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + + var created = await SchoolApiTests.CreateAsync(client, "Сид брошен", Start); + + var before = await YearFiveAsync(client, created.Id); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + var restored = (await SchoolApiTests.GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id); + Assert.Equal(created.Seed, restored.Seed); + Assert.Equal(before, await YearFiveAsync(client, created.Id)); + } + + [Fact] + public async Task PassedSeed_IsVisibleInTheSchoolList() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + + var created = await SchoolApiTests.CreateAsync(client, "Сид в списке", Start, seed: 20260819); + + Assert.Equal(20260819, created.Seed); + + var listed = (await SchoolApiTests.GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id); + Assert.Equal(20260819, listed.Seed); + } + + [Fact] + public async Task TwoUnseededSchools_ArePopulatedDifferently() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + + var first = await SchoolApiTests.CreateAsync(client, "Без сида А", Start); + var second = await SchoolApiTests.CreateAsync(client, "Без сида Б", Start); + + Assert.NotEqual(first.Seed, second.Seed); + Assert.NotEqual(await YearFiveAsync(client, first.Id), await YearFiveAsync(client, second.Id)); + } + + private static async Task<(string Id, string FullName)[]> YearFiveAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?role=student&year=5&sort=surname&pageSize=100", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + Assert.NotEmpty(page.People); + return [.. page.People.Select(person => (person.Id, person.FullName))]; + } + + private sealed record PeoplePage(IReadOnlyList People); + + private sealed record PersonRow(string Id, string FullName); +}