Enhance staffing management with uncovered teacher tracking
ci / server (push) Failing after 3m43s
ci / client (push) Successful in 14s

- Updated `protocol.md` to clarify the concept of uncovered subjects and the number of additional teachers required.
- Introduced `teachersShort` property in the `StaffingSubject` interface to indicate how many more teachers are needed for a subject.
- Enhanced localization strings to reflect the new uncovered teacher information in both English and Russian.
- Updated the management panel UI to display the number of teachers short for each uncovered subject.
- Revised the `Uncovered` method in the staffing logic to return detailed information about uncovered subjects, including the shortfall of teachers.
- Added tests to validate the new uncovered teacher tracking functionality and ensure accurate reporting in the staffing API.
- Marked related tasks as complete in the documentation for the golden fixtures phase.
This commit is contained in:
Leonid Pershin
2026-08-19 23:53:37 +03:00
parent c27d43f66a
commit e5a0ae5b9d
27 changed files with 16055 additions and 38 deletions
+57 -5
View File
@@ -1,3 +1,4 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
@@ -51,6 +52,44 @@ public class LunchTests
Assert.All(hunger.Values, value => Assert.True(value > 0.5f, $"somebody came back to school at {value:F2}"));
}
/// <summary>
/// Measured once: people arrive full, hit about 0.4 by their own lunch sitting, leave hungry,
/// and come back full the next morning. The numbers live in data; this test is the lock.
/// </summary>
[Fact]
public void Hunger_FollowsTheSchoolDayCurve()
{
using var school = StaffedSchool();
var pupils = school.Roster!.People.Where(person => person.IsStudent).Select(person => person.Id)
.ToHashSet(StringComparer.Ordinal);
var (junior, senior) = Parallels(school);
AdvanceTo(school, new DateTime(2012, 4, 3, 8, 40, 0, DateTimeKind.Utc));
var morning = Hunger(school, pupils, onCampus: true);
Assert.NotEmpty(morning);
Assert.All(morning.Values, value => Assert.InRange(value, 0.85f, 1f));
AdvanceTo(school, new DateTime(2012, 4, 3, 11, 0, 0, DateTimeKind.Utc));
var beforeJuniorLunch = Hunger(school, junior, onCampus: true);
Assert.NotEmpty(beforeJuniorLunch);
Assert.InRange(Median(beforeJuniorLunch.Values), 0.35f, 0.50f);
AdvanceTo(school, new DateTime(2012, 4, 3, 12, 5, 0, DateTimeKind.Utc));
var beforeSeniorLunch = Hunger(school, senior, onCampus: true);
Assert.NotEmpty(beforeSeniorLunch);
Assert.InRange(Median(beforeSeniorLunch.Values), 0.20f, 0.50f);
AdvanceTo(school, new DateTime(2012, 4, 3, 14, 45, 0, DateTimeKind.Utc));
var leaving = Hunger(school, pupils, onCampus: true);
Assert.NotEmpty(leaving);
Assert.True(Median(leaving.Values) < 0.55f, $"median hunger at the last lessons was {Median(leaving.Values):F2}");
AdvanceTo(school, new DateTime(2012, 4, 4, 8, 0, 0, DateTimeKind.Utc));
var nextMorning = Hunger(school, pupils, onCampus: false);
Assert.NotEmpty(nextMorning);
Assert.All(nextMorning.Values, value => Assert.InRange(value, 0.9f, 1f));
}
private static School StaffedSchool()
{
var (catalog, map) = Vanilla();
@@ -108,20 +147,33 @@ public class LunchTests
.Select(row => row.PersonId)
.ToArray();
private static Dictionary<string, float> Hunger(School school, HashSet<string> pupils)
private static Dictionary<string, float> Hunger(School school, HashSet<string> pupils, bool onCampus = false)
{
var values = new Dictionary<string, float>(StringComparer.Ordinal);
var query = new Arch.Core.QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) =>
var query = new Arch.Core.QueryDescription().WithAll<PersonIdentity, PersonNeeds, Presence>();
school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs, ref Presence presence) =>
{
if (pupils.Contains(identity.Id) && needs.Values.TryGetValue("Hunger", out var value))
if (!pupils.Contains(identity.Id) || !needs.Values.TryGetValue("Hunger", out var value))
{
values[identity.Id] = value;
return;
}
if (onCampus && !presence.IsOnCampus)
{
return;
}
values[identity.Id] = value;
});
return values;
}
private static float Median(IReadOnlyCollection<float> values)
{
var sorted = values.OrderBy(value => value).ToArray();
return sorted[sorted.Length / 2];
}
private static void AdvanceTo(School school, DateTime until)
{
while (school.Clock.Time < until)