- Increased the `MonthlyPayrollCap` from 10,000 to 40,000, allowing for greater flexibility in hiring and subject assignments. - Revised payroll calculation logic to ensure that staff members are compensated based on their actual weekly hours, with a minimum payment reflecting one full rate. - Updated documentation to clarify the new payroll structure and its implications for hiring and subject assignments. - Enhanced tests to validate the new payroll cap and ensure proper functionality in staffing scenarios, including the handling of uncovered subjects. - Improved localization strings to reflect changes in staffing and payroll terminology.
200 lines
8.5 KiB
C#
200 lines
8.5 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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}")));
|
|
}
|