Merge phase 28 screen tests.
ci / server (push) Failing after 3m39s
ci / client (push) Failing after 14s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 01:08:32 +03:00
co-authored by Cursor
11 changed files with 833 additions and 12 deletions
+4 -3
View File
@@ -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 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 sets `HSchool:AllowSaveReload` so tests can `POST /api/dev/reload-schools` without killing
the shared fixture. the shared fixture.
- The screens have no unit tests — a DOM environment would cost a dependency the project does not - Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the
have. Verify UI changes by running the app. Dictionaries and date formatting are covered in pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and
Vitest (`i18n/strings.test.ts`, `format/gameTime.test.ts`). 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 ## Dependencies
+7 -7
View File
@@ -16,15 +16,15 @@ vitest уже стоит: DOM-окружение — это одна девза
## Задачи ## Задачи
- [ ] `happy-dom` как девзависимость и окружение vitest для тестов экранов - [x] `happy-dom` как девзависимость и окружение vitest для тестов экранов
- [ ] Тесты панели людей: фильтры собирают правильный запрос, пейджер не уезжает за границы - [x] Тесты панели людей: фильтры собирают правильный запрос, пейджер не уезжает за границы
- [ ] Тесты диалога создания: `core` нельзя снять, сброс карты возвращает дефолт, кнопка - [x] Тесты диалога создания: `core` нельзя снять, сброс карты возвращает дефолт, кнопка
блокируется на время запроса блокируется на время запроса
- [ ] Тесты сетки расписания: код отказа планировщика превращается в текст, а не в молчание - [x] Тесты сетки расписания: код отказа планировщика превращается в текст, а не в молчание
- [ ] Тесты панели управления: отказ по пределу фонда показывается текстом - [x] Тесты панели управления: отказ по пределу фонда показывается текстом
- [ ] Политика тестирования в `AGENTS.md` переписывается: что теперь проверяется тестом, а что - [x] Политика тестирования в `AGENTS.md` переписывается: что теперь проверяется тестом, а что
по-прежнему глазами по-прежнему глазами
- [ ] Прогон экранов не должен заметно удлинять `npm test` - [x] Прогон экранов не должен заметно удлинять `npm test`
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
+1 -1
View File
@@ -112,4 +112,4 @@
| [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей | | [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей |
| [26. Свой сид](26-school-seed.md) | ✅ | Состав школы перестаёт зависеть от порядка создания | | [26. Свой сид](26-school-seed.md) | ✅ | Состав школы перестаёт зависеть от порядка создания |
| [27. Версия сейва и дамп](27-serviceability.md) | ✅ | Формат читается, внутрь школы можно заглянуть | | [27. Версия сейва и дамп](27-serviceability.md) | ✅ | Формат читается, внутрь школы можно заглянуть |
| [28. Тесты экранов](28-screen-tests.md) | 🔄 | DOM-окружение и логика клиента под тестом | | [28. Тесты экранов](28-screen-tests.md) | | DOM-окружение и логика клиента под тестом |
+95
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"happy-dom": "^20.11.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.2.1", "vite": "^8.2.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
@@ -321,6 +322,23 @@
"undici-types": "~7.18.0" "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": { "node_modules/@vitest/expect": {
"version": "4.1.10", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -444,6 +462,19 @@
"node": ">=12" "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": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -471,6 +502,19 @@
"node": ">=8" "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": { "node_modules/es-module-lexer": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "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": "^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": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -1204,6 +1277,28 @@
"engines": { "engines": {
"node": ">=8" "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
}
}
} }
} }
} }
+1
View File
@@ -16,6 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"happy-dom": "^20.11.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.2.1", "vite": "^8.2.1",
"vitest": "^4.1.10" "vitest": "^4.1.10"
@@ -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<typeof import('../net/api.ts')>();
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<School>((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;
});
});
@@ -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<typeof import('../net/api.ts')>();
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),
}),
);
});
});
@@ -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<typeof import('../net/api.ts')>();
return {
...actual,
fetchPeople: vi.fn(),
fetchPerson: vi.fn(),
fetchTimetable: vi.fn(),
};
});
const initialLocale = getLocale();
function listPerson(overrides: Partial<PersonListItem> = {}): 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> = {}): 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);
});
});
+6
View File
@@ -56,6 +56,7 @@ export class PeoplePanel {
private sort: PersonSort = 'surname'; private sort: PersonSort = 'surname';
private dir: 'asc' | 'desc' = 'asc'; private dir: 'asc' | 'desc' = 'asc';
private page = 1; private page = 1;
private pages = 1;
private selectedId: string | null = null; private selectedId: string | null = null;
private token = 0; private token = 0;
private cardToken = 0; private cardToken = 0;
@@ -81,6 +82,10 @@ export class PeoplePanel {
} }
}); });
this.nextButton.addEventListener('click', () => { this.nextButton.addEventListener('click', () => {
if (this.page >= this.pages) {
return;
}
this.page += 1; this.page += 1;
void this.reload(); void this.reload();
}); });
@@ -314,6 +319,7 @@ export class PeoplePanel {
const pages = Math.max(1, Math.ceil(page.total / page.pageSize)); const pages = Math.max(1, Math.ceil(page.total / page.pageSize));
this.page = page.page; this.page = page.page;
this.pages = pages;
this.pagerLabel.textContent = t('peoplePager', { page: page.page, pages, total: page.total }); this.pagerLabel.textContent = t('peoplePager', { page: page.page, pages, total: page.total });
this.prevButton.disabled = page.page <= 1; this.prevButton.disabled = page.page <= 1;
this.nextButton.disabled = page.page >= pages; this.nextButton.disabled = page.page >= pages;
@@ -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<typeof import('../net/api.ts')>();
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<string> {
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'));
});
});
+4 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vitest/config';
// Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference, // Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference,
// so the dev server proxies to whatever port the backend actually got. // so the dev server proxies to whatever port the backend actually got.
@@ -24,4 +24,7 @@ export default defineConfig({
target: 'es2022', target: 'es2022',
sourcemap: true, sourcemap: true,
}, },
test: {
environment: 'node',
},
}); });