diff --git a/docs/design/06-foundation/foundation.md b/docs/design/06-foundation/foundation.md index 7d7c8be..b48cebb 100644 --- a/docs/design/06-foundation/foundation.md +++ b/docs/design/06-foundation/foundation.md @@ -17,8 +17,10 @@ ломается. Пак может поднять `lunchWeight` выше урока без форка. **Золотые файлы ловят то, что раньше ловили глазами.** Отпечаток ростера — файл в фикстурах. -Сейв текущего формата и сейв без `nativeLanguage` грузятся тестом хоста. Формат новее своего -школу не стартует и файл не трогает. +Папки `current/` и `legacy-no-native/` живут как исторический формат 2 (без `countryId`): сам +файл школу не стартует. Тест хоста копирует `legacy-no-native/`, ставит сегодняшний формат, +страну и владельца и проверяет, что сейв без `nativeLanguage` не перетасовывает набор. +Формат новее своего школу не стартует и файл не трогает. ## Удостоверение пака @@ -112,12 +114,9 @@ ## Версия сейва читается -`Format` штампуется при записи, копируется при чтении и **никогда не сравнивается**. Файл из -будущей версии загрузится молча и будет понят неправильно, а места для миграции не предусмотрено. - -Правило то же, что с пропавшей папкой мода: формат новее своего — школу не стартовать, файл не -трогать, в лог. Формат старее — явный шов для апгрейда, сегодня пустой, но названный. Пустой -именованный шов дешевле, чем попытка вспомнить через полгода, куда его вставлять. +`Format` сравнивается при загрузке (`SchoolStore.CanStart`). Правило то же, что с пропавшей +папкой мода: формат новее своего — школу не стартовать, файл не трогать, в лог. Формат старее +или без страны — тоже не стартовать; шов для апгрейда назван, миграций пока нет. ## Взгляд внутрь школы diff --git a/docs/phases/06-foundation/reviewed.md b/docs/phases/06-foundation/reviewed.md index a4836e6..8820239 100644 --- a/docs/phases/06-foundation/reviewed.md +++ b/docs/phases/06-foundation/reviewed.md @@ -42,3 +42,45 @@ - `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`. Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5. + +## Перепроверка среза 6. Фундамент + +- **Фазы:** 22–28 +- **Проверен на:** `REVIEW_COMMIT`, 2026-08-20 +- **Пути:** `src/HSchool.Content` (`PackManifest`, `PackLoadOrder`, `BehaviorDef`, `CatalogLoader`), + `src/HSchool.Server/mods/{core/pack.jsonc,example}`, `src/HSchool.Server/Game/SchoolStore.cs`, + `src/HSchool.Server/Api/DevEndpoints.cs`, `src/HSchool.Server/Game/SchoolDumpReader.cs`, + `src/HSchool.People/RosterJson.cs`, `src/HSchool.Client/src/ui/{gameScreen,peoplePanel,createSchoolDialog,timetableGrid,managementPanel}.test.ts`, + `tests/HSchool.Content.Tests/{PackIdentityTests,ExamplePackTests,BehaviorDefTests,CatalogLoaderTests}.cs`, + `tests/HSchool.People.Tests/GoldenRosterTests.cs`, + `tests/HSchool.AppHost.Tests/{ExamplePackTests,GoldenSaveTests,SchoolSeedTests,ServiceabilityTests}.cs`, + `docs/design/06-foundation/foundation.md` +- **Итог:** перепроверка после дрейфа с `cbe739a`. Обещания фаз 22–24, 26–28 держатся. + Хостовые `GoldenSaveTests` (фаза 25/26), снятые в фазе 39, возвращены под текущий `CanStart`. + Дамп с фазы 59 ждёт человека на узле — прошлое открытое замечание закрыто кодом среза 10, не этим проходом. + +Что подтверждено: + +- Пак, пример, веса, золотой ростер, `teachersShort`, свой сид, формат новее, дамп через мейлбокс, + экраны в `happy-dom` — те же классы, что в первом проходе. `UnlabelledDef_WarnsButLoads` на месте. +- `Dump_ReturnsPeopleNodesAndTimetable` больше не принимает «нужды непусты» вместо явки. +- Формат 2 без страны школу не стартует; сид из `people.json` (в т.ч. равный id) поднимается. + +Дописано: + +- `GoldenSaveTests.HistoricFormat2Golden_LeavesTheSchoolUnstartedAndTheFileUntouched` +- `GoldenSaveTests.SaveWithoutNativeLanguage_LoadsWithoutReshuffling` — штамп format 3 + `countryId` + владелец на копию `legacy-no-native/` +- `gameScreen.test.ts` — заголовок показывает сид через `t('schoolSeed')` + +Исправлено: + +- `foundation.md`: «Format никогда не сравнивается» неверно после `CanStart`; золотые папки — исторический формат 2. +- `tests/HSchool.AppHost.Tests/golden/README.md` — то же про формат 2 и абстрактный `Underwear` в `current/1.people.json` + +Открыто: + +- `current/1.people.json` носит абстрактный `Underwear` (потом разложили на Briefs/Boxers/Panties). Даже со штампом сегодняшнего формата школа не стартует (`RequireKnownApparel`). Переписывать отпечаток одежды в этом срезе не стали — это гардероб. + +Замечено рядом: + +- Фаза 39 сняла золотую загрузку в пользу несовместимых сейвов; этот проход вернул узкие тесты, не миграцию формата 2. diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 097eb0d..58da6d2 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -25,7 +25,7 @@ | Срез 3. Штат | `cf1403e` | [03-staffing](03-staffing/reviewed.md) | | Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) | | Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) | -| Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) | +| Срез 6. Фундамент | `REVIEW_COMMIT` | [06-foundation](06-foundation/reviewed.md) | | Срез 7. Этап A — данные и гардероб | `d5547b7` | [07-inventory](07-inventory/reviewed.md) | | Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) | | Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) | diff --git a/src/HSchool.Client/src/ui/gameScreen.test.ts b/src/HSchool.Client/src/ui/gameScreen.test.ts index 84b6bfc..66f2a7a 100644 --- a/src/HSchool.Client/src/ui/gameScreen.test.ts +++ b/src/HSchool.Client/src/ui/gameScreen.test.ts @@ -73,6 +73,20 @@ describe('GameScreen speed buttons', () => { expect(labels).toEqual(['×½', '×1', '×2', '×5', '×10']); }); + it('shows the school seed in the header', () => { + const screen = new GameScreen({ + onLeave: () => {}, + onSetRunning: () => {}, + onSetSpeed: () => {}, + onSkip: () => {}, + }); + + document.body.append(screen.element); + screen.show(school({ seed: 42, name: 'Seeded' })); + + expect(screen.element.querySelector('.screen__seed')?.textContent).toBe(t('schoolSeed', { seed: 42 })); + }); + it('hides management and clock controls for a guest school', () => { const screen = new GameScreen({ onLeave: () => {}, diff --git a/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs b/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs new file mode 100644 index 0000000..3c814e5 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/GoldenSaveTests.cs @@ -0,0 +1,170 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace HSchool.AppHost.Tests; + +/// +/// Phase 25 goldens. The folders stay historic (format 2, no country): that shape must not start. +/// Load tests stamp today's format, country and owner onto a copy so missing-field defaults and +/// seed-equals-id still have a host assertion after later slices raised CanStart. +/// +[Collection(AppHostCollection.Name)] +public class GoldenSaveTests(AppHostFixture fixture) +{ + [Fact] + public async Task HistoricFormat2Golden_LeavesTheSchoolUnstartedAndTheFileUntouched() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var directory = await SchoolApiTests.SavesDirectoryAsync(client); + + try + { + InstallFolder(directory, "current"); + var path = Path.Combine(directory, "1.json"); + var before = File.ReadAllBytes(path); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + var state = await SchoolApiTests.GetSchoolsAsync(client); + Assert.Empty(state.Schools); + var broken = Assert.Single(state.Others); + Assert.Equal(1, broken.Id); + Assert.Equal("Золотая", broken.Name); + Assert.Equal(1, broken.Seed); + + using var people = await client.GetAsync( + $"/api/schools/{broken.Id}/people?pageSize=1", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, people.StatusCode); + + Assert.Equal(before, File.ReadAllBytes(path)); + } + finally + { + DeleteSchoolFiles(directory, 1); + using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + cleanup.EnsureSuccessStatusCode(); + } + } + + [Fact] + public async Task SaveWithoutNativeLanguage_LoadsWithoutReshuffling() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var directory = await SchoolApiTests.SavesDirectoryAsync(client); + + try + { + await InstallRunnableAsync(client, directory, "legacy-no-native", includeNative: false); + + 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); + Assert.Equal(ExpectedNames(), people.Select(row => row.FullName).OrderBy(name => name, StringComparer.Ordinal).ToArray()); + Assert.Contains(people, person => person.Roles.Contains("student")); + Assert.Equal(new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc), school.GameTime); + } + finally + { + DeleteSchoolFiles(directory, 1); + using var cleanup = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + cleanup.EnsureSuccessStatusCode(); + } + } + + private static async Task InstallRunnableAsync( + HttpClient client, + string directory, + string folder, + bool includeNative) + { + InstallFolder(directory, folder); + StampRunnable(Path.Combine(directory, "1.json"), includeNative); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + } + + private static void InstallFolder(string directory, string folder) + { + var source = Path.Combine(AppContext.BaseDirectory, "golden", folder); + foreach (var file in Directory.EnumerateFiles(source)) + { + File.Copy(file, Path.Combine(directory, Path.GetFileName(file)), overwrite: true); + } + } + + private static void StampRunnable(string path, bool includeNative) + { + var node = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException($"Save {path} parsed to nothing."); + node["format"] = 3; + node["countryId"] = "Russia"; + node["climatePresetId"] = "TemperateContinental"; + node["owner"] = SchoolApiTests.TestUserName; + node.AsObject().Remove("nameSetId"); + if (includeNative) + { + node["nativeLanguage"] = "RussianLanguage"; + } + else + { + node.AsObject().Remove("nativeLanguage"); + } + + File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + + private static void DeleteSchoolFiles(string directory, int id) + { + foreach (var name in new[] { $"{id}.json", $"{id}.people.json", $"{id}.timetable.json" }) + { + var path = Path.Combine(directory, name); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + private static async Task> PeopleAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?pageSize=100", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + return page.People + .OrderBy(row => row.Id, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ExpectedNames() + { + var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "golden", "current", "1.people.json")); + using var document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("people") + .EnumerateArray() + .Select(person => + { + var name = person.GetProperty("name"); + var surname = name.GetProperty("surname").GetString() ?? ""; + var given = name.GetProperty("given").GetString() ?? ""; + var patronymic = name.GetProperty("patronymic").GetString() ?? ""; + return string.Join(' ', new[] { surname, given, patronymic }.Where(part => part.Length > 0)); + }) + .OrderBy(full => full, StringComparer.Ordinal) + .ToArray(); + } + + private sealed record PeoplePage(IReadOnlyList People); + + private sealed record PersonRow(string Id, string FullName, IReadOnlyList Roles); +} diff --git a/tests/HSchool.AppHost.Tests/golden/README.md b/tests/HSchool.AppHost.Tests/golden/README.md index d5cfa96..ab12676 100644 --- a/tests/HSchool.AppHost.Tests/golden/README.md +++ b/tests/HSchool.AppHost.Tests/golden/README.md @@ -1,11 +1,13 @@ # Golden saves -`current/` is a school file of today's format. `legacy-no-native/` is the same school without -`nativeLanguage`, so a missing field still has to mean "first language of the name set" rather -than a new roll. +`current/` and `legacy-no-native/` are historic format-2 school files (no `countryId`). They +must not start as-is: later slices made `CanStart` require today's format and a country. +`legacy-no-native/` is the same school without `nativeLanguage`, so a missing field still has +to mean "first language of the name set" rather than a new roll — the host test stamps format, +country and owner onto a copy rather than rewriting this folder. -These files live forever: a new save field must load `current/` and must not reshuffle -`legacy-no-native/`. A real format change adds a new folder; it does not rewrite the old one. +These files live forever: a real format change does not rewrite them. Load tests that need a +running school copy the folder and stamp current fields in the test. Phase 31 rewrote `current/1.people.json` because people are born dressed (`items` on each person, plus `Hauling`). `legacy-no-native/` is left as it was: no inventory, so load still @@ -14,6 +16,9 @@ has to dress them and must not reshuffle names. Phase 32 rewrote `current/1.people.json` again: `Warmth` plus `HeatLoving` / `ColdLoving` in the trait pool. `legacy-no-native/` stays historic. +`current/1.people.json` still names the abstract `Underwear` parent (later split into +Briefs/Boxers/Panties). Stamping today's format onto that file does not start the school. + Regenerate after an intentional roster change (same commit): ```