From 5a049d43c8355fae27c8a237559776ab0e815d1a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 14:58:39 +0300 Subject: [PATCH 1/2] Cover class and person timetable filters that phases 16-17 already promised. GET ?classId=/personId= and personTimetableQuery had no tests; occupancy on the grid comment still pointed at the old map snapshot. Co-authored-by: Cursor --- .../src/ui/timetableGrid.test.ts | 72 ++++++++++++++++++- src/HSchool.Client/src/ui/timetableGrid.ts | 2 +- .../TimetableApiTests.cs | 44 +++++++++++- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/src/HSchool.Client/src/ui/timetableGrid.test.ts b/src/HSchool.Client/src/ui/timetableGrid.test.ts index ace1a15..7d56f56 100644 --- a/src/HSchool.Client/src/ui/timetableGrid.test.ts +++ b/src/HSchool.Client/src/ui/timetableGrid.test.ts @@ -2,10 +2,10 @@ * @vitest-environment happy-dom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiError, pinLesson, type Timetable, type TimetableLesson } from '../net/api.ts'; +import { ApiError, pinLesson, type PersonCard, type Timetable, type TimetableLesson } from '../net/api.ts'; import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; -import { TimetableGrid } from './timetableGrid.ts'; +import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; vi.mock('../net/api.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -100,4 +100,72 @@ describe('TimetableGrid planner errors', () => { it('does not swallow an unknown failure', async () => { await expect(pinAndReadError(new Error('down'))).resolves.toBe(t('timetableErrorUnknown')); }); + + it('marks a pinned lesson with the locked cell class', () => { + const grid = new TimetableGrid({ editable: true, showClass: false }); + document.body.append(grid.element); + grid.setTable({ ...table(), lessons: [{ ...lesson, locked: true }] }, 'c1'); + + const cell = grid.element.querySelector('.timetable__cell--locked'); + expect(cell).not.toBeNull(); + expect(cell?.getAttribute('title')).toBe(t('timetableLocked')); + }); }); + +describe('personTimetableQuery', () => { + it('asks for a staff member by personId', () => { + expect(personTimetableQuery(card({ id: 't1', roles: ['staff'], classId: null }))).toEqual({ + personId: 't1', + }); + }); + + it('asks for a pupil by classId', () => { + expect(personTimetableQuery(card({ id: 'p1', roles: ['student'], classId: 'c1' }))).toEqual({ + classId: 'c1', + }); + }); + + it('gives a parent who is not staff no personal grid', () => { + expect(personTimetableQuery(card({ id: 'a1', roles: ['parent'], classId: null }))).toBeNull(); + }); +}); + +function card(overrides: Partial): PersonCard { + return { + id: 'p1', + fullName: 'Ivanov Ivan', + surname: 'Ivanov', + given: 'Ivan', + patronymic: '', + female: false, + age: 12, + birthDate: '2000-01-01', + roles: ['student'], + classYear: 5, + classLetter: 'A', + classId: 'c1', + position: null, + positionLabel: null, + body: [], + skills: [], + traits: [], + needs: [], + activity: null, + activityLabel: null, + talkCircleMemberIds: [], + talkTopicId: null, + family: { parents: [], children: [], siblings: [], partners: [] }, + worn: [], + carried: [], + carryMass: 0, + carryCapacity: 0, + hasLocker: false, + homeCount: 0, + hasAvatar: false, + hasCustom: false, + hasFullBody: false, + customPortraitPrompt: null, + connections: null, + ...overrides, + }; +} diff --git a/src/HSchool.Client/src/ui/timetableGrid.ts b/src/HSchool.Client/src/ui/timetableGrid.ts index 008a581..821ba0f 100644 --- a/src/HSchool.Client/src/ui/timetableGrid.ts +++ b/src/HSchool.Client/src/ui/timetableGrid.ts @@ -19,7 +19,7 @@ export interface TimetableGridOptions { } /** - * Day × period grid. Occupancy itself lives on the map snapshot; this is the week table the + * Day × period grid. Live occupancy lives on the presence frame; this is the week table the * player reads and, in Management, pins. */ export class TimetableGrid { diff --git a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs index ff5a6b5..675b7d3 100644 --- a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs +++ b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs @@ -186,7 +186,30 @@ public class TimetableApiTests(AppHostFixture fixture) } [Fact] - public async Task Saturday_HasNoOccupancyOnTheMap() + public async Task GetTimetable_ClassAndPersonFilters_NarrowTheLessons() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Расписание фильтр", TuesdayMorning); + var teacher = await HireMathAsync(client, school.Id); + + var table = await GetTimetableAsync(client, school.Id); + var math = table.Lessons.First(lesson => lesson.Subject == "Mathematics"); + var otherClass = table.Classes.First(klass => klass.Id != math.ClassId); + + var byClass = await GetTimetableAsync(client, school.Id, classId: math.ClassId); + Assert.NotEmpty(byClass.Lessons); + Assert.All(byClass.Lessons, lesson => Assert.Equal(math.ClassId, lesson.ClassId)); + Assert.Contains(byClass.Lessons, lesson => lesson.Subject == "Mathematics"); + Assert.DoesNotContain(byClass.Lessons, lesson => lesson.ClassId == otherClass.Id); + + var byTeacher = await GetTimetableAsync(client, school.Id, personId: teacher); + Assert.NotEmpty(byTeacher.Lessons); + Assert.All(byTeacher.Lessons, lesson => Assert.Equal(teacher, lesson.TeacherId)); + } + + [Fact] + public async Task Saturday_StillStoresTheWeekTable() { using var client = fixture.App.CreateHttpClient("server"); await SchoolApiTests.ResetAsync(client); @@ -206,10 +229,25 @@ public class TimetableApiTests(AppHostFixture fixture) return applicant.Id; } - private static async Task GetTimetableAsync(HttpClient client, int schoolId) + private static async Task GetTimetableAsync( + HttpClient client, + int schoolId, + string? classId = null, + string? personId = null) { + var query = "lang=ru"; + if (classId is not null) + { + query += $"&classId={Uri.EscapeDataString(classId)}"; + } + + if (personId is not null) + { + query += $"&personId={Uri.EscapeDataString(personId)}"; + } + var table = await client.GetFromJsonAsync( - $"/api/schools/{schoolId}/timetable?lang=ru", + $"/api/schools/{schoolId}/timetable?{query}", TestContext.Current.CancellationToken); Assert.NotNull(table); return table; From e7a0260a04b3031eca243fdd5bf21f859d9f5224 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 14:59:49 +0300 Subject: [PATCH 2/2] Record slice 4 recheck after staffing, presence and worker split. Planner, pin API and calendar tests still hold; class/person GET filters now have tests. GET catalog day frame still lacks lunchBreaks. Co-authored-by: Cursor --- docs/phases/04-schedule/reviewed.md | 50 +++++++++++++++++++++++++++++ docs/phases/reviewed.md | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/phases/04-schedule/reviewed.md b/docs/phases/04-schedule/reviewed.md index 6f13fd6..a020b17 100644 --- a/docs/phases/04-schedule/reviewed.md +++ b/docs/phases/04-schedule/reviewed.md @@ -2,6 +2,56 @@ Сводка: [`../reviewed.md`](../reviewed.md). Индекс среза: [`README.md`](README.md). +## Срез 4. Расписание — перепроверка + +- **Фазы:** 14–17 +- **Проверен на:** `5a049d4`, 2026-08-20 +- **Пути:** `src/HSchool.Schedule`, `src/HSchool.Content` (каркас дня, каникулы, `SubjectDef.room`), + `src/HSchool.Simulation/SchoolTimetables.cs`, `src/HSchool.Server/Api/TimetableEndpoints.cs`, + `src/HSchool.Server/Api/TimetableModels.cs`, + `src/HSchool.Server/Game/SchoolWorker.Mailbox.cs` (пин), + `src/HSchool.Server/Game/SchoolWorker.Persist.cs` (сейв и перестроение), + `src/HSchool.Server/Game/SchoolWorker.Tick.cs` (`PeopleChanged` + `TimetableDirty`), + `src/HSchool.Client/src/ui/timetableGrid.ts`, `src/HSchool.Client/src/ui/gameScreen.ts`, + `tests/HSchool.Schedule.Tests`, `tests/HSchool.Content.Tests/CalendarTests.cs`, + `tests/HSchool.Content.Tests/LunchBreakTests.cs`, + `tests/HSchool.AppHost.Tests/TimetableApiTests.cs`, + `src/HSchool.Client/src/ui/timetableGrid.test.ts` +- **Итог:** перепроверка после дрейфа с `bdb1253`; обещания фаз 14–17 целы; дописаны тесты + фильтров `GET` и `personTimetableQuery` + +Что подтверждено: + +- Календарь: суббота, каникулы и ночь — «вне учебного дня»; 10:20 вторника — третий урок; + шестидневка; зима через 1 января. Физкультура в спортзале. Обеденные смены в каркасе дня + (младшие после 3-го, старшие после 4-го). +- Планировщик: все шесть тестов фазы 15 на месте; запреты Arch/ASP.NET/`DateTime.Now` живут + в `LayerBoundaryTests` (срез 10), не в `Schedule.Tests`. +- Перестроение только при `PeopleChanged` + `TimetableDirty`, не по тику — после раскола + воркера это `SchoolWorker.Tick.cs`. `GET /timetable` читает снимок; пин требует владельца + (срез 8) и идёт через `TaskCompletionSource`. `Reload_RestoresLockedLessons`. +- Сетка, закрепление, тексты отказа, скобки в дереве из присутствия (ещё и с числом людей), + личное расписание: учитель `?personId=`, ученик `?classId=`, родитель без сетки. Всё через `t(...)`. +- Занятость узла в протоколе v7 живёт в кадре `Presence`, не в снимке карты — фаза 19. + +Дописано: + +- `TimetableApiTests.GetTimetable_ClassAndPersonFilters_NarrowTheLessons` — `?classId=` и + `?personId=` сужают уроки; это обещание фазы 16 без отдельного теста. +- `personTimetableQuery`: staff → `personId`, ученик → `classId`, родитель не staff → `null`. +- Закреплённая ячейка несёт класс `timetable__cell--locked` и `title` из `t('timetableLocked')`. + +Исправлено: + +- Комментарий у `TimetableGrid` всё ещё писал, что занятость живёт в снимке карты. +- `Saturday_HasNoOccupancyOnTheMap` не проверял занятость — переименован в + `Saturday_StillStoresTheWeekTable` (пустой стол в выходной уже в `TimetableClockTests`). + +Открыто: + +- `GET /api/catalog` по-прежнему отдаёт каркас дня без `lunchBreaks` (только `longBreakAfter`). + Сетке смены не нужны; молча расширять HTTP ревью не стало. + ## Срез 4. Расписание - **Фазы:** 14–17 diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index bab81d1..7c43201 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -23,7 +23,7 @@ | Срез 1. Оболочка и карта | `d087486` | [01-shell](01-shell/reviewed.md) | | Срез 2. Люди | `bdb1253` | [02-people](02-people/reviewed.md) | | Срез 3. Штат | `bdb1253` | [03-staffing](03-staffing/reviewed.md) | -| Срез 4. Расписание | `bdb1253` | [04-schedule](04-schedule/reviewed.md) | +| Срез 4. Расписание | `5a049d4` | [04-schedule](04-schedule/reviewed.md) | | Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) | | Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) | | Срез 7. Этап A — данные и гардероб | `d5547b7` | [07-inventory](07-inventory/reviewed.md) |