Add timetable planning and related tests to school simulation
- Introduced a new project for timetable planning, dependent on HSchool.Content. - Updated documentation to reflect the addition of timetable planning and its dependencies. - Added tests for timetable planning to ensure deterministic behavior with the same staff and map. - Revised architecture documentation to include the new HSchool.Schedule component and its interactions.
This commit is contained in:
@@ -12,6 +12,7 @@ way; this file is *how to work in them*.
|
|||||||
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
|
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
|
||||||
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
|
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
|
||||||
| people generation, families, roster records, yearly intake | `src/HSchool.People` |
|
| people generation, families, roster records, yearly intake | `src/HSchool.People` |
|
||||||
|
| timetable planning | `src/HSchool.Schedule` |
|
||||||
| people in a school's World, need decay | `src/HSchool.Simulation` |
|
| people in a school's World, need decay | `src/HSchool.Simulation` |
|
||||||
| the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
|
| the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
|
||||||
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
|
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
|
||||||
@@ -108,6 +109,8 @@ say so explicitly in the change description.
|
|||||||
Putting a roster into `World` and ticking needs belongs there too.
|
Putting a roster into `World` and ticking needs belongs there too.
|
||||||
- People generation belongs in `tests/HSchool.People.Tests`. Same seed, map and name set must
|
- People generation belongs in `tests/HSchool.People.Tests`. Same seed, map and name set must
|
||||||
produce the same roster; the suite does not boot a host.
|
produce the same roster; the suite does not boot a host.
|
||||||
|
- Timetable planning belongs in `tests/HSchool.Schedule.Tests`. Same staff, map and locks must
|
||||||
|
produce the same table; the suite does not boot a host.
|
||||||
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
|
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
|
||||||
Feed the loader documents, not disk paths.
|
Feed the loader documents, not disk paths.
|
||||||
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
|
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ there is no UI on the server.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
|
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the socket. |
|
||||||
| `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. |
|
| `src/HSchool.Content` | JSONC defs, inheritance, patches, locales, map instance and connectivity. No Arch, no ASP.NET. |
|
||||||
|
| `src/HSchool.People` | Roster generation from catalog, map and seed. No Arch, no ASP.NET. |
|
||||||
|
| `src/HSchool.Schedule` | Timetable from curriculum, assignments and map. No Arch, no ASP.NET. |
|
||||||
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. |
|
| `src/HSchool.Simulation` | Schools, the game clock, the Arch ECS world. Holds a frozen catalog and map; no HTTP. |
|
||||||
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `saves/`. |
|
| `src/HSchool.Server` | ASP.NET Core host: the menu API, the WebSocket endpoint, per-school workers, `mods/` and `saves/`. |
|
||||||
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
|
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
|
||||||
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
|
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
|
||||||
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
|
| `src/HSchool.Client` | Vite + TypeScript UI: main menu, creation form, the school screen. |
|
||||||
|
|
||||||
Dependency direction is one-way: `Protocol ← Server → Simulation → Content`. Nothing in
|
Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
|
||||||
`Simulation` or `Content` knows about HTTP, and nothing in `Protocol` knows about schools.
|
`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
|
||||||
|
`Protocol` knows about schools.
|
||||||
|
|
||||||
## Two channels, on purpose
|
## Two channels, on purpose
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
Protocol — ни на кого из игровых проектов (только байты)
|
Protocol — ни на кого из игровых проектов (только байты)
|
||||||
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
|
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
|
||||||
People → Content (defs, карта, склонения; без Arch и хоста)
|
People → Content (defs, карта, склонения; без Arch и хоста)
|
||||||
|
Schedule → Content (раскладка уроков; без Arch и хоста)
|
||||||
Simulation → People, Content (ростер в World; без HTTP)
|
Simulation → People, Content (ростер в World; без HTTP)
|
||||||
Server → Protocol, Simulation, People, Content
|
Server → Protocol, Simulation, People, Content
|
||||||
Client — своя сторона Protocol (TS) + HTTP
|
Client — своя сторона Protocol (TS) + HTTP
|
||||||
@@ -22,6 +23,7 @@ Client — своя сторона Protocol (TS) + HTTP
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **HSchool.Content** | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
|
| **HSchool.Content** | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
|
||||||
| **HSchool.People** | Генерация ростера: семьи, классы, тело/навыки/черты из каталога и сида | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
|
| **HSchool.People** | Генерация ростера: семьи, классы, тело/навыки/черты из каталога и сида | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
|
||||||
|
| **HSchool.Schedule** | Раскладка учебного плана в таблицу уроков вокруг закреплённых правок | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
|
||||||
| **HSchool.Simulation** | `School`, `GameClock`, Arch `World`, тик, применение раскладки к миру | Kestrel, сокеты, `Directory.Enumerate`, сейв-файлы |
|
| **HSchool.Simulation** | `School`, `GameClock`, Arch `World`, тик, применение раскладки к миру | Kestrel, сокеты, `Directory.Enumerate`, сейв-файлы |
|
||||||
| **HSchool.Server** | Хост, супервизор, работник-поток, `mods/` и `saves/` из конфига, HTTP/WS, DTO запросов | Правила «комната должна быть связана», схема Chair |
|
| **HSchool.Server** | Хост, супервизор, работник-поток, `mods/` и `saves/` из конфига, HTTP/WS, DTO запросов | Правила «комната должна быть связана», схема Chair |
|
||||||
| **HSchool.Protocol** | Кадры сокета, в том числе снимок карты и locale в Hello | Имена комнат, JSONC |
|
| **HSchool.Protocol** | Кадры сокета, в том числе снимок карты и locale в Hello | Имена комнат, JSONC |
|
||||||
@@ -49,6 +51,7 @@ Arch и фиксированный тик — про живую школу. Па
|
|||||||
|
|
||||||
- `tests/HSchool.Content.Tests` — фикстуры JSONC, коллизии имён, дырявый граф, двор обязателен.
|
- `tests/HSchool.Content.Tests` — фикстуры JSONC, коллизии имён, дырявый граф, двор обязателен.
|
||||||
- `tests/HSchool.People.Tests` — тот же сид даёт тот же ростер; места и должности заполнены.
|
- `tests/HSchool.People.Tests` — тот же сид даёт тот же ростер; места и должности заполнены.
|
||||||
|
- `tests/HSchool.Schedule.Tests` — тот же штат и карта дают ту же таблицу; четыре запрета.
|
||||||
- `HSchool.Simulation.Tests` — часы, тик, «школа с таким каталогом живёт».
|
- `HSchool.Simulation.Tests` — часы, тик, «школа с таким каталогом живёт».
|
||||||
- `HSchool.AppHost.Tests` — create с картой, рестарт, сейв на диске.
|
- `HSchool.AppHost.Tests` — create с картой, рестарт, сейв на диске.
|
||||||
|
|
||||||
|
|||||||
@@ -12,24 +12,24 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
|
- [x] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
|
||||||
ASP.NET; отдаёт простую таблицу уроков
|
ASP.NET; отдаёт простую таблицу уроков
|
||||||
- [ ] Раскладка часов учебного плана по слотам недели для каждого класса
|
- [x] Раскладка часов учебного плана по слотам недели для каждого класса
|
||||||
- [ ] Кабинет урока: требование предмета, иначе кабинет класса
|
- [x] Кабинет урока: требование предмета, иначе кабинет класса
|
||||||
- [ ] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
|
- [x] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
|
||||||
- [ ] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
|
- [x] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
|
||||||
- [ ] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
|
- [x] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
|
||||||
раскладывается вокруг них
|
раскладывается вокруг них
|
||||||
- [ ] Детерминированность: те же штат, назначения и карта дают ту же таблицу
|
- [x] Детерминированность: те же штат, назначения и карта дают ту же таблицу
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
|
- [x] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
|
||||||
- [ ] Учитель с двумя предметами не стоит в двух местах в одном слоте
|
- [x] Учитель с двумя предметами не стоит в двух местах в одном слоте
|
||||||
- [ ] Класс на 16 не попадает в кабинет на 12
|
- [x] Класс на 16 не попадает в кабинет на 12
|
||||||
- [ ] Предмет без учителя даёт дыру и запись в непокрытых
|
- [x] Предмет без учителя даёт дыру и запись в непокрытых
|
||||||
- [ ] Закреплённый урок остаётся на месте после найма ещё одного учителя
|
- [x] Закреплённый урок остаётся на месте после найма ещё одного учителя
|
||||||
- [ ] Те же входы — та же таблица
|
- [x] Те же входы — та же таблица
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,6 @@
|
|||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы |
|
| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы |
|
||||||
| [15. Планировщик](15-timetable-planner.md) | ⬜ | Раскладка часов по слотам, четыре запрета |
|
| [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета |
|
||||||
| [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» |
|
| [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» |
|
||||||
| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
|
| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People", "src\HScho
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People.Tests", "tests\HSchool.People.Tests\HSchool.People.Tests.csproj", "{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People.Tests", "tests\HSchool.People.Tests\HSchool.People.Tests.csproj", "{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule", "src\HSchool.Schedule\HSchool.Schedule.csproj", "{734AC2F7-B243-4211-95CF-3E662D2E20C9}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Schedule.Tests", "tests\HSchool.Schedule.Tests\HSchool.Schedule.Tests.csproj", "{85FF85BD-F572-4F0A-A11E-FA855D480C1D}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -185,6 +189,30 @@ Global
|
|||||||
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x64.Build.0 = Release|Any CPU
|
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.ActiveCfg = Release|Any CPU
|
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.Build.0 = Release|Any CPU
|
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -202,6 +230,8 @@ Global
|
|||||||
{C59BD649-ED81-40EE-864D-9C7D5378B956} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{C59BD649-ED81-40EE-864D-9C7D5378B956} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{734AC2F7-B243-4211-95CF-3E662D2E20C9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{85FF85BD-F572-4F0A-A11E-FA855D480C1D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
|
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>HSchool.Schedule</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="HSchool.Schedule.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace HSchool.Schedule;
|
||||||
|
|
||||||
|
/// <summary>A homeroom class the planner needs. Not a roster record — that lives in People.</summary>
|
||||||
|
public sealed record PlannerClass(
|
||||||
|
string Id,
|
||||||
|
int Year,
|
||||||
|
string Letter,
|
||||||
|
string HomeroomId,
|
||||||
|
int PupilCount);
|
||||||
|
|
||||||
|
/// <summary>Staff who can take lessons. Subjects are SubjectDef names already assigned.</summary>
|
||||||
|
public sealed record PlannerTeacher(
|
||||||
|
string Id,
|
||||||
|
IReadOnlyList<string> Subjects);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One placed lesson. <see cref="Day"/> is 0 = Monday. <see cref="Period"/> is the 1-based
|
||||||
|
/// lesson number from the day frame. Locked lessons survive a rebuild.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LessonPlacement(
|
||||||
|
string ClassId,
|
||||||
|
string Subject,
|
||||||
|
string TeacherId,
|
||||||
|
string RoomId,
|
||||||
|
int Day,
|
||||||
|
int Period,
|
||||||
|
bool Locked = false);
|
||||||
|
|
||||||
|
/// <summary>Curriculum hours that could not be placed: no teacher, no room, or no free slot.</summary>
|
||||||
|
public sealed record UncoveredDemand(string ClassId, string Subject, int Hours);
|
||||||
|
|
||||||
|
public sealed record Timetable(
|
||||||
|
IReadOnlyList<LessonPlacement> Lessons,
|
||||||
|
IReadOnlyList<UncoveredDemand> Uncovered);
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Schedule;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lays the curriculum onto the week. Deterministic: same catalog, map, staff, locks and week
|
||||||
|
/// length always yield the same table. No clock, no world, no host.
|
||||||
|
/// </summary>
|
||||||
|
public static class TimetablePlanner
|
||||||
|
{
|
||||||
|
public static Timetable Build(
|
||||||
|
DefCatalog catalog,
|
||||||
|
MapLayout map,
|
||||||
|
IReadOnlyList<PlannerClass> classes,
|
||||||
|
IReadOnlyList<PlannerTeacher> teachers,
|
||||||
|
IReadOnlyList<LessonPlacement>? locked = null,
|
||||||
|
int weekDays = 5)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
ArgumentNullException.ThrowIfNull(map);
|
||||||
|
ArgumentNullException.ThrowIfNull(classes);
|
||||||
|
ArgumentNullException.ThrowIfNull(teachers);
|
||||||
|
if (weekDays is < 5 or > 7)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 5–7 days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = catalog.DayFrame;
|
||||||
|
var lessonCount = frame?.LessonCount ?? 0;
|
||||||
|
var rooms = map.Rooms.ToDictionary(room => room.Id, StringComparer.Ordinal);
|
||||||
|
var classById = classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
||||||
|
var occupancy = new Occupancy();
|
||||||
|
var placed = new List<LessonPlacement>();
|
||||||
|
|
||||||
|
foreach (var lesson in Ordered(locked ?? []))
|
||||||
|
{
|
||||||
|
if (TryKeepLock(catalog, rooms, classById, teachers, occupancy, weekDays, lessonCount, lesson))
|
||||||
|
{
|
||||||
|
occupancy.Add(lesson);
|
||||||
|
placed.Add(lesson with { Locked = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = RemainingHours(catalog, classes, placed);
|
||||||
|
foreach (var demand in remaining)
|
||||||
|
{
|
||||||
|
var leftover = PlaceDemand(
|
||||||
|
catalog,
|
||||||
|
rooms,
|
||||||
|
classById[demand.ClassId],
|
||||||
|
teachers,
|
||||||
|
occupancy,
|
||||||
|
weekDays,
|
||||||
|
lessonCount,
|
||||||
|
demand.Subject,
|
||||||
|
demand.Hours,
|
||||||
|
placed);
|
||||||
|
|
||||||
|
if (leftover > 0)
|
||||||
|
{
|
||||||
|
demand.Hours = leftover;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
demand.Hours = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var uncovered = remaining
|
||||||
|
.Where(row => row.Hours > 0)
|
||||||
|
.Select(row => new UncoveredDemand(row.ClassId, row.Subject, row.Hours))
|
||||||
|
.OrderBy(row => row.ClassId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(row => row.Subject, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var lessons = placed
|
||||||
|
.OrderBy(lesson => lesson.Day)
|
||||||
|
.ThenBy(lesson => lesson.Period)
|
||||||
|
.ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new Timetable(lessons, uncovered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Remaining
|
||||||
|
{
|
||||||
|
public required string ClassId { get; init; }
|
||||||
|
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
|
public int Hours { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Remaining> RemainingHours(
|
||||||
|
DefCatalog catalog,
|
||||||
|
IReadOnlyList<PlannerClass> classes,
|
||||||
|
IReadOnlyList<LessonPlacement> placed)
|
||||||
|
{
|
||||||
|
var remaining = new List<Remaining>();
|
||||||
|
foreach (var schoolClass in classes.OrderBy(item => item.Year).ThenBy(item => item.Letter, StringComparer.Ordinal).ThenBy(item => item.Id, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
foreach (var subject in catalog.Subjects.Values
|
||||||
|
.Where(def => !def.Abstract && def.HoursPerWeek > 0 && schoolClass.Year >= def.Grades.Min && schoolClass.Year <= def.Grades.Max)
|
||||||
|
.OrderBy(def => def.DefName, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var already = placed.Count(lesson =>
|
||||||
|
lesson.ClassId == schoolClass.Id && lesson.Subject == subject.DefName);
|
||||||
|
var hours = subject.HoursPerWeek - already;
|
||||||
|
if (hours > 0)
|
||||||
|
{
|
||||||
|
remaining.Add(new Remaining
|
||||||
|
{
|
||||||
|
ClassId = schoolClass.Id,
|
||||||
|
Subject = subject.DefName,
|
||||||
|
Hours = hours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int PlaceDemand(
|
||||||
|
DefCatalog catalog,
|
||||||
|
IReadOnlyDictionary<string, RoomNode> rooms,
|
||||||
|
PlannerClass schoolClass,
|
||||||
|
IReadOnlyList<PlannerTeacher> teachers,
|
||||||
|
Occupancy occupancy,
|
||||||
|
int weekDays,
|
||||||
|
int lessonCount,
|
||||||
|
string subjectName,
|
||||||
|
int hours,
|
||||||
|
List<LessonPlacement> placed)
|
||||||
|
{
|
||||||
|
if (!catalog.Subjects.TryGetValue(subjectName, out var subject) || hours <= 0)
|
||||||
|
{
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = RoomsFor(catalog, rooms, schoolClass, subject);
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftover = hours;
|
||||||
|
foreach (var teacher in TeachersFor(teachers, subjectName, placed))
|
||||||
|
{
|
||||||
|
while (leftover > 0 && TryPlaceOne(occupancy, schoolClass.Id, subjectName, teacher.Id, candidates, weekDays, lessonCount, placed))
|
||||||
|
{
|
||||||
|
leftover--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftover == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftover;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPlaceOne(
|
||||||
|
Occupancy occupancy,
|
||||||
|
string classId,
|
||||||
|
string subject,
|
||||||
|
string teacherId,
|
||||||
|
IReadOnlyList<RoomNode> rooms,
|
||||||
|
int weekDays,
|
||||||
|
int lessonCount,
|
||||||
|
List<LessonPlacement> placed)
|
||||||
|
{
|
||||||
|
for (var day = 0; day < weekDays; day++)
|
||||||
|
{
|
||||||
|
for (var period = 1; period <= lessonCount; period++)
|
||||||
|
{
|
||||||
|
foreach (var room in rooms)
|
||||||
|
{
|
||||||
|
if (!occupancy.IsFree(classId, teacherId, room.Id, day, period))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lesson = new LessonPlacement(classId, subject, teacherId, room.Id, day, period);
|
||||||
|
occupancy.Add(lesson);
|
||||||
|
placed.Add(lesson);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryKeepLock(
|
||||||
|
DefCatalog catalog,
|
||||||
|
IReadOnlyDictionary<string, RoomNode> rooms,
|
||||||
|
IReadOnlyDictionary<string, PlannerClass> classes,
|
||||||
|
IReadOnlyList<PlannerTeacher> teachers,
|
||||||
|
Occupancy occupancy,
|
||||||
|
int weekDays,
|
||||||
|
int lessonCount,
|
||||||
|
LessonPlacement lesson)
|
||||||
|
{
|
||||||
|
if (lesson.Day < 0 || lesson.Day >= weekDays || lesson.Period < 1 || lesson.Period > lessonCount)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classes.TryGetValue(lesson.ClassId, out var schoolClass))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
|
||||||
|
|| subject.Abstract
|
||||||
|
|| schoolClass.Year < subject.Grades.Min
|
||||||
|
|| schoolClass.Year > subject.Grades.Max)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teachers.Any(teacher => teacher.Id == lesson.TeacherId && HasSubject(teacher, lesson.Subject)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rooms.TryGetValue(lesson.RoomId, out var room) || !RoomMatches(catalog, room, schoolClass, subject))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return occupancy.IsFree(lesson.ClassId, lesson.TeacherId, lesson.RoomId, lesson.Day, lesson.Period);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PlannerTeacher> TeachersFor(
|
||||||
|
IReadOnlyList<PlannerTeacher> teachers,
|
||||||
|
string subject,
|
||||||
|
IReadOnlyList<LessonPlacement> placed)
|
||||||
|
{
|
||||||
|
return teachers
|
||||||
|
.Where(teacher => HasSubject(teacher, subject))
|
||||||
|
.OrderBy(teacher => placed.Count(lesson => lesson.TeacherId == teacher.Id))
|
||||||
|
.ThenBy(teacher => teacher.Id, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasSubject(PlannerTeacher teacher, string subject) =>
|
||||||
|
teacher.Subjects.Contains(subject, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static IReadOnlyList<RoomNode> RoomsFor(
|
||||||
|
DefCatalog catalog,
|
||||||
|
IReadOnlyDictionary<string, RoomNode> rooms,
|
||||||
|
PlannerClass schoolClass,
|
||||||
|
SubjectDef subject)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(subject.Room))
|
||||||
|
{
|
||||||
|
return rooms.Values
|
||||||
|
.Where(room => RoomMatches(catalog, room, schoolClass, subject))
|
||||||
|
.OrderBy(room => room.Id, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rooms.TryGetValue(schoolClass.HomeroomId, out var homeroom)
|
||||||
|
&& RoomMatches(catalog, homeroom, schoolClass, subject))
|
||||||
|
{
|
||||||
|
return [homeroom];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RoomMatches(DefCatalog catalog, RoomNode room, PlannerClass schoolClass, SubjectDef subject)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(subject.Room))
|
||||||
|
{
|
||||||
|
if (!room.Def.Equals(subject.Room, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!room.Id.Equals(schoolClass.HomeroomId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClassFits(catalog, room, schoolClass.PupilCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A room with no seated pupil slots (the gym) still hosts a class. A lab with twelve
|
||||||
|
/// computers does not host sixteen pupils.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ClassFits(DefCatalog catalog, RoomNode room, int pupilCount)
|
||||||
|
{
|
||||||
|
var seats = RoomOccupancy.PupilSlots(catalog, room);
|
||||||
|
return seats == 0 || pupilCount <= seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<LessonPlacement> Ordered(IEnumerable<LessonPlacement> lessons) =>
|
||||||
|
lessons
|
||||||
|
.OrderBy(lesson => lesson.Day)
|
||||||
|
.ThenBy(lesson => lesson.Period)
|
||||||
|
.ThenBy(lesson => lesson.ClassId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(lesson => lesson.Subject, StringComparer.Ordinal)
|
||||||
|
.ThenBy(lesson => lesson.RoomId, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private sealed class Occupancy
|
||||||
|
{
|
||||||
|
private readonly HashSet<(string Id, int Day, int Period)> _classes = [];
|
||||||
|
private readonly HashSet<(string Id, int Day, int Period)> _teachers = [];
|
||||||
|
private readonly HashSet<(string Id, int Day, int Period)> _rooms = [];
|
||||||
|
|
||||||
|
public bool IsFree(string classId, string teacherId, string roomId, int day, int period) =>
|
||||||
|
!_classes.Contains((classId, day, period))
|
||||||
|
&& !_teachers.Contains((teacherId, day, period))
|
||||||
|
&& !_rooms.Contains((roomId, day, period));
|
||||||
|
|
||||||
|
public void Add(LessonPlacement lesson)
|
||||||
|
{
|
||||||
|
_classes.Add((lesson.ClassId, lesson.Day, lesson.Period));
|
||||||
|
_teachers.Add((lesson.TeacherId, lesson.Day, lesson.Period));
|
||||||
|
_rooms.Add((lesson.RoomId, lesson.Day, lesson.Period));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Schedule.Tests;
|
||||||
|
|
||||||
|
internal static class PackDocuments
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
|
||||||
|
{
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
|
||||||
|
documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class Fixtures
|
||||||
|
{
|
||||||
|
public static DefCatalog Catalog()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
return new CatalogLoader().Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MapLayout ClassroomsAndGym(int classes, int desks = 16)
|
||||||
|
{
|
||||||
|
var rooms = new List<RoomNode>(classes + 1);
|
||||||
|
for (var i = 0; i < classes; i++)
|
||||||
|
{
|
||||||
|
rooms.Add(Classroom(i, desks));
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.Add(new RoomNode
|
||||||
|
{
|
||||||
|
Id = "gym-hall",
|
||||||
|
Def = "GymHall",
|
||||||
|
Building = "gym",
|
||||||
|
Floor = "gym-floor",
|
||||||
|
Slots = [new SlotFill { Key = "benches", Thing = "Bench", Count = 4 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MapLayout { Rooms = rooms };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MapLayout ClassroomAndLab(int desks = 16, int computers = 12)
|
||||||
|
{
|
||||||
|
return new MapLayout
|
||||||
|
{
|
||||||
|
Rooms =
|
||||||
|
[
|
||||||
|
Classroom(0, desks),
|
||||||
|
new RoomNode
|
||||||
|
{
|
||||||
|
Id = "computer-lab",
|
||||||
|
Def = "ComputerLab",
|
||||||
|
Building = "main",
|
||||||
|
Floor = "floor-1",
|
||||||
|
Slots =
|
||||||
|
[
|
||||||
|
new SlotFill { Key = "teacherDesk", Thing = "Desk", Count = 1 },
|
||||||
|
new SlotFill { Key = "teacherChair", Thing = "Chair", Count = 1 },
|
||||||
|
new SlotFill { Key = "computers", Thing = "Computer", Count = computers },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PlannerClass Class(int index, int year, int pupils, string letter = "A") =>
|
||||||
|
new($"y{year}{letter}", year, letter, $"classroom-{index:00}", pupils);
|
||||||
|
|
||||||
|
public static PlannerTeacher Teacher(string id, params string[] subjects) =>
|
||||||
|
new(id, subjects);
|
||||||
|
|
||||||
|
public static RoomNode Classroom(int index, int desks) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = $"classroom-{index:00}",
|
||||||
|
Def = "Classroom",
|
||||||
|
Building = "main",
|
||||||
|
Floor = "floor-1",
|
||||||
|
Label = $"{101 + index}",
|
||||||
|
Seats = desks,
|
||||||
|
};
|
||||||
|
|
||||||
|
public 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>HSchool.Schedule.Tests</RootNamespace>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Schedule\HSchool.Schedule.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
|
||||||
|
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Schedule.Tests;
|
||||||
|
|
||||||
|
public class TimetablePlannerTests
|
||||||
|
{
|
||||||
|
private readonly DefCatalog _catalog = Fixtures.Catalog();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OneGym_SpreadsPhysicalEducationAcrossSlots()
|
||||||
|
{
|
||||||
|
var classes = Enumerable.Range(1, 11).Select(year => Fixtures.Class(year - 1, year, pupils: 16)).ToArray();
|
||||||
|
var map = Fixtures.ClassroomsAndGym(11);
|
||||||
|
var teachers = new[] { Fixtures.Teacher("pe", "PhysicalEducation") };
|
||||||
|
|
||||||
|
var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
|
||||||
|
|
||||||
|
var pe = table.Lessons.Where(lesson => lesson.Subject == "PhysicalEducation").ToArray();
|
||||||
|
Assert.Equal(11 * 3, pe.Length);
|
||||||
|
Assert.All(pe, lesson => Assert.Equal("gym-hall", lesson.RoomId));
|
||||||
|
Assert.Equal(pe.Length, pe.Select(lesson => (lesson.Day, lesson.Period)).Distinct().Count());
|
||||||
|
Assert.All(pe, lesson => Assert.Equal("pe", lesson.TeacherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TeacherWithTwoSubjects_IsNeverInTwoRoomsAtOnce()
|
||||||
|
{
|
||||||
|
var classes = new[]
|
||||||
|
{
|
||||||
|
Fixtures.Class(0, year: 5, pupils: 16, letter: "A"),
|
||||||
|
Fixtures.Class(1, year: 5, pupils: 16, letter: "B"),
|
||||||
|
};
|
||||||
|
var map = Fixtures.ClassroomsAndGym(2);
|
||||||
|
var teachers = new[] { Fixtures.Teacher("t1", "Mathematics", "PhysicalEducation") };
|
||||||
|
|
||||||
|
var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
|
||||||
|
|
||||||
|
Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics");
|
||||||
|
Assert.Contains(table.Lessons, lesson => lesson.Subject == "PhysicalEducation");
|
||||||
|
Assert.Equal(
|
||||||
|
table.Lessons.Count,
|
||||||
|
table.Lessons.Select(lesson => (lesson.TeacherId, lesson.Day, lesson.Period)).Distinct().Count());
|
||||||
|
Assert.Equal(
|
||||||
|
table.Lessons.Count,
|
||||||
|
table.Lessons.Select(lesson => (lesson.ClassId, lesson.Day, lesson.Period)).Distinct().Count());
|
||||||
|
Assert.Equal(
|
||||||
|
table.Lessons.Count,
|
||||||
|
table.Lessons.Select(lesson => (lesson.RoomId, lesson.Day, lesson.Period)).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassOfSixteen_DoesNotEnterALabOfTwelve()
|
||||||
|
{
|
||||||
|
var classes = new[] { Fixtures.Class(0, year: 5, pupils: 16) };
|
||||||
|
var map = Fixtures.ClassroomAndLab(desks: 16, computers: 12);
|
||||||
|
var teachers = new[] { Fixtures.Teacher("inf", "Informatics") };
|
||||||
|
|
||||||
|
var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(table.Lessons, lesson => lesson.Subject == "Informatics");
|
||||||
|
Assert.Contains(table.Uncovered, row => row.ClassId == "y5A" && row.Subject == "Informatics" && row.Hours == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubjectWithoutATeacher_IsUncoveredAndLeavesAGap()
|
||||||
|
{
|
||||||
|
var classes = new[] { Fixtures.Class(0, year: 5, pupils: 16) };
|
||||||
|
var map = Fixtures.ClassroomsAndGym(1);
|
||||||
|
var teachers = new[] { Fixtures.Teacher("pe", "PhysicalEducation") };
|
||||||
|
|
||||||
|
var table = TimetablePlanner.Build(_catalog, map, classes, teachers);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(table.Lessons, lesson => lesson.Subject == "Mathematics");
|
||||||
|
Assert.Contains(table.Uncovered, row => row.ClassId == "y5A" && row.Subject == "Mathematics" && row.Hours == 5);
|
||||||
|
Assert.Equal(3, table.Lessons.Count(lesson => lesson.Subject == "PhysicalEducation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LockedLesson_SurvivesHiringAnotherTeacher()
|
||||||
|
{
|
||||||
|
var classes = new[] { Fixtures.Class(0, year: 5, pupils: 16) };
|
||||||
|
var map = Fixtures.ClassroomsAndGym(1);
|
||||||
|
var first = TimetablePlanner.Build(
|
||||||
|
_catalog,
|
||||||
|
map,
|
||||||
|
classes,
|
||||||
|
[Fixtures.Teacher("t1", "Mathematics")]);
|
||||||
|
|
||||||
|
var pinned = first.Lessons.First(lesson => lesson.Subject == "Mathematics");
|
||||||
|
var locked = pinned with { Locked = true };
|
||||||
|
|
||||||
|
var rebuilt = TimetablePlanner.Build(
|
||||||
|
_catalog,
|
||||||
|
map,
|
||||||
|
classes,
|
||||||
|
[Fixtures.Teacher("t1", "Mathematics"), Fixtures.Teacher("t2", "Mathematics")],
|
||||||
|
[locked]);
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
rebuilt.Lessons,
|
||||||
|
lesson =>
|
||||||
|
lesson.ClassId == locked.ClassId
|
||||||
|
&& lesson.Subject == locked.Subject
|
||||||
|
&& lesson.TeacherId == locked.TeacherId
|
||||||
|
&& lesson.RoomId == locked.RoomId
|
||||||
|
&& lesson.Day == locked.Day
|
||||||
|
&& lesson.Period == locked.Period
|
||||||
|
&& lesson.Locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameInputs_YieldTheSameTable()
|
||||||
|
{
|
||||||
|
var classes = Enumerable.Range(1, 11).Select(year => Fixtures.Class(year - 1, year, pupils: 16)).ToArray();
|
||||||
|
var map = Fixtures.ClassroomsAndGym(11);
|
||||||
|
var teachers = new[]
|
||||||
|
{
|
||||||
|
Fixtures.Teacher("pe", "PhysicalEducation"),
|
||||||
|
Fixtures.Teacher("math", "Mathematics"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var a = TimetablePlanner.Build(_catalog, map, classes, teachers);
|
||||||
|
var b = TimetablePlanner.Build(_catalog, map, classes, teachers);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Snapshot(Timetable table) =>
|
||||||
|
string.Join(
|
||||||
|
'\n',
|
||||||
|
table.Lessons.Select(lesson =>
|
||||||
|
$"{lesson.Day}/{lesson.Period} {lesson.ClassId} {lesson.Subject} {lesson.TeacherId} {lesson.RoomId} {(lesson.Locked ? "L" : "")}")
|
||||||
|
.Concat(table.Uncovered.Select(row => $"U {row.ClassId} {row.Subject} {row.Hours}")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user