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:
@@ -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