Files
h-school/tests/HSchool.Simulation.Tests/LunchTests.cs
T
Leonid PershinandCursor 1b66c89cd7 Replace selectable name sets with a country that owns names and climate presets.
Create picks a country; climate is rolled from the school seed and stored. Old Slavic saves lift as Russia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:55:13 +03:00

213 lines
8.5 KiB
C#

using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation.Tests;
/// <summary>
/// The canteen is fed in two sittings so it is not the whole school at once: juniors eat in the
/// break after the third lesson, seniors after the fourth. Before that, everybody crossed the
/// hunger threshold at roughly the same minute and the room could not hold them.
/// </summary>
public class LunchTests
{
private static readonly DateTime Tuesday6 = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public void JuniorsEatAtTheFirstSitting_SeniorsAtTheSecond()
{
using var school = StaffedSchool();
var (junior, senior) = Parallels(school);
// Staff belong to no parallel and may eat at either sitting, so the claim is about pupils:
// the two halves of the school never meet in the canteen.
AdvanceTo(school, new DateTime(2012, 4, 3, 11, 15, 0, DateTimeKind.Utc));
var first = InCafeteria(school);
Assert.Contains(first, junior.Contains);
Assert.DoesNotContain(first, senior.Contains);
AdvanceTo(school, new DateTime(2012, 4, 3, 12, 20, 0, DateTimeKind.Utc));
var second = InCafeteria(school);
Assert.Contains(second, senior.Contains);
Assert.DoesNotContain(second, junior.Contains);
}
/// <summary>
/// Hunger is restored off campus — people eat at home — so a school week does not leave the
/// roster starving. It used to: nothing refilled hunger except eight chairs, and by the end of
/// the week a sixth of the school sat at zero.
/// </summary>
[Fact]
public void AfterASchoolDay_NobodyIsLeftStarving()
{
using var school = StaffedSchool();
var pupils = school.Roster!.People.Where(person => person.IsStudent).Select(person => person.Id)
.ToHashSet(StringComparer.Ordinal);
AdvanceTo(school, new DateTime(2012, 4, 4, 8, 0, 0, DateTimeKind.Utc));
var hunger = Hunger(school, pupils);
Assert.NotEmpty(hunger);
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();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", Tuesday6);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", Tuesday6);
foreach (var subject in catalog.Subjects.Values.Where(def => !def.Abstract).Select(def => def.DefName))
{
if (pool.Applicants.Count == 0)
{
break;
}
var candidate = pool.Applicants[0].Person.Id;
var hired = Staffing.Hire(catalog, map, roster, pool, candidate, Staffing.TeacherPosition, 1_000_000f);
if (hired.Error != StaffingError.None)
{
break;
}
roster = hired.Roster;
pool = hired.Pool;
var assigned = Staffing.AssignSubject(catalog, roster, pool, candidate, subject, 1_000_000f);
if (assigned.Error == StaffingError.None)
{
roster = assigned.Roster;
}
}
var school = School.Create(1, "Столовая", Tuesday6, catalog, map);
school.InstallPeople(roster, seed: 1, "Russia", pool);
school.SetTimetable(SchoolTimetables.Build(catalog, map, roster, null, weekDays: 5));
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64);
return school;
}
private static (HashSet<string> Junior, HashSet<string> Senior) Parallels(School school)
{
var junior = new HashSet<string>(StringComparer.Ordinal);
var senior = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in school.Roster!.Classes)
{
foreach (var id in row.PupilIds)
{
(row.Year <= 5 ? junior : senior).Add(id);
}
}
return (junior, senior);
}
private static string[] InCafeteria(School school) =>
school.CapturePresence()
.Where(row => row.NodeId == "cafeteria")
.Select(row => row.PersonId)
.ToArray();
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, 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))
{
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)
{
school.Tick(0.2d, 5d);
}
}
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = PackDocumentsFrom(root);
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
return (catalog, map);
}
private static IReadOnlyList<ContentDocument> PackDocumentsFrom(string root)
{
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(Path.DirectorySeparatorChar, (char)47);
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
return documents;
}
}