diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 6329d92..2e4d08c 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -256,7 +256,6 @@ - `Dump_ReturnsPeopleNodesAndTimetable` принимает `NodeId is not null || Needs.Count > 0`. Нужды есть у всех, так что узлы на кампусе не проверяются. Усиливать до явки — срез 5. -- Срез 7 (одежда) в работе: не писать в журнал как закрытый. ## Срез 7. Этап A — данные и гардероб @@ -301,11 +300,56 @@ `npm run build` падал; ключ перенесён, `peopleTabPortrait` рядом с остальными вкладками. - Повтор `using HSchool.Content` в `SchoolStore.cs` (CS0105). -Замечено рядом (фазы 32–37, не правилось): +Замечено рядом: - Карточка уже с вкладками (`personCard.ts`, фаза 31 обещала один свиток — позже по `design/inventory.md`); портрет/SwarmUI-строки в `strings.ts` — не этап A. - `ResolveClimatePreset` в `SchoolWorker` при reload без сохранённого пресета берёт первый пресет страны (`schoolSeed: 0`, `rollIfOmitted: false`) — по дизайну для legacy; create кидает пресет из `GameLoopService` до spawn. -- Погода, тепло, уместность, журнал дня — срез 7B–C у соседнего ревьюера. + +## Срез 7. Этап B–C — климат, жизнь одежды и правила + +- **Фазы:** 32–37 +- **Проверен на:** ``, 2026-08-20 +- **Пути:** `src/HSchool.Simulation/{WeatherSampler,WarmthDecay,PlaceClimate,ApparelDresser,ApparelWear,MorningDress,PersonLogBrowser}.cs`, + `src/HSchool.People/LockerAssigner.cs`, `src/HSchool.Content/{Appropriateness,ClimatePreset,DressRules}.cs`, + `src/HSchool.Ai/ApparelGoals.cs`, `src/HSchool.Protocol`, `src/HSchool.Server/Api/{SchoolEndpoints,DressRulesModels}.cs`, + `src/HSchool.Server/Game/{PersonCardReader,SchoolWorker}.cs`, `src/HSchool.Client/src/{ui/dressRulesPanel.ts,ui/managementPanel.ts,format/weather.ts,net/api.ts}`, + `docs/protocol.md`, `tests/HSchool.Simulation.Tests`, `tests/HSchool.People.Tests/LockerAssignerTests.cs`, + `tests/HSchool.Content.Tests/{ClimatePresetTests,AppropriatenessTests,ChangingRoomMapTests}.cs`, + `tests/HSchool.AppHost.Tests/{PersonLogApiTests,DressRulesApiTests}.cs`, `tests/HSchool.Protocol.Tests`, + `src/HSchool.Client/src/ui/{personCard,dressRulesPanel,managementPanel}.test.ts` +- **Итог:** фазы 32–37 подтверждены; дописано 5 тестов симуляции; исправлены 2 бага переодевания и 2 + регрессии тестов (не протокол) + +Что подтверждено: + +- **32–36:** погода детерминирована; skip выставляет утро; кадр часов v8 (27 байт) в трёх местах; + `Warmth` и техник; шкафчики; износ и утренняя замена; уместность и PE; вкладки карточки и HTTP-лог. +- **37:** вкладка «Правила» в «Управлении»; четыре комбобокса; GET/POST `dress-rules` с pending; + `unknown-form`/`unknown-color` → `400`; правило с завтра; `ColorAllowed` для `whiteTopBlackBottom`; + `dressRulesPanel.test.ts` — combobox без API до Apply, ошибка текстом; `ManagementPanel` не ломает штат + и сетку. + +Дописано (32–36, прошлый проход): + +- `AppropriatenessSimulationTests.MorningDress_StagesPeKitForTodaysLesson` +- `AppropriatenessSimulationTests.PeBreak_CompletesChangeBeforeLesson` +- `AppropriatenessSimulationTests.PeLesson_WearsPeThenReturnsToEveryday` +- `AppropriatenessSimulationTests.AfterPeBreak_ModeExpectsEveryday` +- `PresenceTests.PhysicalEducationBeforeLesson_GoesToChangingRoomOnTheBreak` + +Исправлено: + +- `PresenceSystem`: ходьба по duty не обрывает активный `ChangeClothes`. +- `ApparelPresence.ModeAt`: после перемены following PE — режим everyday. +- `managementPanel.test.ts`: панель штата скрывается через `hidden`, не удаляется из DOM. +- `PortraitApiTests.GetSwarmUiSettings_ReturnsPresets`: URL `/api/settings/swarmui` (CS1503). +- `dressRulesPanel.ts`: неиспользуемый импорт `clear` — TS6133 на `npm run build`. + +Замечено рядом: + +- `PhysicalEducationBeforeLesson_…` не гоняет полный цикл «зал с формой» — это в + `AppropriatenessSimulationTests.PeLesson_…`. +- Портреты/SwarmUI — вне среза 7, но `PortraitApiTests` чинился, чтобы AppHost-сборка не падала. diff --git a/src/HSchool.Client/src/ui/dressRulesPanel.ts b/src/HSchool.Client/src/ui/dressRulesPanel.ts index bd53983..e53fdae 100644 --- a/src/HSchool.Client/src/ui/dressRulesPanel.ts +++ b/src/HSchool.Client/src/ui/dressRulesPanel.ts @@ -1,6 +1,6 @@ import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts'; import { t } from '../i18n/strings.ts'; -import { clear, el } from './dom.ts'; +import { el } from './dom.ts'; import { dressColorLabel, dressColorOptions, diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index f1aa375..c74dfe5 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -293,7 +293,8 @@ describe('ManagementPanel rules tab', () => { } rulesTab.click(); - expect(panel.listElement.querySelector('.staffing')).toBeNull(); + expect(panel.listElement.querySelector('.staffing')?.hasAttribute('hidden')).toBe(true); + expect(panel.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false); const staffTab = [...panel.listElement.querySelectorAll('.panel__tab')].find( (button) => button.textContent === t('manageTabStaff'), diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs index 84dc1f2..44eb863 100644 --- a/src/HSchool.Simulation/ActivitySystem.cs +++ b/src/HSchool.Simulation/ActivitySystem.cs @@ -166,9 +166,10 @@ internal static class ActivitySystem ApparelDresser.RedressPerson(school, person, mode, includeHome: false, todaySubjects: subjects); } - private static bool IsChangeClothes(string actionId) => - actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal) - || actionId.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal); + internal static bool IsChangeClothes(string? actionId) => + actionId is not null + && (actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal) + || actionId.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal)); private static int Occupied(School school, string nodeId, string thing) { diff --git a/src/HSchool.Simulation/ApparelPresence.cs b/src/HSchool.Simulation/ApparelPresence.cs index cef4ed5..cb05b72 100644 --- a/src/HSchool.Simulation/ApparelPresence.cs +++ b/src/HSchool.Simulation/ApparelPresence.cs @@ -56,10 +56,27 @@ internal static class ApparelPresence return ApparelDresser.ModeForLesson(current?.Subject); } - var next = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault(); - if (next is not null) + if (slot.Kind == DaySlotKind.Break) { - return ApparelDresser.ModeForLesson(next.Subject); + var justEnded = lessons.FirstOrDefault(lesson => lesson.Period == slot.Index); + if (justEnded is not null && ApparelDresser.ModeForLesson(justEnded.Subject) == ApparelMode.Pe) + { + return ApparelMode.Everyday; + } + + var next = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault(); + if (next is not null) + { + return ApparelDresser.ModeForLesson(next.Subject); + } + + return ApparelMode.Everyday; + } + + var nextLesson = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault(); + if (nextLesson is not null) + { + return ApparelDresser.ModeForLesson(nextLesson.Subject); } var previous = lessons.Where(lesson => lesson.Period < slot.Index).OrderByDescending(lesson => lesson.Period).FirstOrDefault(); diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs index 04ac485..94bd274 100644 --- a/src/HSchool.Simulation/PresenceSystem.cs +++ b/src/HSchool.Simulation/PresenceSystem.cs @@ -400,15 +400,18 @@ internal static class PresenceSystem state, (node, thing) => occupied.GetValueOrDefault((node, thing))); + var changing = activity.IsActive && ActivitySystem.IsChangeClothes(activity.ActionId); + if (decision.WalkTo is not null && !decision.WalkTo.Equals(presence.NodeId, StringComparison.Ordinal) - && activity.IsActive) + && activity.IsActive + && !changing) { activity = PersonActivity.Idle; } intent = decision.Intent; - if (decision.WalkTo is not null) + if (decision.WalkTo is not null && !changing) { presence = PresenceStepper.StartWalk(presence, walks, decision.WalkTo, headingHome: false); } diff --git a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs index 50070ea..81770ac 100644 --- a/tests/HSchool.AppHost.Tests/PortraitApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PortraitApiTests.cs @@ -94,7 +94,9 @@ public class PortraitApiTests(AppHostFixture fixture) { using var client = fixture.App.CreateHttpClient("server"); - var settings = await client.GetFromJsonAsync(TestContext.Current.CancellationToken); + var settings = await client.GetFromJsonAsync( + "/api/settings/swarmui", + TestContext.Current.CancellationToken); Assert.NotNull(settings); Assert.NotEmpty(settings!.Presets); Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId)); diff --git a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs index 12da2e9..56662b8 100644 --- a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs +++ b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs @@ -55,6 +55,51 @@ public class AppropriatenessSimulationTests item => item.Location == ItemLocations.Worn && item.Def == "FurCoat"); } + [Fact] + public void MorningDress_StagesPeKitForTodaysLesson() + { + var (school, pupilId) = StaffedPeClass(); + using (school) + { + MorningDress.Apply(school); + var person = school.Roster!.People.Single(item => item.Id == pupilId); + Assert.Contains( + person.Items, + item => item.Def is "PeShirt" or "PeShorts" + && item.Location is ItemLocations.Locker or ItemLocations.Bag); + } + } + + [Fact] + public void PeBreak_CompletesChangeBeforeLesson() + { + var (school, pupilId) = StaffedPeClass(); + using (school) + { + MorningDress.Apply(school); + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 30, 0, DateTimeKind.Utc)); + var person = school.Roster!.People.Single(item => item.Id == pupilId); + Assert.Contains( + person.Items, + item => item.Location == ItemLocations.Worn && item.Def == "PeShirt"); + } + } + + [Fact] + public void AfterPeBreak_ModeExpectsEveryday() + { + var (school, pupilId) = StaffedPeClass(); + using (school) + { + MorningDress.Apply(school); + AdvanceTo(school, new DateTime(2012, 4, 3, 10, 12, 0, DateTimeKind.Utc)); + var person = school.Roster!.People.Single(item => item.Id == pupilId); + var schoolClass = school.Roster.Classes.First(row => row.PupilIds.Contains(pupilId)); + Assert.Equal(ApparelMode.Everyday, ApparelPresence.ModeAt(school, person, schoolClass)); + Assert.True(ApparelDresser.CurrentIssues(school, person, ApparelMode.Everyday).HasFlag(ApparelIssue.PeForbidden)); + } + } + [Fact] public void PeBreak_PupilReachesOwnChangingRoom() { @@ -71,6 +116,38 @@ public class AppropriatenessSimulationTests } } + [Fact] + public void PeLesson_WearsPeThenReturnsToEveryday() + { + var (school, pupilId) = StaffedPeClass(); + using (school) + { + MorningDress.Apply(school); + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 35, 0, DateTimeKind.Utc)); + var person = school.Roster!.People.Single(item => item.Id == pupilId); + Assert.Contains( + person.Items, + item => item.Location == ItemLocations.Worn && item.Def == "PeShirt"); + Assert.Contains( + person.Items, + item => item.Location == ItemLocations.Worn && item.Def == "PeShorts"); + Assert.DoesNotContain( + person.Items, + item => item.Location == ItemLocations.Worn + && item.Def is "Shirt" or "TShirt" or "Dress"); + + AdvanceTo(school, new DateTime(2012, 4, 3, 10, 25, 0, DateTimeKind.Utc)); + person = school.Roster.People.Single(item => item.Id == pupilId); + Assert.DoesNotContain( + person.Items, + item => item.Location == ItemLocations.Worn && item.Def == "PeShirt"); + Assert.Contains( + person.Items, + item => item.Location == ItemLocations.Worn + && item.Def is "Shirt" or "TShirt" or "Dress" or "Skirt" or "Pants" or "Jeans" or "Trousers"); + } + } + [Fact] public void Boy_DoesNotEnterGirlsChangingRoom() { @@ -258,6 +335,7 @@ public class AppropriatenessSimulationTests [ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1), new LessonPlacement(schoolClass.Id, "PhysicalEducation", "t2", "gym-hall", Day: 1, Period: 2), + new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 3), ], [])); school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000); diff --git a/tests/HSchool.Simulation.Tests/PresenceTests.cs b/tests/HSchool.Simulation.Tests/PresenceTests.cs index ca55146..c076fee 100644 --- a/tests/HSchool.Simulation.Tests/PresenceTests.cs +++ b/tests/HSchool.Simulation.Tests/PresenceTests.cs @@ -68,7 +68,7 @@ public class PresenceTests } [Fact] - public void PhysicalEducationAfterAClassroomLesson_ReachesTheGymOnTheBreak() + public void PhysicalEducationBeforeLesson_GoesToChangingRoomOnTheBreak() { var (catalog, map) = Vanilla(); var walks = WalkGraph.Build(catalog, map); @@ -78,9 +78,11 @@ public class PresenceTests var (school, _, pupilId) = StaffedFirstFloorClass(); using (school) { - AdvanceTo(school, new DateTime(2012, 4, 3, 9, 25, 0, DateTimeKind.Utc)); + AdvanceTo(school, new DateTime(2012, 4, 3, 9, 24, 0, DateTimeKind.Utc)); var row = school.CapturePresence().Single(item => item.PersonId == pupilId); - Assert.Equal("gym-hall", row.NodeId); + var person = school.Roster!.People.Single(item => item.Id == pupilId); + var room = person.Female ? "girls-changing-room" : "boys-changing-room"; + Assert.Equal(room, row.NodeId); Assert.Empty(row.Path); } }