From a7955a0dce1e3232b8541f861fe173c7fd705651 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 00:30:39 +0300 Subject: [PATCH] Refuse newer save formats and add a gated dump of a live school. Co-authored-by: Cursor --- AGENTS.md | 7 + docs/phases/27-serviceability.md | 22 +- docs/protocol.md | 51 +++++ src/HSchool.Server/Api/DevEndpoints.cs | 117 +++++++++++ src/HSchool.Server/Game/GameCommand.cs | 3 + src/HSchool.Server/Game/GameLoopService.cs | 13 ++ src/HSchool.Server/Game/SchoolDumpReader.cs | 45 ++++ src/HSchool.Server/Game/SchoolStore.cs | 22 ++ src/HSchool.Server/Game/SchoolWorker.cs | 10 + src/HSchool.Server/Game/WorkerCommand.cs | 2 + src/HSchool.Server/Program.cs | 2 + .../ServiceabilityTests.cs | 194 ++++++++++++++++++ .../HSchool.Simulation.Tests/AssemblyInfo.cs | 2 + 13 files changed, 479 insertions(+), 11 deletions(-) create mode 100644 src/HSchool.Server/Api/DevEndpoints.cs create mode 100644 src/HSchool.Server/Game/SchoolDumpReader.cs create mode 100644 tests/HSchool.AppHost.Tests/ServiceabilityTests.cs create mode 100644 tests/HSchool.Simulation.Tests/AssemblyInfo.cs diff --git a/AGENTS.md b/AGENTS.md index 5dcd2db..3755ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 time.** Presence equality tests must loop `FixedDeltaTime` or one game minute. `string.GetHashCode` 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. diff --git a/docs/phases/27-serviceability.md b/docs/phases/27-serviceability.md index 3133f9c..ebf7d14 100644 --- a/docs/phases/27-serviceability.md +++ b/docs/phases/27-serviceability.md @@ -14,25 +14,25 @@ ## Задачи -- [ ] Загрузка читает `Format`: новее своего — школу не стартовать, файл не трогать, в лог. Как с +- [x] Загрузка читает `Format`: новее своего — школу не стартовать, файл не трогать, в лог. Как с пропавшей папкой мода -- [ ] Формат старее своего — явный шов для апгрейда: сегодня пустой, но названный и с комментарием -- [ ] `GET /api/dev/schools/{id}/dump` — ростер, присутствие, расписание и нужды одним JSON. За тем +- [x] Формат старее своего — явный шов для апгрейда: сегодня пустой, но названный и с комментарием +- [x] `GET /api/dev/schools/{id}/dump` — ростер, присутствие, расписание и нужды одним JSON. За тем же переключателем, что и `reload-schools`, и никогда в проде по умолчанию -- [ ] Дамп читает опубликованные снимки и мейлбокс, а не лезет в `World` мимо работника -- [ ] Раздел «Things that will bite you» в `AGENTS.md` пополняется тем, что нашло ревью: сид школы +- [x] Дамп читает опубликованные снимки и мейлбокс, а не лезет в `World` мимо работника +- [x] Раздел «Things that will bite you» в `AGENTS.md` пополняется тем, что нашло ревью: сид школы был её id; тесты хоста делят один сервер и одну папку сейвов, поэтому начинают с очистки; на паузе кадры присутствия не приходят вовсе -- [ ] Классы тестов вокруг Arch перестают идти параллельно: `HSchool.Simulation.Tests` получает +- [x] Классы тестов вокруг Arch перестают идти параллельно: `HSchool.Simulation.Tests` получает запрет параллельности, потому что нативная память Arch этого не любит -- [ ] `docs/protocol.md` описывает дамп в разделе дев-ручек +- [x] `docs/protocol.md` описывает дамп в разделе дев-ручек ## Тесты, без которых фаза не закрыта -- [ ] Сейв с `format` больше текущего оставляет школу незапущенной и файл нетронутым -- [ ] Сейв текущего формата грузится как раньше -- [ ] Дамп отдаёт людей, их узлы и текущее расписание для живой школы -- [ ] Дамп неизвестной школы — `404` +- [x] Сейв с `format` больше текущего оставляет школу незапущенной и файл нетронутым +- [x] Сейв текущего формата грузится как раньше +- [x] Дамп отдаёт людей, их узлы и текущее расписание для живой школы +- [x] Дамп неизвестной школы — `404` ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 9603020..e2f70d2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -443,6 +443,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 `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 Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted diff --git a/src/HSchool.Server/Api/DevEndpoints.cs b/src/HSchool.Server/Api/DevEndpoints.cs new file mode 100644 index 0000000..dc95e8e --- /dev/null +++ b/src/HSchool.Server/Api/DevEndpoints.cs @@ -0,0 +1,117 @@ +using HSchool.People; +using HSchool.Schedule; +using HSchool.Server.Game; +using HSchool.Simulation; + +namespace HSchool.Server.Api; + +/// +/// Diagnostic HTTP that exists only when HSchool:AllowSaveReload is on. Never mapped in +/// production by default. +/// +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()); + 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 presence, + IReadOnlyDictionary> needs) + { + string? nodeId = null; + if (presence.TryGetValue(person.Id, out var row)) + { + nodeId = row.NodeId; + } + + IReadOnlyDictionary 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 NewCompletion() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static IResult Problem(int statusCode, string code, string detail) => + Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary { ["code"] = code }); +} + +internal sealed record SchoolDumpResponse( + int Id, + string Name, + DateTime GameTime, + bool Running, + IReadOnlyList People, + IReadOnlyList Now, + IReadOnlyList Lessons); + +internal sealed record SchoolDumpPersonResponse( + string Id, + string FullName, + string? NodeId, + IReadOnlyDictionary Needs); + +internal sealed record SchoolDumpLessonResponse( + string ClassId, + string Subject, + string TeacherId, + string RoomId, + int Day, + int Period); diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 9917e5e..6bea20d 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -48,6 +48,9 @@ internal abstract record GameCommand /// internal sealed record WorkerFailed(int SchoolId) : GameCommand; + /// Live presence and needs for a diagnostic dump. Completes on that school's worker. + internal sealed record DumpSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; + /// One person's card, including live needs. Completes on that school's worker thread. internal sealed record GetPerson( int SchoolId, diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index a5e41bb..0df9c1d 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -179,6 +179,10 @@ internal sealed class GameLoopService( HandleWorkerFailed(failed.SchoolId); break; + case GameCommand.DumpSchool dump: + HandleDump(dump); + break; + case GameCommand.GetPerson getPerson: HandleGetPerson(getPerson); 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) { if (!_workers.TryGetValue(command.SchoolId, out var worker) diff --git a/src/HSchool.Server/Game/SchoolDumpReader.cs b/src/HSchool.Server/Game/SchoolDumpReader.cs new file mode 100644 index 0000000..0367a6f --- /dev/null +++ b/src/HSchool.Server/Game/SchoolDumpReader.cs @@ -0,0 +1,45 @@ +using Arch.Core; +using HSchool.Schedule; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +/// +/// Live slice of one school for a diagnostic dump. Built on the worker thread; HTTP never sees +/// the World. +/// +internal sealed record SchoolLiveDump( + DateTime GameTime, + bool Running, + IReadOnlyList Presence, + IReadOnlyDictionary> Needs, + IReadOnlyList Now); + +/// Reads presence and needs from the live school. Call only on that school's worker. +internal static class SchoolDumpReader +{ + private static readonly QueryDescription IdentityAndNeeds = + new QueryDescription().WithAll(); + + public static SchoolLiveDump Read(School school, int weekDays) + { + var needs = new Dictionary>(StringComparer.Ordinal); + school.World.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds live) => + { + needs[identity.Id] = new Dictionary(live.Values, StringComparer.Ordinal); + }); + + IReadOnlyList 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); + } +} diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 7d8c54b..c807740 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -142,6 +142,21 @@ internal sealed class SchoolStore 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. if (!claimed.TryAdd(save.Id, path)) { @@ -178,6 +193,13 @@ internal sealed class SchoolStore return saves; } + /// + /// Named upgrade seam for saves older than . Empty on purpose: + /// missing fields already default and extra fields are ignored. Put a migration here when a + /// format bump actually needs one. + /// + internal static SchoolSave UpgradeOlderSave(SchoolSave save) => save; + public void Save(SchoolSave save) { WriteAtomic(SchoolPath(save.Id), save); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 972b94e..c14fc14 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -410,6 +410,10 @@ internal sealed class SchoolWorker ApplySkip(school); break; + case WorkerCommand.Dump dump: + dump.Result.TrySetResult(SchoolDumpReader.Read(school, _options.SchoolWeekDays)); + break; + case WorkerCommand.GetPerson getPerson: var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale); getPerson.Result.TrySetResult( @@ -468,6 +472,9 @@ internal sealed class SchoolWorker { switch (command) { + case WorkerCommand.Dump dump: + dump.Result.TrySetResult(null); + break; case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); break; @@ -493,6 +500,9 @@ internal sealed class SchoolWorker { switch (command) { + case WorkerCommand.Dump dump: + dump.Result.TrySetException(exception); + break; case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetException(exception); break; diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 17b2068..5b263ae 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -20,6 +20,8 @@ internal abstract record WorkerCommand internal sealed record SkipEmpty : WorkerCommand; + internal sealed record Dump(TaskCompletionSource Result) : WorkerCommand; + internal sealed record GetPerson( string PersonId, string Locale, diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index 4965956..9c992c3 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -73,6 +73,8 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false)) app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath })) .WithName("GetSavesDirectory"); + + app.MapDevEndpoints(); } // The realtime channel: one binary frame per protocol message, see docs/protocol.md. diff --git a/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs new file mode 100644 index 0000000..abc7633 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/ServiceabilityTests.cs @@ -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( + $"/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( + $"/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 SavesDirectoryAsync(HttpClient client) + { + var payload = await client.GetFromJsonAsync( + "/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 ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record SavesDirectoryResponse(string Path); + + private sealed record ProblemResponse(string? Code); + + private sealed record StaffingResponse(IReadOnlyList Applicants); + + private sealed record ApplicantResponse(string Id); + + private sealed record SchoolDumpResponse( + int Id, + string Name, + DateTime GameTime, + bool Running, + IReadOnlyList People, + IReadOnlyList Now, + IReadOnlyList Lessons); + + private sealed record DumpPersonResponse( + string Id, + string FullName, + string? NodeId, + IReadOnlyDictionary Needs); + + private sealed record DumpLessonResponse( + string ClassId, + string Subject, + string TeacherId, + string RoomId, + int Day, + int Period); +} diff --git a/tests/HSchool.Simulation.Tests/AssemblyInfo.cs b/tests/HSchool.Simulation.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..ff623c6 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/AssemblyInfo.cs @@ -0,0 +1,2 @@ +// Arch's native allocator is not safe across parallel test classes in this suite. +[assembly: CollectionBehavior(DisableTestParallelization = true)]