Restore golden-save host tests for slice 6 after later formats stopped loading historic files.
Phase 39 dropped GoldenSaveTests when format 2 saves became unstartable. Recheck restores the missing-field and seed-equals-id assertions against a stamped copy, and records the recheck. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,8 +17,10 @@
|
|||||||
ломается. Пак может поднять `lunchWeight` выше урока без форка.
|
ломается. Пак может поднять `lunchWeight` выше урока без форка.
|
||||||
|
|
||||||
**Золотые файлы ловят то, что раньше ловили глазами.** Отпечаток ростера — файл в фикстурах.
|
**Золотые файлы ловят то, что раньше ловили глазами.** Отпечаток ростера — файл в фикстурах.
|
||||||
Сейв текущего формата и сейв без `nativeLanguage` грузятся тестом хоста. Формат новее своего
|
Папки `current/` и `legacy-no-native/` живут как исторический формат 2 (без `countryId`): сам
|
||||||
школу не стартует и файл не трогает.
|
файл школу не стартует. Тест хоста копирует `legacy-no-native/`, ставит сегодняшний формат,
|
||||||
|
страну и владельца и проверяет, что сейв без `nativeLanguage` не перетасовывает набор.
|
||||||
|
Формат новее своего школу не стартует и файл не трогает.
|
||||||
|
|
||||||
## Удостоверение пака
|
## Удостоверение пака
|
||||||
|
|
||||||
@@ -112,12 +114,9 @@
|
|||||||
|
|
||||||
## Версия сейва читается
|
## Версия сейва читается
|
||||||
|
|
||||||
`Format` штампуется при записи, копируется при чтении и **никогда не сравнивается**. Файл из
|
`Format` сравнивается при загрузке (`SchoolStore.CanStart`). Правило то же, что с пропавшей
|
||||||
будущей версии загрузится молча и будет понят неправильно, а места для миграции не предусмотрено.
|
папкой мода: формат новее своего — школу не стартовать, файл не трогать, в лог. Формат старее
|
||||||
|
или без страны — тоже не стартовать; шов для апгрейда назван, миграций пока нет.
|
||||||
Правило то же, что с пропавшей папкой мода: формат новее своего — школу не стартовать, файл не
|
|
||||||
трогать, в лог. Формат старее — явный шов для апгрейда, сегодня пустой, но названный. Пустой
|
|
||||||
именованный шов дешевле, чем попытка вспомнить через полгода, куда его вставлять.
|
|
||||||
|
|
||||||
## Взгляд внутрь школы
|
## Взгляд внутрь школы
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,45 @@
|
|||||||
|
|
||||||
- `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`.
|
- `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`.
|
||||||
Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5.
|
Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 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.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
| Срез 3. Штат | `cf1403e` | [03-staffing](03-staffing/reviewed.md) |
|
| Срез 3. Штат | `cf1403e` | [03-staffing](03-staffing/reviewed.md) |
|
||||||
| Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) |
|
| Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) |
|
||||||
| Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/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. Этап A — данные и гардероб | `d5547b7` | [07-inventory](07-inventory/reviewed.md) |
|
||||||
| Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) |
|
| Срез 7. Этап B–C — климат, жизнь одежды и правила | `5dcdee6` | [07-inventory](07-inventory/reviewed.md) |
|
||||||
| Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) |
|
| Срез 8. Сессия и темп | `1c3ae9a` | [08-session](08-session/reviewed.md) |
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ describe('GameScreen speed buttons', () => {
|
|||||||
expect(labels).toEqual(['×½', '×1', '×2', '×5', '×10']);
|
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', () => {
|
it('hides management and clock controls for a guest school', () => {
|
||||||
const screen = new GameScreen({
|
const screen = new GameScreen({
|
||||||
onLeave: () => {},
|
onLeave: () => {},
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>CanStart</c>.
|
||||||
|
/// </summary>
|
||||||
|
[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<IReadOnlyList<PersonRow>> PeopleAsync(HttpClient client, int schoolId)
|
||||||
|
{
|
||||||
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/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<PersonRow> People);
|
||||||
|
|
||||||
|
private sealed record PersonRow(string Id, string FullName, IReadOnlyList<string> Roles);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
# Golden saves
|
# Golden saves
|
||||||
|
|
||||||
`current/` is a school file of today's format. `legacy-no-native/` is the same school without
|
`current/` and `legacy-no-native/` are historic format-2 school files (no `countryId`). They
|
||||||
`nativeLanguage`, so a missing field still has to mean "first language of the name set" rather
|
must not start as-is: later slices made `CanStart` require today's format and a country.
|
||||||
than a new roll.
|
`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
|
These files live forever: a real format change does not rewrite them. Load tests that need a
|
||||||
`legacy-no-native/`. A real format change adds a new folder; it does not rewrite the old one.
|
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
|
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
|
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
|
Phase 32 rewrote `current/1.people.json` again: `Warmth` plus `HeatLoving` / `ColdLoving` in
|
||||||
the trait pool. `legacy-no-native/` stays historic.
|
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):
|
Regenerate after an intentional roster change (same commit):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user