From 4096d5306eb8582503b46884fc239723037ee950 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 09:37:37 +0300 Subject: [PATCH 1/2] Add class-teacher slots on SchoolClass with owner-only assign/clear. EOF Co-authored-by: Cursor --- docs/phases/12-office/68-class-teacher.md | 26 +- docs/protocol.md | 40 ++- src/HSchool.Client/src/i18n/strings.ts | 18 ++ src/HSchool.Client/src/net/api.ts | 40 +++ .../src/ui/managementPanel.test.ts | 49 ++++ src/HSchool.Client/src/ui/managementPanel.ts | 91 ++++++- src/HSchool.Client/src/ui/personCard.test.ts | 15 ++ src/HSchool.Client/src/ui/personCard.ts | 13 +- src/HSchool.Client/src/ui/staffingUi.ts | 17 ++ .../src/ui/timetableGrid.test.ts | 2 + src/HSchool.People/ClassTeachers.cs | 82 ++++++ src/HSchool.People/Roster.cs | 4 +- src/HSchool.Server/Api/PeopleModels.cs | 49 +++- src/HSchool.Server/Api/SchoolEndpoints.cs | 107 ++++++++ src/HSchool.Server/Game/GameCommand.cs | 11 + src/HSchool.Server/Game/GameLoopService.cs | 25 ++ src/HSchool.Server/Game/PersonCardReader.cs | 12 +- .../Game/SchoolWorker.Mailbox.cs | 47 ++++ src/HSchool.Server/Game/WorkerCommand.cs | 9 + src/HSchool.Simulation/School.cs | 11 + .../ClassTeacherApiTests.cs | 251 ++++++++++++++++++ .../HSchool.People.Tests/ClassTeacherTests.cs | 95 +++++++ 22 files changed, 992 insertions(+), 22 deletions(-) create mode 100644 src/HSchool.People/ClassTeachers.cs create mode 100644 tests/HSchool.AppHost.Tests/ClassTeacherApiTests.cs create mode 100644 tests/HSchool.People.Tests/ClassTeacherTests.cs diff --git a/docs/phases/12-office/68-class-teacher.md b/docs/phases/12-office/68-class-teacher.md index 479329c..5d570bb 100644 --- a/docs/phases/12-office/68-class-teacher.md +++ b/docs/phases/12-office/68-class-teacher.md @@ -12,22 +12,22 @@ ## Задачи -- [ ] У `SchoolClass` опциональный `classTeacherId` (или эквивалент); сейв в ростере -- [ ] Назначить / снять — HTTP через мейлбокс школы; только хозяин; гость `403` -- [ ] Кандидат — нанятый с должностью `Teacher`; иначе отказ с текстом, не молчание -- [ ] Один учитель может быть классным у нескольких классов -- [ ] UI в «Управлении»: список классов со слотом классного (выбор / снять) -- [ ] Имя классного видно там, где уже показывают класс (карточка ученика / управление) -- [ ] Строки через `t(...)`, обе локали -- [ ] Пустой слот — школа живёт как сейчас; собраний ещё нет +- [x] У `SchoolClass` опциональный `classTeacherId` (или эквивалент); сейв в ростере +- [x] Назначить / снять — HTTP через мейлбокс школы; только хозяин; гость `403` +- [x] Кандидат — нанятый с должностью `Teacher`; иначе отказ с текстом, не молчание +- [x] Один учитель может быть классным у нескольких классов +- [x] UI в «Управлении»: список классов со слотом классного (выбор / снять) +- [x] Имя классного видно там, где уже показывают класс (карточка ученика / управление) +- [x] Строки через `t(...)`, обе локали +- [x] Пустой слот — школа живёт как сейчас; собраний ещё нет ## Тесты, без которых фаза не закрыта -- [ ] Назначение учителя класса пишет id в ростер и переживает сейв / reload -- [ ] Не-учитель и чужой id отклоняются -- [ ] Гость не назначает (API) -- [ ] Снятие очищает слот; класс без классного валиден -- [ ] Два класса могут указать одного и того же учителя +- [x] Назначение учителя класса пишет id в ростер и переживает сейв / reload +- [x] Не-учитель и чужой id отклоняются +- [x] Гость не назначает (API) +- [x] Снятие очищает слот; класс без классного валиден +- [x] Два класса могут указать одного и того же учителя ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 7b4eec9..50e933d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -333,7 +333,9 @@ tick with the clock. Unknown school is `404` `unknown-school`; unknown person is Family links are other members of the same family: parents and siblings for a child, children and partners for a parent. The client opens another card by id; this response does not nest cards. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable -grid fetches `GET .../timetable?classId=` with it. +grid fetches `GET .../timetable?classId=` with it. `classTeacherId` / `classTeacherName` come from +that class's slot when a class teacher is assigned; both are null when the slot is empty or the +person has no class. `activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel` is that def in the request locale. HTTP JSON is additive — no protocol version bump. @@ -376,6 +378,8 @@ compute thresholds. There is no school-wide opinions endpoint. "classYear": 5, "classLetter": "А", "classId": "class-classroom-105", + "classTeacherId": "f3.p1", + "classTeacherName": "Иванова Ольга Михайловна", "body": [{ "id": "Height", "label": "Рост", "value": "164" }], "skills": [{ "id": "Math", "label": "Математика", "value": "62" }], "traits": [{ "defName": "Diligent", "label": "Усидчивый" }], @@ -694,10 +698,23 @@ one. "gradeMax": 11, "hoursPerWeek": 5 } + ], + "classes": [ + { + "id": "c5A", + "year": 5, + "letter": "А", + "classTeacherId": "f3.p1", + "classTeacherName": "Иванова Ольга Михайловна" + } ] } ``` +`classes` lists every homeroom with an optional class-teacher slot. `classTeacherId` / +`classTeacherName` are null when the slot is empty. Assign and clear (below) return this same +payload so the Management panel can refresh the slots without a second GET. + Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same id on the roster; hiring them sets `isStaff` on that person and does not create a second entity. Generated candidates (`aN.p0`) join the roster only when hired. `skills` on an @@ -747,6 +764,27 @@ and the same `?lang=ru|en`. Removes one assignment. Payroll drops when the subject was not the only one. Same success payload and `?lang=ru|en` as the other two. Unknown assignment is `404` `unknown-assignment`. +### `PUT /api/schools/{id}/classes/{classId}/class-teacher` + +Body: `{ "personId": "f3.p1" }`. Sets the class-teacher slot on that class. Only a hired +person with position `Teacher` is accepted — not a new `PositionDef`. One teacher may cover +several classes. Goes through the school's mailbox; only the owner may call it (`403` +`not-owner`). Success returns the same payload as `GET .../staffing` (including `classes`), +so it takes the same `?lang=ru|en`. + +| Status | `code` | When | +| --- | --- | --- | +| `403` | `not-owner` | The session is not the school's owner. | +| `404` | `unknown-school` | No school with that id. | +| `404` | `unknown-class` | `classId` is not in the roster. | +| `404` | `unknown-person` | `personId` is not on the roster. | +| `400` | `not-teacher` | Person is not hired staff with position `Teacher`. | + +### `DELETE /api/schools/{id}/classes/{classId}/class-teacher` + +Clears the class-teacher slot. An empty slot is valid — the class lives as before; parent +meetings (later) simply do not start. Same success payload and owner gate as the PUT. + ### `GET /api/schools/{id}/timetable` The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 39a6ae0..e51eed7 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -311,6 +311,15 @@ const ru = { staffErrorNotTeacher: 'Предмет можно назначить только учителю.', staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorSubject: 'Такого предмета нет.', + classTeachersTitle: 'Классные руководители', + classTeacherLabel: 'Классный', + classTeacherNone: 'Не назначен', + classTeacherClear: 'Снять', + classTeacherEmpty: 'Нет классов.', + classTeacherErrorNotTeacher: 'Классным можно назначить только нанятого учителя.', + classTeacherErrorUnknownClass: 'Такого класса нет.', + classTeacherErrorUnknownPerson: 'Этого человека нет в школе.', + classTeacherWithName: 'классный: {name}', manageTabStaff: 'Штат', manageTabRules: 'Правила', @@ -705,6 +714,15 @@ const en: Messages = { staffErrorNotTeacher: 'Only a teacher can be assigned a subject.', staffErrorAssigned: 'That subject is already assigned.', staffErrorSubject: 'That subject is not in the catalog.', + classTeachersTitle: 'Class teachers', + classTeacherLabel: 'Class teacher', + classTeacherNone: 'None', + classTeacherClear: 'Clear', + classTeacherEmpty: 'No classes.', + classTeacherErrorNotTeacher: 'Only a hired teacher can be the class teacher.', + classTeacherErrorUnknownClass: 'That class is not in this school.', + classTeacherErrorUnknownPerson: 'That person is not on the roster.', + classTeacherWithName: 'class teacher: {name}', manageTabStaff: 'Staff', manageTabRules: 'Rules', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 166ccb6..16f71fe 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -327,6 +327,8 @@ export interface PersonCard { readonly classYear: number | null; readonly classLetter: string | null; readonly classId: string | null; + readonly classTeacherId: string | null; + readonly classTeacherName: string | null; readonly position: string | null; readonly positionLabel: string | null; readonly body: readonly LabeledStat[]; @@ -713,6 +715,14 @@ export interface StaffMember { readonly subjects: readonly DefLabel[]; } +export interface ClassTeacherSlot { + readonly id: string; + readonly year: number; + readonly letter: string; + readonly classTeacherId: string | null; + readonly classTeacherName: string | null; +} + export interface Staffing { readonly allocated: number; readonly payroll: number; @@ -722,6 +732,7 @@ export interface Staffing { readonly staff: readonly StaffMember[]; readonly positions: readonly DefLabel[]; readonly subjects: readonly StaffingSubject[]; + readonly classes: readonly ClassTeacherSlot[]; } export async function fetchStaffing(schoolId: number, lang: string): Promise { @@ -773,6 +784,35 @@ export async function unassignSubject( ); } +export async function assignClassTeacher( + schoolId: number, + classId: string, + personId: string, + lang: string, +): Promise { + const params = new URLSearchParams({ lang }); + return request( + `/api/schools/${schoolId}/classes/${encodeURIComponent(classId)}/class-teacher?${params.toString()}`, + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ personId }), + }, + ); +} + +export async function clearClassTeacher( + schoolId: number, + classId: string, + lang: string, +): Promise { + const params = new URLSearchParams({ lang }); + return request( + `/api/schools/${schoolId}/classes/${encodeURIComponent(classId)}/class-teacher?${params.toString()}`, + { method: 'DELETE' }, + ); +} + export interface DressRulePair { readonly form: string; readonly color: string; diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index c351a47..e9c7f4c 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, + assignClassTeacher, assignSubject, + clearClassTeacher, fetchPerson, fetchGameStatus, fetchDressRules, @@ -45,6 +47,8 @@ vi.mock('../net/api.ts', async (importOriginal) => { hireStaff: vi.fn(), assignSubject: vi.fn(), unassignSubject: vi.fn(), + assignClassTeacher: vi.fn(), + clearClassTeacher: vi.fn(), }; }); @@ -64,6 +68,8 @@ function personCard(): PersonCard { classYear: null, classLetter: null, classId: null, + classTeacherId: null, + classTeacherName: null, position: null, positionLabel: null, body: [], @@ -111,6 +117,7 @@ function staffing(): Staffing { staff: [], positions: [{ defName: 'Teacher', label: 'Teacher' }], subjects: [], + classes: [], }; } @@ -392,6 +399,7 @@ function hiredTeacher(): Staffing { teachersShort: 1, }, ], + classes: [{ id: 'c1', year: 5, letter: 'A', classTeacherId: null, classTeacherName: null }], }; } @@ -404,6 +412,8 @@ describe('ManagementPanel money and subjects', () => { vi.mocked(fetchPerson).mockReset(); vi.mocked(assignSubject).mockReset(); vi.mocked(unassignSubject).mockReset(); + vi.mocked(assignClassTeacher).mockReset(); + vi.mocked(clearClassTeacher).mockReset(); vi.mocked(fetchStaffing).mockResolvedValue(hiredTeacher()); vi.mocked(fetchTimetable).mockResolvedValue(timetable()); vi.mocked(fetchPerson).mockResolvedValue({ @@ -505,4 +515,43 @@ describe('ManagementPanel money and subjects', () => { ); await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(6_000))); }); + + it('assigns and clears a class teacher from the class list', async () => { + const withTeacher: Staffing = { + ...hiredTeacher(), + classes: [ + { + id: 'c1', + year: 5, + letter: 'A', + classTeacherId: 't1', + classTeacherName: 'Petrov Ivan', + }, + ], + }; + vi.mocked(assignClassTeacher).mockResolvedValue(withTeacher); + vi.mocked(clearClassTeacher).mockResolvedValue(hiredTeacher()); + + const panel = new ManagementPanel(); + document.body.append(panel.listElement, panel.cardElement); + panel.show(2); + const select = await vi.waitFor(() => { + const node = panel.listElement.querySelector('.staffing__class-teachers select'); + if (!(node instanceof HTMLSelectElement)) { + throw new Error('class teacher select is missing'); + } + + return node; + }); + + expect(panel.listElement.textContent).toContain(t('classTeachersTitle')); + select.value = 't1'; + select.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(assignClassTeacher).toHaveBeenCalledWith(2, 'c1', 't1', 'en')); + await vi.waitFor(() => expect(select.value).toBe('t1')); + + select.value = ''; + select.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(clearClassTeacher).toHaveBeenCalledWith(2, 'c1', 'en')); + }); }); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 79c5b5c..5c433da 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -1,5 +1,7 @@ import { + assignClassTeacher, assignSubject, + clearClassTeacher, fetchPerson, fetchStaffing, fetchTimetable, @@ -16,11 +18,12 @@ import { DressRulesPanel } from './dressRulesPanel.ts'; import { clear, el } from './dom.ts'; import { formatPersonPlace } from './personCard.ts'; import { PersonCardHost } from './personCardHost.ts'; -import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; +import { actionError, classTeacherError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; import { loadChrome, patchChrome } from './viewState.ts'; const TEACHER = 'Teacher'; +const CLEAR_VALUE = ''; /** * Left: money, coverage, a door into the applicant window, staff. Right: the person card plus @@ -45,6 +48,9 @@ export class ManagementPanel { private readonly staffTitle = el('h3', { class: 'panel__section-title' }); private readonly staffTable = el('table', { class: 'people__table' }); private readonly staffEmpty = el('p', { class: 'panel__empty' }); + private readonly classTeachersTitle = el('h3', { class: 'panel__section-title' }); + private readonly classTeachersList = el('div', { class: 'staffing__class-teachers' }); + private readonly classTeachersEmpty = el('p', { class: 'panel__empty' }); private readonly timetableTitle = el('h3', { class: 'panel__section-title' }); private readonly classLabel = el('span', { class: 'people__label' }); private readonly classSelect = el('select', { class: 'input people__input' }); @@ -95,6 +101,13 @@ export class ManagementPanel { this.staffTitle, el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty), ), + el( + 'div', + { class: 'panel__section' }, + this.classTeachersTitle, + this.classTeachersEmpty, + this.classTeachersList, + ), el( 'div', { class: 'panel__section' }, @@ -126,6 +139,8 @@ export class ManagementPanel { this.applicantsTitle.textContent = t('staffApplicants'); this.applicantsButton.textContent = t('staffApplicantsOpen'); this.staffTitle.textContent = t('staffHired'); + this.classTeachersTitle.textContent = t('classTeachersTitle'); + this.classTeachersEmpty.textContent = t('classTeacherEmpty'); this.timetableTitle.textContent = t('timetableTitle'); this.classLabel.textContent = t('timetableClass'); this.uncoveredEmpty.textContent = t('staffUncoveredEmpty'); @@ -283,6 +298,7 @@ export class ManagementPanel { this.paintUncovered(staffing); this.paintApplicants(staffing); this.paintStaff(staffing); + this.paintClassTeachers(staffing); this.paintClasses(this.timetable); } @@ -390,6 +406,79 @@ export class ManagementPanel { } } + private paintClassTeachers(staffing: Staffing | null): void { + const classes = staffing?.classes ?? []; + this.classTeachersEmpty.hidden = classes.length > 0; + this.classTeachersList.hidden = classes.length === 0; + clear(this.classTeachersList); + if (classes.length === 0) { + return; + } + + const teachers = (staffing?.staff ?? []).filter((member) => member.position === TEACHER); + for (const schoolClass of classes) { + const select = el('select', { + class: 'input people__input', + disabled: this.busy, + }) as HTMLSelectElement; + const none = el('option', { text: t('classTeacherNone') }); + none.value = CLEAR_VALUE; + select.append(none); + for (const teacher of teachers) { + const option = el('option', { text: teacher.fullName }); + option.value = teacher.id; + select.append(option); + } + + const current = schoolClass.classTeacherId ?? CLEAR_VALUE; + if (current !== CLEAR_VALUE && ![...select.options].some((option) => option.value === current)) { + const missing = el('option', { + text: schoolClass.classTeacherName ?? schoolClass.classTeacherId ?? current, + }); + missing.value = current; + select.append(missing); + } + + select.value = [...select.options].some((option) => option.value === current) ? current : CLEAR_VALUE; + select.addEventListener('change', () => { + void this.setClassTeacher(schoolClass.id, select.value === CLEAR_VALUE ? null : select.value); + }); + + this.classTeachersList.append( + el( + 'label', + { class: 'people__field staffing__class-teacher' }, + el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }), + select, + ), + ); + } + } + + private async setClassTeacher(classId: string, personId: string | null): Promise { + const schoolId = this.schoolId; + if (schoolId === null || this.busy) { + return; + } + + this.busy = true; + this.clearError(); + this.paint(); + try { + this.staffing = + personId === null + ? await clearClassTeacher(schoolId, classId, getLocale()) + : await assignClassTeacher(schoolId, classId, personId, getLocale()); + this.paint(); + } catch (error) { + this.showError(classTeacherError(error)); + this.paint(); + } finally { + this.busy = false; + this.paint(); + } + } + private async select(personId: string): Promise { this.selectedId = personId; this.persist(); diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index b942c4d..fdbb906 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -25,6 +25,8 @@ function card(overrides: Partial = {}): PersonCard { classYear: 5, classLetter: 'А', classId: 'class-1', + classTeacherId: null, + classTeacherName: null, position: null, positionLabel: null, body: [{ id: 'Height', label: 'Рост', value: '164' }], @@ -120,6 +122,19 @@ function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement { } describe('renderPersonCard', () => { + it('shows the class teacher name next to the class', () => { + setLocale('ru'); + const root = document.createElement('div'); + renderPersonCard( + root, + card({ classTeacherId: 't1', classTeacherName: 'Петров Иван' }), + () => {}, + options(), + ); + + expect(root.textContent).toContain(t('classTeacherWithName', { name: 'Петров Иван' })); + }); + it('switches overview, clothes, carried and now', () => { setLocale('ru'); const root = document.createElement('div'); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index cd76be0..a141e28 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -56,10 +56,19 @@ export function roleLabels(roles: readonly string[]): string { .join(', '); } -export function placement(person: Pick): string { +export function placement( + person: Pick & { + readonly classTeacherName?: string | null; + }, +): string { const parts: string[] = []; if (person.classYear !== null && person.classLetter !== null) { - parts.push(`${person.classYear}${person.classLetter}`); + let classBit = `${person.classYear}${person.classLetter}`; + if (person.classTeacherName !== null && person.classTeacherName !== undefined && person.classTeacherName.length > 0) { + classBit += ` · ${t('classTeacherWithName', { name: person.classTeacherName })}`; + } + + parts.push(classBit); } if (person.positionLabel !== null && person.positionLabel.length > 0) { diff --git a/src/HSchool.Client/src/ui/staffingUi.ts b/src/HSchool.Client/src/ui/staffingUi.ts index ae8e550..02e342c 100644 --- a/src/HSchool.Client/src/ui/staffingUi.ts +++ b/src/HSchool.Client/src/ui/staffingUi.ts @@ -71,6 +71,23 @@ export function actionError(error: unknown): string { } } +export function classTeacherError(error: unknown): string { + if (!(error instanceof ApiError)) { + return t('staffActionFailed'); + } + + switch (error.code) { + case 'not-teacher': + return t('classTeacherErrorNotTeacher'); + case 'unknown-class': + return t('classTeacherErrorUnknownClass'); + case 'unknown-person': + return t('classTeacherErrorUnknownPerson'); + default: + return error.message.length > 0 ? error.message : t('staffActionFailed'); + } +} + export function parseOptionalInt(value: string): number | undefined { if (value === '') { return undefined; diff --git a/src/HSchool.Client/src/ui/timetableGrid.test.ts b/src/HSchool.Client/src/ui/timetableGrid.test.ts index 7d56f56..d8fbc63 100644 --- a/src/HSchool.Client/src/ui/timetableGrid.test.ts +++ b/src/HSchool.Client/src/ui/timetableGrid.test.ts @@ -144,6 +144,8 @@ function card(overrides: Partial): PersonCard { classYear: 5, classLetter: 'A', classId: 'c1', + classTeacherId: null, + classTeacherName: null, position: null, positionLabel: null, body: [], diff --git a/src/HSchool.People/ClassTeachers.cs b/src/HSchool.People/ClassTeachers.cs new file mode 100644 index 0000000..54cd025 --- /dev/null +++ b/src/HSchool.People/ClassTeachers.cs @@ -0,0 +1,82 @@ +namespace HSchool.People; + +public enum ClassTeacherError +{ + None, + UnknownSchool, + UnknownClass, + UnknownPerson, + NotTeacher, +} + +public sealed record ClassTeacherOutcome(ClassTeacherError Error, Roster Roster); + +/// +/// Assigns or clears the class-teacher slot on a . Not a +/// — any hired teacher may cover several classes. +/// +public static class ClassTeachers +{ + public static ClassTeacherOutcome UnknownSchool() => + new(ClassTeacherError.UnknownSchool, new Roster([], [], [])); + + public static ClassTeacherOutcome Assign(Roster roster, string classId, string personId) + { + ArgumentNullException.ThrowIfNull(roster); + ArgumentNullException.ThrowIfNull(classId); + ArgumentNullException.ThrowIfNull(personId); + + var index = IndexOf(roster.Classes, classId); + if (index < 0) + { + return new ClassTeacherOutcome(ClassTeacherError.UnknownClass, roster); + } + + var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal)); + if (person is null) + { + return new ClassTeacherOutcome(ClassTeacherError.UnknownPerson, roster); + } + + if (!person.IsStaff || !string.Equals(person.Position, Staffing.TeacherPosition, StringComparison.Ordinal)) + { + return new ClassTeacherOutcome(ClassTeacherError.NotTeacher, roster); + } + + return Replace(roster, index, personId); + } + + public static ClassTeacherOutcome Clear(Roster roster, string classId) + { + ArgumentNullException.ThrowIfNull(roster); + ArgumentNullException.ThrowIfNull(classId); + + var index = IndexOf(roster.Classes, classId); + if (index < 0) + { + return new ClassTeacherOutcome(ClassTeacherError.UnknownClass, roster); + } + + return Replace(roster, index, null); + } + + private static ClassTeacherOutcome Replace(Roster roster, int index, string? teacherId) + { + var classes = roster.Classes.ToArray(); + classes[index] = classes[index] with { ClassTeacherId = teacherId }; + return new ClassTeacherOutcome(ClassTeacherError.None, roster with { Classes = classes }); + } + + private static int IndexOf(IReadOnlyList classes, string classId) + { + for (var i = 0; i < classes.Count; i++) + { + if (classes[i].Id.Equals(classId, StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index 00c0ee2..f374dda 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -142,4 +142,6 @@ public sealed record SchoolClass( string Letter, string RoomId, int Capacity, - IReadOnlyList PupilIds); + IReadOnlyList PupilIds, + /// Hired teacher who is the class teacher, or null when the slot is empty. + string? ClassTeacherId = null); diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index eedfc89..fa105ae 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -85,7 +85,9 @@ internal sealed record PersonCardResponse( PersonConnectionsResponse? Connections = null, DefLabelResponse? Orientation = null, IReadOnlyList? TalkCircleMemberIds = null, - string? TalkTopicId = null); + string? TalkTopicId = null, + string? ClassTeacherId = null, + string? ClassTeacherName = null); internal sealed record WornItemResponse( @@ -246,6 +248,8 @@ internal sealed record HireStaffRequest(string? PersonId, string? Position); internal sealed record AssignSubjectRequest(string? Subject); +internal sealed record AssignClassTeacherRequest(string? PersonId); + internal sealed record StaffingResponse( float Allocated, float Payroll, @@ -254,7 +258,15 @@ internal sealed record StaffingResponse( IReadOnlyList Applicants, IReadOnlyList Staff, IReadOnlyList Positions, - IReadOnlyList Subjects); + IReadOnlyList Subjects, + IReadOnlyList Classes); + +internal sealed record ClassTeacherSlotResponse( + string Id, + int Year, + string Letter, + string? ClassTeacherId, + string? ClassTeacherName); internal sealed record UncoveredSubjectResponse( string DefName, @@ -357,7 +369,38 @@ internal static class StaffingMapper 0)) .ToArray(); - return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects); + var peopleById = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var classes = roster.Classes + .OrderBy(schoolClass => schoolClass.Year) + .ThenBy(schoolClass => schoolClass.Letter, StringComparer.Ordinal) + .Select(schoolClass => + { + string? teacherName = null; + if (schoolClass.ClassTeacherId is { } teacherId + && peopleById.TryGetValue(teacherId, out var teacher)) + { + teacherName = teacher.Name.Full; + } + + return new ClassTeacherSlotResponse( + schoolClass.Id, + schoolClass.Year, + schoolClass.Letter, + schoolClass.ClassTeacherId, + teacherName); + }) + .ToArray(); + + return new StaffingResponse( + allocated, + payroll, + remaining, + uncovered, + applicants, + staff, + positions, + subjects, + classes); } private static IReadOnlyList SkillsOf(Person person, DefCatalog? catalog, string locale) diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 3be6ead..0f521f1 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -682,6 +682,78 @@ internal static class SchoolEndpoints }) .WithName("UnassignSchoolSubject"); + schools.MapPut("/{id:int}/classes/{classId}/class-teacher", async ( + int id, + string classId, + AssignClassTeacherRequest request, + string? lang, + HttpContext context, + SessionService sessions, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser)) + { + return Results.Unauthorized(); + } + + var denied = SchoolAccess.RequireManage(loop, id, normalizedUser); + if (denied is not null) + { + return denied; + } + + if (!TryDefName(classId, "classId", out var classIdValue, out var error) + || !TryPersonId(request.PersonId, out var personId, out error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.AssignClassTeacher( + id, + classIdValue, + personId, + NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return ClassTeacherResult(id, outcome, loop, ParseLocale(lang)); + }) + .WithName("AssignSchoolClassTeacher"); + + schools.MapDelete("/{id:int}/classes/{classId}/class-teacher", async ( + int id, + string classId, + string? lang, + HttpContext context, + SessionService sessions, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!SchoolAccess.TryGetNormalizedUser(context, sessions, out var normalizedUser)) + { + return Results.Unauthorized(); + } + + var denied = SchoolAccess.RequireManage(loop, id, normalizedUser); + if (denied is not null) + { + return denied; + } + + if (!TryDefName(classId, "classId", out var classIdValue, out var error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.ClearClassTeacher(id, classIdValue, NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return ClassTeacherResult(id, outcome, loop, ParseLocale(lang)); + }) + .WithName("ClearSchoolClassTeacher"); + schools.MapPost("/{id:int}/notices/{noticeId:long}/dismiss", async ( int id, long noticeId, @@ -976,6 +1048,41 @@ internal static class SchoolEndpoints return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale)); } + private static IResult ClassTeacherResult( + int schoolId, + ClassTeacherOutcome outcome, + GameLoopService loop, + string locale) + { + if (outcome.Error != ClassTeacherError.None) + { + return ClassTeacherProblem(outcome); + } + + var published = loop.FindPeople(schoolId); + if (published is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + return Results.Ok(MapStaffing(published, loop.Options.MonthlyPayrollCap, locale)); + } + + private static IResult ClassTeacherProblem(ClassTeacherOutcome outcome) => + outcome.Error switch + { + ClassTeacherError.UnknownClass => + Problem(StatusCodes.Status404NotFound, "unknown-class", "That class is not in this school."), + ClassTeacherError.UnknownPerson => + Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not on the roster."), + ClassTeacherError.NotTeacher => + Problem( + StatusCodes.Status400BadRequest, + "not-teacher", + "Only a hired teacher can be the class teacher."), + _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."), + }; + private static IResult StaffingProblem(StaffingOutcome outcome) => outcome.Error switch { diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index bb7a39b..6969b8c 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -110,6 +110,17 @@ internal abstract record GameCommand string Subject, TaskCompletionSource Result) : GameCommand; + internal sealed record AssignClassTeacher( + int SchoolId, + string ClassId, + string PersonId, + TaskCompletionSource Result) : GameCommand; + + internal sealed record ClearClassTeacher( + int SchoolId, + string ClassId, + TaskCompletionSource Result) : GameCommand; + internal sealed record PinLesson( int SchoolId, string ClassId, diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 0e142fe..0778abd 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -286,6 +286,23 @@ internal sealed class GameLoopService( unassign.Result); break; + case GameCommand.AssignClassTeacher assignClassTeacher: + HandleClassTeacher( + assignClassTeacher.SchoolId, + new WorkerCommand.AssignClassTeacher( + assignClassTeacher.ClassId, + assignClassTeacher.PersonId, + assignClassTeacher.Result), + assignClassTeacher.Result); + break; + + case GameCommand.ClearClassTeacher clearClassTeacher: + HandleClassTeacher( + clearClassTeacher.SchoolId, + new WorkerCommand.ClearClassTeacher(clearClassTeacher.ClassId, clearClassTeacher.Result), + clearClassTeacher.Result); + break; + case GameCommand.PinLesson pin: HandleTimetable( pin.SchoolId, @@ -385,6 +402,14 @@ internal sealed class GameLoopService( } } + private void HandleClassTeacher(int schoolId, WorkerCommand command, TaskCompletionSource result) + { + if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) + { + result.TrySetResult(ClassTeachers.UnknownSchool()); + } + } + private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 27d639f..47a61a1 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -43,12 +43,20 @@ internal static partial class PersonCardReader var catalog = school.Catalog; var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); int? year = null; string? letter = null; + string? classTeacherId = null; + string? classTeacherName = null; if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)) { year = schoolClass.Year; letter = schoolClass.Letter; + classTeacherId = schoolClass.ClassTeacherId; + if (classTeacherId is { } teacherId && people.TryGetValue(teacherId, out var teacher)) + { + classTeacherName = teacher.Name.Full; + } } var needs = LiveNeeds(school.World, personId) ?? person.Needs; @@ -98,7 +106,9 @@ internal static partial class PersonCardReader Connections: Connections(roster, person, catalog, locale), Orientation: OrientationOf(person, catalog, locale), TalkCircleMemberIds: circle?.MemberIds ?? [], - TalkTopicId: circle?.TopicId); + TalkTopicId: circle?.TopicId, + ClassTeacherId: classTeacherId, + ClassTeacherName: classTeacherName); } private static IReadOnlyDictionary? LiveNeeds(World world, string personId) diff --git a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs index cf6cfc3..39092c7 100644 --- a/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs +++ b/src/HSchool.Server/Game/SchoolWorker.Mailbox.cs @@ -141,6 +141,15 @@ internal sealed partial class SchoolWorker unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); break; + case WorkerCommand.AssignClassTeacher assignClassTeacher: + assignClassTeacher.Result.TrySetResult( + ApplyAssignClassTeacher(school, assignClassTeacher.ClassId, assignClassTeacher.PersonId)); + break; + + case WorkerCommand.ClearClassTeacher clearClassTeacher: + clearClassTeacher.Result.TrySetResult(ApplyClearClassTeacher(school, clearClassTeacher.ClassId)); + break; + case WorkerCommand.PinLesson pin: pin.Result.TrySetResult( ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period)); @@ -256,6 +265,12 @@ internal sealed partial class SchoolWorker case WorkerCommand.UnassignSubject unassign: unassign.Result.TrySetResult(Staffing.UnknownSchool()); break; + case WorkerCommand.AssignClassTeacher assignClassTeacher: + assignClassTeacher.Result.TrySetResult(ClassTeachers.UnknownSchool()); + break; + case WorkerCommand.ClearClassTeacher clearClassTeacher: + clearClassTeacher.Result.TrySetResult(ClassTeachers.UnknownSchool()); + break; case WorkerCommand.PinLesson pin: pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); break; @@ -311,6 +326,12 @@ internal sealed partial class SchoolWorker case WorkerCommand.UnassignSubject unassign: unassign.Result.TrySetException(exception); break; + case WorkerCommand.AssignClassTeacher assignClassTeacher: + assignClassTeacher.Result.TrySetException(exception); + break; + case WorkerCommand.ClearClassTeacher clearClassTeacher: + clearClassTeacher.Result.TrySetException(exception); + break; case WorkerCommand.PinLesson pin: pin.Result.TrySetException(exception); break; @@ -344,6 +365,32 @@ internal sealed partial class SchoolWorker ApplyStaffingChange(school, (catalog, roster, pool) => Staffing.UnassignSubject(catalog, roster, pool, personId, subject, _options.MonthlyPayrollCap)); + private ClassTeacherOutcome ApplyAssignClassTeacher(School school, string classId, string personId) => + ApplyClassTeacherChange(school, roster => ClassTeachers.Assign(roster, classId, personId)); + + private ClassTeacherOutcome ApplyClearClassTeacher(School school, string classId) => + ApplyClassTeacherChange(school, roster => ClassTeachers.Clear(roster, classId)); + + private ClassTeacherOutcome ApplyClassTeacherChange( + School school, + Func apply) + { + if (school.Roster is null) + { + return ClassTeachers.UnknownSchool(); + } + + var outcome = apply(school.Roster); + if (outcome.Error == ClassTeacherError.None) + { + school.ApplyRosterData(outcome.Roster); + PersistPeople(); + PublishSnapshot(); + } + + return outcome; + } + private StaffingOutcome ApplyStaffingChange( School school, Func apply) diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 829ad75..58d1838 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -70,6 +70,15 @@ internal abstract record WorkerCommand string Subject, TaskCompletionSource Result) : WorkerCommand; + internal sealed record AssignClassTeacher( + string ClassId, + string PersonId, + TaskCompletionSource Result) : WorkerCommand; + + internal sealed record ClearClassTeacher( + string ClassId, + TaskCompletionSource Result) : WorkerCommand; + internal sealed record PinLesson( string ClassId, string Subject, diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index d9f96dc..b1e7d00 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -320,6 +320,17 @@ public sealed class School : IDisposable TimetableDirty = true; } + /// + /// Writes roster fields that do not change who is in the World (class-teacher slots). No + /// respawn and no timetable dirty — unlike . + /// + public void ApplyRosterData(Roster roster) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(roster); + Roster = roster; + } + public void SetTimetable(Timetable timetable) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/tests/HSchool.AppHost.Tests/ClassTeacherApiTests.cs b/tests/HSchool.AppHost.Tests/ClassTeacherApiTests.cs new file mode 100644 index 0000000..0cb28d2 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/ClassTeacherApiTests.cs @@ -0,0 +1,251 @@ +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class ClassTeacherApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task Assign_WritesId_AndSurvivesReload() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Классный сейв", Start); + var staffing = await GetStaffingAsync(client, school.Id); + var teacher = staffing.Applicants[0]; + await HireAsync(client, school.Id, teacher.Id, "Teacher"); + var schoolClass = Assert.Single((await GetStaffingAsync(client, school.Id)).Classes.Take(1)); + + var assigned = await AssignClassTeacherAsync(client, school.Id, schoolClass.Id, teacher.Id); + var slot = Assert.Single(assigned.Classes, row => row.Id == schoolClass.Id); + Assert.Equal(teacher.Id, slot.ClassTeacherId); + Assert.False(string.IsNullOrWhiteSpace(slot.ClassTeacherName)); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + var after = await GetStaffingAsync(client, school.Id); + var reloaded = Assert.Single(after.Classes, row => row.Id == schoolClass.Id); + Assert.Equal(teacher.Id, reloaded.ClassTeacherId); + + var pupil = await FirstPupilOfClassAsync(client, school.Id, schoolClass.Id); + var card = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil)}?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(card); + Assert.Equal(teacher.Id, card.ClassTeacherId); + Assert.Equal(reloaded.ClassTeacherName, card.ClassTeacherName); + } + + [Fact] + public async Task Assign_RejectsNonTeacherAndUnknownPerson() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Классный отказ", Start); + var staffing = await GetStaffingAsync(client, school.Id); + var schoolClass = staffing.Classes[0]; + var pupil = await FirstPupilOfClassAsync(client, school.Id, schoolClass.Id); + + using var notTeacher = await client.PutAsJsonAsync( + $"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher", + new { personId = pupil }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, notTeacher.StatusCode); + Assert.Equal("not-teacher", await ProblemCodeAsync(notTeacher)); + + using var unknown = await client.PutAsJsonAsync( + $"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher", + new { personId = "nobody" }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + Assert.Equal("unknown-person", await ProblemCodeAsync(unknown)); + } + + [Fact] + public async Task Guest_CannotAssign() + { + using var wipe = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.WipeAllSavesAsync(wipe); + + var owner = await CreateUserAsync("ClassTeacherOwner"); + var guest = await CreateUserAsync("ClassTeacherGuest"); + await ResetUserAsync(owner); + + var school = await SchoolApiTests.CreateAsync(owner, "Чужой классный", Start); + var staffing = await GetStaffingAsync(owner, school.Id); + var schoolClass = staffing.Classes[0]; + + using var response = await guest.PutAsJsonAsync( + $"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher", + new { personId = "a0.p0" }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal("not-owner", await ProblemCodeAsync(response)); + + owner.Dispose(); + guest.Dispose(); + + using var cleanup = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.WipeAllSavesAsync(cleanup); + } + + [Fact] + public async Task Clear_EmptiesSlot_AndClassWithoutTeacherIsValid() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Классный снять", Start); + var staffing = await GetStaffingAsync(client, school.Id); + var teacher = staffing.Applicants[0]; + await HireAsync(client, school.Id, teacher.Id, "Teacher"); + var schoolClass = (await GetStaffingAsync(client, school.Id)).Classes[0]; + await AssignClassTeacherAsync(client, school.Id, schoolClass.Id, teacher.Id); + + using var clear = await client.DeleteAsync( + $"/api/schools/{school.Id}/classes/{Uri.EscapeDataString(schoolClass.Id)}/class-teacher?lang=ru", + TestContext.Current.CancellationToken); + clear.EnsureSuccessStatusCode(); + var after = await clear.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(after); + var slot = Assert.Single(after.Classes, row => row.Id == schoolClass.Id); + Assert.Null(slot.ClassTeacherId); + Assert.Null(slot.ClassTeacherName); + Assert.NotEmpty(after.Classes); + } + + [Fact] + public async Task TwoClasses_MayShareTheSameTeacher() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Классный двое", Start); + var staffing = await GetStaffingAsync(client, school.Id); + Assert.True(staffing.Classes.Count >= 2); + var teacher = staffing.Applicants[0]; + await HireAsync(client, school.Id, teacher.Id, "Teacher"); + var first = staffing.Classes[0]; + var second = staffing.Classes[1]; + + await AssignClassTeacherAsync(client, school.Id, first.Id, teacher.Id); + var after = await AssignClassTeacherAsync(client, school.Id, second.Id, teacher.Id); + + Assert.Equal(teacher.Id, Assert.Single(after.Classes, row => row.Id == first.Id).ClassTeacherId); + Assert.Equal(teacher.Id, Assert.Single(after.Classes, row => row.Id == second.Id).ClassTeacherId); + } + + private static async Task GetStaffingAsync(HttpClient client, int schoolId) + { + var staffing = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/staffing?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + return staffing; + } + + private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position) + { + using var response = await client.PostAsJsonAsync( + $"/api/schools/{schoolId}/staff/hire", + new { personId, position }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + } + + private static async Task AssignClassTeacherAsync( + HttpClient client, + int schoolId, + string classId, + string personId) + { + using var response = await client.PutAsJsonAsync( + $"/api/schools/{schoolId}/classes/{Uri.EscapeDataString(classId)}/class-teacher?lang=ru", + new { personId }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + return staffing; + } + + private static async Task FirstPupilOfClassAsync(HttpClient client, int schoolId, string classId) + { + var people = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?role=student&pageSize=50", + TestContext.Current.CancellationToken); + Assert.NotNull(people); + foreach (var person in people.People) + { + var card = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people/{Uri.EscapeDataString(person.Id)}", + TestContext.Current.CancellationToken); + if (card?.ClassId == classId) + { + return person.Id; + } + } + + Assert.Fail($"No pupil found for class {classId}."); + return string.Empty; + } + + private async Task CreateUserAsync(string userName) => + await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, userName); + + private static async Task ResetUserAsync(HttpClient client) + { + await SchoolApiTests.LoginAsync(client); + var state = await SchoolApiTests.GetSchoolsAsync(client); + foreach (var school in state.Schools) + { + using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + } + + foreach (var other in state.Others) + { + using var response = await client.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken); + if (response.StatusCode == HttpStatusCode.Forbidden) + { + continue; + } + + response.EnsureSuccessStatusCode(); + } + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record ProblemResponse(string? Code); + + private sealed record StaffingResponse( + float Allocated, + float Payroll, + float Remaining, + IReadOnlyList Applicants, + IReadOnlyList Staff, + IReadOnlyList Classes); + + private sealed record ApplicantResponse(string Id, string FullName); + + private sealed record StaffMemberResponse(string Id, string FullName, string Position); + + private sealed record ClassTeacherSlotResponse( + string Id, + int Year, + string Letter, + string? ClassTeacherId, + string? ClassTeacherName); + + private sealed record PeopleListResponse(IReadOnlyList People); + + private sealed record PersonListItemResponse(string Id); + + private sealed record PersonCardResponse(string Id, string? ClassId, string? ClassTeacherId, string? ClassTeacherName); +} diff --git a/tests/HSchool.People.Tests/ClassTeacherTests.cs b/tests/HSchool.People.Tests/ClassTeacherTests.cs new file mode 100644 index 0000000..0f183e2 --- /dev/null +++ b/tests/HSchool.People.Tests/ClassTeacherTests.cs @@ -0,0 +1,95 @@ +using HSchool.Content; + +namespace HSchool.People.Tests; + +public class ClassTeacherTests +{ + private const float Cap = 100_000f; + + [Fact] + public void Assign_WritesIdIntoRoster_AndSurvivesPeopleJsonRoundTrip() + { + var (catalog, map, roster, pool) = Fresh(); + var hired = HireTeacher(catalog, map, roster, pool); + var schoolClass = hired.Roster.Classes[0]; + + var assigned = ClassTeachers.Assign(hired.Roster, schoolClass.Id, hired.TeacherId); + Assert.Equal(ClassTeacherError.None, assigned.Error); + Assert.Equal(hired.TeacherId, assigned.Roster.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId); + + var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, hired.Pool)); + var loaded = RosterJson.Parse(json); + Assert.Equal(hired.TeacherId, loaded.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId); + } + + [Fact] + public void Assign_RejectsNonTeacherAndUnknownPerson() + { + var (catalog, map, roster, pool) = Fresh(); + var hired = HireTeacher(catalog, map, roster, pool); + var schoolClass = hired.Roster.Classes[0]; + var pupil = Assert.Single(hired.Roster.People.Where(person => person.IsStudent).Take(1)); + + Assert.Equal( + ClassTeacherError.NotTeacher, + ClassTeachers.Assign(hired.Roster, schoolClass.Id, pupil.Id).Error); + Assert.Equal( + ClassTeacherError.UnknownPerson, + ClassTeachers.Assign(hired.Roster, schoolClass.Id, "nobody").Error); + Assert.Null(hired.Roster.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId); + } + + [Fact] + public void Clear_EmptiesSlot_AndClassWithoutTeacherIsValid() + { + var (catalog, map, roster, pool) = Fresh(); + var hired = HireTeacher(catalog, map, roster, pool); + var schoolClass = hired.Roster.Classes[0]; + var assigned = ClassTeachers.Assign(hired.Roster, schoolClass.Id, hired.TeacherId); + Assert.Equal(ClassTeacherError.None, assigned.Error); + + var cleared = ClassTeachers.Clear(assigned.Roster, schoolClass.Id); + Assert.Equal(ClassTeacherError.None, cleared.Error); + Assert.Null(cleared.Roster.Classes.Single(row => row.Id == schoolClass.Id).ClassTeacherId); + Assert.Contains(cleared.Roster.Classes, row => row.Id == schoolClass.Id); + } + + [Fact] + public void TwoClasses_MayShareTheSameTeacher() + { + var (catalog, map, roster, pool) = Fresh(); + var hired = HireTeacher(catalog, map, roster, pool); + Assert.True(hired.Roster.Classes.Count >= 2); + + var first = hired.Roster.Classes[0]; + var second = hired.Roster.Classes[1]; + var one = ClassTeachers.Assign(hired.Roster, first.Id, hired.TeacherId); + Assert.Equal(ClassTeacherError.None, one.Error); + var two = ClassTeachers.Assign(one.Roster, second.Id, hired.TeacherId); + Assert.Equal(ClassTeacherError.None, two.Error); + + Assert.Equal(hired.TeacherId, two.Roster.Classes.Single(row => row.Id == first.Id).ClassTeacherId); + Assert.Equal(hired.TeacherId, two.Roster.Classes.Single(row => row.Id == second.Id).ClassTeacherId); + } + + private static (DefCatalog Catalog, MapLayout Map, Roster Roster, ApplicantPool Pool) Fresh() + { + var catalog = Fixtures.Catalog(); + var map = Fixtures.VanillaMap(); + var roster = Fixtures.Generate(map); + var pool = ApplicantPool.Create(catalog, roster, Fixtures.SchoolSeed, "Russia", Fixtures.AsOf); + return (catalog, map, roster, pool); + } + + private static (Roster Roster, ApplicantPool Pool, string TeacherId) HireTeacher( + DefCatalog catalog, + MapLayout map, + Roster roster, + ApplicantPool pool) + { + var applicant = pool.Applicants[0]; + var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); + Assert.Equal(StaffingError.None, hired.Error); + return (hired.Roster, hired.Pool, applicant.Person.Id); + } +} From bdf0c1e76e459f57077ed64cd9b82507ddd22bfa Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 09:38:03 +0300 Subject: [PATCH 2/2] Mark phase 68 done. EOF Co-authored-by: Cursor --- docs/phases/12-office/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/12-office/README.md b/docs/phases/12-office/README.md index 4585bfc..e9a73f0 100644 --- a/docs/phases/12-office/README.md +++ b/docs/phases/12-office/README.md @@ -13,7 +13,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [68. Классный руководитель](68-class-teacher.md) | 🔄 | Назначение в Управлении, только учителя | +| [68. Классный руководитель](68-class-teacher.md) | ✅ | Назначение в Управлении, только учителя | **Этап B — проступки и кабинет.** Память, авто-вызов, экран.