diff --git a/AGENTS.md b/AGENTS.md index 8f01f8f..8104e82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 2a82174..1eb1cdd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/design/projects.md b/docs/design/projects.md index fc27e2f..fd44a59 100644 --- a/docs/design/projects.md +++ b/docs/design/projects.md @@ -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 с картой, рестарт, сейв на диске. diff --git a/docs/phases/15-timetable-planner.md b/docs/phases/15-timetable-planner.md index 6379864..0cf95c4 100644 --- a/docs/phases/15-timetable-planner.md +++ b/docs/phases/15-timetable-planner.md @@ -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] Те же входы — та же таблица ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 9cfbf6d..f32aef9 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -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) | ⬜ | Скобки в дереве, сетка класса, расписание учителя | diff --git a/h-school.sln b/h-school.sln index e077706..50249d9 100644 --- a/h-school.sln +++ b/h-school.sln @@ -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} diff --git a/src/HSchool.Schedule/HSchool.Schedule.csproj b/src/HSchool.Schedule/HSchool.Schedule.csproj new file mode 100644 index 0000000..7775b52 --- /dev/null +++ b/src/HSchool.Schedule/HSchool.Schedule.csproj @@ -0,0 +1,15 @@ + + + + HSchool.Schedule + + + + + + + + + + + diff --git a/src/HSchool.Schedule/Timetable.cs b/src/HSchool.Schedule/Timetable.cs new file mode 100644 index 0000000..141a6e0 --- /dev/null +++ b/src/HSchool.Schedule/Timetable.cs @@ -0,0 +1,34 @@ +namespace HSchool.Schedule; + +/// A homeroom class the planner needs. Not a roster record — that lives in People. +public sealed record PlannerClass( + string Id, + int Year, + string Letter, + string HomeroomId, + int PupilCount); + +/// Staff who can take lessons. Subjects are SubjectDef names already assigned. +public sealed record PlannerTeacher( + string Id, + IReadOnlyList Subjects); + +/// +/// One placed lesson. is 0 = Monday. is the 1-based +/// lesson number from the day frame. Locked lessons survive a rebuild. +/// +public sealed record LessonPlacement( + string ClassId, + string Subject, + string TeacherId, + string RoomId, + int Day, + int Period, + bool Locked = false); + +/// Curriculum hours that could not be placed: no teacher, no room, or no free slot. +public sealed record UncoveredDemand(string ClassId, string Subject, int Hours); + +public sealed record Timetable( + IReadOnlyList Lessons, + IReadOnlyList Uncovered); diff --git a/src/HSchool.Schedule/TimetablePlanner.cs b/src/HSchool.Schedule/TimetablePlanner.cs new file mode 100644 index 0000000..7ea664f --- /dev/null +++ b/src/HSchool.Schedule/TimetablePlanner.cs @@ -0,0 +1,329 @@ +using HSchool.Content; + +namespace HSchool.Schedule; + +/// +/// 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. +/// +public static class TimetablePlanner +{ + public static Timetable Build( + DefCatalog catalog, + MapLayout map, + IReadOnlyList classes, + IReadOnlyList teachers, + IReadOnlyList? 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(); + + 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 RemainingHours( + DefCatalog catalog, + IReadOnlyList classes, + IReadOnlyList placed) + { + var remaining = new List(); + 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 rooms, + PlannerClass schoolClass, + IReadOnlyList teachers, + Occupancy occupancy, + int weekDays, + int lessonCount, + string subjectName, + int hours, + List 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 rooms, + int weekDays, + int lessonCount, + List 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 rooms, + IReadOnlyDictionary classes, + IReadOnlyList 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 TeachersFor( + IReadOnlyList teachers, + string subject, + IReadOnlyList 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 RoomsFor( + DefCatalog catalog, + IReadOnlyDictionary 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); + } + + /// + /// A room with no seated pupil slots (the gym) still hosts a class. A lab with twelve + /// computers does not host sixteen pupils. + /// + private static bool ClassFits(DefCatalog catalog, RoomNode room, int pupilCount) + { + var seats = RoomOccupancy.PupilSlots(catalog, room); + return seats == 0 || pupilCount <= seats; + } + + private static IEnumerable Ordered(IEnumerable 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)); + } + } +} diff --git a/tests/HSchool.Schedule.Tests/Fixtures.cs b/tests/HSchool.Schedule.Tests/Fixtures.cs new file mode 100644 index 0000000..fd397f0 --- /dev/null +++ b/tests/HSchool.Schedule.Tests/Fixtures.cs @@ -0,0 +1,112 @@ +using HSchool.Content; + +namespace HSchool.Schedule.Tests; + +internal static class PackDocuments +{ + public static IReadOnlyList FromDirectory(string packId, string packRoot) + { + var documents = new List(); + 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(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; + } +} diff --git a/tests/HSchool.Schedule.Tests/HSchool.Schedule.Tests.csproj b/tests/HSchool.Schedule.Tests/HSchool.Schedule.Tests.csproj new file mode 100644 index 0000000..fd0ad00 --- /dev/null +++ b/tests/HSchool.Schedule.Tests/HSchool.Schedule.Tests.csproj @@ -0,0 +1,31 @@ + + + + HSchool.Schedule.Tests + true + Exe + + + + + + + + + + + + + + + + + + + + vanilla\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + + diff --git a/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs new file mode 100644 index 0000000..4b079bb --- /dev/null +++ b/tests/HSchool.Schedule.Tests/TimetablePlannerTests.cs @@ -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}"))); +}