From 121c28e4a382bfaaa259d59b3937ffa83119e61d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 12:48:00 +0300 Subject: [PATCH] Put layer and wall-clock bans in one test project so Content and Protocol fail the same way as People. Co-authored-by: Cursor --- AGENTS.md | 2 + README.md | 9 +- docs/phases/10-craft/56-architecture-tests.md | 24 ++-- h-school.sln | 15 ++ tests/HSchool.Ai.Tests/WalkingTests.cs | 21 --- .../HSchool.Architecture.Tests.csproj | 28 ++++ .../LayerBoundaryTests.cs | 131 ++++++++++++++++++ .../RosterGeneratorTests.cs | 21 --- .../TimetablePlannerTests.cs | 21 --- .../HSchool.Simulation.Tests/PresenceTests.cs | 8 -- 10 files changed, 194 insertions(+), 86 deletions(-) create mode 100644 tests/HSchool.Architecture.Tests/HSchool.Architecture.Tests.csproj create mode 100644 tests/HSchool.Architecture.Tests/LayerBoundaryTests.cs diff --git a/AGENTS.md b/AGENTS.md index 8187ba0..c1117cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,8 @@ only resolved a status line. Solution-wide is CI. same route and commute; the suite does not boot a host, and it does not reference Arch. - Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`. Feed the loader documents, not disk paths. +- Layer bans (Arch, ASP.NET, sockets, wall-clock) belong in `tests/HSchool.Architecture.Tests`. + They do not boot a host. - Protocol changes need a round-trip test **and** a byte-layout assertion on both sides. - Server wiring, endpoints and the WebSocket belong in `tests/HSchool.AppHost.Tests`. That suite shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting per test costs diff --git a/README.md b/README.md index 2277a51..cc46fbe 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ in-game calendars, a TypeScript client that renders the menus, and .NET Aspire t for local runs and integration tests. What exists today is the shell around a game: a main menu of schools, a creation form, and a -school screen with a running game clock. The school itself is still empty. +school screen with a running game clock. People already walk, dress, take lessons and talk. ## Stack @@ -65,7 +65,8 @@ Do **not** add port 5180 or the Aspire dashboard to `tailscale serve`. ticking live. Deleting one asks for confirmation first. The footer switches the UI between Russian and English; the choice is remembered in the browser. - **Create a school** — type a name or roll a random one, pick a start date (3 April 2012, 06:00 - by default). At six schools the create button is disabled and says why. + by default). When the player has filled their school slots the create button is disabled and + says why. - **Inside a school** — the date, time and weekday of the game calendar, play/pause and the ×½ ×1 ×2 ×5 ×10 speed buttons, plus a way back to the menu. @@ -94,6 +95,8 @@ which matches the server's launch profile. dotnet test ``` +- `tests/HSchool.Architecture.Tests` — layer bans (Arch, ASP.NET, sockets, wall-clock) for + Protocol through Simulation. No host. - `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts. - `tests/HSchool.Content.Tests` — JSONC catalog, inheritance, patches, map connectivity. No host. - `tests/HSchool.Simulation.Tests` — the game clock, school load/create, and the school registry, no host involved. @@ -133,7 +136,7 @@ Simulation tunables live under the `Simulation` section of | Key | Default | Meaning | | --- | --- | --- | | `TickRate` | 20 | fixed simulation steps per second | -| `MaxSchools` | 6 | how many schools may exist at once | +| `MaxSchools` | 2 | how many schools each player may own | | `GameMinutesPerRealSecond` | 1 | game minutes per real second at ×1 | | `DefaultStartDate` | `2012-03-31T06:00:00` | prefilled start of a new school | | `SavesDirectory` | `saves` | per-school JSON files | diff --git a/docs/phases/10-craft/56-architecture-tests.md b/docs/phases/10-craft/56-architecture-tests.md index 0495552..358a169 100644 --- a/docs/phases/10-craft/56-architecture-tests.md +++ b/docs/phases/10-craft/56-architecture-tests.md @@ -11,24 +11,24 @@ ## Задачи -- [ ] Проект `tests/HSchool.Architecture.Tests` в solution, `IsTestProject`, без AppHost и без +- [x] Проект `tests/HSchool.Architecture.Tests` в solution, `IsTestProject`, без AppHost и без нового NuGet. Ссылки на Protocol, Content, People, Schedule, Ai, Simulation -- [ ] Тесты ссылок: каждая библиотека из таблицы в [`craft.md`](../../design/10-craft/craft.md) — этап A -- [ ] Тесты исходников: в `src/HSchool.{Protocol,Content,People,Schedule,Ai,Simulation}` нет +- [x] Тесты ссылок: каждая библиотека из таблицы в [`craft.md`](../../design/10-craft/craft.md) — этап A +- [x] Тесты исходников: в `src/HSchool.{Protocol,Content,People,Schedule,Ai,Simulation}` нет `DateTime.Now` / `DateTime.UtcNow` -- [ ] Удалить копипасты `Assembly_DoesNotReference*` и `Sources_DoNotUseWallClock` из People, +- [x] Удалить копипасты `Assembly_DoesNotReference*` и `Sources_DoNotUseWallClock` из People, Ai, Schedule, Simulation (остальные факты тех файлов не трогать) -- [ ] Testing policy в `AGENTS.md`: слои — `HSchool.Architecture.Tests`, не хост -- [ ] README: школа не «ещё пустая»; потолок create — слоты игрока, не «шесть» +- [x] Testing policy в `AGENTS.md`: слои — `HSchool.Architecture.Tests`, не хост +- [x] README: школа не «ещё пустая»; потолок create — слоты игрока, не «шесть» ## Тесты, без которых фаза не закрыта -- [ ] Protocol не ссылается на Arch, ASP.NET, Sockets, Content, Simulation -- [ ] Content не ссылается на Arch, ASP.NET, Sockets, Protocol, Simulation -- [ ] People, Schedule, Ai не ссылаются на Arch, ASP.NET, Sockets -- [ ] Simulation не ссылается на ASP.NET и Sockets (Arch — можно) -- [ ] Исходники шести библиотек выше не содержат `DateTime.Now` / `DateTime.UtcNow` -- [ ] В People/Ai/Schedule/Simulation.Tests больше нет этих двух фактов +- [x] Protocol не ссылается на Arch, ASP.NET, Sockets, Content, Simulation +- [x] Content не ссылается на Arch, ASP.NET, Sockets, Protocol, Simulation +- [x] People, Schedule, Ai не ссылаются на Arch, ASP.NET, Sockets +- [x] Simulation не ссылается на ASP.NET и Sockets (Arch — можно) +- [x] Исходники шести библиотек выше не содержат `DateTime.Now` / `DateTime.UtcNow` +- [x] В People/Ai/Schedule/Simulation.Tests больше нет этих двух фактов ## Критерий готовности diff --git a/h-school.sln b/h-school.sln index 22ab466..0520235 100644 --- a/h-school.sln +++ b/h-school.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai.Tests", "tests\H EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Server.Tests", "tests\HSchool.Server.Tests\HSchool.Server.Tests.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Architecture.Tests", "tests\HSchool.Architecture.Tests\HSchool.Architecture.Tests.csproj", "{75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -255,6 +257,18 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Debug|x64.ActiveCfg = Debug|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Debug|x64.Build.0 = Debug|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Debug|x86.ActiveCfg = Debug|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Debug|x86.Build.0 = Debug|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Release|Any CPU.Build.0 = Release|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Release|x64.ActiveCfg = Release|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Release|x64.Build.0 = Release|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Release|x86.ActiveCfg = Release|Any CPU + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -277,6 +291,7 @@ Global {C21FB23F-F131-4651-8236-4F3E076B40BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C355307C-D7D3-48C7-9615-CB5FBCF83D49} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {75B7B736-9775-4F3E-B39B-29A3FAF1CCBB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829} diff --git a/tests/HSchool.Ai.Tests/WalkingTests.cs b/tests/HSchool.Ai.Tests/WalkingTests.cs index 92447e8..d0fdd2d 100644 --- a/tests/HSchool.Ai.Tests/WalkingTests.cs +++ b/tests/HSchool.Ai.Tests/WalkingTests.cs @@ -188,27 +188,6 @@ public class WalkingTests } } - [Fact] - public void Assembly_DoesNotReferenceArchAspNetOrSockets() - { - var names = typeof(WalkGraph).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!); - Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public void Sources_DoNotUseWallClock() - { - var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.Ai"); - foreach (var path in Directory.EnumerateFiles(root, "*.cs")) - { - var text = File.ReadAllText(path); - Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal); - Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal); - } - } - private static (DayPlan Clear, DayPlan Weather, int Extra) PlansWithExtra(Func extraOf) { var (catalog, map) = Fixtures.Vanilla(); diff --git a/tests/HSchool.Architecture.Tests/HSchool.Architecture.Tests.csproj b/tests/HSchool.Architecture.Tests/HSchool.Architecture.Tests.csproj new file mode 100644 index 0000000..d54f4c5 --- /dev/null +++ b/tests/HSchool.Architecture.Tests/HSchool.Architecture.Tests.csproj @@ -0,0 +1,28 @@ + + + + HSchool.Architecture.Tests + true + Exe + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/HSchool.Architecture.Tests/LayerBoundaryTests.cs b/tests/HSchool.Architecture.Tests/LayerBoundaryTests.cs new file mode 100644 index 0000000..4bb4e6d --- /dev/null +++ b/tests/HSchool.Architecture.Tests/LayerBoundaryTests.cs @@ -0,0 +1,131 @@ +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Protocol; +using HSchool.Schedule; +using HSchool.Simulation; + +namespace HSchool.Architecture.Tests; + +public class LayerBoundaryTests +{ + private static readonly string[] LibraryFolders = + [ + "HSchool.Protocol", + "HSchool.Content", + "HSchool.People", + "HSchool.Schedule", + "HSchool.Ai", + "HSchool.Simulation" + ]; + + private static readonly string[] OldTestProjects = + [ + "HSchool.People.Tests", + "HSchool.Ai.Tests", + "HSchool.Schedule.Tests", + "HSchool.Simulation.Tests" + ]; + + [Fact] + public void Protocol_DoesNotReferenceArchAspNetSocketsContentOrSimulation() + { + var names = References(typeof(ProtocolCodec)); + AssertNoArchAspNetOrSockets(names); + Assert.DoesNotContain("HSchool.Content", names, StringComparer.Ordinal); + Assert.DoesNotContain("HSchool.People", names, StringComparer.Ordinal); + Assert.DoesNotContain("HSchool.Schedule", names, StringComparer.Ordinal); + Assert.DoesNotContain("HSchool.Ai", names, StringComparer.Ordinal); + Assert.DoesNotContain("HSchool.Simulation", names, StringComparer.Ordinal); + } + + [Fact] + public void Content_DoesNotReferenceArchAspNetSocketsProtocolOrSimulation() + { + var names = References(typeof(MapLayout)); + AssertNoArchAspNetOrSockets(names); + Assert.DoesNotContain("HSchool.Protocol", names, StringComparer.Ordinal); + Assert.DoesNotContain("HSchool.Simulation", names, StringComparer.Ordinal); + } + + [Theory] + [InlineData(typeof(RosterGenerator))] + [InlineData(typeof(TimetablePlanner))] + [InlineData(typeof(WalkGraph))] + public void PeopleScheduleAi_DoNotReferenceArchAspNetOrSockets(Type marker) + { + AssertNoArchAspNetOrSockets(References(marker)); + } + + [Fact] + public void Simulation_DoesNotReferenceAspNetOrSockets() + { + var names = References(typeof(School)); + Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SixLibrarySources_DoNotUseWallClock() + { + foreach (var folder in LibraryFolders) + { + var root = Path.Combine(RepoRoot(), "src", folder); + Assert.True(Directory.Exists(root), root); + foreach (var path in SourceFiles(root)) + { + var text = File.ReadAllText(path); + Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal); + Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal); + // Unused `using System.Net.Http` never becomes a referenced assembly. + Assert.DoesNotContain("System.Net.Http", text, StringComparison.Ordinal); + } + } + } + + [Fact] + public void PeopleAiScheduleSimulationTests_NoLongerCarryLayerBans() + { + foreach (var project in OldTestProjects) + { + var root = Path.Combine(RepoRoot(), "tests", project); + Assert.True(Directory.Exists(root), root); + foreach (var path in SourceFiles(root)) + { + var text = File.ReadAllText(path); + Assert.DoesNotContain("Assembly_DoesNotReference", text, StringComparison.Ordinal); + Assert.DoesNotContain("Sources_DoNotUseWallClock", text, StringComparison.Ordinal); + } + } + } + + private static IEnumerable SourceFiles(string root) => + Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(path => path.Split(Path.DirectorySeparatorChar).All(part => part is not ("bin" or "obj"))); + + private static string[] References(Type marker) => + [.. marker.Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!)]; + + private static void AssertNoArchAspNetOrSockets(IEnumerable names) + { + Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "h-school.sln"))) + { + dir = dir.Parent; + } + + if (dir is null) + { + throw new InvalidOperationException("Could not find h-school.sln from the test output directory."); + } + + return dir.FullName; + } +} diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs index 49eaf6e..f7de1c4 100644 --- a/tests/HSchool.People.Tests/RosterGeneratorTests.cs +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -143,27 +143,6 @@ public class RosterGeneratorTests Assert.False(RosterFit.Matches(loaded, SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(2)))); } - [Fact] - public void Assembly_DoesNotReferenceArchAspNetOrSockets() - { - var names = typeof(RosterGenerator).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!); - Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public void Sources_DoNotUseWallClock() - { - var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.People"); - foreach (var path in Directory.EnumerateFiles(root, "*.cs")) - { - var text = File.ReadAllText(path); - Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal); - Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal); - } - } - private static void AssertFilled(Roster roster, int classrooms) { Assert.Equal(classrooms, roster.Classes.Count); diff --git a/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs index e349b04..11f0479 100644 --- a/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs +++ b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs @@ -125,27 +125,6 @@ public class TimetablePlannerTests Assert.Equal(Snapshot(a), Snapshot(b)); } - [Fact] - public void Assembly_DoesNotReferenceArchAspNetOrSockets() - { - var names = typeof(TimetablePlanner).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!); - Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public void Sources_DoNotUseWallClock() - { - var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.Schedule"); - foreach (var path in Directory.EnumerateFiles(root, "*.cs")) - { - var text = File.ReadAllText(path); - Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal); - Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal); - } - } - /// /// The four bans alone allowed a legal but unusable week. Taking the first free slot day by /// day gave a full school 77 lessons on Monday and 16 on Friday, with three foreign-language diff --git a/tests/HSchool.Simulation.Tests/PresenceTests.cs b/tests/HSchool.Simulation.Tests/PresenceTests.cs index c076fee..09bc151 100644 --- a/tests/HSchool.Simulation.Tests/PresenceTests.cs +++ b/tests/HSchool.Simulation.Tests/PresenceTests.cs @@ -274,14 +274,6 @@ public class PresenceTests "stale day plans after an assign that did not change headcount left the campus empty"); } - [Fact] - public void Simulation_DoesNotReferenceSockets() - { - var names = typeof(School).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!); - Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); - } - private static School UnstaffedDuringLessonThree() { var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);