Implement yearly intake functionality in school simulation, allowing for the graduation of students and the addition of new first-year pupils. Update the roster management to reflect these changes, ensuring proper entity handling in the simulation. Enhance the API to support name sets during roster installation and revise related tests to validate the new intake process and roster updates.
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 20s

This commit is contained in:
Leonid Pershin
2026-08-18 19:57:59 +03:00
parent 533bd80f5e
commit 7f6852548d
12 changed files with 600 additions and 17 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ way; this file is *how to work in them*.
| schools, the game clock, game rules | `src/HSchool.Simulation` | | schools, the game clock, game rules | `src/HSchool.Simulation` |
| defs, JSONC catalog, map validation | `src/HSchool.Content` | | defs, JSONC catalog, map validation | `src/HSchool.Content` |
| skills, traits, body, needs, name sets | `src/HSchool.Content` | | skills, traits, body, needs, name sets | `src/HSchool.Content` |
| people generation, families, roster records | `src/HSchool.People` | | people generation, families, roster records, yearly intake | `src/HSchool.People` |
| people in a school's World, need decay | `src/HSchool.Simulation` | | people in a school's World, need decay | `src/HSchool.Simulation` |
| the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` | | the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` | | what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
+6 -6
View File
@@ -11,16 +11,16 @@
## Задачи ## Задачи
- [ ] Переход по игровому календарю на 1 сентября: у каждого класса параллель +1 - [x] Переход по игровому календарю на 1 сентября: у каждого класса параллель +1
- [ ] Выпуск **старшей имеющейся** параллели — школа из четырёх кабинетов не ждёт одиннадцатого - [x] Выпуск **старшей имеющейся** параллели — школа из четырёх кабинетов не ждёт одиннадцатого
класса, чтобы кого-то выпустить класса, чтобы кого-то выпустить
- [ ] Освободившийся кабинет достаётся новому первому классу; вместимость школы не меняется — - [x] Освободившийся кабинет достаётся новому первому классу; вместимость школы не меняется —
её меняет только стройка её меняет только стройка
- [ ] Набор первого класса тем же генератором и теми же семьями: у нового ученика может - [x] Набор первого класса тем же генератором и теми же семьями: у нового ученика может
оказаться брат или сестра в старшей параллели оказаться брат или сестра в старшей параллели
- [ ] Выпускники уходят из ростера; их родители уходят тоже, если не осталось других детей в - [x] Выпускники уходят из ростера; их родители уходят тоже, если не осталось других детей в
школе и они в ней не работают школе и они в ней не работают
- [ ] Ростер переписывается один раз за переход, снимок публикуется заново — не по тику - [x] Ростер переписывается один раз за переход, снимок публикуется заново — не по тику
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -35,4 +35,4 @@
| [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида | | [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида |
| [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | | [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` |
| [8. Просмотр людей](08-people-browser.md) | ✅ | Панель со списком, фильтрами и карточкой | | [8. Просмотр людей](08-people-browser.md) | ✅ | Панель со списком, фильтрами и карточкой |
| [9. Годовой набор](09-yearly-intake.md) | | Первое сентября: переход, выпуск, набор | | [9. Годовой набор](09-yearly-intake.md) | | Первое сентября: переход, выпуск, набор |
+58
View File
@@ -108,6 +108,64 @@ internal static class FamilyFactory
return (family, members); return (family, members);
} }
/// <summary>
/// A younger sibling entering year 1, sharing the parents' surname and the father's patronymic.
/// </summary>
public static Person AddChild(
DefCatalog catalog,
NameSetDef names,
Random rng,
Family family,
IReadOnlyList<Person> members,
PupilSeat seat,
DateTime yearStart,
DateTime asOf,
int childIndex)
{
var father = members.First(person => !person.IsStudent && !person.Female);
var mother = members.FirstOrDefault(person => !person.IsStudent && person.Female);
var usedGiven = new HashSet<string>(
members.Where(person => person.IsStudent).Select(person => person.Name.Given),
StringComparer.Ordinal);
var female = rng.Next(2) == 0;
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng, usedGiven);
var (first, last) = SchoolYears.BirthWindow(yearStart, seat.Year);
var birth = SchoolYears.RandomInRange(rng, first, last);
var age = SchoolYears.AgeYears(birth, asOf);
var patronymic = NameGrammar.Patronymic(father.Name.Given, female, names.PatronymicRule);
var surnameNom = female ? (mother ?? father).Name.Surname : father.Name.Surname;
var surnameCases = female ? (mother ?? father).Name.SurnameCases : father.Name.SurnameCases;
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
var traits = PersonSampler.Traits(catalog, rng, [PersonRoles.Student], age);
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
var needs = PersonSampler.Needs(catalog);
var name = new PersonName(
given.Form,
surnameNom,
patronymic,
GivenTable(given, names.DefaultGivenDeclension),
surnameCases,
PatronymicTable(patronymic, female));
return new Person
{
Id = $"{family.Id}.c{childIndex}",
FamilyId = family.Id,
Female = female,
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
Name = name,
IsStudent = true,
IsStaff = false,
IsParent = false,
ClassId = seat.ClassId,
Numbers = numbers,
Choices = choices,
Skills = skills,
Traits = traits,
Needs = needs,
};
}
public static (Family Family, List<Person> Members) CreateStaffOnly( public static (Family Family, List<Person> Members) CreateStaffOnly(
DefCatalog catalog, DefCatalog catalog,
NameSetDef names, NameSetDef names,
+2 -2
View File
@@ -9,11 +9,11 @@ internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilS
/// </summary> /// </summary>
internal static class FamilyPlanner internal static class FamilyPlanner
{ {
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats) public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats, int startIndex = 0)
{ {
var plans = new List<FamilyPlan>(); var plans = new List<FamilyPlan>();
var offset = 0; var offset = 0;
var index = 0; var index = startIndex;
while (offset < seats.Count) while (offset < seats.Count)
{ {
var remaining = seats.Count - offset; var remaining = seats.Count - offset;
+1
View File
@@ -8,6 +8,7 @@ internal static class Seed
{ {
public const int ChildCountSalt = 1; public const int ChildCountSalt = 1;
public const int AppearanceSalt = 2; public const int AppearanceSalt = 2;
public const int IntakeSalt = 3;
public static int Mix(int schoolSeed, int familyIndex, int salt) public static int Mix(int schoolSeed, int familyIndex, int salt)
{ {
+260
View File
@@ -0,0 +1,260 @@
namespace HSchool.People;
/// <summary>
/// 1 September: promote every class, graduate the oldest year present, fill the vacated rooms
/// with a new first year. Capacity is the map — intake replaces graduates, it does not grow.
/// </summary>
public static class YearlyIntake
{
public const int MaxChildrenInFamily = 3;
/// <summary>
/// September 1sts strictly after <paramref name="before"/> and at or before <paramref name="after"/>.
/// A school created on 1 September does not intake that morning — its roster is already that year.
/// </summary>
public static IEnumerable<DateTime> DatesBetween(DateTime before, DateTime after)
{
var utcBefore = DateTime.SpecifyKind(before, DateTimeKind.Utc);
var utcAfter = DateTime.SpecifyKind(after, DateTimeKind.Utc);
if (utcAfter <= utcBefore)
{
yield break;
}
var date = new DateTime(utcBefore.Year, 9, 1, 0, 0, 0, DateTimeKind.Utc);
if (date <= utcBefore)
{
date = date.AddYears(1);
}
while (date <= utcAfter)
{
yield return date;
date = date.AddYears(1);
}
}
public static Roster Apply(
DefCatalog catalog,
Roster roster,
int schoolSeed,
string nameSetId,
DateTime asOf)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(roster);
ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId);
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
if (roster.Classes.Count == 0)
{
return roster;
}
var when = DateTime.SpecifyKind(asOf, DateTimeKind.Utc);
var yearStart = SchoolYears.StartOn(when);
var maxYear = roster.Classes.Max(schoolClass => schoolClass.Year);
var graduatingIds = roster.Classes
.Where(schoolClass => schoolClass.Year == maxYear)
.SelectMany(schoolClass => schoolClass.PupilIds)
.ToHashSet(StringComparer.Ordinal);
var remainingPeople = roster.People
.Where(person => !graduatingIds.Contains(person.Id))
.ToDictionary(person => person.Id, StringComparer.Ordinal);
var families = new List<Family>();
foreach (var family in roster.Families.OrderBy(candidate => IndexOf(candidate.Id)))
{
var childIds = family.ChildIds.Where(id => !graduatingIds.Contains(id)).ToArray();
var parentIds = family.ParentIds.Where(id => remainingPeople.ContainsKey(id)).ToArray();
var keepParents = childIds.Length > 0
? parentIds
: parentIds.Where(id => remainingPeople[id].IsStaff).ToArray();
foreach (var parentId in parentIds)
{
if (!keepParents.Contains(parentId, StringComparer.Ordinal))
{
remainingPeople.Remove(parentId);
}
}
if (keepParents.Length == 0 && childIds.Length == 0)
{
continue;
}
families.Add(new Family(family.Id, keepParents, childIds));
foreach (var parentId in keepParents)
{
var parent = remainingPeople[parentId];
remainingPeople[parentId] = parent with { IsParent = childIds.Length > 0 };
}
}
var classes = new SchoolClass[roster.Classes.Count];
var newSeats = new List<PupilSeat>();
for (var i = 0; i < roster.Classes.Count; i++)
{
var schoolClass = roster.Classes[i];
if (schoolClass.Year == maxYear)
{
classes[i] = schoolClass with { Year = 1, PupilIds = [] };
for (var seat = 0; seat < schoolClass.Capacity; seat++)
{
newSeats.Add(new PupilSeat(schoolClass.Id, schoolClass.RoomId, Year: 1, schoolClass.Letter));
}
}
else
{
var kept = schoolClass.PupilIds.Where(id => remainingPeople.ContainsKey(id)).ToArray();
classes[i] = schoolClass with { Year = schoolClass.Year + 1, PupilIds = kept };
}
}
FillFirstYear(
catalog,
names,
schoolSeed,
yearStart,
when,
families,
remainingPeople,
newSeats,
nextFamilyIndex: roster.Families.Select(family => IndexOf(family.Id)).DefaultIfEmpty(-1).Max() + 1);
var people = remainingPeople.Values.ToList();
var filled = AttachPupils(classes, people);
return new Roster(people, families, filled);
}
private static void FillFirstYear(
DefCatalog catalog,
NameSetDef names,
int schoolSeed,
DateTime yearStart,
DateTime asOf,
List<Family> families,
Dictionary<string, Person> people,
List<PupilSeat> seats,
int nextFamilyIndex)
{
var cursor = 0;
foreach (var family in families.ToArray())
{
if (cursor >= seats.Count)
{
break;
}
var index = IndexOf(family.Id);
if (index < 0 || family.ChildIds.Count == 0 || family.ChildIds.Count >= MaxChildrenInFamily)
{
continue;
}
if (!WantsYoungerSibling(schoolSeed, index, yearStart.Year))
{
continue;
}
var members = family.ParentIds.Concat(family.ChildIds)
.Select(id => people[id])
.ToArray();
if (!members.Any(person => !person.IsStudent && !person.Female))
{
continue;
}
var childIndex = NextChildIndex(family);
var rng = new Random(Seed.Mix(schoolSeed, index, Seed.IntakeSalt + yearStart.Year * 10 + childIndex));
var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex);
people[child.Id] = child;
var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal));
families[familyAt] = family with { ChildIds = [.. family.ChildIds, child.Id] };
foreach (var parentId in family.ParentIds)
{
people[parentId] = people[parentId] with { IsParent = true };
}
cursor++;
}
if (cursor >= seats.Count)
{
return;
}
var leftover = seats.Skip(cursor).ToArray();
foreach (var plan in FamilyPlanner.Plan(schoolSeed, leftover, Math.Max(nextFamilyIndex, 0)))
{
var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf);
families.Add(created);
foreach (var member in members)
{
people[member.Id] = member;
}
}
}
private static IReadOnlyList<SchoolClass> AttachPupils(SchoolClass[] classes, List<Person> people)
{
var pupils = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var schoolClass in classes)
{
pupils[schoolClass.Id] = [.. schoolClass.PupilIds];
}
foreach (var person in people)
{
if (!person.IsStudent || person.ClassId is not { } classId || !pupils.TryGetValue(classId, out var list))
{
continue;
}
if (!list.Contains(person.Id, StringComparer.Ordinal))
{
list.Add(person.Id);
}
}
for (var i = 0; i < classes.Length; i++)
{
var schoolClass = classes[i];
classes[i] = schoolClass with { PupilIds = pupils[schoolClass.Id] };
}
return classes;
}
private static bool WantsYoungerSibling(int schoolSeed, int familyIndex, int intakeYear)
{
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.IntakeSalt ^ intakeYear));
return rng.Next(100) < 40;
}
private static int NextChildIndex(Family family)
{
var max = -1;
foreach (var id in family.ChildIds)
{
var marker = id.LastIndexOf(".c", StringComparison.Ordinal);
if (marker >= 0 && int.TryParse(id.AsSpan(marker + 2), out var index) && index > max)
{
max = index;
}
}
return max + 1;
}
private static int IndexOf(string familyId) =>
familyId.Length > 1 && familyId[0] == 'f' && int.TryParse(familyId.AsSpan(1), out var index)
? index
: -1;
}
+9 -2
View File
@@ -239,6 +239,7 @@ internal sealed class SchoolWorker
var lastTimestamp = Stopwatch.GetTimestamp(); var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d; var accumulator = 0d;
var lastSave = lastTimestamp; var lastSave = lastTimestamp;
var peopleChanged = false;
try try
{ {
@@ -256,10 +257,11 @@ internal sealed class SchoolWorker
lastTimestamp = now; lastTimestamp = now;
var steps = 0; var steps = 0;
peopleChanged = false;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps) while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{ {
var stepStarted = Stopwatch.GetTimestamp(); var stepStarted = Stopwatch.GetTimestamp();
school.Tick(fixedDelta, _options.GameMinutesPerRealSecond); peopleChanged |= school.Tick(fixedDelta, _options.GameMinutesPerRealSecond);
_metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds); _metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta; accumulator -= fixedDelta;
@@ -275,6 +277,11 @@ internal sealed class SchoolWorker
accumulator = 0d; accumulator = 0d;
} }
if (peopleChanged)
{
PersistPeople();
}
if (steps > 0) if (steps > 0)
{ {
PublishSnapshot(); PublishSnapshot();
@@ -508,7 +515,7 @@ internal sealed class SchoolWorker
$"School {_id} roster does not match its map; the people file was left untouched."); $"School {_id} roster does not match its map; the people file was left untouched.");
} }
school.InstallPeople(roster, seed); school.InstallPeople(roster, seed, nameSetId);
return generated; return generated;
} }
+21
View File
@@ -6,6 +6,9 @@ namespace HSchool.Simulation;
/// <summary>Turns roster records into Arch entities. Parents have no map location — by design.</summary> /// <summary>Turns roster records into Arch entities. Parents have no map location — by design.</summary>
public static class RosterSpawner public static class RosterSpawner
{ {
private static readonly QueryDescription People = new QueryDescription().WithAll<PersonIdentity>();
private static readonly QueryDescription Classes = new QueryDescription().WithAll<ClassIdentity>();
public static void Spawn(World world, Roster roster) public static void Spawn(World world, Roster roster)
{ {
foreach (var schoolClass in roster.Classes) foreach (var schoolClass in roster.Classes)
@@ -36,4 +39,22 @@ public static class RosterSpawner
person.WorkplaceRoomId)); person.WorkplaceRoomId));
} }
} }
/// <summary>Drops the previous composition and spawns <paramref name="roster"/>. Called on yearly intake.</summary>
public static void Replace(World world, Roster roster)
{
DestroyAll(world, People);
DestroyAll(world, Classes);
Spawn(world, roster);
}
private static void DestroyAll(World world, QueryDescription query)
{
var entities = new List<Entity>();
world.Query(in query, (Entity entity) => entities.Add(entity));
foreach (var entity in entities)
{
world.Destroy(entity);
}
}
} }
+39 -4
View File
@@ -70,31 +70,66 @@ public sealed class School : IDisposable
public int PeopleSeed { get; private set; } public int PeopleSeed { get; private set; }
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
public string? NameSetId { get; private set; }
/// <summary> /// <summary>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary> /// </summary>
public void InstallPeople(Roster roster, int seed) public void InstallPeople(Roster roster, int seed, string? nameSetId = null)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(roster); ArgumentNullException.ThrowIfNull(roster);
Roster = roster; Roster = roster;
PeopleSeed = seed; PeopleSeed = seed;
NameSetId = nameSetId;
RosterSpawner.Spawn(World, roster); RosterSpawner.Spawn(World, roster);
} }
/// <summary>Runs one fixed step of the school: calendar, then need decay.</summary> /// <summary>Runs one fixed step of the school: calendar, yearly intake if 1 September passed, then need decay.</summary>
public void Tick(double deltaTime, double gameMinutesPerRealSecond) /// <returns><see langword="true"/> when the roster changed this step.</returns>
public bool Tick(double deltaTime, double gameMinutesPerRealSecond)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
var before = Clock.Time;
var gameMinutes = Clock.Advance(deltaTime, gameMinutesPerRealSecond); var gameMinutes = Clock.Advance(deltaTime, gameMinutesPerRealSecond);
if (gameMinutes > 0 && Catalog is not null) var peopleChanged = false;
if (gameMinutes > 0)
{
peopleChanged = TryYearlyIntake(before, Clock.Time);
if (Catalog is not null)
{ {
NeedDecay.Apply(World, Catalog, gameMinutes); NeedDecay.Apply(World, Catalog, gameMinutes);
} }
} }
return peopleChanged;
}
private bool TryYearlyIntake(DateTime before, DateTime after)
{
if (Roster is null || Catalog is null || NameSetId is null)
{
return false;
}
var changed = false;
foreach (var date in YearlyIntake.DatesBetween(before, after))
{
Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date);
changed = true;
}
if (changed)
{
RosterSpawner.Replace(World, Roster);
}
return changed;
}
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)
@@ -0,0 +1,122 @@
namespace HSchool.People.Tests;
public class YearlyIntakeTests
{
private static readonly DateTime September = new(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public void DatesBetween_SkipsAStartAlreadyOnFirstSeptember()
{
var start = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc);
Assert.Empty(YearlyIntake.DatesBetween(start, start.AddMinutes(1)));
Assert.Equal(
[new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)],
YearlyIntake.DatesBetween(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc), start));
}
[Fact]
public void FourClassrooms_GraduatesTheOldestYearNotEleventh()
{
var map = Fixtures.Classrooms(4);
var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(map);
Assert.Equal(4, before.Classes.Max(schoolClass => schoolClass.Year));
Assert.DoesNotContain(before.Classes, schoolClass => schoolClass.Year == 11);
var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September);
var demand = SchoolDemand.From(catalog, map);
Assert.True(RosterFit.Matches(after, demand));
Assert.Equal(before.People.Count(person => person.IsStudent), after.People.Count(person => person.IsStudent));
Assert.Equal(before.Classes.Sum(schoolClass => schoolClass.Capacity), after.Classes.Sum(schoolClass => schoolClass.Capacity));
Assert.Equal(4, after.Classes.Max(schoolClass => schoolClass.Year));
Assert.Contains(after.Classes, schoolClass => schoolClass.Year == 1);
Assert.All(after.Classes, schoolClass => Assert.Equal(schoolClass.Capacity, schoolClass.PupilIds.Count));
var graduated = before.Classes
.Where(schoolClass => schoolClass.Year == 4)
.SelectMany(schoolClass => schoolClass.PupilIds)
.ToHashSet(StringComparer.Ordinal);
Assert.All(graduated, id => Assert.DoesNotContain(after.People, person => person.Id == id));
}
[Fact]
public void SameInputs_YieldTheSameIntake()
{
var map = Fixtures.Classrooms(4);
var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(map);
var a = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September));
var b = Snapshot(YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September));
Assert.Equal(a, b);
}
[Fact]
public void NewFirstYear_CanShareAFamilyWithAnOlderPupil()
{
var map = Fixtures.Classrooms(4);
var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(map);
var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September);
var people = after.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var yearOf = after.Classes.ToDictionary(schoolClass => schoolClass.Id, schoolClass => schoolClass.Year, StringComparer.Ordinal);
Assert.Contains(after.Families, family =>
{
var years = family.ChildIds
.Select(id => people[id])
.Where(person => person.IsStudent && person.ClassId is not null)
.Select(person => yearOf[person.ClassId!])
.ToHashSet();
return years.Contains(1) && years.Any(year => year > 1);
});
}
[Fact]
public void ParentsOfOnlyGraduates_LeaveUnlessTheyWorkHere()
{
var map = Fixtures.Classrooms(4);
var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(map);
var people = before.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var graduating = before.Classes
.Where(schoolClass => schoolClass.Year == 4)
.SelectMany(schoolClass => schoolClass.PupilIds)
.ToHashSet(StringComparer.Ordinal);
var shouldLeave = new List<string>();
var staffWhoStay = new List<string>();
foreach (var family in before.Families)
{
if (family.ChildIds.Count == 0 || family.ChildIds.Any(id => !graduating.Contains(id)))
{
continue;
}
foreach (var parentId in family.ParentIds)
{
if (people[parentId].IsStaff)
{
staffWhoStay.Add(parentId);
}
else
{
shouldLeave.Add(parentId);
}
}
}
var after = YearlyIntake.Apply(catalog, before, Fixtures.SchoolSeed, "Slavic", September);
var afterIds = after.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal);
Assert.NotEmpty(shouldLeave);
Assert.All(shouldLeave, id => Assert.DoesNotContain(id, afterIds));
Assert.All(staffWhoStay, id => Assert.Contains(id, afterIds));
Assert.All(
after.People.Where(person => staffWhoStay.Contains(person.Id)),
person => Assert.False(person.IsParent));
}
private static string Snapshot(Roster roster) =>
string.Join('\n', roster.People.Select(person =>
$"{person.Id}|{person.FamilyId}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Name.Full}"));
}
@@ -94,6 +94,85 @@ public class PeopleInSchoolTests
second.People.Select(person => $"{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}")); second.People.Select(person => $"{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}"));
} }
[Fact]
public void TickPastFirstSeptember_GraduatesTheOldestYearAndKeepsCapacity()
{
var catalog = Catalog();
var map = Classrooms(4);
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start);
var demand = SchoolDemand.From(catalog, map);
var oldest = roster.Classes.Max(schoolClass => schoolClass.Year);
var graduated = roster.Classes
.Where(schoolClass => schoolClass.Year == oldest)
.SelectMany(schoolClass => schoolClass.PupilIds)
.ToHashSet(StringComparer.Ordinal);
using var school = School.Create(1, "Четыре кабинета", Start, catalog, map);
school.InstallPeople(roster, seed: 1, "Slavic");
var sept = new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc);
var changed = school.Tick((sept - Start).TotalMinutes / 5d, 5d);
Assert.True(changed);
Assert.True(school.Clock.Time >= sept);
Assert.NotNull(school.Roster);
Assert.True(RosterFit.Matches(school.Roster, demand));
Assert.Equal(roster.People.Count(person => person.IsStudent), school.Roster.People.Count(person => person.IsStudent));
Assert.DoesNotContain(school.Roster.People, person => graduated.Contains(person.Id));
Assert.Contains(school.Roster.Classes, schoolClass => schoolClass.Year == 1);
var peopleQuery = new QueryDescription().WithAll<PersonIdentity>();
var classesQuery = new QueryDescription().WithAll<ClassIdentity>();
Assert.Equal(school.Roster.People.Count, school.World.CountEntities(in peopleQuery));
Assert.Equal(school.Roster.Classes.Count, school.World.CountEntities(in classesQuery));
}
[Fact]
public void TickOnCreateDateOfFirstSeptember_DoesNotIntake()
{
var start = new DateTime(2012, 9, 1, 6, 0, 0, DateTimeKind.Utc);
var catalog = Catalog();
var map = Classrooms(4);
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 3, "Slavic", start);
using var school = School.Create(3, "Первое сентября", start, catalog, map);
school.InstallPeople(roster, seed: 3, "Slavic");
var years = roster.Classes.Select(schoolClass => schoolClass.Year).OrderBy(year => year).ToArray();
var changed = school.Tick(1d / 20d, 5d);
Assert.False(changed);
Assert.Same(roster, school.Roster);
Assert.Equal(years, school.Roster!.Classes.Select(schoolClass => schoolClass.Year).OrderBy(year => year));
}
private static DefCatalog Catalog()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
return new CatalogLoader().Load([CatalogLoader.CorePackId], PackDocumentsFrom(root));
}
private static MapLayout Classrooms(int count, int desks = 16)
{
var rooms = new RoomNode[count];
for (var i = 0; i < count; i++)
{
rooms[i] = new RoomNode
{
Id = $"classroom-{i:00}",
Def = "Classroom",
Building = "main",
Floor = "floor-1",
Label = $"{101 + i}",
Slots =
[
new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks },
],
};
}
return new MapLayout { Rooms = rooms };
}
private static (DefCatalog Catalog, MapLayout Map) Vanilla() private static (DefCatalog Catalog, MapLayout Map) Vanilla()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); var root = Path.Combine(AppContext.BaseDirectory, "vanilla");