diff --git a/docs/phases/03-staffing/reviewed.md b/docs/phases/03-staffing/reviewed.md index 8831c8e..1095102 100644 --- a/docs/phases/03-staffing/reviewed.md +++ b/docs/phases/03-staffing/reviewed.md @@ -42,3 +42,48 @@ Исправлено: - `docs/protocol.md` не упоминал `?lang=` у hire/subjects, хотя клиент его шлёт. + +## Перепроверка среза 3. Штат + +- **Фазы:** 10–13 +- **Проверен на:** `a0f0152`, 2026-08-20 +- **Пути:** `src/HSchool.People/Staffing.cs`, `src/HSchool.People/ApplicantPool.cs`, + `src/HSchool.Content/SubjectDef` и `defs/staffing`, `src/HSchool.Server/Api/SchoolEndpoints.cs` + (staffing/hire/subjects), `src/HSchool.Server/mods/core/defs/{subjects,positions,rooms,staffing,traits}`, + `src/HSchool.Client/src/ui/{managementPanel,mapEditor,applicantsDialog}.ts`, + `src/HSchool.Client/src/net/api.ts`, `docs/protocol.md` (staffing), + `tests/HSchool.People.Tests/{StaffingTests,ApplicantPoolTests}.cs`, + `tests/HSchool.AppHost.Tests/StaffingApiTests.cs`, + `tests/HSchool.Content.Tests/VanillaCoreTests.cs`, + `src/HSchool.Client/src/ui/{managementPanel,mapEditor}.test.ts` +- **Итог:** перепроверка после дрейфа с `bdb1253`. Обещания фаз 10–13 целы. Дописаны 3 теста; + в `protocol.md` у соискателя — все навыки, не «три сильных». + +Что подтверждено: + +- `PETeacher` нет; кабинет — `defaultSeats`, без учительской должности; слоты у столовой, + медкабинета, кабинета директора и библиотеки. Неизвестный навык роняет каталог. + Старт — суббота 31 марта 2012. `GET /api/catalog` отдаёт предметы. +- Пул 32, детерминизм по сиду и неделе, частичное обновление, устойчивый размер, навыки и черты + в цене часа, пустой штат при полных местах, соискатель вне ростера до найма. +- Фонд = цена часа × нагрузка, не ниже ставки; часы делятся; `MaxWeeklyHours` не запрещает + назначение. `GET /staffing` читает снимок; наём и назначение — мейлбокс с `TaskCompletionSource`. + Предел 100 000 проверяется в момент действия. `PrimarySchool` на ванили — трое (`teachersShort`). +- Экран: Обзор / Управление, строка денег, непокрытые, окно пула, штат, назначение с карточки, + отказ по пределу текстом, `t(...)`. + +Дрейф с прошлой проверки (не регрессия среза 3): + +- `ApplicantPool` берёт имена страны, не набор имён; новым соискателям назначается ориентация. +- Во «Управлении» рядом вкладка правил одежды и речи; пул по-прежнему отдельным окном. + +Дописано: + +- `VanillaCoreTests` — у столовой, медкабинета, кабинета директора и библиотеки слоты на месте. +- `mapEditor.test.ts` — у кабинета поле «мест», у столовой именованные слоты, не доска. +- `ManagementPanel` — строка денег; назначение и снятие предмета с карточки нанятого. + +Исправлено: + +- `docs/protocol.md` всё ещё писал, что у соискателя в `GET /staffing` «три сильных навыка»; + API давно отдаёт все навыки (окно пула сравнивает целиком). diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index b0bb775..f2e9136 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -22,7 +22,7 @@ | --- | --- | --- | | Срез 1. Оболочка и карта | `d087486` | [01-shell](01-shell/reviewed.md) | | Срез 2. Люди | `8108508` | [02-people](02-people/reviewed.md) | -| Срез 3. Штат | `bdb1253` | [03-staffing](03-staffing/reviewed.md) | +| Срез 3. Штат | `a0f0152` | [03-staffing](03-staffing/reviewed.md) | | Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) | | Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) | | Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) | diff --git a/docs/protocol.md b/docs/protocol.md index fef1bbe..ee563dd 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -690,9 +690,9 @@ one. 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 -applicant are the three strongest, for the management list; the card lists every skill they -have, not the three strongest. `positions` and `subjects` are the school's catalog, so the hire and assign pickers -do not need a second request. +applicant are every skill they have (the applicant window compares the whole row, not a +top-three summary); the person card lists the same set. `positions` and `subjects` are the +school's catalog, so the hire and assign pickers do not need a second request. `GET /api/schools/{id}/people/{personId}` also opens a card for someone who is only in the applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index fcbbfe5..c351a47 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, + assignSubject, fetchPerson, fetchGameStatus, fetchDressRules, @@ -11,6 +12,7 @@ import { fetchStaffing, fetchTimetable, hireStaff, + unassignSubject, type PersonCard, type Staffing, type Timetable, @@ -356,3 +358,151 @@ describe('ManagementPanel rules tab', () => { expect(second.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false); }); }); + +function hiredTeacher(): Staffing { + return { + allocated: 10_000, + payroll: 4_000, + remaining: 6_000, + uncovered: [], + applicants: [], + staff: [ + { + id: 't1', + fullName: 'Petrov Ivan', + female: false, + age: 40, + isParent: false, + position: 'Teacher', + positionLabel: 'Teacher', + hourlyWageAsk: 50, + weeklyHours: 20, + monthlyPay: 4_000, + subjects: [], + }, + ], + positions: [{ defName: 'Teacher', label: 'Teacher' }], + subjects: [ + { + defName: 'Mathematics', + label: 'Math', + gradeMin: 5, + gradeMax: 11, + hoursPerWeek: 5, + teachersShort: 1, + }, + ], + }; +} + +describe('ManagementPanel money and subjects', () => { + beforeEach(() => { + sessionStorage.clear(); + setLocale('en'); + vi.mocked(fetchStaffing).mockReset(); + vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchPerson).mockReset(); + vi.mocked(assignSubject).mockReset(); + vi.mocked(unassignSubject).mockReset(); + vi.mocked(fetchStaffing).mockResolvedValue(hiredTeacher()); + vi.mocked(fetchTimetable).mockResolvedValue(timetable()); + vi.mocked(fetchPerson).mockResolvedValue({ + ...personCard(), + id: 't1', + fullName: 'Petrov Ivan', + female: false, + roles: ['staff'], + position: 'Teacher', + positionLabel: 'Teacher', + }); + vi.mocked(fetchGameStatus).mockResolvedValue({ + tick: 0, + tickRate: 20, + schools: 1, + maxSchools: 6, + connections: 0, + swarmUiConfigured: false, + swarmUiConnected: null, + }); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('shows allocated, committed and remaining', async () => { + const panel = new ManagementPanel(); + document.body.append(panel.listElement, panel.cardElement); + panel.show(2); + await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(10_000))); + expect(panel.listElement.textContent).toContain(t('staffAllocated')); + expect(panel.listElement.textContent).toContain(t('staffPayroll')); + expect(panel.listElement.textContent).toContain(t('staffRemaining')); + expect(panel.listElement.textContent).toContain(formatMoney(4_000)); + expect(panel.listElement.textContent).toContain(formatMoney(6_000)); + }); + + it('assigns and unassigns a subject from the hired card', async () => { + const assigned: Staffing = { + ...hiredTeacher(), + payroll: 7_000, + remaining: 3_000, + staff: [ + { + ...hiredTeacher().staff[0], + weeklyHours: 35, + monthlyPay: 7_000, + subjects: [{ defName: 'Mathematics', label: 'Math' }], + }, + ], + }; + vi.mocked(assignSubject).mockResolvedValue(assigned); + vi.mocked(unassignSubject).mockResolvedValue(hiredTeacher()); + + const panel = new ManagementPanel(); + document.body.append(panel.listElement, panel.cardElement); + panel.show(2); + const row = await vi.waitFor(() => { + const node = panel.listElement.querySelector('tr.people__row'); + if (!(node instanceof HTMLTableRowElement)) { + throw new Error('staff row is missing'); + } + + return node; + }); + + row.click(); + const assign = await vi.waitFor(() => { + const button = [...panel.cardElement.querySelectorAll('button')].find( + (candidate) => candidate.textContent === t('staffAssign'), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('assign button is missing'); + } + + return button; + }); + + assign.click(); + await vi.waitFor(() => expect(assignSubject).toHaveBeenCalledWith(2, 't1', 'Mathematics', 'en')); + await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(3_000))); + + const unassign = await vi.waitFor(() => { + const button = [...panel.cardElement.querySelectorAll('button')].find( + (candidate) => candidate.textContent === t('staffUnassign'), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('unassign button is missing'); + } + + return button; + }); + + unassign.click(); + await vi.waitFor(() => + expect(unassignSubject).toHaveBeenCalledWith(2, 't1', 'Mathematics', 'en'), + ); + await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(6_000))); + }); +}); diff --git a/src/HSchool.Client/src/ui/mapEditor.test.ts b/src/HSchool.Client/src/ui/mapEditor.test.ts new file mode 100644 index 0000000..5f43a67 --- /dev/null +++ b/src/HSchool.Client/src/ui/mapEditor.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it } from 'vitest'; +import type { CatalogResponse, MapLayout } from '../net/api.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { mapEditor } from './mapEditor.ts'; + +const initialLocale = getLocale(); + +function catalog(): CatalogResponse { + return { + territories: [{ defName: 'Yard', label: 'Yard' }], + buildings: [{ defName: 'SchoolBuilding', label: 'Building' }], + floors: [{ defName: 'Floor', label: 'Floor' }], + rooms: [ + { + defName: 'Classroom', + label: 'Classroom', + homeroom: true, + seatThing: 'StudentDesk', + defaultSeats: 16, + slots: [], + positions: [], + }, + { + defName: 'Cafeteria', + label: 'Cafeteria', + homeroom: false, + defaultSeats: 0, + slots: [ + { key: 'counter', thing: 'DiningTable', count: 1 }, + { key: 'seats', thing: 'Chair', count: 8 }, + ], + positions: ['CafeteriaCook'], + }, + ], + things: [ + { defName: 'DiningTable', label: 'Table' }, + { defName: 'Chair', label: 'Chair' }, + ], + defaultMap: map(), + countries: [], + subjects: [], + dayFrame: null, + holidays: [], + }; +} + +function map(): MapLayout { + return { + territory: { id: 'yard', def: 'Yard' }, + buildings: [{ id: 'b1', def: 'SchoolBuilding' }], + floors: [{ id: 'f1', def: 'Floor', building: 'b1' }], + rooms: [ + { id: 'r-class', def: 'Classroom', building: 'b1', floor: 'f1', seats: 16 }, + { + id: 'r-cafe', + def: 'Cafeteria', + building: 'b1', + floor: 'f1', + slots: [ + { key: 'counter', thing: 'DiningTable', count: 1 }, + { key: 'seats', thing: 'Chair', count: 8 }, + ], + }, + ], + links: [ + { a: 'yard', b: 'b1' }, + { a: 'b1', b: 'f1' }, + { a: 'f1', b: 'r-class' }, + { a: 'f1', b: 'r-cafe' }, + ], + }; +} + +function furnishing(root: HTMLElement): HTMLElement { + const section = [...root.querySelectorAll('.editor-section')].find((node) => + (node.querySelector('.editor-section__title')?.textContent ?? '').startsWith(t('editorSlots')), + ); + if (!(section instanceof HTMLElement)) { + throw new Error('furnishing section is missing'); + } + + return section; +} + +function clickRoom(root: HTMLElement, name: string): void { + const button = [...root.querySelectorAll('.tree__button')].find( + (candidate) => candidate.textContent === name, + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`${name} is missing from the tree`); + } + + button.click(); +} + +describe('mapEditor homeroom seats', () => { + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('shows a seats field on a classroom and named slots on a cafeteria', () => { + setLocale('en'); + const editor = mapEditor({ catalog: catalog(), map: map(), onChange: () => {} }); + document.body.append(editor.element); + + clickRoom(editor.element, 'Classroom'); + const classroom = furnishing(editor.element); + expect(classroom.textContent).toContain(t('editorSeats')); + expect(classroom.querySelector('input.input--count')).not.toBeNull(); + expect(classroom.querySelector('select')).toBeNull(); + + clickRoom(editor.element, 'Cafeteria'); + const cafeteria = furnishing(editor.element); + expect(cafeteria.textContent).not.toContain(t('editorSeats')); + expect(cafeteria.querySelectorAll('select').length).toBeGreaterThan(0); + }); +}); diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 57c69ad..91d52e0 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -33,6 +33,10 @@ public class VanillaCoreTests Assert.Equal("StudentDesk", catalog.Rooms["Classroom"].SeatThing); Assert.Equal(16, catalog.Rooms["Classroom"].DefaultSeats); Assert.Empty(catalog.Rooms["Classroom"].Slots); + Assert.NotEmpty(catalog.Rooms["Cafeteria"].Slots); + Assert.NotEmpty(catalog.Rooms["MedicalOffice"].Slots); + Assert.NotEmpty(catalog.Rooms["PrincipalsOffice"].Slots); + Assert.NotEmpty(catalog.Rooms["Library"].Slots); Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots); Assert.Equal(1, catalog.Things["Computer"].PupilSlots); Assert.Equal(0, catalog.Things["Desk"].PupilSlots);