Merge phase 27 save format and dump.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -170,3 +170,10 @@ say so explicitly in the change description.
|
|||||||
- **A large one-shot `Tick(week)` jumps the clock, then applies all those minutes at the new
|
- **A large one-shot `Tick(week)` jumps the clock, then applies all those minutes at the new
|
||||||
time.** Presence equality tests must loop `FixedDeltaTime` or one game minute. `string.GetHashCode`
|
time.** Presence equality tests must loop `FixedDeltaTime` or one game minute. `string.GetHashCode`
|
||||||
is randomized — commute slack goes through `Seed.Mix`, never that.
|
is randomized — commute slack goes through `Seed.Mix`, never that.
|
||||||
|
- **The school seed used to be its id.** A test that needs a specific family shape is green on
|
||||||
|
one machine and red on a clean clone. Assert a property, not the first pupil of school 1.
|
||||||
|
- **Host tests share one server and one save folder.** Start each test by clearing the list
|
||||||
|
through the API (`SchoolApiTests.ResetAsync`).
|
||||||
|
- **Presence frames do not arrive at all while a school is paused.** They are broadcast only
|
||||||
|
after a tick step, and a paused clock produces none. Opening still sends one snapshot; a test
|
||||||
|
that waits for presence on pause will hang.
|
||||||
|
|||||||
@@ -14,25 +14,25 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Загрузка читает `Format`: новее своего — школу не стартовать, файл не трогать, в лог. Как с
|
- [x] Загрузка читает `Format`: новее своего — школу не стартовать, файл не трогать, в лог. Как с
|
||||||
пропавшей папкой мода
|
пропавшей папкой мода
|
||||||
- [ ] Формат старее своего — явный шов для апгрейда: сегодня пустой, но названный и с комментарием
|
- [x] Формат старее своего — явный шов для апгрейда: сегодня пустой, но названный и с комментарием
|
||||||
- [ ] `GET /api/dev/schools/{id}/dump` — ростер, присутствие, расписание и нужды одним JSON. За тем
|
- [x] `GET /api/dev/schools/{id}/dump` — ростер, присутствие, расписание и нужды одним JSON. За тем
|
||||||
же переключателем, что и `reload-schools`, и никогда в проде по умолчанию
|
же переключателем, что и `reload-schools`, и никогда в проде по умолчанию
|
||||||
- [ ] Дамп читает опубликованные снимки и мейлбокс, а не лезет в `World` мимо работника
|
- [x] Дамп читает опубликованные снимки и мейлбокс, а не лезет в `World` мимо работника
|
||||||
- [ ] Раздел «Things that will bite you» в `AGENTS.md` пополняется тем, что нашло ревью: сид школы
|
- [x] Раздел «Things that will bite you» в `AGENTS.md` пополняется тем, что нашло ревью: сид школы
|
||||||
был её id; тесты хоста делят один сервер и одну папку сейвов, поэтому начинают с очистки;
|
был её id; тесты хоста делят один сервер и одну папку сейвов, поэтому начинают с очистки;
|
||||||
на паузе кадры присутствия не приходят вовсе
|
на паузе кадры присутствия не приходят вовсе
|
||||||
- [ ] Классы тестов вокруг Arch перестают идти параллельно: `HSchool.Simulation.Tests` получает
|
- [x] Классы тестов вокруг Arch перестают идти параллельно: `HSchool.Simulation.Tests` получает
|
||||||
запрет параллельности, потому что нативная память Arch этого не любит
|
запрет параллельности, потому что нативная память Arch этого не любит
|
||||||
- [ ] `docs/protocol.md` описывает дамп в разделе дев-ручек
|
- [x] `docs/protocol.md` описывает дамп в разделе дев-ручек
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Сейв с `format` больше текущего оставляет школу незапущенной и файл нетронутым
|
- [x] Сейв с `format` больше текущего оставляет школу незапущенной и файл нетронутым
|
||||||
- [ ] Сейв текущего формата грузится как раньше
|
- [x] Сейв текущего формата грузится как раньше
|
||||||
- [ ] Дамп отдаёт людей, их узлы и текущее расписание для живой школы
|
- [x] Дамп отдаёт людей, их узлы и текущее расписание для живой школы
|
||||||
- [ ] Дамп неизвестной школы — `404`
|
- [x] Дамп неизвестной школы — `404`
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -111,5 +111,5 @@
|
|||||||
| [24. Числа поведения](24-behavior-numbers.md) | 🔄 | Веса целей в `BehaviorDef`, а не в коде |
|
| [24. Числа поведения](24-behavior-numbers.md) | 🔄 | Веса целей в `BehaviorDef`, а не в коде |
|
||||||
| [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей |
|
| [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей |
|
||||||
| [26. Свой сид](26-school-seed.md) | 🔄 | Состав школы перестаёт зависеть от порядка создания |
|
| [26. Свой сид](26-school-seed.md) | 🔄 | Состав школы перестаёт зависеть от порядка создания |
|
||||||
| [27. Версия сейва и дамп](27-serviceability.md) | 🔄 | Формат читается, внутрь школы можно заглянуть |
|
| [27. Версия сейва и дамп](27-serviceability.md) | ✅ | Формат читается, внутрь школы можно заглянуть |
|
||||||
| [28. Тесты экранов](28-screen-tests.md) | ⬜ | DOM-окружение и логика клиента под тестом |
|
| [28. Тесты экранов](28-screen-tests.md) | ⬜ | DOM-окружение и логика клиента под тестом |
|
||||||
|
|||||||
@@ -454,6 +454,57 @@ rebuilds the rest around it. Same success payload as GET timetable.
|
|||||||
Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is
|
Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is
|
||||||
`404` `unknown-lesson`.
|
`404` `unknown-lesson`.
|
||||||
|
|
||||||
|
## Dev endpoints
|
||||||
|
|
||||||
|
These exist only when `HSchool:AllowSaveReload` is true (headless AppHost tests). They are never
|
||||||
|
mapped in production by default. Same switch as `POST /api/dev/reload-schools`.
|
||||||
|
|
||||||
|
### `GET /api/dev/schools/{id}/dump`
|
||||||
|
|
||||||
|
Roster, live presence, live needs and the timetable as one JSON. The roster and lesson table come
|
||||||
|
from the published snapshots; nodes and needs go through that school's mailbox — HTTP does not
|
||||||
|
read the `World`. Unknown `{id}` is `404` `unknown-school`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Гимназия №14",
|
||||||
|
"gameTime": "2012-04-03T10:20:00Z",
|
||||||
|
"running": true,
|
||||||
|
"people": [
|
||||||
|
{
|
||||||
|
"id": "f3.p1",
|
||||||
|
"fullName": "Иванова Ольга Михайловна",
|
||||||
|
"nodeId": "classroom-101",
|
||||||
|
"needs": { "Hunger": 0.92, "Toilet": 1, "Social": 0.8, "Sleep": 1 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"now": [
|
||||||
|
{
|
||||||
|
"classId": "c5A",
|
||||||
|
"subject": "Mathematics",
|
||||||
|
"teacherId": "f3.p1",
|
||||||
|
"roomId": "classroom-101",
|
||||||
|
"day": 1,
|
||||||
|
"period": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lessons": [
|
||||||
|
{
|
||||||
|
"classId": "c5A",
|
||||||
|
"subject": "Mathematics",
|
||||||
|
"teacherId": "f3.p1",
|
||||||
|
"roomId": "classroom-101",
|
||||||
|
"day": 1,
|
||||||
|
"period": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`nodeId` is null when the person is off campus. `now` is the lessons occurring at `gameTime`
|
||||||
|
(empty on a break, night, weekend or holiday). `lessons` is the published table.
|
||||||
|
|
||||||
## WebSocket message ids
|
## WebSocket message ids
|
||||||
|
|
||||||
Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
|
Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
using HSchool.Server.Game;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic HTTP that exists only when <c>HSchool:AllowSaveReload</c> is on. Never mapped in
|
||||||
|
/// production by default.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DevEndpoints
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
public static void MapDevEndpoints(this IEndpointRouteBuilder builder)
|
||||||
|
{
|
||||||
|
builder.MapGet("/api/dev/schools/{id:int}/dump", async (
|
||||||
|
int id,
|
||||||
|
GameLoopService loop,
|
||||||
|
GameCommandQueue commands,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var published = loop.FindPeople(id);
|
||||||
|
if (published is null)
|
||||||
|
{
|
||||||
|
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = new GameCommand.DumpSchool(id, NewCompletion<SchoolLiveDump?>());
|
||||||
|
commands.Enqueue(command);
|
||||||
|
var live = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
|
||||||
|
if (live is null)
|
||||||
|
{
|
||||||
|
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(MapDump(published, live));
|
||||||
|
})
|
||||||
|
.WithName("DumpSchool");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchoolDumpResponse MapDump(PublishedSchoolPeople published, SchoolLiveDump live)
|
||||||
|
{
|
||||||
|
var byId = live.Presence.ToDictionary(row => row.PersonId, StringComparer.Ordinal);
|
||||||
|
var people = published.Roster?.People
|
||||||
|
.OrderBy(person => person.Id, StringComparer.Ordinal)
|
||||||
|
.Select(person => Person(person, byId, live.Needs))
|
||||||
|
.ToArray() ?? [];
|
||||||
|
|
||||||
|
var lessons = (published.Timetable?.Lessons ?? [])
|
||||||
|
.Select(Lesson)
|
||||||
|
.ToArray();
|
||||||
|
var now = live.Now.Select(Lesson).ToArray();
|
||||||
|
|
||||||
|
return new SchoolDumpResponse(
|
||||||
|
published.School.Id,
|
||||||
|
published.School.Name,
|
||||||
|
live.GameTime,
|
||||||
|
live.Running,
|
||||||
|
people,
|
||||||
|
now,
|
||||||
|
lessons);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchoolDumpPersonResponse Person(
|
||||||
|
Person person,
|
||||||
|
IReadOnlyDictionary<string, PresenceSnapshot> presence,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyDictionary<string, float>> needs)
|
||||||
|
{
|
||||||
|
string? nodeId = null;
|
||||||
|
if (presence.TryGetValue(person.Id, out var row))
|
||||||
|
{
|
||||||
|
nodeId = row.NodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, float> values = person.Needs;
|
||||||
|
if (needs.TryGetValue(person.Id, out var live))
|
||||||
|
{
|
||||||
|
values = live;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SchoolDumpPersonResponse(person.Id, person.Name.Full, nodeId, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SchoolDumpLessonResponse Lesson(LessonPlacement lesson) =>
|
||||||
|
new(lesson.ClassId, lesson.Subject, lesson.TeacherId, lesson.RoomId, lesson.Day, lesson.Period);
|
||||||
|
|
||||||
|
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||||
|
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SchoolDumpResponse(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
DateTime GameTime,
|
||||||
|
bool Running,
|
||||||
|
IReadOnlyList<SchoolDumpPersonResponse> People,
|
||||||
|
IReadOnlyList<SchoolDumpLessonResponse> Now,
|
||||||
|
IReadOnlyList<SchoolDumpLessonResponse> Lessons);
|
||||||
|
|
||||||
|
internal sealed record SchoolDumpPersonResponse(
|
||||||
|
string Id,
|
||||||
|
string FullName,
|
||||||
|
string? NodeId,
|
||||||
|
IReadOnlyDictionary<string, float> Needs);
|
||||||
|
|
||||||
|
internal sealed record SchoolDumpLessonResponse(
|
||||||
|
string ClassId,
|
||||||
|
string Subject,
|
||||||
|
string TeacherId,
|
||||||
|
string RoomId,
|
||||||
|
int Day,
|
||||||
|
int Period);
|
||||||
@@ -48,6 +48,9 @@ internal abstract record GameCommand
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
|
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
|
||||||
|
|
||||||
|
/// <summary>Live presence and needs for a diagnostic dump. Completes on that school's worker.</summary>
|
||||||
|
internal sealed record DumpSchool(int SchoolId, TaskCompletionSource<SchoolLiveDump?> Result) : GameCommand;
|
||||||
|
|
||||||
/// <summary>One person's card, including live needs. Completes on that school's worker thread.</summary>
|
/// <summary>One person's card, including live needs. Completes on that school's worker thread.</summary>
|
||||||
internal sealed record GetPerson(
|
internal sealed record GetPerson(
|
||||||
int SchoolId,
|
int SchoolId,
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ internal sealed class GameLoopService(
|
|||||||
HandleWorkerFailed(failed.SchoolId);
|
HandleWorkerFailed(failed.SchoolId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case GameCommand.DumpSchool dump:
|
||||||
|
HandleDump(dump);
|
||||||
|
break;
|
||||||
|
|
||||||
case GameCommand.GetPerson getPerson:
|
case GameCommand.GetPerson getPerson:
|
||||||
HandleGetPerson(getPerson);
|
HandleGetPerson(getPerson);
|
||||||
break;
|
break;
|
||||||
@@ -220,6 +224,15 @@ internal sealed class GameLoopService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleDump(GameCommand.DumpSchool command)
|
||||||
|
{
|
||||||
|
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|
||||||
|
|| !worker.Post(new WorkerCommand.Dump(command.Result)))
|
||||||
|
{
|
||||||
|
command.Result.TrySetResult(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleGetPerson(GameCommand.GetPerson command)
|
private void HandleGetPerson(GameCommand.GetPerson command)
|
||||||
{
|
{
|
||||||
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live slice of one school for a diagnostic dump. Built on the worker thread; HTTP never sees
|
||||||
|
/// the <c>World</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record SchoolLiveDump(
|
||||||
|
DateTime GameTime,
|
||||||
|
bool Running,
|
||||||
|
IReadOnlyList<PresenceSnapshot> Presence,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyDictionary<string, float>> Needs,
|
||||||
|
IReadOnlyList<LessonPlacement> Now);
|
||||||
|
|
||||||
|
/// <summary>Reads presence and needs from the live school. Call only on that school's worker.</summary>
|
||||||
|
internal static class SchoolDumpReader
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription IdentityAndNeeds =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||||
|
|
||||||
|
public static SchoolLiveDump Read(School school, int weekDays)
|
||||||
|
{
|
||||||
|
var needs = new Dictionary<string, IReadOnlyDictionary<string, float>>(StringComparer.Ordinal);
|
||||||
|
school.World.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds live) =>
|
||||||
|
{
|
||||||
|
needs[identity.Id] = new Dictionary<string, float>(live.Values, StringComparer.Ordinal);
|
||||||
|
});
|
||||||
|
|
||||||
|
IReadOnlyList<LessonPlacement> now = [];
|
||||||
|
if (school.Timetable is not null && school.Catalog is not null)
|
||||||
|
{
|
||||||
|
now = TimetableClock.OccurringAt(school.Timetable, school.Catalog, school.Clock.Time, weekDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SchoolLiveDump(
|
||||||
|
school.Clock.Time,
|
||||||
|
school.Clock.IsRunning,
|
||||||
|
school.CapturePresence(),
|
||||||
|
needs,
|
||||||
|
now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,21 @@ internal sealed class SchoolStore
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (save.Format > CurrentFormat)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Save {Path} is format {Format}; this build reads format {Current}. Leaving the file in place.",
|
||||||
|
path,
|
||||||
|
save.Format,
|
||||||
|
CurrentFormat);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (save.Format < CurrentFormat)
|
||||||
|
{
|
||||||
|
save = UpgradeOlderSave(save);
|
||||||
|
}
|
||||||
|
|
||||||
// Claimed last, so a file rejected above does not reserve an id a good file needs.
|
// Claimed last, so a file rejected above does not reserve an id a good file needs.
|
||||||
if (!claimed.TryAdd(save.Id, path))
|
if (!claimed.TryAdd(save.Id, path))
|
||||||
{
|
{
|
||||||
@@ -178,6 +193,13 @@ internal sealed class SchoolStore
|
|||||||
return saves;
|
return saves;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named upgrade seam for saves older than <see cref="CurrentFormat"/>. Empty on purpose:
|
||||||
|
/// missing fields already default and extra fields are ignored. Put a migration here when a
|
||||||
|
/// format bump actually needs one.
|
||||||
|
/// </summary>
|
||||||
|
internal static SchoolSave UpgradeOlderSave(SchoolSave save) => save;
|
||||||
|
|
||||||
public void Save(SchoolSave save)
|
public void Save(SchoolSave save)
|
||||||
{
|
{
|
||||||
WriteAtomic(SchoolPath(save.Id), save);
|
WriteAtomic(SchoolPath(save.Id), save);
|
||||||
|
|||||||
@@ -411,6 +411,10 @@ internal sealed class SchoolWorker
|
|||||||
ApplySkip(school);
|
ApplySkip(school);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case WorkerCommand.Dump dump:
|
||||||
|
dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays));
|
||||||
|
break;
|
||||||
|
|
||||||
case WorkerCommand.GetPerson getPerson:
|
case WorkerCommand.GetPerson getPerson:
|
||||||
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
|
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
|
||||||
getPerson.Result.TrySetResult(
|
getPerson.Result.TrySetResult(
|
||||||
@@ -469,6 +473,9 @@ internal sealed class SchoolWorker
|
|||||||
{
|
{
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
|
case WorkerCommand.Dump dump:
|
||||||
|
dump.Result.TrySetResult(null);
|
||||||
|
break;
|
||||||
case WorkerCommand.GetPerson getPerson:
|
case WorkerCommand.GetPerson getPerson:
|
||||||
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
|
||||||
break;
|
break;
|
||||||
@@ -494,6 +501,9 @@ internal sealed class SchoolWorker
|
|||||||
{
|
{
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
|
case WorkerCommand.Dump dump:
|
||||||
|
dump.Result.TrySetException(exception);
|
||||||
|
break;
|
||||||
case WorkerCommand.GetPerson getPerson:
|
case WorkerCommand.GetPerson getPerson:
|
||||||
getPerson.Result.TrySetException(exception);
|
getPerson.Result.TrySetException(exception);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ internal abstract record WorkerCommand
|
|||||||
|
|
||||||
internal sealed record SkipEmpty : WorkerCommand;
|
internal sealed record SkipEmpty : WorkerCommand;
|
||||||
|
|
||||||
|
internal sealed record Dump(TaskCompletionSource<SchoolLiveDump?> Result) : WorkerCommand;
|
||||||
|
|
||||||
internal sealed record GetPerson(
|
internal sealed record GetPerson(
|
||||||
string PersonId,
|
string PersonId,
|
||||||
string Locale,
|
string Locale,
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
|
|||||||
|
|
||||||
app.MapGet("/api/dev/mods-directory", (ModContent mods) => Results.Json(new { path = mods.Root }))
|
app.MapGet("/api/dev/mods-directory", (ModContent mods) => Results.Json(new { path = mods.Root }))
|
||||||
.WithName("GetModsDirectory");
|
.WithName("GetModsDirectory");
|
||||||
|
|
||||||
|
app.MapDevEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
|
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
[Collection(AppHostCollection.Name)]
|
||||||
|
public class ServiceabilityTests(AppHostFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FutureFormatSave_LeavesTheSchoolUnstartedAndTheFileUntouched()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var directory = await SavesDirectoryAsync(client);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
InstallGolden(directory, "current");
|
||||||
|
var path = Path.Combine(directory, "1.json");
|
||||||
|
BumpFormat(path, 99);
|
||||||
|
var before = File.ReadAllBytes(path);
|
||||||
|
|
||||||
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
Assert.Empty((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
|
||||||
|
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 CurrentFormatSave_LoadsAsBefore()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var directory = await SavesDirectoryAsync(client);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
InstallGolden(directory, "current");
|
||||||
|
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
|
||||||
|
reload.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var school = Assert.Single((await SchoolApiTests.GetSchoolsAsync(client)).Schools);
|
||||||
|
Assert.Equal(1, school.Id);
|
||||||
|
Assert.Equal("Золотая", school.Name);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dump_ReturnsPeopleNodesAndTimetable()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Дамп", TuesdayMorning);
|
||||||
|
await HireMathAsync(client, school.Id);
|
||||||
|
|
||||||
|
var dump = await client.GetFromJsonAsync<SchoolDumpResponse>(
|
||||||
|
$"/api/dev/schools/{school.Id}/dump",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(dump);
|
||||||
|
Assert.Equal(school.Id, dump.Id);
|
||||||
|
Assert.NotEmpty(dump.People);
|
||||||
|
Assert.Contains(dump.People, person => person.NodeId is not null || person.Needs.Count > 0);
|
||||||
|
Assert.All(dump.People, person =>
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(person.Id));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(person.FullName));
|
||||||
|
Assert.NotEmpty(person.Needs);
|
||||||
|
});
|
||||||
|
Assert.Contains(dump.Lessons, lesson => lesson.Subject == "Mathematics");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dump_UnknownSchool_IsNotFound()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
|
||||||
|
using var response = await client.GetAsync("/api/dev/schools/999999/dump", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
Assert.Equal("unknown-school", await ProblemCodeAsync(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HireMathAsync(HttpClient client, int schoolId)
|
||||||
|
{
|
||||||
|
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
|
||||||
|
$"/api/schools/{schoolId}/staffing",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(staffing);
|
||||||
|
var applicant = Assert.Single(staffing.Applicants.Take(1));
|
||||||
|
|
||||||
|
using var hire = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{schoolId}/staff/hire",
|
||||||
|
new { personId = applicant.Id, position = "Teacher" },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
hire.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var assign = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(applicant.Id)}/subjects",
|
||||||
|
new { subject = "Mathematics" },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
assign.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> SavesDirectoryAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
var payload = await client.GetFromJsonAsync<SavesDirectoryResponse>(
|
||||||
|
"/api/dev/saves-directory",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(payload);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
|
||||||
|
return payload.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void InstallGolden(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 BumpFormat(string path, int format)
|
||||||
|
{
|
||||||
|
var node = JsonNode.Parse(File.ReadAllText(path))
|
||||||
|
?? throw new InvalidOperationException($"Save {path} parsed to nothing.");
|
||||||
|
node["format"] = format;
|
||||||
|
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<string?> ProblemCodeAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
||||||
|
return problem?.Code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SavesDirectoryResponse(string Path);
|
||||||
|
|
||||||
|
private sealed record ProblemResponse(string? Code);
|
||||||
|
|
||||||
|
private sealed record StaffingResponse(IReadOnlyList<ApplicantResponse> Applicants);
|
||||||
|
|
||||||
|
private sealed record ApplicantResponse(string Id);
|
||||||
|
|
||||||
|
private sealed record SchoolDumpResponse(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
DateTime GameTime,
|
||||||
|
bool Running,
|
||||||
|
IReadOnlyList<DumpPersonResponse> People,
|
||||||
|
IReadOnlyList<DumpLessonResponse> Now,
|
||||||
|
IReadOnlyList<DumpLessonResponse> Lessons);
|
||||||
|
|
||||||
|
private sealed record DumpPersonResponse(
|
||||||
|
string Id,
|
||||||
|
string FullName,
|
||||||
|
string? NodeId,
|
||||||
|
IReadOnlyDictionary<string, float> Needs);
|
||||||
|
|
||||||
|
private sealed record DumpLessonResponse(
|
||||||
|
string ClassId,
|
||||||
|
string Subject,
|
||||||
|
string TeacherId,
|
||||||
|
string RoomId,
|
||||||
|
int Day,
|
||||||
|
int Period);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Arch's native allocator is not safe across parallel test classes in this suite.
|
||||||
|
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||||
Reference in New Issue
Block a user