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)); } /// /// The four bans alone allowed a legal but unusable week. Taking the first free slot day by /// day gave a full school 77 lessons on Monday and 16 on Friday, with three foreign-language /// lessons back to back. These are the numbers a timetable is judged by, so they are asserted. /// [Fact] public void AWeek_IsSpreadAcrossDaysAndSubjectsDoNotRunInBlocks() { var catalog = Fixtures.Catalog(); var map = Fixtures.ClassroomsAndGym(11); var classes = Enumerable.Range(0, 11).Select(index => Fixtures.Class(index, index + 1, 16)).ToArray(); var teachers = catalog.Subjects.Values .Where(subject => !subject.Abstract) .SelectMany(subject => Enumerable .Range(0, 3) .Select(copy => Fixtures.Teacher($"t{subject.DefName}{copy}", subject.DefName))) .ToArray(); var table = TimetablePlanner.Build(catalog, map, classes, teachers); var perDay = Enumerable.Range(0, 5).Select(day => table.Lessons.Count(lesson => lesson.Day == day)).ToArray(); Assert.All(perDay, count => Assert.True(count > 0, $"a school day is empty: {string.Join("/", perDay)}")); Assert.True( perDay.Max() <= perDay.Min() * 1.5, $"the week is lopsided: {string.Join("/", perDay)}"); // Spread as evenly as the curriculum allows, not "never twice a day": primary school is // twenty hours over five days, so four a day is the best that exists. foreach (var group in table.Lessons.GroupBy(lesson => (lesson.ClassId, lesson.Subject))) { var bySubjectDay = Enumerable .Range(0, 5) .Select(day => group.Count(lesson => lesson.Day == day)) .ToArray(); // Two, not one: the day order is a preference, and a busy room or teacher can still // push an hour onto a day that already has the subject. Before the fix the same // measurement read 5/5/5/0/0. Assert.True( bySubjectDay.Max() - bySubjectDay.Min() <= 2, $"{group.Key.ClassId} has {group.Key.Subject} bunched into days: {string.Join("/", bySubjectDay)}"); } } 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}"))); }