Test screen logic under happy-dom so filter and error-code mistakes fail in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 01:05:47 +03:00
co-authored by Cursor
parent edd3d35c79
commit 840b8fd234
10 changed files with 832 additions and 11 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
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
+7 -7
View File
@@ -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`
## Тесты, без которых фаза не закрыта
+95
View File
@@ -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
}
}
}
}
}
+1
View File
@@ -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"
@@ -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 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;
@@ -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,
// 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',
},
});