Add timetable planning and related tests to school simulation
ci / server (push) Failing after 3m33s
ci / client (push) Successful in 13s

- 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:
Leonid Pershin
2026-08-19 00:55:02 +03:00
parent 2ebc783585
commit 2011d12b1d
12 changed files with 731 additions and 16 deletions
+3
View File
@@ -12,6 +12,7 @@ way; this file is *how to work in them*.
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
| 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` |
| 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` |
@@ -108,6 +109,8 @@ say so explicitly in the change description.
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
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`.
Feed the loader documents, not disk paths.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
+5 -2
View File
@@ -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.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.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.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. |
Dependency direction is one-way: `Protocol ← Server → Simulation → Content`. Nothing in
`Simulation` or `Content` knows about HTTP, and nothing in `Protocol` knows about schools.
Dependency direction is one-way: `Protocol ← Server → Simulation → People → Content`, and
`Schedule → Content`. Nothing in `Simulation` or `Content` knows about HTTP, and nothing in
`Protocol` knows about schools.
## Two channels, on purpose
+3
View File
@@ -9,6 +9,7 @@
Protocol — ни на кого из игровых проектов (только байты)
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
People → Content (defs, карта, склонения; без Arch и хоста)
Schedule → Content (раскладка уроков; без Arch и хоста)
Simulation → People, Content (ростер в World; без HTTP)
Server → Protocol, Simulation, People, Content
Client — своя сторона Protocol (TS) + HTTP
@@ -22,6 +23,7 @@ Client — своя сторона Protocol (TS) + HTTP
| --- | --- | --- |
| **HSchool.Content** | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
| **HSchool.People** | Генерация ростера: семьи, классы, тело/навыки/черты из каталога и сида | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Schedule** | Раскладка учебного плана в таблицу уроков вокруг закреплённых правок | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Simulation** | `School`, `GameClock`, Arch `World`, тик, применение раскладки к миру | Kestrel, сокеты, `Directory.Enumerate`, сейв-файлы |
| **HSchool.Server** | Хост, супервизор, работник-поток, `mods/` и `saves/` из конфига, HTTP/WS, DTO запросов | Правила «комната должна быть связана», схема Chair |
| **HSchool.Protocol** | Кадры сокета, в том числе снимок карты и locale в Hello | Имена комнат, JSONC |
@@ -49,6 +51,7 @@ Arch и фиксированный тик — про живую школу. Па
- `tests/HSchool.Content.Tests` — фикстуры JSONC, коллизии имён, дырявый граф, двор обязателен.
- `tests/HSchool.People.Tests` — тот же сид даёт тот же ростер; места и должности заполнены.
- `tests/HSchool.Schedule.Tests` — тот же штат и карта дают ту же таблицу; четыре запрета.
- `HSchool.Simulation.Tests` — часы, тик, «школа с таким каталогом живёт».
- `HSchool.AppHost.Tests` — create с картой, рестарт, сейв на диске.
+13 -13
View File
@@ -12,24 +12,24 @@
## Задачи
- [ ] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
- [x] Новый проект рядом с `HSchool.People`: зависит только на `HSchool.Content`, без Arch и
ASP.NET; отдаёт простую таблицу уроков
- [ ] Раскладка часов учебного плана по слотам недели для каждого класса
- [ ] Кабинет урока: требование предмета, иначе кабинет класса
- [ ] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
- [ ] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
- [ ] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
- [x] Раскладка часов учебного плана по слотам недели для каждого класса
- [x] Кабинет урока: требование предмета, иначе кабинет класса
- [x] Четыре запрета соблюдаются: учитель, класс и кабинет не заняты дважды, класс влезает в кабинет
- [x] Непокрытые часы не ставятся, а возвращаются отдельным списком — их покажет «Управление»
- [x] Закреплённые игроком уроки принимаются на вход и переживают перестроение; остальное
раскладывается вокруг них
- [ ] Детерминированность: те же штат, назначения и карта дают ту же таблицу
- [x] Детерминированность: те же штат, назначения и карта дают ту же таблицу
## Тесты, без которых фаза не закрыта
- [ ] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
- [ ] Учитель с двумя предметами не стоит в двух местах в одном слоте
- [ ] Класс на 16 не попадает в кабинет на 12
- [ ] Предмет без учителя даёт дыру и запись в непокрытых
- [ ] Закреплённый урок остаётся на месте после найма ещё одного учителя
- [ ] Те же входы — та же таблица
- [x] Один спортзал на одиннадцать классов — физкультура разъезжается по слотам, а не дублируется
- [x] Учитель с двумя предметами не стоит в двух местах в одном слоте
- [x] Класс на 16 не попадает в кабинет на 12
- [x] Предмет без учителя даёт дыру и запись в непокрытых
- [x] Закреплённый урок остаётся на месте после найма ещё одного учителя
- [x] Те же входы — та же таблица
## Критерий готовности
+1 -1
View File
@@ -65,6 +65,6 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы |
| [15. Планировщик](15-timetable-planner.md) | | Раскладка часов по слотам, четыре запрета |
| [15. Планировщик](15-timetable-planner.md) | | Раскладка часов по слотам, четыре запрета |
| [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» |
| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
+30
View File
@@ -31,6 +31,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People", "src\HScho
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People.Tests", "tests\HSchool.People.Tests\HSchool.People.Tests.csproj", "{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -202,6 +230,8 @@ Global
{C59BD649-ED81-40EE-864D-9C7D5378B956} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{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
GlobalSection(ExtensibilityGlobals) = postSolution
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>
+34
View File
@@ -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);
+329
View File
@@ -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 57 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));
}
}
}
+112
View File
@@ -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}")));
}