using Arch.Core; using HSchool.Ai; using HSchool.Content; using HSchool.People; using HSchool.Protocol; using HSchool.Simulation; namespace HSchool.Simulation.Tests; public class ParentMeetingTests { private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); [Fact] public void WithoutClassTeacher_SameDaySeed_DoesNotStart() { var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: false); using (school) { Assert.Null(schoolClass.ClassTeacherId); Assert.False(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f)); Assert.Empty(school.ActiveParentMeetings); Assert.DoesNotContain( school.DrainWorldEvents(), row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal)); } } [Fact] public void WithClassTeacher_ParentsAppearThenLeaveAfterMeeting() { var (school, schoolClass, teacherId) = ClassWithParentsAfterLastLesson(assignTeacher: true); using (school) { Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f)); var session = Assert.Single(school.ActiveParentMeetings); Assert.NotEmpty(session.AttendeeIds); PlaceAt(school, teacherId, schoolClass.RoomId); foreach (var parentId in session.AttendeeIds) { PlaceAt(school, parentId, schoolClass.RoomId); Assert.Equal(schoolClass.RoomId, NodeOf(school, parentId)); } ParentMeetingSystem.Apply(school); Assert.True(ActivitySystem.IsParentMeeting(ActivityOf(school, teacherId))); ParentMeetingSystem.CompleteMeeting(school, teacherId); Assert.Empty(school.ActiveParentMeetings); foreach (var parentId in session.AttendeeIds) { var person = school.Roster!.People.First(row => row.Id == parentId); if (person.IsStaff) { continue; } var node = NodeOf(school, parentId); var headingHome = IsHeadingHome(school, parentId); Assert.True( node is null || headingHome, $"Parent {parentId} should leave campus, node={node}, headingHome={headingHome}"); } } } [Fact] public void MeetingDoesNotStartBeforeLastLessonOfClass() { var (school, schoolClass, teacherId) = ClassWithParents(assignTeacher: true, advancePastLastLesson: false); using (school) { PlaceAt(school, teacherId, schoolClass.RoomId); // Still during period 1 — last lesson has not ended. Assert.False(ParentMeetings.IsAfterLastLesson( school.Catalog!, school.Timetable, schoolClass, school.Clock.Time, school.SchoolWeekDays)); Assert.False(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f)); Assert.Empty(school.ActiveParentMeetings); } } [Fact] public void SameClassAndDaySeed_SameAttendees() { var first = AttendeesFromSeed(seed: 42); var second = AttendeesFromSeed(seed: 42); Assert.Equal(first, second); Assert.NotEmpty(first); } [Fact] public void PlayerCannotScheduleMeeting_NoProtocolOrPublicApi() { Assert.DoesNotContain( Enum.GetNames(), name => name.Contains("ParentMeeting", StringComparison.OrdinalIgnoreCase) || name.Contains("Meeting", StringComparison.OrdinalIgnoreCase) && name.Contains("Parent", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain( typeof(School).GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public), method => method.Name.Contains("Meeting", StringComparison.OrdinalIgnoreCase) && (method.Name.StartsWith("Set", StringComparison.Ordinal) || method.Name.StartsWith("Start", StringComparison.Ordinal) || method.Name.StartsWith("Schedule", StringComparison.Ordinal) || method.Name.StartsWith("Enqueue", StringComparison.Ordinal))); } [Fact] public void SkipEmpty_DoesNotLeaveParentsInHomeroom() { var (school, schoolClass, teacherId) = ClassWithParentsAfterLastLesson(assignTeacher: true); using (school) { Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f)); var session = Assert.Single(school.ActiveParentMeetings); PlaceAt(school, teacherId, schoolClass.RoomId); foreach (var parentId in session.AttendeeIds) { PlaceAt(school, parentId, schoolClass.RoomId); } // Jump past the work window so skip is allowed, with campus cleared of pupils/staff // except we force parents off via ClearAll path used by skip. ParentMeetingSystem.ClearAll(school); Assert.Empty(school.ActiveParentMeetings); foreach (var parentId in session.AttendeeIds) { var person = school.Roster!.People.First(row => row.Id == parentId); if (person.IsStaff || person.IsStudent) { continue; } Assert.Null(NodeOf(school, parentId)); } } } [Fact] public void StartingMeeting_RaisesParentMeetingWorldEvent() { var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: true); using (school) { school.DrainWorldEvents(); Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 1f)); var fact = Assert.Single( school.DrainWorldEvents(), row => row.Trigger.Equals(EventTriggers.ParentMeeting, StringComparison.Ordinal)); Assert.Equal(schoolClass.ClassTeacherId, fact.PersonKey); } } private static IReadOnlyList AttendeesFromSeed(int seed) { var (school, schoolClass, _) = ClassWithParentsAfterLastLesson(assignTeacher: true, seed: seed); using (school) { Assert.True(ParentMeetingSystem.TryStart(school, schoolClass.Id, chance: 1f, fraction: 0.5f)); return Assert.Single(school.ActiveParentMeetings).AttendeeIds.ToArray(); } } private static (School School, SchoolClass Class, string TeacherId) ClassWithParentsAfterLastLesson( bool assignTeacher, int seed = 42) => ClassWithParents(assignTeacher, advancePastLastLesson: true, seed); private static (School School, SchoolClass Class, string TeacherId) ClassWithParents( bool assignTeacher, bool advancePastLastLesson, int seed = 42) { var (catalog, map) = Vanilla(); var start = TuesdayMorning; var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start); var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start); var schoolClass = roster.Classes.First(row => row.RoomId == "classroom-101"); var school = School.Create(seed, "ParentMeeting", start, catalog, map); school.InstallPeople(roster, seed, "Russia", pool); school.SetTimetable(new HSchool.Schedule.Timetable( [ new HSchool.Schedule.LessonPlacement( schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), ], [])); school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); var teacherId = ""; if (assignTeacher) { teacherId = HireTeacher(school); var assigned = ClassTeachers.Assign(school.Roster!, schoolClass.Id, teacherId); Assert.Equal(ClassTeacherError.None, assigned.Error); school.ApplyRosterData(assigned.Roster); schoolClass = school.Roster!.Classes.First(row => row.Id == schoolClass.Id); PlaceAt(school, teacherId, schoolClass.RoomId); } if (advancePastLastLesson) { var end = ParentMeetings.LastLessonEnd(catalog, school.Timetable, schoolClass, school.Clock.Time); Assert.NotNull(end); AdvanceTo(school, end.Value.AddMinutes(1)); if (assignTeacher) { PlaceAt(school, teacherId, schoolClass.RoomId); } } else if (assignTeacher) { // Mid-lesson: period 1 is still running. var mid = SchoolDay.PeriodStart(catalog.DayFrame!, 1); var midTime = TuesdayMorning.Date + mid.ToTimeSpan() + TimeSpan.FromMinutes(10); AdvanceTo(school, DateTime.SpecifyKind(midTime, DateTimeKind.Utc)); PlaceAt(school, teacherId, schoolClass.RoomId); } schoolClass = school.Roster!.Classes.First(row => row.Id == schoolClass.Id); return (school, schoolClass, teacherId); } private static string HireTeacher(School school) { const float cap = 100_000f; var pool = school.Applicants!; var hired = Staffing.Hire( school.Catalog!, school.Map!, school.Roster!, pool, pool.Applicants[0].Person.Id, Staffing.TeacherPosition, cap); Assert.Equal(StaffingError.None, hired.Error); school.ApplyStaffing(hired.Roster, hired.Pool); return school.Roster!.People.First(person => person.IsStaff && Staffing.TeacherPosition.Equals(person.Position, StringComparison.Ordinal)).Id; } private static void AdvanceTo(School school, DateTime until) { while (school.Clock.Time < until) { school.Tick(0.2d, 5d); } } private static void PlaceAt(School school, string personId, string? nodeId) { TalkCircleSystem.Interrupt(school, personId); var query = new QueryDescription().WithAll(); school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { presence = nodeId is null ? Presence.OffCampus : new Presence(nodeId, 0f, nodeId, false, []); activity = PersonActivity.Idle; intent = Intent.None; } }); } private static string? NodeOf(School school, string personId) { string? found = null; var query = new QueryDescription().WithAll(); school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { found = presence.NodeId; } }); return found; } private static bool IsHeadingHome(School school, string personId) { var heading = false; var query = new QueryDescription().WithAll(); school.World.Query(in query, (ref PersonIdentity identity, ref Presence presence) => { if (identity.Id.Equals(personId, StringComparison.Ordinal)) { heading = presence.HeadingHome; } }); return heading; } private static string? ActivityOf(School school, string personId) { string? found = null; var query = new QueryDescription().WithAll(); school.World.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) => { if (identity.Id.Equals(personId, StringComparison.Ordinal) && activity.IsActive) { found = activity.ActionId; } }); return found; } 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; } documents.Add(new ContentDocument( CatalogLoader.CorePackId, Path.GetRelativePath(root, path).Replace('\\', '/'), File.ReadAllText(path))); } var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); Assert.NotNull(map); return (catalog, map); } }