From 840b8fd23486aac9fb2ce133138fcfc08817f0f1 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 01:05:47 +0300 Subject: [PATCH 1/2] Test screen logic under happy-dom so filter and error-code mistakes fail in CI. Co-authored-by: Cursor --- AGENTS.md | 7 +- docs/phases/28-screen-tests.md | 14 +- src/HSchool.Client/package-lock.json | 95 +++++++ src/HSchool.Client/package.json | 1 + .../src/ui/createSchoolDialog.test.ts | 234 ++++++++++++++++++ .../src/ui/managementPanel.test.ts | 167 +++++++++++++ src/HSchool.Client/src/ui/peoplePanel.test.ts | 211 ++++++++++++++++ src/HSchool.Client/src/ui/peoplePanel.ts | 6 + .../src/ui/timetableGrid.test.ts | 103 ++++++++ src/HSchool.Client/vite.config.ts | 5 +- 10 files changed, 832 insertions(+), 11 deletions(-) create mode 100644 src/HSchool.Client/src/ui/createSchoolDialog.test.ts create mode 100644 src/HSchool.Client/src/ui/managementPanel.test.ts create mode 100644 src/HSchool.Client/src/ui/peoplePanel.test.ts create mode 100644 src/HSchool.Client/src/ui/timetableGrid.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3755ed9..e98791a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,9 +125,10 @@ say so explicitly in the change description. is empty. Reset deletes through the API, which deletes the save files. Headless AppHost sets `HSchool:AllowSaveReload` so tests can `POST /api/dev/reload-schools` without killing the shared fixture. -- The screens have no unit tests — a DOM environment would cost a dependency the project does not - have. Verify UI changes by running the app. Dictionaries and date formatting are covered in - Vitest (`i18n/strings.test.ts`, `format/gameTime.test.ts`). +- Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the + pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and + the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries + and date formatting stay in `i18n/strings.test.ts` and `format/gameTime.test.ts`. ## Dependencies diff --git a/docs/phases/28-screen-tests.md b/docs/phases/28-screen-tests.md index f26d423..db316a8 100644 --- a/docs/phases/28-screen-tests.md +++ b/docs/phases/28-screen-tests.md @@ -16,15 +16,15 @@ vitest уже стоит: DOM-окружение — это одна девза ## Задачи -- [ ] `happy-dom` как девзависимость и окружение vitest для тестов экранов -- [ ] Тесты панели людей: фильтры собирают правильный запрос, пейджер не уезжает за границы -- [ ] Тесты диалога создания: `core` нельзя снять, сброс карты возвращает дефолт, кнопка +- [x] `happy-dom` как девзависимость и окружение vitest для тестов экранов +- [x] Тесты панели людей: фильтры собирают правильный запрос, пейджер не уезжает за границы +- [x] Тесты диалога создания: `core` нельзя снять, сброс карты возвращает дефолт, кнопка блокируется на время запроса -- [ ] Тесты сетки расписания: код отказа планировщика превращается в текст, а не в молчание -- [ ] Тесты панели управления: отказ по пределу фонда показывается текстом -- [ ] Политика тестирования в `AGENTS.md` переписывается: что теперь проверяется тестом, а что +- [x] Тесты сетки расписания: код отказа планировщика превращается в текст, а не в молчание +- [x] Тесты панели управления: отказ по пределу фонда показывается текстом +- [x] Политика тестирования в `AGENTS.md` переписывается: что теперь проверяется тестом, а что по-прежнему глазами -- [ ] Прогон экранов не должен заметно удлинять `npm test` +- [x] Прогон экранов не должен заметно удлинять `npm test` ## Тесты, без которых фаза не закрыта diff --git a/src/HSchool.Client/package-lock.json b/src/HSchool.Client/package-lock.json index 4e40d58..a3a300a 100644 --- a/src/HSchool.Client/package-lock.json +++ b/src/HSchool.Client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "devDependencies": { "@types/node": "^24.10.1", + "happy-dom": "^20.11.2", "typescript": "~5.9.3", "vite": "^8.2.1", "vitest": "^4.1.10" @@ -321,6 +322,23 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -444,6 +462,19 @@ "node": ">=12" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -471,6 +502,19 @@ "node": ">=8" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -531,6 +575,25 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/happy-dom": { + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1188,6 +1251,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1204,6 +1277,28 @@ "engines": { "node": ">=8" } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/src/HSchool.Client/package.json b/src/HSchool.Client/package.json index d8dcbc3..ac695e9 100644 --- a/src/HSchool.Client/package.json +++ b/src/HSchool.Client/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", + "happy-dom": "^20.11.2", "typescript": "~5.9.3", "vite": "^8.2.1", "vitest": "^4.1.10" diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.test.ts b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts new file mode 100644 index 0000000..84d49ff --- /dev/null +++ b/src/HSchool.Client/src/ui/createSchoolDialog.test.ts @@ -0,0 +1,234 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchCatalog, + fetchMods, + type CatalogResponse, + type MapLayout, + type School, +} from '../net/api.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { createSchoolDialog } from './createSchoolDialog.ts'; +import { mapEditorDialog } from './mapEditorDialog.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchCatalog: vi.fn(), + fetchMods: vi.fn(), + }; +}); + +const initialLocale = getLocale(); + +const defaultMap: MapLayout = { + territory: { id: 'yard', def: 'Yard' }, + buildings: [{ id: 'b1', def: 'SchoolBuilding' }], + floors: [{ id: 'f1', def: 'Floor', building: 'b1' }], + rooms: [{ id: 'r1', def: 'Classroom', building: 'b1', floor: 'f1' }], + links: [ + { a: 'yard', b: 'b1' }, + { a: 'b1', b: 'f1' }, + { a: 'f1', b: 'r1' }, + ], +}; + +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, + defaultSeats: 16, + slots: [], + positions: [], + }, + ], + things: [], + defaultMap, + nameSets: [ + { + defName: 'Russian', + label: 'Russian', + nativeLanguages: [{ defName: 'Russian', label: 'Russian' }], + }, + ], + subjects: [], + dayFrame: null, + holidays: [], + }; +} + +function school(): School { + return { + id: 1, + name: 'North', + gameTime: '2024-09-01T00:00:00.000Z', + running: false, + speedIndex: 0, + seed: 1, + }; +} + +function byText(root: ParentNode, selector: string, text: string): HTMLElement { + const match = [...root.querySelectorAll(selector)].find((node) => node.textContent === text); + if (!(match instanceof HTMLElement)) { + throw new Error(`missing ${selector} with text "${text}"`); + } + + return match; +} + +describe('createSchoolDialog', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchMods).mockReset(); + vi.mocked(fetchCatalog).mockReset(); + vi.mocked(fetchMods).mockResolvedValue([ + { id: 'core', required: true, label: 'Core', version: '1.0', requires: [] }, + { id: 'example', required: false, label: 'Example', version: '1.0', requires: ['core'] }, + ]); + vi.mocked(fetchCatalog).mockResolvedValue(catalog()); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('keeps core checked and disabled', async () => { + const opened = createSchoolDialog({ + defaultStartDate: new Date('2024-09-01T00:00:00.000Z'), + suggestName: async () => 'North', + create: async () => school(), + }); + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled()); + const dialog = document.querySelector('dialog'); + if (dialog === null) { + throw new Error('create dialog is missing'); + } + + const boxes = [...dialog.querySelectorAll('.mod-list input[type="checkbox"]')]; + const core = boxes[0]; + const extra = boxes[1]; + if (!(core instanceof HTMLInputElement) || !(extra instanceof HTMLInputElement)) { + throw new Error('mod checkboxes are missing'); + } + + expect(core.checked).toBe(true); + expect(core.disabled).toBe(true); + expect(extra.checked).toBe(false); + expect(extra.disabled).toBe(false); + expect(core.closest('label')?.textContent).toBe(t('coreModLocked', { id: 'Core' })); + + const calls = vi.mocked(fetchCatalog).mock.calls.length; + core.click(); + expect(core.checked).toBe(true); + expect(vi.mocked(fetchCatalog).mock.calls.length).toBe(calls); + void opened; + }); + + it('disables create while the request is in flight', async () => { + let finish: (value: School) => void = () => {}; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const opened = createSchoolDialog({ + defaultStartDate: new Date('2024-09-01T00:00:00.000Z'), + suggestName: async () => 'North', + create: () => pending, + }); + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled()); + const dialog = document.querySelector('dialog'); + const form = dialog?.querySelector('form'); + const name = dialog?.querySelector('input[type="text"]'); + const submit = byText(dialog ?? document, 'button[type="submit"]', t('create')); + if (!(form instanceof HTMLFormElement) || !(name instanceof HTMLInputElement)) { + throw new Error('create form is missing'); + } + + await vi.waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)); + name.value = 'North'; + form.requestSubmit(); + await vi.waitFor(() => expect(submit.hasAttribute('disabled')).toBe(true)); + finish(school()); + await expect(opened).resolves.toMatchObject({ name: 'North' }); + }); +}); + +describe('map reset from the create editor', () => { + beforeEach(() => setLocale('en')); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('returns the catalog default after reset', async () => { + const edited: MapLayout = { + ...defaultMap, + rooms: [ + ...defaultMap.rooms, + { id: 'r2', def: 'Classroom', building: 'b1', floor: 'f1' }, + ], + }; + const opened = mapEditorDialog({ catalog: catalog(), map: edited }); + const dialog = document.querySelector('dialog'); + if (dialog === null) { + throw new Error('map editor is missing'); + } + + byText(dialog, 'button', t('resetMap')).click(); + byText(dialog, 'button', t('done')).click(); + await expect(opened).resolves.toEqual(defaultMap); + }); + + it('clears the edited hint on the create form after reset', async () => { + vi.mocked(fetchMods).mockResolvedValue([ + { id: 'core', required: true, label: 'Core', version: '1.0', requires: [] }, + ]); + vi.mocked(fetchCatalog).mockResolvedValue(catalog()); + const opened = createSchoolDialog({ + defaultStartDate: new Date('2024-09-01T00:00:00.000Z'), + suggestName: async () => 'North', + create: async () => school(), + }); + await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled()); + const form = document.querySelector('dialog'); + if (form === null) { + throw new Error('create dialog is missing'); + } + + const edit = byText(form, 'button', t('editMap')); + const hint = edit.nextElementSibling; + await vi.waitFor(() => expect(edit.hasAttribute('disabled')).toBe(false)); + edit.click(); + const editor = [...document.querySelectorAll('dialog')].at(-1); + if (editor === undefined) { + throw new Error('map editor is missing'); + } + + byText(editor, 'button', t('addRoom')).click(); + byText(editor, 'button', t('done')).click(); + await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapEditedHint'))); + + edit.click(); + const again = [...document.querySelectorAll('dialog')].at(-1); + if (again === undefined) { + throw new Error('map editor is missing'); + } + + byText(again, 'button', t('resetMap')).click(); + byText(again, 'button', t('done')).click(); + await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint'))); + void opened; + }); +}); diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts new file mode 100644 index 0000000..edb414c --- /dev/null +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + fetchPerson, + fetchStaffing, + fetchTimetable, + hireStaff, + type PersonCard, + type Staffing, + type Timetable, +} from '../net/api.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { ManagementPanel } from './managementPanel.ts'; +import { formatMoney } from './staffingUi.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchStaffing: vi.fn(), + fetchTimetable: vi.fn(), + fetchPerson: vi.fn(), + hireStaff: vi.fn(), + assignSubject: vi.fn(), + unassignSubject: vi.fn(), + }; +}); + +const initialLocale = getLocale(); + +function personCard(): PersonCard { + return { + id: 'a1', + fullName: 'Sidorova Anna', + surname: 'Sidorova', + given: 'Anna', + patronymic: '', + female: true, + age: 28, + birthDate: '1998-03-01', + roles: ['parent'], + classYear: null, + classLetter: null, + classId: null, + position: null, + positionLabel: null, + body: [], + skills: [], + traits: [], + needs: [], + activity: null, + activityLabel: null, + family: { parents: [], children: [], siblings: [], partners: [] }, + }; +} + +function staffing(): Staffing { + return { + allocated: 10_000, + payroll: 9_000, + remaining: 1_000, + uncovered: [], + applicants: [ + { + id: 'a1', + fullName: 'Sidorova Anna', + female: true, + age: 28, + isParent: true, + hourlyWageAsk: 12, + monthlyBase: 2_000, + skills: [], + traits: [], + }, + ], + staff: [], + positions: [{ defName: 'Teacher', label: 'Teacher' }], + subjects: [], + }; +} + +function timetable(): Timetable { + return { + weekDays: 5, + lessonCount: 6, + lessons: [], + uncovered: [], + classes: [{ id: 'c1', year: 5, letter: 'A' }], + rooms: [{ id: 'r1', label: '101' }], + }; +} + +describe('ManagementPanel payroll cap', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchStaffing).mockReset(); + vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchPerson).mockReset(); + vi.mocked(hireStaff).mockReset(); + vi.mocked(fetchStaffing).mockResolvedValue(staffing()); + vi.mocked(fetchTimetable).mockResolvedValue(timetable()); + vi.mocked(fetchPerson).mockResolvedValue(personCard()); + vi.mocked(hireStaff).mockRejectedValue( + new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000), + ); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('shows a payroll-exceeded hire as text, not silence', async () => { + const panel = new ManagementPanel(); + document.body.append(panel.listElement, panel.cardElement); + panel.show(2); + const open = [...panel.listElement.querySelectorAll('button')].find( + (button) => button.textContent === t('staffApplicantsOpen'), + ); + if (!(open instanceof HTMLButtonElement)) { + throw new Error('applicants button is missing'); + } + + await vi.waitFor(() => expect(open.disabled).toBe(false)); + open.click(); + const dialog = await vi.waitFor(() => { + const node = document.querySelector('dialog.dialog--applicants'); + if (node === null) { + throw new Error('applicants dialog is not open'); + } + + return node; + }); + + const row = dialog.querySelector('tr.people__row'); + if (!(row instanceof HTMLTableRowElement)) { + throw new Error('applicant row is missing'); + } + + row.click(); + await vi.waitFor(() => expect(fetchPerson).toHaveBeenCalled()); + const hire = [...dialog.querySelectorAll('button')].find((button) => button.textContent === t('staffHire')); + if (!(hire instanceof HTMLButtonElement)) { + throw new Error('hire button is missing'); + } + + hire.click(); + const banner = dialog.querySelector('.staffing__error'); + if (!(banner instanceof HTMLElement)) { + throw new Error('error banner is missing'); + } + + await vi.waitFor(() => expect(banner.hidden).toBe(false)); + expect(banner.textContent).toBe( + t('staffErrorPayroll', { + allocated: formatMoney(10_000), + payroll: formatMoney(9_000), + remaining: formatMoney(1_000), + attempted: formatMoney(12_000), + }), + ); + }); +}); diff --git a/src/HSchool.Client/src/ui/peoplePanel.test.ts b/src/HSchool.Client/src/ui/peoplePanel.test.ts new file mode 100644 index 0000000..d6f2185 --- /dev/null +++ b/src/HSchool.Client/src/ui/peoplePanel.test.ts @@ -0,0 +1,211 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchPeople, + fetchPerson, + fetchTimetable, + type PeoplePage, + type PeopleQuery, + type PersonListItem, +} from '../net/api.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { PeoplePanel } from './peoplePanel.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchPeople: vi.fn(), + fetchPerson: vi.fn(), + fetchTimetable: vi.fn(), + }; +}); + +const initialLocale = getLocale(); + +function listPerson(overrides: Partial = {}): PersonListItem { + return { + id: 'p1', + fullName: 'Ivanov Ivan', + surname: 'Ivanov', + given: 'Ivan', + patronymic: '', + female: false, + age: 12, + roles: ['student'], + classYear: 5, + classLetter: 'A', + position: null, + positionLabel: null, + ...overrides, + }; +} + +function page(overrides: Partial = {}): PeoplePage { + return { + total: 1, + page: 1, + pageSize: 50, + people: [listPerson()], + filters: { + years: [1, 5], + letters: ['A', 'B'], + positions: [{ defName: 'Teacher', label: 'Teacher' }], + }, + ...overrides, + }; +} + +function lastQuery(): PeopleQuery { + const call = vi.mocked(fetchPeople).mock.calls.at(-1); + if (call === undefined) { + throw new Error('fetchPeople was not called'); + } + + return call[1]; +} + +function controls(root: HTMLElement): { + readonly role: HTMLSelectElement; + readonly year: HTMLSelectElement; + readonly letter: HTMLSelectElement; + readonly position: HTMLSelectElement; + readonly sex: HTMLSelectElement; + readonly ageMin: HTMLInputElement; + readonly ageMax: HTMLInputElement; + readonly prev: HTMLButtonElement; + readonly next: HTMLButtonElement; + readonly pager: HTMLElement; +} { + const selects = [...root.querySelectorAll('select')]; + const ages = [...root.querySelectorAll('input[type="number"]')]; + const buttons = [...root.querySelectorAll('.people__pager button')]; + const pager = root.querySelector('.people__pager-label'); + const role = selects[0]; + const year = selects[1]; + const letter = selects[2]; + const position = selects[3]; + const sex = selects[4]; + const ageMin = ages[0]; + const ageMax = ages[1]; + const prev = buttons[0]; + const next = buttons[1]; + if ( + !(role instanceof HTMLSelectElement) || + !(year instanceof HTMLSelectElement) || + !(letter instanceof HTMLSelectElement) || + !(position instanceof HTMLSelectElement) || + !(sex instanceof HTMLSelectElement) || + !(ageMin instanceof HTMLInputElement) || + !(ageMax instanceof HTMLInputElement) || + !(prev instanceof HTMLButtonElement) || + !(next instanceof HTMLButtonElement) || + !(pager instanceof HTMLElement) + ) { + throw new Error('people panel controls are missing'); + } + + return { role, year, letter, position, sex, ageMin, ageMax, prev, next, pager }; +} + +describe('PeoplePanel', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(fetchPeople).mockReset(); + vi.mocked(fetchPerson).mockReset(); + vi.mocked(fetchTimetable).mockReset(); + vi.mocked(fetchPeople).mockResolvedValue(page()); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + it('sends the selected filters in the people request', async () => { + const panel = new PeoplePanel({ onSelect: () => {} }); + panel.show(3); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(1)); + expect(lastQuery()).toEqual({ + role: undefined, + year: undefined, + letter: undefined, + position: undefined, + sex: undefined, + ageMin: undefined, + ageMax: undefined, + sort: 'surname', + dir: 'asc', + page: 1, + pageSize: 50, + }); + + const { role, year, letter, position, sex, ageMin, ageMax } = controls(panel.listElement); + role.value = 'student'; + year.value = '5'; + letter.value = 'A'; + position.value = 'Teacher'; + sex.value = 'female'; + ageMin.value = '10'; + ageMax.value = '12'; + role.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(2)); + expect(lastQuery()).toMatchObject({ + role: 'student', + year: 5, + letter: 'A', + position: 'Teacher', + sex: 'female', + ageMin: 10, + ageMax: 12, + page: 1, + pageSize: 50, + }); + }); + + it('resets to page 1 when a filter changes', async () => { + vi.mocked(fetchPeople).mockImplementation(async (_id, query) => + page({ total: 120, page: query.page ?? 1, people: [listPerson()] }), + ); + const panel = new PeoplePanel({ onSelect: () => {} }); + panel.show(1); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(1)); + controls(panel.listElement).next.click(); + await vi.waitFor(() => expect(lastQuery().page).toBe(2)); + + controls(panel.listElement).role.value = 'staff'; + controls(panel.listElement).role.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(3)); + expect(lastQuery()).toMatchObject({ role: 'staff', page: 1 }); + }); + + it('does not let the pager walk past the last page', async () => { + vi.mocked(fetchPeople).mockImplementation(async (_id, query) => + page({ total: 120, page: query.page ?? 1, people: [listPerson()] }), + ); + const panel = new PeoplePanel({ onSelect: () => {} }); + panel.show(1); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(1)); + const { prev, next, pager } = controls(panel.listElement); + expect(prev.disabled).toBe(true); + expect(next.disabled).toBe(false); + expect(pager.textContent).toBe(t('peoplePager', { page: 1, pages: 3, total: 120 })); + + next.click(); + await vi.waitFor(() => expect(lastQuery().page).toBe(2)); + next.click(); + await vi.waitFor(() => expect(lastQuery().page).toBe(3)); + expect(next.disabled).toBe(true); + expect(prev.disabled).toBe(false); + expect(pager.textContent).toBe(t('peoplePager', { page: 3, pages: 3, total: 120 })); + + const calls = vi.mocked(fetchPeople).mock.calls.length; + next.disabled = false; + next.click(); + expect(vi.mocked(fetchPeople).mock.calls.length).toBe(calls); + expect(lastQuery().page).toBe(3); + }); +}); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index de428aa..e20ab8f 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -56,6 +56,7 @@ export class PeoplePanel { private sort: PersonSort = 'surname'; private dir: 'asc' | 'desc' = 'asc'; private page = 1; + private pages = 1; private selectedId: string | null = null; private token = 0; private cardToken = 0; @@ -81,6 +82,10 @@ export class PeoplePanel { } }); this.nextButton.addEventListener('click', () => { + if (this.page >= this.pages) { + return; + } + this.page += 1; void this.reload(); }); @@ -314,6 +319,7 @@ export class PeoplePanel { const pages = Math.max(1, Math.ceil(page.total / page.pageSize)); this.page = page.page; + this.pages = pages; this.pagerLabel.textContent = t('peoplePager', { page: page.page, pages, total: page.total }); this.prevButton.disabled = page.page <= 1; this.nextButton.disabled = page.page >= pages; diff --git a/src/HSchool.Client/src/ui/timetableGrid.test.ts b/src/HSchool.Client/src/ui/timetableGrid.test.ts new file mode 100644 index 0000000..ace1a15 --- /dev/null +++ b/src/HSchool.Client/src/ui/timetableGrid.test.ts @@ -0,0 +1,103 @@ +/** + * @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 { getLocale, setLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { TimetableGrid } from './timetableGrid.ts'; + +vi.mock('../net/api.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + pinLesson: vi.fn(), + unpinLesson: vi.fn(), + }; +}); + +const initialLocale = getLocale(); + +const lesson: TimetableLesson = { + classId: 'c1', + classYear: 5, + classLetter: 'A', + subject: 'Math', + subjectLabel: 'Math', + teacherId: 't1', + teacherName: 'Petrov', + roomId: 'r1', + roomLabel: '101', + day: 0, + period: 1, + locked: false, +}; + +function table(): Timetable { + return { + weekDays: 5, + lessonCount: 2, + lessons: [lesson], + uncovered: [], + classes: [{ id: 'c1', year: 5, letter: 'A' }], + rooms: [ + { id: 'r1', label: '101' }, + { id: 'r2', label: '102' }, + ], + }; +} + +describe('TimetableGrid planner errors', () => { + beforeEach(() => { + setLocale('en'); + vi.mocked(pinLesson).mockReset(); + }); + + afterEach(() => { + document.body.replaceChildren(); + setLocale(initialLocale); + }); + + async function pinAndReadError(error: unknown): Promise { + vi.mocked(pinLesson).mockRejectedValue(error); + const grid = new TimetableGrid({ editable: true, showClass: false }); + document.body.append(grid.element); + grid.attach(4); + grid.setTable(table(), 'c1'); + const cell = grid.element.querySelector('.timetable__cell'); + if (!(cell instanceof HTMLElement)) { + throw new Error('grid cell is missing'); + } + + cell.click(); + const apply = [...grid.element.querySelectorAll('button')].find((button) => button.textContent === t('timetableApply')); + if (!(apply instanceof HTMLButtonElement)) { + throw new Error('apply button is missing'); + } + + apply.click(); + const banner = grid.element.querySelector('.staffing__error'); + if (!(banner instanceof HTMLElement)) { + throw new Error('error banner is missing'); + } + + await vi.waitFor(() => expect(banner.hidden).toBe(false)); + return banner.textContent ?? ''; + } + + it('turns pin-rejected into a localized sentence', async () => { + await expect(pinAndReadError(new ApiError(409, 'pin-rejected', 'no'))).resolves.toBe(t('timetableErrorPin')); + }); + + it('turns no-teacher into a localized sentence', async () => { + await expect(pinAndReadError(new ApiError(409, 'no-teacher', 'no'))).resolves.toBe(t('timetableErrorNoTeacher')); + }); + + it('turns unknown-lesson into a localized sentence', async () => { + await expect(pinAndReadError(new ApiError(404, 'unknown-lesson', 'no'))).resolves.toBe(t('timetableErrorLesson')); + }); + + it('does not swallow an unknown failure', async () => { + await expect(pinAndReadError(new Error('down'))).resolves.toBe(t('timetableErrorUnknown')); + }); +}); diff --git a/src/HSchool.Client/vite.config.ts b/src/HSchool.Client/vite.config.ts index fbbf710..1a57a55 100644 --- a/src/HSchool.Client/vite.config.ts +++ b/src/HSchool.Client/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; // Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference, // so the dev server proxies to whatever port the backend actually got. @@ -24,4 +24,7 @@ export default defineConfig({ target: 'es2022', sourcemap: true, }, + test: { + environment: 'node', + }, }); From 4460780c7f764810194bbd21d61a32e8e82beb6d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 01:06:24 +0300 Subject: [PATCH 2/2] Mark phase 28 as done. Co-authored-by: Cursor --- docs/phases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/phases/README.md b/docs/phases/README.md index 4626e8d..32d26d3 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -112,4 +112,4 @@ | [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей | | [26. Свой сид](26-school-seed.md) | ✅ | Состав школы перестаёт зависеть от порядка создания | | [27. Версия сейва и дамп](27-serviceability.md) | ✅ | Формат читается, внутрь школы можно заглянуть | -| [28. Тесты экранов](28-screen-tests.md) | ⬜ | DOM-окружение и логика клиента под тестом | +| [28. Тесты экранов](28-screen-tests.md) | ✅ | DOM-окружение и логика клиента под тестом |