Enhance school day structure and decision-making for lunch breaks
- Updated `ai.md` to clarify the mechanics of hunger restoration and the importance of lunch breaks in the school schedule. - Revised `schedule.md` to detail the new lunch break structure, allowing for separate sittings for different grade levels. - Enhanced `Decision.cs` and `DecisionPlanner.cs` to incorporate logic for lunch breaks, ensuring that students only leave lessons during their designated lunch windows. - Updated `DayFrameDef` and related classes to support multiple lunch breaks and validate their configurations. - Adjusted tests to validate the new decision-making logic regarding lunch breaks and hunger management, ensuring robust functionality. - Improved localization strings to reflect changes in the school day structure and lunch functionalities.
This commit is contained in:
@@ -202,13 +202,77 @@ public class DecisionPlannerTests
|
||||
Assert.Null(decision.StartAction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lunch is a timetable, not an urge. Inside the sitting a pupil who is merely peckish still
|
||||
/// goes; outside it a hungry one does not walk out of the corridor to the canteen, and a
|
||||
/// hungry one in class does not walk out of the lesson either.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OutsideTheSitting_HungerFindsNoAction()
|
||||
{
|
||||
var (catalog, map, walks) = World();
|
||||
var corridor = map.Rooms.First(room => room.Def == "Corridor").Id;
|
||||
var needs = FullNeeds();
|
||||
needs["Hunger"] = 0.05f;
|
||||
|
||||
var closed = DecisionPlanner.Decide(
|
||||
catalog, map, walks,
|
||||
Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: false),
|
||||
(_, _) => 0);
|
||||
|
||||
Assert.NotEqual("EatLunch", closed.Intent.ActionId);
|
||||
|
||||
var open = DecisionPlanner.Decide(
|
||||
catalog, map, walks,
|
||||
Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: true),
|
||||
(_, _) => 0);
|
||||
|
||||
Assert.Equal(GoalKind.Need, open.Intent.Kind);
|
||||
Assert.Equal("EatLunch", open.Intent.ActionId);
|
||||
Assert.Equal("Cafeteria", map.Rooms.First(room => room.Id == open.WalkTo).Def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsideTheSitting_EvenAPeckishPupilGoesToEat()
|
||||
{
|
||||
var (catalog, map, walks) = World();
|
||||
var corridor = map.Rooms.First(room => room.Def == "Corridor").Id;
|
||||
var needs = FullNeeds();
|
||||
needs["Hunger"] = 0.6f;
|
||||
|
||||
var decision = DecisionPlanner.Decide(
|
||||
catalog, map, walks,
|
||||
Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: true),
|
||||
(_, _) => 0);
|
||||
|
||||
Assert.Equal("EatLunch", decision.Intent.ActionId);
|
||||
Assert.Equal(DecisionPlanner.LunchWeight, decision.Intent.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALessonOutweighsTheSitting()
|
||||
{
|
||||
var (catalog, map, walks) = World();
|
||||
var classroom = map.Rooms.First(room => room.Def == "Classroom").Id;
|
||||
var needs = FullNeeds();
|
||||
needs["Hunger"] = 0.6f;
|
||||
|
||||
var decision = DecisionPlanner.Decide(
|
||||
catalog, map, walks,
|
||||
Actor(classroom, boundToLesson: true, classroom, needs, lunchWindowOpen: true),
|
||||
(_, _) => 0);
|
||||
|
||||
Assert.Equal(GoalKind.Duty, decision.Intent.Kind);
|
||||
}
|
||||
|
||||
private static ActorState Actor(
|
||||
string node,
|
||||
bool boundToLesson,
|
||||
string dutyRoom,
|
||||
IReadOnlyDictionary<string, float> needs,
|
||||
Intent? intent = null,
|
||||
bool activityActive = false) =>
|
||||
bool activityActive = false,
|
||||
bool lunchWindowOpen = false) =>
|
||||
new(
|
||||
node,
|
||||
node,
|
||||
@@ -220,7 +284,8 @@ public class DecisionPlannerTests
|
||||
boundToLesson,
|
||||
dutyRoom,
|
||||
needs,
|
||||
intent ?? Intent.None);
|
||||
intent ?? Intent.None,
|
||||
lunchWindowOpen);
|
||||
|
||||
private static Dictionary<string, float> FullNeeds() => new(StringComparer.Ordinal)
|
||||
{
|
||||
|
||||
@@ -204,12 +204,17 @@ public class GameSocketTests(AppHostFixture fixture)
|
||||
Assert.NotNull(directory);
|
||||
Assert.Contains(directory.People, person => person.Id == applicant.Id && person.FullName == applicant.FullName);
|
||||
|
||||
// Resume and check that people ride the frame at all — that is what "occupancy is on
|
||||
// presence, not the snapshot" means. Do not wait for the freshly hired teacher in
|
||||
// particular: whether somebody hired mid-day comes in today depends on their day plan,
|
||||
// and the frozen moment drifts with however long this test's own HTTP calls took (the
|
||||
// school runs at five game minutes per real second until it is paused). Waiting for that
|
||||
// one person made this test fail under load.
|
||||
await SendAsync(socket, buffer =>
|
||||
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
|
||||
presence = await ReceivePresenceWhereAsync(
|
||||
socket,
|
||||
frame => frame.People.Any(person => person.Id == applicant.Id));
|
||||
Assert.Contains(presence.People, person => person.Id == applicant.Id);
|
||||
presence = await ReceivePresenceWhereAsync(socket, frame => frame.People.Count > 0);
|
||||
Assert.All(presence.People, person => Assert.False(string.IsNullOrWhiteSpace(person.NodeId)));
|
||||
Assert.Contains(presence.People, person => directory.People.Any(row => row.Id == person.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -179,25 +179,10 @@ public class StaffingApiTests(AppHostFixture fixture)
|
||||
Assert.True(assigned.Payroll > hired.Payroll);
|
||||
Assert.Equal(2, assigned.Staff.Single().Subjects.Count);
|
||||
|
||||
using var over = await client.PostAsJsonAsync(
|
||||
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects",
|
||||
new { subject = "Literature" },
|
||||
TestContext.Current.CancellationToken);
|
||||
// Cheap asks may still fit; only the numbers matter when the cap actually bites.
|
||||
if (over.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
var problem = await over.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("payroll-exceeded", problem?.Code);
|
||||
Assert.True(problem?.Attempted > problem?.Allocated);
|
||||
Assert.Equal(assigned.Payroll, problem?.Payroll);
|
||||
}
|
||||
else
|
||||
{
|
||||
over.EnsureSuccessStatusCode();
|
||||
assigned = await over.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(assigned);
|
||||
}
|
||||
|
||||
// A third subject used to be tried here with an if/else around the cap, which meant the
|
||||
// test passed whichever way it went. The refusal has its own test now
|
||||
// (AssigningPastTheCap_IsRejectedWithTheNumbers); this one is about assign, unassign and
|
||||
// what survives a restart.
|
||||
var payrollWithSubjects = assigned.Payroll;
|
||||
using var unassign = await client.DeleteAsync(
|
||||
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher.Id)}/subjects/RussianLanguage",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace HSchool.Content.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Two sittings instead of one crowded canteen: juniors eat after the third lesson, seniors after
|
||||
/// the fourth. The list also decides which breaks are long, because fifteen minutes of lunch do
|
||||
/// not fit into ten.
|
||||
/// </summary>
|
||||
public class LunchBreakTests
|
||||
{
|
||||
private readonly CatalogLoader _loader = new();
|
||||
|
||||
[Fact]
|
||||
public void VanillaCore_FeedsJuniorsAndSeniorsAtDifferentBreaks()
|
||||
{
|
||||
var frame = LoadVanilla().DayFrame!;
|
||||
|
||||
Assert.Equal(2, frame.LunchBreaks.Count);
|
||||
Assert.Equal(3, SchoolDay.LunchBreakAfter(frame, year: 1));
|
||||
Assert.Equal(3, SchoolDay.LunchBreakAfter(frame, year: 5));
|
||||
Assert.Equal(4, SchoolDay.LunchBreakAfter(frame, year: 6));
|
||||
Assert.Equal(4, SchoolDay.LunchBreakAfter(frame, year: 11));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryBreakSomebodyEatsIn_IsALongOne()
|
||||
{
|
||||
var frame = LoadVanilla().DayFrame!;
|
||||
|
||||
Assert.True(SchoolDay.IsLongBreakAfter(frame, 3));
|
||||
Assert.True(SchoolDay.IsLongBreakAfter(frame, 4));
|
||||
Assert.False(SchoolDay.IsLongBreakAfter(frame, 2));
|
||||
|
||||
// The fourth break is twenty minutes now, so the fifth lesson starts at half past noon
|
||||
// rather than twenty past: the second sitting has to fit into the day.
|
||||
Assert.Equal(new TimeOnly(12, 30), SchoolDay.PeriodStart(frame, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LunchWindow_IsOpenOnlyAtTheSittingOfThatParallel()
|
||||
{
|
||||
var frame = LoadVanilla().DayFrame!;
|
||||
|
||||
Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(3), year: 4));
|
||||
Assert.False(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(4), year: 4));
|
||||
Assert.False(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(3), year: 9));
|
||||
Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(4), year: 9));
|
||||
|
||||
// Staff belong to no parallel and can eat at either sitting; a lesson is never a window.
|
||||
Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(3), year: null));
|
||||
Assert.True(SchoolDay.IsLunchWindow(frame, DaySlot.BreakAfter(4), year: null));
|
||||
Assert.False(SchoolDay.IsLunchWindow(frame, DaySlot.Lesson(3), year: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASittingAfterTheLastLesson_FailsTheCatalog()
|
||||
{
|
||||
var error = Assert.Throws<ContentLoadException>(() => LoadWithSittings(
|
||||
"""{ "afterLesson": 7, "gradeMin": 1, "gradeMax": 11 }"""));
|
||||
|
||||
Assert.Contains("lunch break", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoSittingsForTheSameParallel_FailTheCatalog()
|
||||
{
|
||||
var error = Assert.Throws<ContentLoadException>(() => LoadWithSittings(
|
||||
"""{ "afterLesson": 3, "gradeMin": 1, "gradeMax": 5 }, { "afterLesson": 4, "gradeMin": 5, "gradeMax": 11 }"""));
|
||||
|
||||
Assert.Contains("two lunch breaks", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private DefCatalog LoadWithSittings(string sittings) =>
|
||||
_loader.Load(
|
||||
[CatalogLoader.CorePackId],
|
||||
[
|
||||
PackDocuments.Def(
|
||||
CatalogLoader.CorePackId,
|
||||
"dayframe",
|
||||
"day",
|
||||
$$"""
|
||||
{
|
||||
"defName": "Day",
|
||||
"firstLesson": "08:30",
|
||||
"lessonCount": 7,
|
||||
"lessonMinutes": 45,
|
||||
"breakMinutes": 10,
|
||||
"longBreakAfter": 3,
|
||||
"longBreakMinutes": 20,
|
||||
"lunchBreaks": [{{sittings}}]
|
||||
}
|
||||
"""),
|
||||
]);
|
||||
|
||||
private DefCatalog LoadVanilla()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||
return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class PeopleDefTests
|
||||
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind);
|
||||
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
|
||||
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus);
|
||||
Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour);
|
||||
Assert.Equal(0.2f, catalog.Needs["Hunger"].DecayPerHour);
|
||||
Assert.True(catalog.Skills["Communication"].Always);
|
||||
Assert.True(catalog.Skills["Agility"].Always);
|
||||
Assert.True(catalog.Skills["Pedagogy"].Work);
|
||||
|
||||
@@ -71,7 +71,11 @@ public class VanillaCoreTests
|
||||
Assert.Equal("Hunger", catalog.Actions["EatLunch"].Need);
|
||||
Assert.Equal(0.5f, catalog.Actions["EatLunch"].NeedGain);
|
||||
Assert.Equal("SchoolYard", catalog.Actions["WalkYard"].Room);
|
||||
Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour);
|
||||
// Hunger falls fast enough to be felt inside one school day and, like sleep, comes back
|
||||
// off campus — people eat at home. Before that pair of numbers the school went hungry and
|
||||
// never recovered: half of it sat below the urgency threshold permanently.
|
||||
Assert.Equal(0.2f, catalog.Needs["Hunger"].DecayPerHour);
|
||||
Assert.True(catalog.Needs["Hunger"].RestoredOffCampus);
|
||||
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus);
|
||||
Assert.NotNull(catalog.BehaviorRules);
|
||||
Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin);
|
||||
|
||||
@@ -129,6 +129,37 @@ public class SkillGrantTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A school saved before native languages carries no pick, and the reload path asks for one
|
||||
/// with <c>rollIfOmitted: false</c>. The contract is that it takes the first listed language
|
||||
/// instead of rolling — a roll would change an existing school's tongue on the next start,
|
||||
/// and the intake after it would draw different people.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReloadWithoutASavedPick_TakesTheFirstLanguageInsteadOfRolling()
|
||||
{
|
||||
var names = Fixtures.Catalog().NameSets["Slavic"];
|
||||
Assert.True(names.Spoken.Count > 1, "this pins behaviour that only matters for a multi-language set");
|
||||
|
||||
foreach (var seed in new[] { 1, 2, 7, 20260818 })
|
||||
{
|
||||
Assert.Equal(names.Spoken[0], NativeLanguages.Pick(names, seed, requested: null, rollIfOmitted: false));
|
||||
}
|
||||
|
||||
// A new school rolls instead, and the roll is stable for one seed.
|
||||
var rolled = NativeLanguages.Pick(names, 7, requested: null, rollIfOmitted: true);
|
||||
Assert.Contains(rolled, names.Spoken);
|
||||
Assert.Equal(rolled, NativeLanguages.Pick(names, 7, requested: null, rollIfOmitted: true));
|
||||
|
||||
// A saved pick is honoured whichever way it is asked for.
|
||||
Assert.Equal(
|
||||
names.Spoken[^1],
|
||||
NativeLanguages.Pick(names, 7, names.Spoken[^1], rollIfOmitted: false));
|
||||
|
||||
// Anything outside the set comes back null so the create path can answer 400.
|
||||
Assert.Null(NativeLanguages.Pick(names, 7, "Klingon", rollIfOmitted: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OmittedNativeLanguage_IsStableForTheSameSeed()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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}"));
|
||||
}
|
||||
|
||||
private static School StaffedSchool()
|
||||
{
|
||||
var (catalog, map) = Vanilla();
|
||||
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Tuesday6);
|
||||
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", 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, "Slavic", 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)
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
if (pupils.Contains(identity.Id) && needs.Values.TryGetValue("Hunger", out var value))
|
||||
{
|
||||
values[identity.Id] = value;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class NeedDecayTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sleep_ReturnsToMaxOffCampus()
|
||||
public void SleepAndHunger_ReturnToMaxOffCampus()
|
||||
{
|
||||
var catalog = VanillaCatalog();
|
||||
var world = World.Create();
|
||||
@@ -50,8 +50,9 @@ public class NeedDecayTests
|
||||
var query = new QueryDescription().WithAll<PersonNeeds>();
|
||||
world.Query(in query, (ref PersonNeeds needs) =>
|
||||
{
|
||||
// Both are restored за кадром: the day at home covers a night and meals alike.
|
||||
Assert.Equal(catalog.Needs["Sleep"].Max, needs.Values["Sleep"]);
|
||||
Assert.Equal(0.4f, needs.Values["Hunger"]);
|
||||
Assert.Equal(catalog.Needs["Hunger"].Max, needs.Values["Hunger"]);
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace HSchool.Simulation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What used to be checked through <c>SchoolRegistry</c>. The registry is gone — the server gives
|
||||
/// every school its own worker — but the name and start-date rules it guarded are still the ones
|
||||
/// the create endpoint applies.
|
||||
/// </summary>
|
||||
public class SchoolNamesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\u0009\u000A")]
|
||||
public void BlankNames_AreRejected(string? name)
|
||||
{
|
||||
Assert.False(SchoolNames.TryNormalize(name, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlongNames_AreRejected()
|
||||
{
|
||||
Assert.False(SchoolNames.TryNormalize(new string('ш', School.MaxNameLength + 1), out _));
|
||||
Assert.True(SchoolNames.TryNormalize(new string('ш', School.MaxNameLength), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SurroundingSpaceAndControlCharacters_AreStripped()
|
||||
{
|
||||
Assert.True(SchoolNames.TryNormalize(" Лицей ", out var normalized));
|
||||
Assert.Equal("Лицей", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDatesOutsideTheSupportedRange_AreRejected()
|
||||
{
|
||||
Assert.False(GameClock.IsValidStartDate(new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
Assert.False(GameClock.IsValidStartDate(new DateTime(3200, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
Assert.True(GameClock.IsValidStartDate(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestedNames_AreUsableAndNeverRepeatATakenOne()
|
||||
{
|
||||
var generator = new SchoolNameGenerator(new Random(1234));
|
||||
var taken = new List<string>();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var suggestion = generator.Next(taken, SchoolNameLanguage.Russian);
|
||||
Assert.True(SchoolNames.TryNormalize(suggestion, out _), $"\"{suggestion}\" is not a usable name.");
|
||||
Assert.DoesNotContain(suggestion, taken, StringComparer.OrdinalIgnoreCase);
|
||||
taken.Add(suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestedNames_FitTheNameLimitInBothLanguages()
|
||||
{
|
||||
var generator = new SchoolNameGenerator(new Random(1234));
|
||||
|
||||
foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English })
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
Assert.InRange(generator.Next([], language).Length, 1, School.MaxNameLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestedEnglishNames_AreAscii()
|
||||
{
|
||||
var generator = new SchoolNameGenerator(new Random(1234));
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
Assert.Matches("^[A-Za-z0-9 .]+$", generator.Next([], SchoolNameLanguage.English));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
namespace HSchool.Simulation.Tests;
|
||||
|
||||
public class SchoolRegistryTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static SchoolRegistry NewRegistry(int maxSchools = 6) =>
|
||||
new(new SimulationOptions { MaxSchools = maxSchools, TickRate = 20, GameMinutesPerRealSecond = 5 });
|
||||
|
||||
[Fact]
|
||||
public void NewRegistry_IsEmpty()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
Assert.Equal(0, registry.Count);
|
||||
Assert.Equal(6, registry.MaxSchools);
|
||||
Assert.False(registry.IsFull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_AddsASchoolAtTheGivenStartDate()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
var result = registry.Create("Гимназия №1", Start);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("Гимназия №1", result.School!.Name);
|
||||
Assert.Equal(Start, result.School.Clock.Time);
|
||||
|
||||
// A new school starts living straight away; only the pause button stops it.
|
||||
Assert.True(result.School.Clock.IsRunning);
|
||||
Assert.Equal(1, registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_BeyondTheLimit_Fails()
|
||||
{
|
||||
using var registry = NewRegistry(maxSchools: 2);
|
||||
registry.Create("Первая", Start);
|
||||
registry.Create("Вторая", Start);
|
||||
|
||||
var result = registry.Create("Третья", Start);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(SchoolCreationError.LimitReached, result.Error);
|
||||
Assert.True(registry.IsFull);
|
||||
Assert.Equal(2, registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_FreesASlot()
|
||||
{
|
||||
using var registry = NewRegistry(maxSchools: 1);
|
||||
var first = registry.Create("Первая", Start).School!;
|
||||
|
||||
Assert.False(registry.Create("Вторая", Start).Succeeded);
|
||||
Assert.True(registry.Delete(first.Id));
|
||||
|
||||
Assert.True(registry.Create("Вторая", Start).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_UnknownId_ReportsFailure()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
Assert.False(registry.Delete(42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ids_AreNotReusedAfterDeletion()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
var first = registry.Create("Первая", Start).School!;
|
||||
registry.Delete(first.Id);
|
||||
|
||||
var second = registry.Create("Вторая", Start).School!;
|
||||
|
||||
Assert.NotEqual(first.Id, second.Id);
|
||||
Assert.Null(registry.Find(first.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t\n")]
|
||||
public void Create_RejectsBlankNames(string name)
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
Assert.Equal(SchoolCreationError.InvalidName, registry.Create(name, Start).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_RejectsOverlongNames()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
var result = registry.Create(new string('ш', School.MaxNameLength + 1), Start);
|
||||
|
||||
Assert.Equal(SchoolCreationError.InvalidName, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_TrimsAndStripsControlCharacters()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
var result = registry.Create(" Лицей ", Start);
|
||||
|
||||
Assert.Equal("Лицей", result.School!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_RejectsStartDatesOutsideTheSupportedRange()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
|
||||
var result = registry.Create("Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(SchoolCreationError.InvalidStartDate, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_AdvancesOnlyRunningSchools()
|
||||
{
|
||||
using var registry = NewRegistry();
|
||||
var running = registry.Create("Идёт", Start).School!;
|
||||
var paused = registry.Create("Стоит", Start).School!;
|
||||
paused.Clock.IsRunning = false;
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
registry.Tick();
|
||||
}
|
||||
|
||||
Assert.Equal(Start.AddMinutes(5), running.Clock.Time);
|
||||
Assert.Equal(Start, paused.Clock.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestName_NeverRepeatsAnExistingName()
|
||||
{
|
||||
using var registry = NewRegistry(maxSchools: 20);
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var suggestion = registry.SuggestName();
|
||||
Assert.True(registry.Create(suggestion, Start).Succeeded, $"\"{suggestion}\" was rejected.");
|
||||
}
|
||||
|
||||
var names = registry.Schools.Select(school => school.Name).ToArray();
|
||||
Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestedNames_FitTheNameLimit()
|
||||
{
|
||||
var generator = new SchoolNameGenerator(new Random(1234));
|
||||
|
||||
foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English })
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var name = generator.Next([], language);
|
||||
Assert.InRange(name.Length, 1, School.MaxNameLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestedEnglishNames_AreAscii()
|
||||
{
|
||||
var generator = new SchoolNameGenerator(new Random(1234));
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var name = generator.Next([], SchoolNameLanguage.English);
|
||||
Assert.Matches("^[A-Za-z0-9 .]+$", name);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DropsEverySchool()
|
||||
{
|
||||
var registry = NewRegistry();
|
||||
registry.Create("Школа", Start);
|
||||
|
||||
registry.Dispose();
|
||||
|
||||
Assert.Equal(0, registry.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user