diff --git a/docs/phases/13-grades/74-attendance.md b/docs/phases/13-grades/74-attendance.md index d1dcc0a..22640c5 100644 --- a/docs/phases/13-grades/74-attendance.md +++ b/docs/phases/13-grades/74-attendance.md @@ -11,20 +11,20 @@ ## Задачи -- [ ] На слот урока класса: присутствовал (узел = кабинет, не идёт), опоздал (порог минут в +- [x] На слот урока класса: присутствовал (узел = кабинет, не идёт), опоздал (порог минут в данных), отсутствовал -- [ ] Писать в сейв рядом с журналом оценок -- [ ] Источник — присутствие и расписание, не клиент -- [ ] Заготовка причины отсутствия (прогул по умолчанию); «болезнь» заполнит срез 14 -- [ ] Опоздание не отменяет оценку целиком, если правило этапа A иначе — согласовать с 73 в данных +- [x] Писать в сейв рядом с журналом оценок +- [x] Источник — присутствие и расписание, не клиент +- [x] Заготовка причины отсутствия (прогул по умолчанию); «болезнь» заполнит срез 14 +- [x] Опоздание не отменяет оценку целиком, если правило этапа A иначе — согласовать с 73 в данных ## Тесты, без которых фаза не закрыта -- [ ] Ученик в кабинете к началу → присутствовал -- [ ] Пришёл после порога → опоздал -- [ ] Вне школы / другой узел весь слот → отсутствовал -- [ ] Тот же день и сид → та же явка -- [ ] Сейв переживает reload +- [x] Ученик в кабинете к началу → присутствовал +- [x] Пришёл после порога → опоздал +- [x] Вне школы / другой узел весь слот → отсутствовал +- [x] Тот же день и сид → та же явка +- [x] Сейв переживает reload ## Критерий готовности diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 4fb0aed..ffdcb28 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -722,6 +722,17 @@ internal static class PeopleDefValidator previous = floor; } } + + if (behavior.AttendanceMax < 0) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' attendanceMax cannot be negative."); + } + + if (behavior.LessonLateMinutes < 0f) + { + throw new ContentLoadException( + $"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative."); + } } private static void ValidateTopic(TopicDef topic, DefCatalog catalog) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index b65078a..29fa813 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -619,6 +619,23 @@ public sealed class BehaviorDef : Def /// public int? LessonMarkWhenNoTeacher { get; init; } = 2; + /// + /// How many recent attendance rows a person keeps. Oldest drop first. Zero disables attendance. + /// + public int AttendanceMax { get; init; } = 40; + + /// + /// Minutes after the bell: first standing in the room within this window is present; + /// later is late. Inclusive — arrival at exactly this many minutes still counts as present. + /// + public float LessonLateMinutes { get; init; } = 5f; + + /// + /// When true (vanilla), a late arrival still receives a lesson mark if they are standing in + /// the room — stage A quality path. False skips the mark for late pupils. + /// + public bool LessonMarkWhenLate { get; init; } = true; + public static IReadOnlyList DefaultLessonMarkThresholds { get; } = [ 0.85f, diff --git a/src/HSchool.People/Attendance.cs b/src/HSchool.People/Attendance.cs new file mode 100644 index 0000000..3b3075a --- /dev/null +++ b/src/HSchool.People/Attendance.cs @@ -0,0 +1,154 @@ +using System.Text.Json.Serialization; +using HSchool.Content; + +namespace HSchool.People; + +/// Stable status ids written into . +public static class AttendanceStatuses +{ + public const string Present = "present"; + + public const string Late = "late"; + + public const string Absent = "absent"; +} + +/// +/// Why a pupil was absent. Vanilla writes ; slice 14 fills illness. +/// +public static class AbsenceReasons +{ + public const string Truancy = "truancy"; +} + +/// One lesson-slot attendance row. Sparse list on the person — not a year journal. +public sealed class AttendanceRecord +{ + public required string Subject { get; init; } + + /// id. + public required string Status { get; init; } + + public required DateTime Time { get; init; } + + public required int Period { get; init; } + + /// Set when is absent. Omitted for present/late. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AbsenceReason { get; init; } +} + +/// Appends or upgrades recent attendance and drops the oldest when over the BehaviorDef ceiling. +public static class AttendanceMemory +{ + public static AttendanceRecord? Find(Person person, DateTime day, int period) + { + ArgumentNullException.ThrowIfNull(person); + var list = person.Attendance; + if (list is null || list.Count == 0) + { + return null; + } + + var date = DateTime.SpecifyKind(day, DateTimeKind.Utc).Date; + for (var i = list.Count - 1; i >= 0; i--) + { + var row = list[i]; + if (row.Period == period && row.Time.Date == date) + { + return row; + } + } + + return null; + } + + /// + /// Writes a new row, or upgrades an existing absent row to present/late. Present/late once + /// claimed for the slot stay put — first arrival wins. + /// + public static bool Record( + Person person, + string subject, + string status, + DateTime time, + int period, + BehaviorDef rules, + string? absenceReason = null) + { + ArgumentNullException.ThrowIfNull(person); + ArgumentNullException.ThrowIfNull(rules); + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + ArgumentException.ThrowIfNullOrWhiteSpace(status); + + if (rules.AttendanceMax <= 0 || !IsKnownStatus(status)) + { + return false; + } + + var list = person.Attendance; + if (list is null) + { + list = []; + person.Attendance = list; + } + + var date = DateTime.SpecifyKind(time, DateTimeKind.Utc).Date; + for (var i = list.Count - 1; i >= 0; i--) + { + var existing = list[i]; + if (existing.Period != period || existing.Time.Date != date) + { + continue; + } + + if (existing.Status is AttendanceStatuses.Present or AttendanceStatuses.Late) + { + return false; + } + + if (existing.Status == AttendanceStatuses.Absent + && status is AttendanceStatuses.Present or AttendanceStatuses.Late) + { + list[i] = Build(subject, status, time, period, absenceReason: null); + return true; + } + + return false; + } + + list.Add(Build( + subject, + status, + time, + period, + status == AttendanceStatuses.Absent + ? (string.IsNullOrWhiteSpace(absenceReason) ? AbsenceReasons.Truancy : absenceReason) + : null)); + + while (list.Count > rules.AttendanceMax) + { + list.RemoveAt(0); + } + + return true; + } + + private static AttendanceRecord Build( + string subject, + string status, + DateTime time, + int period, + string? absenceReason) => + new() + { + Subject = subject, + Status = status, + Time = DateTime.SpecifyKind(time, DateTimeKind.Utc), + Period = period, + AbsenceReason = absenceReason, + }; + + private static bool IsKnownStatus(string status) => + status is AttendanceStatuses.Present or AttendanceStatuses.Late or AttendanceStatuses.Absent; +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index ce79764..c2abedb 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -81,6 +81,12 @@ public sealed record Person /// public List? LessonMarks { get; set; } + /// + /// Recent lesson attendance (present / late / absent). Null when empty so people.json stays compact. + /// Ceiling and late threshold live on BehaviorDef. + /// + public List? Attendance { get; set; } + /// /// Active medical conditions. Null when healthy (empty) so people.json stays compact. /// Needs are not replaced by this list. diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index d03d435..4874084 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -110,4 +110,10 @@ "lessonMarkThresholds": [0.85, 0.6, 0.35], // Teacher not in the room: write 2 (null would skip the mark entirely). "lessonMarkWhenNoTeacher": 2, + // Attendance from presence (slice 13 phase 74). One row per slot. + "attendanceMax": 40, + // First standing in the room within this many minutes after the bell → present; later → late. + "lessonLateMinutes": 5, + // Late still gets a mark when standing (stage A). False would skip the mark for late arrivals. + "lessonMarkWhenLate": true, } diff --git a/src/HSchool.Simulation/AttendanceSystem.cs b/src/HSchool.Simulation/AttendanceSystem.cs new file mode 100644 index 0000000..dc81974 --- /dev/null +++ b/src/HSchool.Simulation/AttendanceSystem.cs @@ -0,0 +1,180 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation; + +/// +/// Writes present / late / absent for each pupil lesson slot from live presence and the +/// timetable. Marks stay on stage A's path; late does not cancel them unless +/// is false. +/// +internal static class AttendanceSystem +{ + private static readonly QueryDescription Places = + new QueryDescription().WithAll(); + + public static void Apply(School school) + { + if (school.Catalog is null || school.Timetable is null || school.Roster is null) + { + return; + } + + var rules = school.Catalog.BehaviorRules; + var frame = school.Catalog.DayFrame; + if (rules is null || rules.AttendanceMax <= 0 || frame is null) + { + return; + } + + var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays); + var previous = school.LastAttendanceSlot; + school.LastAttendanceSlot = slot; + + if (previous is { Kind: DaySlotKind.Lesson } left + && (slot.Kind != DaySlotKind.Lesson || slot.Index != left.Index)) + { + FinalizeAbsents(school, left.Index, rules); + } + + if (slot.Kind != DaySlotKind.Lesson) + { + return; + } + + Observe(school, slot.Index, frame, rules); + } + + private static void Observe(School school, int period, DayFrameDef frame, BehaviorDef rules) + { + var places = new Dictionary(StringComparer.Ordinal); + school.World.Query( + in Places, + (ref PersonIdentity identity, ref Presence presence) => + { + places[identity.Id] = presence; + }); + + var weekday = SchoolDay.WeekdayIndex(school.Clock.Time); + var minutesInto = MinutesIntoPeriod(school, frame, period); + var status = minutesInto <= rules.LessonLateMinutes + ? AttendanceStatuses.Present + : AttendanceStatuses.Late; + + foreach (var person in school.Roster!.People) + { + if (!person.IsStudent) + { + continue; + } + + var lesson = CurrentLesson(school, person, weekday, period); + if (lesson is null) + { + continue; + } + + if (!places.TryGetValue(person.Id, out var presence) || !IsStandingIn(presence, lesson.RoomId)) + { + continue; + } + + if (AttendanceMemory.Record( + person, + lesson.Subject, + status, + school.Clock.Time, + lesson.Period, + rules)) + { + school.RosterTalkDirty = true; + } + } + } + + private static void FinalizeAbsents(School school, int period, BehaviorDef rules) + { + var weekday = SchoolDay.WeekdayIndex(school.Clock.Time); + var day = school.Clock.Time.Date; + foreach (var person in school.Roster!.People) + { + if (!person.IsStudent) + { + continue; + } + + var lesson = CurrentLesson(school, person, weekday, period); + if (lesson is null) + { + continue; + } + + if (AttendanceMemory.Find(person, day, period) is not null) + { + continue; + } + + if (AttendanceMemory.Record( + person, + lesson.Subject, + AttendanceStatuses.Absent, + school.Clock.Time, + period, + rules, + AbsenceReasons.Truancy)) + { + school.RosterTalkDirty = true; + } + } + } + + internal static double MinutesIntoPeriod(School school, DayFrameDef frame, int period) + { + var start = DateTime.SpecifyKind( + school.Clock.Time.Date.Add(SchoolDay.PeriodStart(frame, period).ToTimeSpan()), + DateTimeKind.Utc); + return (school.Clock.Time - start).TotalMinutes; + } + + internal static bool IsLateForMark(School school, BehaviorDef rules, int period) + { + if (rules.LessonMarkWhenLate || school.Catalog?.DayFrame is not { } frame) + { + return false; + } + + return MinutesIntoPeriod(school, frame, period) > rules.LessonLateMinutes; + } + + private static bool IsStandingIn(Presence presence, string roomId) => + presence.NodeId is not null + && presence.Path.Length == 0 + && presence.RemainingMinutes <= 0 + && presence.NodeId.Equals(roomId, StringComparison.Ordinal); + + private static LessonPlacement? CurrentLesson(School school, Person person, int weekday, int period) + { + foreach (var lesson in Duty.LessonsToday(person, ClassOf(school, person), school.Timetable, weekday)) + { + if (lesson.Period == period) + { + return lesson; + } + } + + return null; + } + + private static SchoolClass? ClassOf(School school, Person person) + { + if (person.ClassId is null) + { + return null; + } + + return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal)); + } +} diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index 19c3780..bba899a 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -87,7 +87,11 @@ internal static class LessonLearningSystem if (!TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId)) { school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTeacher, lesson.Subject); - TryWriteAbsentTeacherMark(school, person, lesson, rules); + if (!AttendanceSystem.IsLateForMark(school, rules, lesson.Period)) + { + TryWriteAbsentTeacherMark(school, person, lesson, rules); + } + return; } @@ -174,6 +178,11 @@ internal static class LessonLearningSystem float warmth, float textbookFactor) { + if (AttendanceSystem.IsLateForMark(school, rules, lesson.Period)) + { + return; + } + if (!school.TryClaimLessonMark(person.Id, school.Clock.Time.Date, lesson.Period)) { return; diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index abc2eb3..3e69a93 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -116,6 +116,9 @@ public sealed class School : IDisposable internal DaySlot? LastDecisionSlot { get; set; } + /// Last slot observed — detects leaving a lesson to finalize absents. + internal DaySlot? LastAttendanceSlot { get; set; } + internal Dictionary Plans { get; } = new(StringComparer.Ordinal); internal Queue DecisionQueue { get; } = new(); @@ -162,6 +165,7 @@ public sealed class School : IDisposable LoggedActivity.Clear(); _lessonLogOnce.Clear(); _lessonMarkOnce.Clear(); + LastAttendanceSlot = null; } internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); @@ -214,6 +218,7 @@ public sealed class School : IDisposable RosterSpawner.Spawn(World, roster, Catalog); PlanDay = null; LastDecisionSlot = null; + LastAttendanceSlot = null; Plans.Clear(); DecisionQueue.Clear(); LoggedActivity.Clear(); @@ -461,6 +466,7 @@ public sealed class School : IDisposable PresenceSystem.EnqueueNewlyUrgent(this, below); PresenceSystem.DrainDecisions(this); LessonLearningSystem.Apply(this, gameMinutes); + AttendanceSystem.Apply(this); peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes)); peopleChanged |= AffinitySystem.Apply(this, talked); } diff --git a/tests/HSchool.Content.Tests/AttendanceBehaviorTests.cs b/tests/HSchool.Content.Tests/AttendanceBehaviorTests.cs new file mode 100644 index 0000000..1fc18a6 --- /dev/null +++ b/tests/HSchool.Content.Tests/AttendanceBehaviorTests.cs @@ -0,0 +1,43 @@ +using HSchool.Content; + +namespace HSchool.Content.Tests; + +public class AttendanceBehaviorTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void MissingAttendanceFields_UseVanillaDefaults() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "needThreshold": 0.35, "lessonSkillPerHour": 0.05, "commuteSlackMin": 0, "commuteSlackMax": 6, "switchMargin": 0.15 }"""), + ]); + + Assert.NotNull(catalog.BehaviorRules); + Assert.Equal(40, catalog.BehaviorRules.AttendanceMax); + Assert.Equal(5f, catalog.BehaviorRules.LessonLateMinutes); + Assert.True(catalog.BehaviorRules.LessonMarkWhenLate); + } + + [Fact] + public void NegativeLateMinutes_FailTheCatalog() + { + var error = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "lessonLateMinutes": -1 }"""), + ])); + + Assert.Contains("lessonLateMinutes", error.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/HSchool.People.Tests/AttendanceMemoryTests.cs b/tests/HSchool.People.Tests/AttendanceMemoryTests.cs new file mode 100644 index 0000000..da2e4c3 --- /dev/null +++ b/tests/HSchool.People.Tests/AttendanceMemoryTests.cs @@ -0,0 +1,136 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class AttendanceMemoryTests +{ + [Fact] + public void OverCeiling_DropsTheOldest() + { + var person = Blank("a"); + var rules = Rules(max: 2); + var t0 = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc); + Assert.True(AttendanceMemory.Record(person, "Mathematics", AttendanceStatuses.Present, t0, 1, rules)); + Assert.True(AttendanceMemory.Record(person, "Literature", AttendanceStatuses.Late, t0.AddMinutes(1), 2, rules)); + Assert.True(AttendanceMemory.Record(person, "History", AttendanceStatuses.Absent, t0.AddMinutes(2), 3, rules, AbsenceReasons.Truancy)); + + Assert.Equal(2, person.Attendance!.Count); + Assert.Equal("Literature", person.Attendance[0].Subject); + Assert.Equal("History", person.Attendance[1].Subject); + Assert.Equal(AbsenceReasons.Truancy, person.Attendance[1].AbsenceReason); + } + + [Fact] + public void AbsentUpgradesToPresent_SameSlot() + { + var person = Blank("a"); + var rules = Rules(max: 40); + var t0 = new DateTime(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc); + Assert.True(AttendanceMemory.Record(person, "Mathematics", AttendanceStatuses.Absent, t0, 1, rules)); + Assert.True(AttendanceMemory.Record(person, "Mathematics", AttendanceStatuses.Present, t0.AddMinutes(2), 1, rules)); + Assert.Single(person.Attendance!); + Assert.Equal(AttendanceStatuses.Present, person.Attendance![0].Status); + Assert.Null(person.Attendance[0].AbsenceReason); + } + + [Fact] + public void RosterJson_RoundTripsAttendance_AndOmitsEmpty() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var pupil = roster.People.First(person => person.IsStudent && !person.IsParent); + var time = new DateTime(2012, 4, 3, 11, 20, 0, DateTimeKind.Utc); + AttendanceMemory.Record(pupil, "Mathematics", AttendanceStatuses.Late, time, period: 1, Rules(max: 40)); + + var json = RosterJson.Serialize(RosterDocument.From(1, roster)); + Assert.Contains("\"attendance\"", json, StringComparison.Ordinal); + Assert.Contains("\"status\": \"late\"", json, StringComparison.Ordinal); + Assert.Contains("\"subject\": \"Mathematics\"", json, StringComparison.Ordinal); + + var without = roster.People.First(person => person.Attendance is null || person.Attendance.Count == 0); + var withoutSlice = PersonJsonSlice(json, without.Id); + Assert.DoesNotContain("\"attendance\"", withoutSlice, StringComparison.Ordinal); + + var loaded = RosterJson.Parse(json).ToRoster(); + var loadedPupil = loaded.People.First(person => person.Id.Equals(pupil.Id, StringComparison.Ordinal)); + Assert.NotNull(loadedPupil.Attendance); + Assert.Single(loadedPupil.Attendance!); + Assert.Equal(AttendanceStatuses.Late, loadedPupil.Attendance[0].Status); + Assert.Equal("Mathematics", loadedPupil.Attendance[0].Subject); + Assert.Equal(1, loadedPupil.Attendance[0].Period); + Assert.Equal(time, loadedPupil.Attendance[0].Time); + Assert.Null(loadedPupil.Attendance[0].AbsenceReason); + } + + [Fact] + public void RosterJson_RoundTripsAbsentReason() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var pupil = roster.People.First(person => person.IsStudent && !person.IsParent); + var time = new DateTime(2012, 4, 3, 11, 20, 0, DateTimeKind.Utc); + AttendanceMemory.Record( + pupil, + "Mathematics", + AttendanceStatuses.Absent, + time, + period: 1, + Rules(max: 40), + AbsenceReasons.Truancy); + + var loaded = RosterJson.Parse(RosterJson.Serialize(RosterDocument.From(1, roster))).ToRoster(); + var loadedPupil = loaded.People.First(person => person.Id.Equals(pupil.Id, StringComparison.Ordinal)); + Assert.Equal(AttendanceStatuses.Absent, loadedPupil.Attendance![0].Status); + Assert.Equal(AbsenceReasons.Truancy, loadedPupil.Attendance[0].AbsenceReason); + } + + private static string PersonJsonSlice(string json, string personId) + { + var marker = $"\"id\": \"{personId}\""; + var start = json.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var end = json.IndexOf("},", start, StringComparison.Ordinal); + if (end < 0) + { + end = json.Length; + } + + return json[start..end]; + } + + private static BehaviorDef Rules(int max) => new() + { + DefName = "Behavior", + AttendanceMax = max, + LessonLateMinutes = 5f, + LessonMarkWhenLate = true, + }; + + private static Person Blank(string id) + { + var cases = new CaseTable + { + Nom = id, + Gen = id, + Dat = id, + Acc = id, + Ins = id, + Pre = id, + }; + return new Person + { + Id = id, + FamilyId = "f", + Female = false, + BirthDate = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Name = new PersonName(id, id, id, cases, cases, cases), + IsStudent = true, + IsStaff = false, + IsParent = false, + Numbers = new Dictionary(StringComparer.Ordinal), + Choices = new Dictionary(StringComparer.Ordinal), + Skills = new Dictionary(StringComparer.Ordinal), + Traits = [], + Needs = new Dictionary(StringComparer.Ordinal), + Opinions = new Dictionary(StringComparer.Ordinal), + }; + } +} diff --git a/tests/HSchool.Simulation.Tests/AttendanceSimulationTests.cs b/tests/HSchool.Simulation.Tests/AttendanceSimulationTests.cs new file mode 100644 index 0000000..c11e4e2 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/AttendanceSimulationTests.cs @@ -0,0 +1,256 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation.Tests; + +public class AttendanceSimulationTests +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime LessonStart = new(2012, 4, 3, 8, 30, 0, DateTimeKind.Utc); + + [Fact] + public void InRoomAtStart_IsPresent() + { + var (school, room, pupilId, _) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart.AddMinutes(-1)); + SetPlace(school, pupilId, room); + school.Clock.JumpTo(LessonStart); + AttendanceSystem.Apply(school); + + var row = RowOf(school, pupilId); + Assert.NotNull(row); + Assert.Equal(AttendanceStatuses.Present, row!.Status); + Assert.Equal("Mathematics", row.Subject); + Assert.Equal(1, row.Period); + Assert.Null(row.AbsenceReason); + } + } + + [Fact] + public void ArrivesAfterThreshold_IsLate() + { + var (school, room, pupilId, _) = StaffedMath(); + using (school) + { + Assert.Equal(5f, school.Catalog!.BehaviorRules!.LessonLateMinutes); + AdvanceTo(school, LessonStart.AddMinutes(-1)); + SetPlace(school, pupilId, null); + school.Clock.JumpTo(LessonStart); + AttendanceSystem.Apply(school); + Assert.Null(RowOf(school, pupilId)); + + school.Clock.JumpTo(LessonStart.AddMinutes(6)); + SetPlace(school, pupilId, room); + AttendanceSystem.Apply(school); + + var row = RowOf(school, pupilId); + Assert.NotNull(row); + Assert.Equal(AttendanceStatuses.Late, row!.Status); + Assert.Null(row.AbsenceReason); + } + } + + [Fact] + public void OffCampusWholeSlot_IsAbsentTruancy() + { + var (school, _, pupilId, _) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart.AddMinutes(-1)); + SetPlace(school, pupilId, null); + school.Clock.JumpTo(LessonStart); + AttendanceSystem.Apply(school); + Assert.Null(RowOf(school, pupilId)); + + school.Clock.JumpTo(LessonStart.AddMinutes(45)); + AttendanceSystem.Apply(school); + + var row = RowOf(school, pupilId); + Assert.NotNull(row); + Assert.Equal(AttendanceStatuses.Absent, row!.Status); + Assert.Equal(AbsenceReasons.Truancy, row.AbsenceReason); + } + } + + [Fact] + public void OtherNodeWholeSlot_IsAbsent() + { + var (school, room, pupilId, _) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart.AddMinutes(-1)); + var other = room.Equals("classroom-101", StringComparison.Ordinal) ? "classroom-102" : "classroom-101"; + SetPlace(school, pupilId, other); + school.Clock.JumpTo(LessonStart); + AttendanceSystem.Apply(school); + school.Clock.JumpTo(LessonStart.AddMinutes(45)); + AttendanceSystem.Apply(school); + + var row = RowOf(school, pupilId); + Assert.NotNull(row); + Assert.Equal(AttendanceStatuses.Absent, row!.Status); + } + } + + [Fact] + public void SameDayAndSeed_SameAttendance() + { + var first = CapturePresent(); + var second = CapturePresent(); + Assert.Equal(first.Status, second.Status); + Assert.Equal(first.Subject, second.Subject); + Assert.Equal(first.Period, second.Period); + } + + [Fact] + public void Late_DoesNotCancelMarkWhenConfigured() + { + var (school, room, pupilId, teacherId) = StaffedMath(); + using (school) + { + Assert.True(school.Catalog!.BehaviorRules!.LessonMarkWhenLate); + AdvanceTo(school, LessonStart.AddMinutes(-1)); + SetIdle(school, pupilId); + SetIdle(school, teacherId); + school.Clock.JumpTo(LessonStart.AddMinutes(6)); + SetPlace(school, pupilId, room); + SetPlace(school, teacherId, room); + PlaceTextbook(school, pupilId, "Mathematics"); + LessonLearningSystem.Apply(school, 5); + AttendanceSystem.Apply(school); + + Assert.Equal(AttendanceStatuses.Late, RowOf(school, pupilId)!.Status); + var marks = school.Roster!.People.First(row => row.Id == pupilId).LessonMarks; + Assert.NotNull(marks); + Assert.Single(marks!); + } + } + + private static AttendanceRecord CapturePresent() + { + var (school, room, pupilId, _) = StaffedMath(); + using (school) + { + AdvanceTo(school, LessonStart.AddMinutes(-1)); + SetPlace(school, pupilId, room); + school.Clock.JumpTo(LessonStart); + AttendanceSystem.Apply(school); + var row = RowOf(school, pupilId); + Assert.NotNull(row); + return row!; + } + } + + private static AttendanceRecord? RowOf(School school, string pupilId) => + school.Roster!.People.First(row => row.Id == pupilId).Attendance?.LastOrDefault(); + + private static (School School, string Homeroom, string PupilId, string TeacherId) StaffedMath() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning); + var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning); + var hired = Staffing.Hire(catalog, map, roster, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, 1_000_000f); + Assert.Equal(StaffingError.None, hired.Error); + roster = hired.Roster; + pool = hired.Pool; + var hiredId = roster.People.First(person => person.IsStaff).Id; + var schoolClass = roster.Classes.First(row => + row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104"); + var school = School.Create(1, "Явка", TuesdayMorning, catalog, map); + school.InstallPeople(roster, seed: 1, "Russia", pool); + school.SetTimetable(new Timetable( + [new LessonPlacement(schoolClass.Id, "Mathematics", hiredId, schoolClass.RoomId, Day: 1, Period: 1)], + [])); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); + var pupil = schoolClass.PupilIds + .Select(id => school.Roster!.People.First(person => person.Id == id)) + .First(person => !person.Traits.Contains("Lazy")); + return (school, schoolClass.RoomId, pupil.Id, hiredId); + } + + private static void AdvanceTo(School school, DateTime until) + { + while (school.Clock.Time < until) + { + school.Tick(0.2d, 5d); + } + } + + private static void SetPlace(School school, string personId, string? node, string[]? path = null, float remaining = 0f) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref Presence presence) => + { + if (!identity.Id.Equals(personId, StringComparison.Ordinal)) + { + return; + } + + presence = node is null + ? Presence.OffCampus + : new Presence(node, remaining, node, HeadingHome: false, path ?? []); + }); + } + + private static void SetIdle(School school, string personId) + { + var query = new QueryDescription().WithAll(); + school.World.Query( + in query, + (ref PersonIdentity identity, ref PersonActivity activity) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + activity = PersonActivity.Idle; + } + }); + } + + private static void PlaceTextbook(School school, string personId, string subject) + { + var person = school.Roster!.People.First(row => row.Id.Equals(personId, StringComparison.Ordinal)); + if (person.Items is not IList items || items.IsReadOnly) + { + throw new InvalidOperationException($"Cannot mutate items for {personId}."); + } + + for (var i = items.Count - 1; i >= 0; i--) + { + if (items[i].Subject is not null) + { + items.RemoveAt(i); + } + } + + items.Add(new InventoryItem("Textbook", Color: null, Condition: 1f, ItemLocations.Bag, subject)); + } + + private static (DefCatalog Catalog, MapLayout Map) Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); + var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); + Assert.NotNull(map); + return (catalog, map); + } +}