Add lesson attendance from presence (present/late/absent).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,20 +11,20 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] На слот урока класса: присутствовал (узел = кабинет, не идёт), опоздал (порог минут в
|
- [x] На слот урока класса: присутствовал (узел = кабинет, не идёт), опоздал (порог минут в
|
||||||
данных), отсутствовал
|
данных), отсутствовал
|
||||||
- [ ] Писать в сейв рядом с журналом оценок
|
- [x] Писать в сейв рядом с журналом оценок
|
||||||
- [ ] Источник — присутствие и расписание, не клиент
|
- [x] Источник — присутствие и расписание, не клиент
|
||||||
- [ ] Заготовка причины отсутствия (прогул по умолчанию); «болезнь» заполнит срез 14
|
- [x] Заготовка причины отсутствия (прогул по умолчанию); «болезнь» заполнит срез 14
|
||||||
- [ ] Опоздание не отменяет оценку целиком, если правило этапа A иначе — согласовать с 73 в данных
|
- [x] Опоздание не отменяет оценку целиком, если правило этапа A иначе — согласовать с 73 в данных
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Ученик в кабинете к началу → присутствовал
|
- [x] Ученик в кабинете к началу → присутствовал
|
||||||
- [ ] Пришёл после порога → опоздал
|
- [x] Пришёл после порога → опоздал
|
||||||
- [ ] Вне школы / другой узел весь слот → отсутствовал
|
- [x] Вне школы / другой узел весь слот → отсутствовал
|
||||||
- [ ] Тот же день и сид → та же явка
|
- [x] Тот же день и сид → та же явка
|
||||||
- [ ] Сейв переживает reload
|
- [x] Сейв переживает reload
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -722,6 +722,17 @@ internal static class PeopleDefValidator
|
|||||||
previous = floor;
|
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)
|
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
|
||||||
|
|||||||
@@ -619,6 +619,23 @@ public sealed class BehaviorDef : Def
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? LessonMarkWhenNoTeacher { get; init; } = 2;
|
public int? LessonMarkWhenNoTeacher { get; init; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many recent attendance rows a person keeps. Oldest drop first. Zero disables attendance.
|
||||||
|
/// </summary>
|
||||||
|
public int AttendanceMax { get; init; } = 40;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public float LessonLateMinutes { get; init; } = 5f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool LessonMarkWhenLate { get; init; } = true;
|
||||||
|
|
||||||
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
|
||||||
[
|
[
|
||||||
0.85f,
|
0.85f,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.People;
|
||||||
|
|
||||||
|
/// <summary>Stable status ids written into <see cref="AttendanceRecord.Status"/>.</summary>
|
||||||
|
public static class AttendanceStatuses
|
||||||
|
{
|
||||||
|
public const string Present = "present";
|
||||||
|
|
||||||
|
public const string Late = "late";
|
||||||
|
|
||||||
|
public const string Absent = "absent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why a pupil was absent. Vanilla writes <see cref="Truancy"/>; slice 14 fills illness.
|
||||||
|
/// </summary>
|
||||||
|
public static class AbsenceReasons
|
||||||
|
{
|
||||||
|
public const string Truancy = "truancy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One lesson-slot attendance row. Sparse list on the person — not a year journal.</summary>
|
||||||
|
public sealed class AttendanceRecord
|
||||||
|
{
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
|
/// <summary><see cref="AttendanceStatuses"/> id.</summary>
|
||||||
|
public required string Status { get; init; }
|
||||||
|
|
||||||
|
public required DateTime Time { get; init; }
|
||||||
|
|
||||||
|
public required int Period { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Set when <see cref="Status"/> is absent. Omitted for present/late.</summary>
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? AbsenceReason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends or upgrades recent attendance and drops the oldest when over the BehaviorDef ceiling.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -81,6 +81,12 @@ public sealed record Person
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<LessonMarkRecord>? LessonMarks { get; set; }
|
public List<LessonMarkRecord>? LessonMarks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recent lesson attendance (present / late / absent). Null when empty so people.json stays compact.
|
||||||
|
/// Ceiling and late threshold live on <c>BehaviorDef</c>.
|
||||||
|
/// </summary>
|
||||||
|
public List<AttendanceRecord>? Attendance { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Active medical conditions. Null when healthy (empty) so people.json stays compact.
|
/// Active medical conditions. Null when healthy (empty) so people.json stays compact.
|
||||||
/// Needs are not replaced by this list.
|
/// Needs are not replaced by this list.
|
||||||
|
|||||||
@@ -110,4 +110,10 @@
|
|||||||
"lessonMarkThresholds": [0.85, 0.6, 0.35],
|
"lessonMarkThresholds": [0.85, 0.6, 0.35],
|
||||||
// Teacher not in the room: write 2 (null would skip the mark entirely).
|
// Teacher not in the room: write 2 (null would skip the mark entirely).
|
||||||
"lessonMarkWhenNoTeacher": 2,
|
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="BehaviorDef.LessonMarkWhenLate"/> is false.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AttendanceSystem
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription Places =
|
||||||
|
new QueryDescription().WithAll<PersonIdentity, Presence>();
|
||||||
|
|
||||||
|
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<string, Presence>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,11 @@ internal static class LessonLearningSystem
|
|||||||
if (!TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId))
|
if (!TeacherStandingIn(places, lesson.TeacherId, lesson.RoomId))
|
||||||
{
|
{
|
||||||
school.TryLogLessonOnce(personId, PersonLogTypes.LessonNoTeacher, lesson.Subject);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +178,11 @@ internal static class LessonLearningSystem
|
|||||||
float warmth,
|
float warmth,
|
||||||
float textbookFactor)
|
float textbookFactor)
|
||||||
{
|
{
|
||||||
|
if (AttendanceSystem.IsLateForMark(school, rules, lesson.Period))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!school.TryClaimLessonMark(person.Id, school.Clock.Time.Date, lesson.Period))
|
if (!school.TryClaimLessonMark(person.Id, school.Clock.Time.Date, lesson.Period))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
internal DaySlot? LastDecisionSlot { get; set; }
|
internal DaySlot? LastDecisionSlot { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last slot <see cref="AttendanceSystem"/> observed — detects leaving a lesson to finalize absents.</summary>
|
||||||
|
internal DaySlot? LastAttendanceSlot { get; set; }
|
||||||
|
|
||||||
internal Dictionary<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
|
internal Dictionary<string, DayPlan> Plans { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
internal Queue<string> DecisionQueue { get; } = new();
|
internal Queue<string> DecisionQueue { get; } = new();
|
||||||
@@ -162,6 +165,7 @@ public sealed class School : IDisposable
|
|||||||
LoggedActivity.Clear();
|
LoggedActivity.Clear();
|
||||||
_lessonLogOnce.Clear();
|
_lessonLogOnce.Clear();
|
||||||
_lessonMarkOnce.Clear();
|
_lessonMarkOnce.Clear();
|
||||||
|
LastAttendanceSlot = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
|
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
|
||||||
@@ -214,6 +218,7 @@ public sealed class School : IDisposable
|
|||||||
RosterSpawner.Spawn(World, roster, Catalog);
|
RosterSpawner.Spawn(World, roster, Catalog);
|
||||||
PlanDay = null;
|
PlanDay = null;
|
||||||
LastDecisionSlot = null;
|
LastDecisionSlot = null;
|
||||||
|
LastAttendanceSlot = null;
|
||||||
Plans.Clear();
|
Plans.Clear();
|
||||||
DecisionQueue.Clear();
|
DecisionQueue.Clear();
|
||||||
LoggedActivity.Clear();
|
LoggedActivity.Clear();
|
||||||
@@ -461,6 +466,7 @@ public sealed class School : IDisposable
|
|||||||
PresenceSystem.EnqueueNewlyUrgent(this, below);
|
PresenceSystem.EnqueueNewlyUrgent(this, below);
|
||||||
PresenceSystem.DrainDecisions(this);
|
PresenceSystem.DrainDecisions(this);
|
||||||
LessonLearningSystem.Apply(this, gameMinutes);
|
LessonLearningSystem.Apply(this, gameMinutes);
|
||||||
|
AttendanceSystem.Apply(this);
|
||||||
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
|
peopleChanged |= ApparelWear.Apply(this, gameMinutes, Clock.Time.AddMinutes(-gameMinutes));
|
||||||
peopleChanged |= AffinitySystem.Apply(this, talked);
|
peopleChanged |= AffinitySystem.Apply(this, talked);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ContentLoadException>(() => _loader.Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
[
|
||||||
|
PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"behavior",
|
||||||
|
"rules",
|
||||||
|
"""{ "defName": "Behavior", "lessonLateMinutes": -1 }"""),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Assert.Contains("lessonLateMinutes", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, int>(StringComparer.Ordinal),
|
||||||
|
Choices = new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
Skills = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
Traits = [],
|
||||||
|
Needs = new Dictionary<string, float>(StringComparer.Ordinal),
|
||||||
|
Opinions = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PersonIdentity, Presence>();
|
||||||
|
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<PersonIdentity, PersonActivity>();
|
||||||
|
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<InventoryItem> 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<ContentDocument>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user