diff --git a/AGENTS.md b/AGENTS.md index 76f678d..1b7a83f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ way; this file is *how to work in them*. | skills, traits, body, needs, name sets | `src/HSchool.Content` | | people generation, families, roster records | `src/HSchool.People` | | people in a school's World, need decay | `src/HSchool.Simulation` | -| the menu API (list, create, delete, mods, catalog) | `src/HSchool.Server/Api` **and** `docs/protocol.md` | +| the menu API (list, create, delete, mods, catalog) and the people list/card | `src/HSchool.Server/Api` **and** `docs/protocol.md` | | what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` | | connection handling, workers, saves | `src/HSchool.Server` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` | diff --git a/docs/phases/08-people-browser.md b/docs/phases/08-people-browser.md index eb3e19a..feba2cf 100644 --- a/docs/phases/08-people-browser.md +++ b/docs/phases/08-people-browser.md @@ -11,19 +11,19 @@ ## Задачи -- [ ] `GET /api/schools/{id}/people`: фильтры (роль, параллель, должность, пол, диапазон +- [x] `GET /api/schools/{id}/people`: фильтры (роль, параллель, должность, пол, диапазон возраста), сортировка, страница. Читает **опубликованный снимок** ростера и не трогает воркер — инвариант «только поток школы касается школы» остаётся целым -- [ ] `GET /api/schools/{id}/people/{personId}`: карточка через мейлбокс воркера с +- [x] `GET /api/schools/{id}/people/{personId}`: карточка через мейлбокс воркера с `TaskCompletionSource`, потому что нужды живые -- [ ] Панель «Люди» нижним рядом оболочки менеджера, во всю ширину: фильтры, сортируемые +- [x] Панель «Люди» нижним рядом оболочки менеджера, во всю ширину: фильтры, сортируемые колонки, пейджер -- [ ] Строка списка — только лёгкие поля: ФИО, роль, класс или должность, возраст, пол -- [ ] Карточка: тело, навыки, черты, нужды, семья со ссылками на родных — по ссылке открывается +- [x] Строка списка — только лёгкие поля: ФИО, роль, класс или должность, возраст, пол +- [x] Карточка: тело, навыки, черты, нужды, семья со ссылками на родных — по ссылке открывается карточка родственника -- [ ] Строки и подписи через `t(...)`, как всё остальное -- [ ] `docs/protocol.md` пополняется в том же коммите, что и обработчики -- [ ] Тесты API: фильтр по роли и параллели, сортировка, границы страниц, неизвестная школа, +- [x] Строки и подписи через `t(...)`, как всё остальное +- [x] `docs/protocol.md` пополняется в том же коммите, что и обработчики +- [x] Тесты API: фильтр по роли и параллели, сортировка, границы страниц, неизвестная школа, неизвестный человек ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index e9030fe..a988032 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -34,5 +34,5 @@ | [5. Дефы человека](05-people-defs.md) | ✅ | Навыки, черты, тело, нужды, наборы имён в каталоге | | [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида | | [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | -| [8. Просмотр людей](08-people-browser.md) | ⬜ | Панель со списком, фильтрами и карточкой | +| [8. Просмотр людей](08-people-browser.md) | ✅ | Панель со списком, фильтрами и карточкой | | [9. Годовой набор](09-yearly-intake.md) | ⬜ | Первое сентября: переход, выпуск, набор | diff --git a/docs/protocol.md b/docs/protocol.md index 64c477c..25524b5 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -2,8 +2,9 @@ The client talks to the server two ways: -- **HTTP/JSON** for the main menu — listing, creating and deleting schools, listing mods and - loading a catalog for the create editor. Those are request/response by nature, so they are +- **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and + deleting schools, listing mods, loading a catalog for the create editor, and reading a school's + roster (filtered list + one-person card). Those are request/response by nature, so they are plain REST. - **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the one-shot map snapshot sent when a school is opened. @@ -112,6 +113,92 @@ Failures are RFC 7807 problem details with an extra `code` field — that is wha `204` when deleted, `404` when the id is unknown. Anyone watching that school over a WebSocket gets a `SchoolGone` frame. +### `GET /api/schools/{id}/people` + +The in-school people list. Reads the **published roster snapshot** and that school's last clock; +it does not post to the worker. Unknown `{id}` is `404` `unknown-school`. Bad query parameters +are `400` `invalid-query`. `lang` is `ru` or `en`, same as the catalog — anything other than +`en` is Russian. + +| Query | Meaning | +| --- | --- | +| `role` | `student`, `staff` or `parent`. A staff parent matches `parent`. | +| `year` | Class parallel (`5` for fifth year). | +| `letter` | Class letter as stored on the roster (`А`, not a room number). | +| `position` | Staff `PositionDef` name (`Teacher`). | +| `sex` | `male` or `female`. | +| `ageMin` / `ageMax` | Inclusive age in full years at the school's current game time. | +| `sort` | `surname` (default), `age`, `year`, `position`. | +| `dir` | `asc` (default) or `desc`. | +| `page` | 1-based. Default `1`. `0` is invalid. A page past the end is empty and still reports `total`. | +| `pageSize` | Default `50`, max `100`. | +| `lang` | Label language for positions and filter options. | + +```json +{ + "total": 512, + "page": 1, + "pageSize": 50, + "people": [ + { + "id": "f0.c0", + "fullName": "Иванова Мария Петровна", + "surname": "Иванова", + "given": "Мария", + "patronymic": "Петровна", + "female": true, + "age": 12, + "roles": ["student"], + "classYear": 5, + "classLetter": "А", + "position": null, + "positionLabel": null + } + ], + "filters": { + "years": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + "letters": ["А"], + "positions": [{ "defName": "Teacher", "label": "Учитель" }] + } +} +``` + +The list row is identity only — skills, traits and needs stay on the card. Age is computed from +`birthDate` and the published game time, not from the World. + +### `GET /api/schools/{id}/people/{personId}` + +One person's card. Goes through the school's mailbox because need values live on entities and +tick with the clock. Unknown school is `404` `unknown-school`; unknown person is `404` +`unknown-person`. `?lang=ru|en` labels body, skills, traits, needs and the position. + +Family links are other members of the same family: parents and siblings for a child, children +and partners for a parent. The client opens another card by id; this response does not nest +cards. + +```json +{ + "id": "f0.c0", + "fullName": "Иванова Мария Петровна", + "female": true, + "age": 12, + "birthDate": "2000-03-14T00:00:00Z", + "roles": ["student"], + "classYear": 5, + "classLetter": "А", + "body": [{ "id": "Height", "label": "Рост", "value": "164" }], + "skills": [{ "id": "Math", "label": "Математика", "value": "62" }], + "traits": [{ "defName": "Diligent", "label": "Усидчивый" }], + "needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }], + "family": { + "parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }], + "children": [], + "siblings": [{ "id": "f0.c1", "fullName": "Иванов Кирилл Петрович", "female": false }], + "partners": [] + } +} +``` + ## WebSocket message ids Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index fcd17d8..27246ce 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -21,6 +21,7 @@ describe('t', () => { setLocale('en'); expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.'); expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16'); + expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512'); }); }); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index d9c6aee..c4c37ba 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -83,6 +83,46 @@ const ru = { charactersEmpty: 'Никого нет.', activitiesEmpty: 'Ничего не происходит.', positionsEmpty: 'Нет должностей.', + + peopleTitle: 'Люди', + peopleRole: 'Роль', + peopleRoleAll: 'Все', + peopleRoleStudent: 'Ученик', + peopleRoleStaff: 'Работник', + peopleRoleParent: 'Родитель', + peopleYear: 'Параллель', + peopleYearAll: 'Все', + peopleLetter: 'Литера', + peopleLetterAll: 'Все', + peoplePosition: 'Должность', + peoplePositionAll: 'Все', + peopleSex: 'Пол', + peopleSexAll: 'Все', + peopleMale: 'муж.', + peopleFemale: 'жен.', + peopleAgeFrom: 'Возраст от', + peopleAgeTo: 'до', + peopleColName: 'ФИО', + peopleColRole: 'Роль', + peopleColPlace: 'Класс / должность', + peopleColAge: 'Возраст', + peopleColSex: 'Пол', + peopleEmpty: 'Никого нет.', + peopleLoadFailed: 'Не удалось загрузить список людей.', + peopleCardFailed: 'Не удалось открыть карточку.', + peoplePickHint: 'Выберите человека в списке.', + peoplePager: 'Стр. {page} из {pages} · {total}', + peoplePrev: 'Назад', + peopleNext: 'Вперёд', + peopleBody: 'Тело', + peopleSkills: 'Навыки', + peopleTraits: 'Черты', + peopleNeeds: 'Нужды', + peopleFamily: 'Семья', + peopleParents: 'Родители', + peopleChildren: 'Дети', + peopleSiblings: 'Братья и сёстры', + peoplePartners: 'Супруг(а)', } as const; type Messages = { [K in keyof typeof ru]: string }; @@ -170,6 +210,46 @@ const en: Messages = { charactersEmpty: 'Nobody here.', activitiesEmpty: 'Nothing is happening.', positionsEmpty: 'No positions.', + + peopleTitle: 'People', + peopleRole: 'Role', + peopleRoleAll: 'All', + peopleRoleStudent: 'Student', + peopleRoleStaff: 'Staff', + peopleRoleParent: 'Parent', + peopleYear: 'Year', + peopleYearAll: 'All', + peopleLetter: 'Letter', + peopleLetterAll: 'All', + peoplePosition: 'Position', + peoplePositionAll: 'All', + peopleSex: 'Sex', + peopleSexAll: 'All', + peopleMale: 'male', + peopleFemale: 'female', + peopleAgeFrom: 'Age from', + peopleAgeTo: 'to', + peopleColName: 'Name', + peopleColRole: 'Role', + peopleColPlace: 'Class / position', + peopleColAge: 'Age', + peopleColSex: 'Sex', + peopleEmpty: 'Nobody here.', + peopleLoadFailed: 'Could not load the people list.', + peopleCardFailed: 'Could not open the card.', + peoplePickHint: 'Select a person in the list.', + peoplePager: 'Page {page} of {pages} · {total}', + peoplePrev: 'Previous', + peopleNext: 'Next', + peopleBody: 'Body', + peopleSkills: 'Skills', + peopleTraits: 'Traits', + peopleNeeds: 'Needs', + peopleFamily: 'Family', + peopleParents: 'Parents', + peopleChildren: 'Children', + peopleSiblings: 'Siblings', + peoplePartners: 'Spouse', }; const catalogs: Record = { ru, en }; diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index f14d6c7..86e906f 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -1,6 +1,6 @@ /** - * HTTP side of the server: everything the main menu needs. The realtime clock arrives over the - * WebSocket instead — see `connection.ts`. + * HTTP side of the server: the main menu and the in-school people list/card. The realtime clock + * arrives over the WebSocket instead — see `connection.ts`. */ export interface School { @@ -132,6 +132,120 @@ export async function deleteSchool(id: number): Promise { await request(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false }); } +export type PersonRole = 'student' | 'staff' | 'parent'; + +export type PersonSort = 'surname' | 'age' | 'year' | 'position'; + +export interface PeopleQuery { + readonly role?: PersonRole | ''; + readonly year?: number | ''; + readonly letter?: string; + readonly position?: string; + readonly sex?: 'male' | 'female' | ''; + readonly ageMin?: number | ''; + readonly ageMax?: number | ''; + readonly sort?: PersonSort; + readonly dir?: 'asc' | 'desc'; + readonly page?: number; + readonly pageSize?: number; +} + +export interface PersonListItem { + readonly id: string; + readonly fullName: string; + readonly surname: string; + readonly given: string; + readonly patronymic: string; + readonly female: boolean; + readonly age: number; + readonly roles: readonly string[]; + readonly classYear: number | null; + readonly classLetter: string | null; + readonly position: string | null; + readonly positionLabel: string | null; +} + +export interface DefLabel { + readonly defName: string; + readonly label: string; +} + +export interface PeoplePage { + readonly total: number; + readonly page: number; + readonly pageSize: number; + readonly people: readonly PersonListItem[]; + readonly filters: { + readonly years: readonly number[]; + readonly letters: readonly string[]; + readonly positions: readonly DefLabel[]; + }; +} + +export interface LabeledStat { + readonly id: string; + readonly label: string; + readonly value: string; +} + +export interface NeedStat { + readonly id: string; + readonly label: string; + readonly value: number; +} + +export interface PersonRel { + readonly id: string; + readonly fullName: string; + readonly female: boolean; +} + +export interface PersonCard { + readonly id: string; + readonly fullName: string; + readonly surname: string; + readonly given: string; + readonly patronymic: string; + readonly female: boolean; + readonly age: number; + readonly birthDate: string; + readonly roles: readonly string[]; + readonly classYear: number | null; + readonly classLetter: string | null; + readonly position: string | null; + readonly positionLabel: string | null; + readonly body: readonly LabeledStat[]; + readonly skills: readonly LabeledStat[]; + readonly traits: readonly DefLabel[]; + readonly needs: readonly NeedStat[]; + readonly family: { + readonly parents: readonly PersonRel[]; + readonly children: readonly PersonRel[]; + readonly siblings: readonly PersonRel[]; + readonly partners: readonly PersonRel[]; + }; +} + +export async function fetchPeople(schoolId: number, query: PeopleQuery, lang: string): Promise { + const params = new URLSearchParams({ lang }); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === '') { + continue; + } + + params.set(key, String(value)); + } + + return request(`/api/schools/${schoolId}/people?${params.toString()}`); +} + +export async function fetchPerson(schoolId: number, personId: string, lang: string): Promise { + const params = new URLSearchParams({ lang }); + return request( + `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}?${params.toString()}`, + ); +} + async function request( url: string, init?: RequestInit, diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index df43785..16b47d5 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -244,13 +244,21 @@ body { /* Manager */ +.shell { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin-top: 16px; + gap: 16px; +} + .manager { display: grid; grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.25fr); gap: 16px; flex: 1; min-height: 0; - margin-top: 16px; } .panel { @@ -357,6 +365,168 @@ body { color: var(--accent); } +.panel--people { + flex: 0 1 38vh; + min-height: 220px; + max-height: 42vh; +} + +.people { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + height: 100%; +} + +.people__toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: flex-end; +} + +.people__field { + display: flex; + flex-direction: column; + gap: 2px; +} + +.people__label { + font-size: 11px; + color: var(--text-muted); +} + +.people__input { + min-width: 7.5rem; + padding: 6px 8px; + font-size: 13px; +} + +.people__main { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 0.42fr); + gap: 12px; + flex: 1; + min-height: 0; +} + +.people__list { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.people__table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} + +.people__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.people__table th, +.people__table td { + padding: 5px 8px; + border-bottom: 1px solid var(--border); + text-align: left; + white-space: nowrap; +} + +.people__table th { + position: sticky; + top: 0; + background: var(--surface-raised); + z-index: 1; +} + +.people__sort { + padding: 0; + border: 0; + background: transparent; + color: var(--text-muted); + font: inherit; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; +} + +.people__sort:hover, +.people__sort[aria-sort='ascending'], +.people__sort[aria-sort='descending'] { + color: var(--accent); +} + +.people__row { + cursor: pointer; +} + +.people__row:hover { + background: rgba(76, 201, 240, 0.08); +} + +.people__row--active { + background: rgba(76, 201, 240, 0.16); +} + +.people__pager { + display: flex; + align-items: center; + gap: 8px; + padding-top: 8px; + color: var(--text-muted); + font-size: 13px; +} + +.people__pager-label { + flex: 1; +} + +.people__card { + min-width: 0; + min-height: 0; + overflow: auto; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-sunken); + font-size: 13px; +} + +.people__card-name { + margin: 0 0 4px; + font-size: 16px; +} + +.people__card-meta { + margin: 0 0 12px; + color: var(--text-muted); +} + +.people__section { + margin-top: 12px; +} + +.people__stats { + margin: 0; + padding-left: 18px; +} + +.people__link { + padding: 0; + border: 0; + background: transparent; + color: var(--accent); + font: inherit; + text-decoration: underline; + cursor: pointer; +} + /* Below three columns the panels stop competing for height and the page scrolls instead. */ @media (max-width: 900px) { #app { @@ -372,6 +542,15 @@ body { .panel__body { overflow: visible; } + + .panel--people { + flex: 0 0 auto; + max-height: none; + } + + .people__main { + grid-template-columns: 1fr; + } } /* Controls */ diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index c1c90a0..dd9afb2 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -3,6 +3,7 @@ import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../forma import { t } from '../i18n/strings.ts'; import type { School } from '../net/api.ts'; import { clear, el } from './dom.ts'; +import { PeoplePanel } from './peoplePanel.ts'; interface GameScreenOptions { readonly onLeave: () => void; @@ -15,7 +16,7 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4']; /** * The inside of a school: calendar controls plus the manager shell. The tree and location * lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot - * on the client. People, in-place activities and events stay empty in this slice. + * on the client. The people panel loads its page over HTTP. */ export class GameScreen { private readonly root = el('section', { class: 'screen game' }); @@ -45,6 +46,8 @@ export class GameScreen { private readonly positionsEmpty = el('p', { class: 'panel__empty' }); private readonly positionsList = el('ul', { class: 'panel__list' }); + private readonly people = new PeoplePanel(); + private readonly treeButtons = new Map(); private nodes: readonly MapSnapshotNode[] = []; private selectedId: string | null = null; @@ -81,33 +84,38 @@ export class GameScreen { ), el( 'div', - { class: 'manager' }, + { class: 'shell' }, el( - 'section', - { class: 'panel' }, - this.mapTitle, - el('div', { class: 'panel__body' }, this.tree), - ), - el( - 'section', - { class: 'panel' }, - this.eventsTitle, - el('div', { class: 'panel__body' }, this.eventsEmpty), - ), - el( - 'section', - { class: 'panel' }, - this.locationTitle, + 'div', + { class: 'manager' }, el( - 'div', - { class: 'panel__body' }, - this.locationName, - el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine), - el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty), - el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), - el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList), + 'section', + { class: 'panel' }, + this.mapTitle, + el('div', { class: 'panel__body' }, this.tree), + ), + el( + 'section', + { class: 'panel' }, + this.eventsTitle, + el('div', { class: 'panel__body' }, this.eventsEmpty), + ), + el( + 'section', + { class: 'panel' }, + this.locationTitle, + el( + 'div', + { class: 'panel__body' }, + this.locationName, + el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine), + el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty), + el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), + el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList), + ), ), ), + this.people.element, ), ); @@ -133,6 +141,7 @@ export class GameScreen { this.positionsHeading.textContent = t('locationPositions'); this.positionsEmpty.textContent = t('positionsEmpty'); + this.people.localize(); this.paintSelection(); if (this.lastGameTime !== null) { @@ -150,6 +159,7 @@ export class GameScreen { this.selectedId = null; this.rebuildTree(); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex); + this.people.show(school.id); } applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void { diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts new file mode 100644 index 0000000..9e3db6c --- /dev/null +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -0,0 +1,483 @@ +import { + fetchPeople, + fetchPerson, + type PeoplePage, + type PersonCard, + type PersonListItem, + type PersonRel, + type PersonRole, + type PersonSort, +} from '../net/api.ts'; +import { getLocale } from '../i18n/locale.ts'; +import { t, type MessageKey } from '../i18n/strings.ts'; +import { clear, el } from './dom.ts'; + +const ROLE_KEYS: Record = { + student: 'peopleRoleStudent', + staff: 'peopleRoleStaff', + parent: 'peopleRoleParent', +}; + +const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ + { sort: 'surname', label: 'peopleColName' }, + { sort: 'year', label: 'peopleColPlace' }, + { sort: 'age', label: 'peopleColAge' }, +]; + +/** + * Bottom row of the manager shell: filters, a page of people, and one open card. + * Fetches over HTTP; never ticks locally. + */ +export class PeoplePanel { + readonly element: HTMLElement; + + private readonly title = el('h2', { class: 'panel__title' }); + private readonly roleSelect = el('select', { class: 'input people__input' }); + private readonly yearSelect = el('select', { class: 'input people__input' }); + private readonly letterSelect = el('select', { class: 'input people__input' }); + private readonly positionSelect = el('select', { class: 'input people__input' }); + private readonly sexSelect = el('select', { class: 'input people__input' }); + private readonly ageMinInput = el('input', { class: 'input input--count', type: 'number' }); + private readonly ageMaxInput = el('input', { class: 'input input--count', type: 'number' }); + private readonly roleLabel = el('span', { class: 'people__label' }); + private readonly yearLabel = el('span', { class: 'people__label' }); + private readonly letterLabel = el('span', { class: 'people__label' }); + private readonly positionLabel = el('span', { class: 'people__label' }); + private readonly sexLabel = el('span', { class: 'people__label' }); + private readonly ageFromLabel = el('span', { class: 'people__label' }); + private readonly ageToLabel = el('span', { class: 'people__label' }); + private readonly table = el('table', { class: 'people__table' }); + private readonly thead = el('thead'); + private readonly tbody = el('tbody'); + private readonly empty = el('p', { class: 'panel__empty' }); + private readonly pagerLabel = el('span', { class: 'people__pager-label' }); + private readonly prevButton = el('button', { class: 'button button--small', type: 'button' }); + private readonly nextButton = el('button', { class: 'button button--small', type: 'button' }); + private readonly card = el('aside', { class: 'people__card' }); + + private schoolId: number | null = null; + private sort: PersonSort = 'surname'; + private dir: 'asc' | 'desc' = 'asc'; + private page = 1; + private selectedId: string | null = null; + private token = 0; + private cardToken = 0; + + constructor() { + this.ageMinInput.min = '0'; + this.ageMaxInput.min = '0'; + this.table.append(this.thead, this.tbody); + + this.roleSelect.addEventListener('change', () => this.onFilterChange()); + this.yearSelect.addEventListener('change', () => this.onFilterChange()); + this.letterSelect.addEventListener('change', () => this.onFilterChange()); + this.positionSelect.addEventListener('change', () => this.onFilterChange()); + this.sexSelect.addEventListener('change', () => this.onFilterChange()); + this.ageMinInput.addEventListener('change', () => this.onFilterChange()); + this.ageMaxInput.addEventListener('change', () => this.onFilterChange()); + this.prevButton.addEventListener('click', () => { + if (this.page > 1) { + this.page -= 1; + void this.reload(); + } + }); + this.nextButton.addEventListener('click', () => { + this.page += 1; + void this.reload(); + }); + + this.element = el( + 'section', + { class: 'panel panel--people' }, + this.title, + el( + 'div', + { class: 'panel__body people' }, + el( + 'div', + { class: 'people__toolbar' }, + field(this.roleLabel, this.roleSelect), + field(this.yearLabel, this.yearSelect), + field(this.letterLabel, this.letterSelect), + field(this.positionLabel, this.positionSelect), + field(this.sexLabel, this.sexSelect), + field(this.ageFromLabel, this.ageMinInput), + field(this.ageToLabel, this.ageMaxInput), + ), + el( + 'div', + { class: 'people__main' }, + el( + 'div', + { class: 'people__list' }, + el('div', { class: 'people__table-wrap' }, this.table, this.empty), + el('div', { class: 'people__pager' }, this.prevButton, this.pagerLabel, this.nextButton), + ), + this.card, + ), + ), + ); + + this.localize(); + } + + localize(): void { + this.title.textContent = t('peopleTitle'); + this.roleLabel.textContent = t('peopleRole'); + this.yearLabel.textContent = t('peopleYear'); + this.letterLabel.textContent = t('peopleLetter'); + this.positionLabel.textContent = t('peoplePosition'); + this.sexLabel.textContent = t('peopleSex'); + this.ageFromLabel.textContent = t('peopleAgeFrom'); + this.ageToLabel.textContent = t('peopleAgeTo'); + this.prevButton.textContent = t('peoplePrev'); + this.nextButton.textContent = t('peopleNext'); + this.empty.textContent = t('peopleEmpty'); + fillFixedSelect(this.roleSelect, [ + { value: '', label: t('peopleRoleAll') }, + { value: 'student', label: t('peopleRoleStudent') }, + { value: 'staff', label: t('peopleRoleStaff') }, + { value: 'parent', label: t('peopleRoleParent') }, + ]); + fillFixedSelect(this.sexSelect, [ + { value: '', label: t('peopleSexAll') }, + { value: 'female', label: t('peopleFemale') }, + { value: 'male', label: t('peopleMale') }, + ]); + this.paintHeader(); + if (this.schoolId !== null) { + void this.reload(); + if (this.selectedId !== null) { + void this.openCard(this.selectedId); + } else { + this.paintCard(null); + } + } else { + this.paintCard(null); + } + } + + show(schoolId: number): void { + this.schoolId = schoolId; + this.sort = 'surname'; + this.dir = 'asc'; + this.page = 1; + this.selectedId = null; + this.roleSelect.value = ''; + this.yearSelect.value = ''; + this.letterSelect.value = ''; + this.positionSelect.value = ''; + this.sexSelect.value = ''; + this.ageMinInput.value = ''; + this.ageMaxInput.value = ''; + this.paintCard(null); + void this.reload(); + } + + private onFilterChange(): void { + this.page = 1; + void this.reload(); + } + + private onSort(sort: PersonSort): void { + if (this.sort === sort) { + this.dir = this.dir === 'asc' ? 'desc' : 'asc'; + } else { + this.sort = sort; + this.dir = 'asc'; + } + + this.page = 1; + void this.reload(); + } + + private async reload(): Promise { + const schoolId = this.schoolId; + if (schoolId === null) { + return; + } + + const token = ++this.token; + try { + const page = await fetchPeople( + schoolId, + { + role: (this.roleSelect.value || undefined) as PersonRole | undefined, + year: parseOptionalInt(this.yearSelect.value), + letter: this.letterSelect.value || undefined, + position: this.positionSelect.value || undefined, + sex: (this.sexSelect.value || undefined) as 'male' | 'female' | undefined, + ageMin: parseOptionalInt(this.ageMinInput.value), + ageMax: parseOptionalInt(this.ageMaxInput.value), + sort: this.sort, + dir: this.dir, + page: this.page, + pageSize: 50, + }, + getLocale(), + ); + if (token !== this.token) { + return; + } + + this.fillDynamicFilters(page); + this.paintRows(page); + } catch { + if (token !== this.token) { + return; + } + + clear(this.tbody); + this.empty.hidden = false; + this.empty.textContent = t('peopleLoadFailed'); + this.pagerLabel.textContent = ''; + this.prevButton.disabled = true; + this.nextButton.disabled = true; + } + } + + private fillDynamicFilters(page: PeoplePage): void { + fillSelect( + this.yearSelect, + page.filters.years.map((year) => ({ value: String(year), label: String(year) })), + t('peopleYearAll'), + ); + fillSelect( + this.letterSelect, + page.filters.letters.map((letter) => ({ value: letter, label: letter })), + t('peopleLetterAll'), + ); + fillSelect( + this.positionSelect, + page.filters.positions.map((position) => ({ value: position.defName, label: position.label })), + t('peoplePositionAll'), + ); + } + + private paintHeader(): void { + clear(this.thead); + const row = el('tr'); + for (const column of COLUMNS) { + const button = el('button', { + class: 'people__sort', + type: 'button', + text: t(column.label), + onClick: () => this.onSort(column.sort === 'year' && this.roleSelect.value === 'staff' ? 'position' : column.sort), + }); + button.setAttribute( + 'aria-sort', + this.sort === column.sort || (column.sort === 'year' && this.sort === 'position') + ? this.dir === 'asc' + ? 'ascending' + : 'descending' + : 'none', + ); + row.append(el('th', {}, button)); + } + + row.append( + el('th', {}, el('span', { text: t('peopleColRole') })), + el('th', {}, el('span', { text: t('peopleColSex') })), + ); + this.thead.append(row); + } + + private paintRows(page: PeoplePage): void { + this.paintHeader(); + clear(this.tbody); + this.empty.textContent = t('peopleEmpty'); + this.empty.hidden = page.people.length > 0; + this.table.hidden = page.people.length === 0; + + for (const person of page.people) { + const row = el('tr', { class: 'people__row' }); + row.dataset.personId = person.id; + row.classList.toggle('people__row--active', person.id === this.selectedId); + row.addEventListener('click', () => { + this.selectedId = person.id; + this.highlightSelection(); + void this.openCard(person.id); + }); + row.append( + el('td', { text: person.fullName }), + el('td', { text: placement(person) }), + el('td', { text: String(person.age) }), + el('td', { text: roleLabels(person.roles) }), + el('td', { text: person.female ? t('peopleFemale') : t('peopleMale') }), + ); + this.tbody.append(row); + } + + const pages = Math.max(1, Math.ceil(page.total / page.pageSize)); + this.page = page.page; + this.pagerLabel.textContent = t('peoplePager', { page: page.page, pages, total: page.total }); + this.prevButton.disabled = page.page <= 1; + this.nextButton.disabled = page.page >= pages; + } + + private highlightSelection(): void { + for (const row of this.tbody.querySelectorAll('tr')) { + row.classList.toggle('people__row--active', row instanceof HTMLTableRowElement && row.dataset.personId === this.selectedId); + } + } + + private async openCard(personId: string): Promise { + const schoolId = this.schoolId; + if (schoolId === null) { + return; + } + + const token = ++this.cardToken; + try { + const card = await fetchPerson(schoolId, personId, getLocale()); + if (token !== this.cardToken) { + return; + } + + this.selectedId = card.id; + this.highlightSelection(); + this.paintCard(card); + } catch { + if (token !== this.cardToken) { + return; + } + + clear(this.card); + this.card.append(el('p', { class: 'panel__empty', text: t('peopleCardFailed') })); + } + } + + private paintCard(card: PersonCard | null): void { + clear(this.card); + if (card === null) { + this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') })); + return; + } + + this.card.append( + el('h3', { class: 'people__card-name', text: card.fullName }), + el('p', { class: 'people__card-meta', text: cardMeta(card) }), + ); + appendStats(this.card, t('peopleBody'), card.body.map((row) => `${row.label}: ${row.value}`)); + appendStats(this.card, t('peopleSkills'), card.skills.map((row) => `${row.label}: ${row.value}`)); + appendStats(this.card, t('peopleTraits'), card.traits.map((row) => row.label)); + appendStats( + this.card, + t('peopleNeeds'), + card.needs.map((row) => `${row.label}: ${Math.round(row.value * 100)}%`), + ); + + const family = el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: t('peopleFamily') })); + appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id)); + appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id)); + appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id)); + appendRelatives(family, t('peoplePartners'), card.family.partners, (id) => void this.openCard(id)); + if (family.childElementCount > 1) { + this.card.append(family); + } + } +} + +function field(label: HTMLElement, control: HTMLElement): HTMLLabelElement { + return el('label', { class: 'people__field' }, label, control); +} + +function fillFixedSelect(select: HTMLSelectElement, items: readonly { value: string; label: string }[]): void { + const current = select.value; + select.replaceChildren(); + for (const item of items) { + const option = el('option', { text: item.label }); + option.value = item.value; + select.append(option); + } + + if ([...select.options].some((option) => option.value === current)) { + select.value = current; + } +} + +function fillSelect( + select: HTMLSelectElement, + items: readonly { value: string; label: string }[], + allLabel: string, +): void { + fillFixedSelect(select, [{ value: '', label: allLabel }, ...items]); +} + +function parseOptionalInt(value: string): number | undefined { + if (value === '') { + return undefined; + } + + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : undefined; +} + +function placement(person: PersonListItem): string { + const parts: string[] = []; + if (person.classYear !== null && person.classLetter !== null) { + parts.push(`${person.classYear}${person.classLetter}`); + } + + if (person.positionLabel !== null && person.positionLabel.length > 0) { + parts.push(person.positionLabel); + } + + return parts.length > 0 ? parts.join(' · ') : '—'; +} + +function roleLabels(roles: readonly string[]): string { + return roles + .map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role)) + .join(', '); +} + +function cardMeta(card: PersonCard): string { + const bits = [ + roleLabels(card.roles), + card.female ? t('peopleFemale') : t('peopleMale'), + String(card.age), + placement(card), + ].filter((bit) => bit.length > 0 && bit !== '—'); + return bits.join(' · '); +} + +function appendStats(parent: HTMLElement, title: string, values: readonly string[]): void { + if (values.length === 0) { + return; + } + + const list = el('ul', { class: 'people__stats' }); + for (const value of values) { + list.append(el('li', { text: value })); + } + + parent.append(el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: title }), list)); +} + +function appendRelatives( + parent: HTMLElement, + title: string, + relatives: readonly PersonRel[], + open: (id: string) => void, +): void { + if (relatives.length === 0) { + return; + } + + const list = el('ul', { class: 'people__stats' }); + for (const relative of relatives) { + const item = el('li'); + item.append( + el('button', { + class: 'people__link', + type: 'button', + text: relative.fullName, + onClick: () => open(relative.id), + }), + ); + list.append(item); + } + + parent.append(el('h4', { class: 'panel__section-title', text: title }), list); +} diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 5af2626..5525aed 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -112,11 +112,10 @@ public sealed class DefCatalog /// Locale string for a def, walking parent when the key is missing. Falls back to defName. public string Label(string locale, Def def) { - var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru; var current = def; while (true) { - if (table.TryGetValue(current.DefName, out var label)) + if (TryText(locale, current.DefName, out var label)) { return label; } @@ -130,6 +129,18 @@ public sealed class DefCatalog } } + /// + /// Raw locale lookup for keys that are not defs — body-build bands, hair colours, and so on. + /// Missing keys fall back to so a card never goes blank. + /// + public string Text(string locale, string key) => TryText(locale, key, out var text) ? text : key; + + private bool TryText(string locale, string key, out string text) + { + var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru; + return table.TryGetValue(key, out text!); + } + internal static DefKind KindOf(Def def) => def switch { ActionDef => DefKind.Action, diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs index aa8c064..feb6373 100644 --- a/src/HSchool.People/Roster.cs +++ b/src/HSchool.People/Roster.cs @@ -49,7 +49,11 @@ public sealed record PersonName( string Patronymic, CaseTable GivenCases, CaseTable SurnameCases, - CaseTable PatronymicCases); + CaseTable PatronymicCases) +{ + /// Nominative ФИО as the list and cards show it: surname, given, patronymic. + public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0)); +} public sealed record Family( string Id, diff --git a/src/HSchool.People/RosterBrowser.cs b/src/HSchool.People/RosterBrowser.cs new file mode 100644 index 0000000..dc7033e --- /dev/null +++ b/src/HSchool.People/RosterBrowser.cs @@ -0,0 +1,202 @@ +namespace HSchool.People; + +public enum PersonSort +{ + Surname, + Age, + Year, + Position, +} + +/// Filter, sort and page a published roster. Does not touch a World. +public readonly record struct RosterQuery( + string? Role, + int? Year, + string? Letter, + string? Position, + bool? Female, + int? AgeMin, + int? AgeMax, + PersonSort Sort, + bool Descending, + int Page, + int PageSize); + +public sealed record RosterPage(int Total, IReadOnlyList People); + +public static class RosterBrowser +{ + public const int DefaultPageSize = 50; + + public const int MaxPageSize = 100; + + public static RosterPage Apply(Roster roster, DateTime asOf, RosterQuery query) + { + var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); + var filtered = new List(); + foreach (var person in roster.People) + { + if (!Matches(person, classes, asOf, query)) + { + continue; + } + + filtered.Add(person); + } + + var ordered = query.Sort switch + { + PersonSort.Age => query.Descending + ? filtered.OrderByDescending(person => person.AgeOn(asOf)).ThenByDescending(SurnameKey, StringComparer.Ordinal) + : filtered.OrderBy(person => person.AgeOn(asOf)).ThenBy(SurnameKey, StringComparer.Ordinal), + PersonSort.Year => SortByYear(filtered, classes, query.Descending), + PersonSort.Position => query.Descending + ? filtered + .OrderBy(person => person.Position is null) + .ThenByDescending(person => person.Position ?? string.Empty, StringComparer.Ordinal) + .ThenByDescending(SurnameKey, StringComparer.Ordinal) + : filtered + .OrderBy(person => person.Position is null) + .ThenBy(person => person.Position ?? string.Empty, StringComparer.Ordinal) + .ThenBy(SurnameKey, StringComparer.Ordinal), + _ => query.Descending + ? filtered.OrderByDescending(SurnameKey, StringComparer.Ordinal).ThenByDescending(GivenKey, StringComparer.Ordinal) + : filtered.OrderBy(SurnameKey, StringComparer.Ordinal).ThenBy(GivenKey, StringComparer.Ordinal), + }; + + var sorted = ordered.ThenBy(person => person.Id, StringComparer.Ordinal).ToArray(); + var page = Math.Max(query.Page, 1); + var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize); + var skip = (page - 1) * pageSize; + var slice = skip >= sorted.Length ? [] : sorted.Skip(skip).Take(pageSize).ToArray(); + return new RosterPage(sorted.Length, slice); + } + + public static bool TryParseSort(string? value, out PersonSort sort) + { + if (string.IsNullOrWhiteSpace(value) || value.Equals("surname", StringComparison.OrdinalIgnoreCase)) + { + sort = PersonSort.Surname; + return true; + } + + if (value.Equals("age", StringComparison.OrdinalIgnoreCase)) + { + sort = PersonSort.Age; + return true; + } + + if (value.Equals("year", StringComparison.OrdinalIgnoreCase)) + { + sort = PersonSort.Year; + return true; + } + + if (value.Equals("position", StringComparison.OrdinalIgnoreCase)) + { + sort = PersonSort.Position; + return true; + } + + sort = PersonSort.Surname; + return false; + } + + private static IOrderedEnumerable SortByYear( + List filtered, + IReadOnlyDictionary classes, + bool descending) + { + var studentsFirst = filtered.OrderBy(person => YearOf(person, classes) is null); + return descending + ? studentsFirst + .ThenByDescending(person => YearOf(person, classes) ?? 0) + .ThenByDescending(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal) + .ThenByDescending(SurnameKey, StringComparer.Ordinal) + : studentsFirst + .ThenBy(person => YearOf(person, classes) ?? 0) + .ThenBy(person => LetterOf(person, classes) ?? string.Empty, StringComparer.Ordinal) + .ThenBy(SurnameKey, StringComparer.Ordinal); + } + + private static bool Matches( + Person person, + IReadOnlyDictionary classes, + DateTime asOf, + RosterQuery query) + { + if (query.Role is not null && !HasRole(person, query.Role)) + { + return false; + } + + if (query.Year is int year && YearOf(person, classes) != year) + { + return false; + } + + if (query.Letter is not null + && !string.Equals(LetterOf(person, classes), query.Letter, StringComparison.Ordinal)) + { + return false; + } + + if (query.Position is not null + && !string.Equals(person.Position, query.Position, StringComparison.Ordinal)) + { + return false; + } + + if (query.Female is bool female && person.Female != female) + { + return false; + } + + var age = person.AgeOn(asOf); + if (query.AgeMin is int ageMin && age < ageMin) + { + return false; + } + + if (query.AgeMax is int ageMax && age > ageMax) + { + return false; + } + + return true; + } + + private static bool HasRole(Person person, string role) + { + if (role.Equals(PersonRoles.Student, StringComparison.OrdinalIgnoreCase)) + { + return person.IsStudent; + } + + if (role.Equals(PersonRoles.Staff, StringComparison.OrdinalIgnoreCase)) + { + return person.IsStaff; + } + + if (role.Equals(PersonRoles.Parent, StringComparison.OrdinalIgnoreCase)) + { + return person.IsParent; + } + + return false; + } + + private static int? YearOf(Person person, IReadOnlyDictionary classes) => + person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass) + ? schoolClass.Year + : null; + + private static string? LetterOf(Person person, IReadOnlyDictionary classes) => + person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass) + ? schoolClass.Letter + : null; + + private static string SurnameKey(Person person) => person.Name.Surname; + + private static string GivenKey(Person person) => person.Name.Given; +} diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs new file mode 100644 index 0000000..56b9050 --- /dev/null +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -0,0 +1,171 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Server.Api; + +internal enum PersonLookupError +{ + None, + UnknownSchool, + UnknownPerson, +} + +internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error); + +internal sealed record PeopleListResponse( + int Total, + int Page, + int PageSize, + IReadOnlyList People, + PeopleFilterOptionsResponse Filters); + +internal sealed record PeopleFilterOptionsResponse( + IReadOnlyList Years, + IReadOnlyList Letters, + IReadOnlyList Positions); + +internal sealed record DefLabelResponse(string DefName, string Label); + +internal sealed record PersonListItemResponse( + string Id, + string FullName, + string Surname, + string Given, + string Patronymic, + bool Female, + int Age, + IReadOnlyList Roles, + int? ClassYear, + string? ClassLetter, + string? Position, + string? PositionLabel); + +internal sealed record PersonCardResponse( + string Id, + string FullName, + string Surname, + string Given, + string Patronymic, + bool Female, + int Age, + DateTime BirthDate, + IReadOnlyList Roles, + int? ClassYear, + string? ClassLetter, + string? Position, + string? PositionLabel, + IReadOnlyList Body, + IReadOnlyList Skills, + IReadOnlyList Traits, + IReadOnlyList Needs, + PersonFamilyResponse Family); + +internal sealed record LabeledStatResponse(string Id, string Label, string Value); + +internal sealed record NeedStatResponse(string Id, string Label, float Value); + +internal sealed record PersonFamilyResponse( + IReadOnlyList Parents, + IReadOnlyList Children, + IReadOnlyList Siblings, + IReadOnlyList Partners); + +internal sealed record PersonRelResponse(string Id, string FullName, bool Female); + +internal static class PeopleListMapper +{ + public static PeopleListResponse From( + Roster roster, + RosterPage page, + DateTime asOf, + DefCatalog? catalog, + string locale, + RosterQuery query) + { + var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); + var people = page.People + .Select(person => Item(person, classes, asOf, catalog, locale)) + .ToArray(); + return new PeopleListResponse(page.Total, query.Page, query.PageSize, people, FilterOptions(roster, catalog, locale)); + } + + private static PersonListItemResponse Item( + Person person, + IReadOnlyDictionary classes, + DateTime asOf, + DefCatalog? catalog, + string locale) + { + int? year = null; + string? letter = null; + if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)) + { + year = schoolClass.Year; + letter = schoolClass.Letter; + } + + return new PersonListItemResponse( + person.Id, + person.Name.Full, + person.Name.Surname, + person.Name.Given, + person.Name.Patronymic, + person.Female, + person.AgeOn(asOf), + RolesOf(person), + year, + letter, + person.Position, + PositionLabel(catalog, locale, person.Position)); + } + + internal static string[] RolesOf(Person person) + { + var roles = new List(3); + if (person.IsStudent) + { + roles.Add(PersonRoles.Student); + } + + if (person.IsStaff) + { + roles.Add(PersonRoles.Staff); + } + + if (person.IsParent) + { + roles.Add(PersonRoles.Parent); + } + + return [.. roles]; + } + + internal static string? PositionLabel(DefCatalog? catalog, string locale, string? position) + { + if (position is null) + { + return null; + } + + if (catalog is not null && catalog.Positions.TryGetValue(position, out var def)) + { + return catalog.Label(locale, def); + } + + return position; + } + + private static PeopleFilterOptionsResponse FilterOptions(Roster roster, DefCatalog? catalog, string locale) + { + var years = roster.Classes.Select(schoolClass => schoolClass.Year).Distinct().OrderBy(year => year).ToArray(); + var letters = roster.Classes.Select(schoolClass => schoolClass.Letter).Distinct().OrderBy(letter => letter, StringComparer.Ordinal).ToArray(); + var positions = roster.People + .Select(person => person.Position) + .Where(position => position is not null) + .Distinct(StringComparer.Ordinal) + .OrderBy(position => PositionLabel(catalog, locale, position) ?? position, StringComparer.Ordinal) + .Select(position => new DefLabelResponse(position!, PositionLabel(catalog, locale, position) ?? position!)) + .ToArray(); + return new PeopleFilterOptionsResponse(years, letters, positions); + } +} diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 6c04bbd..bfd6aa4 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -1,12 +1,13 @@ using HSchool.Content; +using HSchool.People; using HSchool.Server.Game; using HSchool.Simulation; namespace HSchool.Server.Api; /// -/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to -/// the supervisor as a command and awaited, so each school stays on its own worker thread. +/// The main menu talks to these: list, create, delete. The people list reads a published roster +/// snapshot; the person card goes through the school's mailbox because needs are live. /// internal static class SchoolEndpoints { @@ -91,6 +92,70 @@ internal static class SchoolEndpoints return deleted ? Results.NoContent() : Results.NotFound(); }) .WithName("DeleteSchool"); + + schools.MapGet("/{id:int}/people", ( + int id, + string? role, + int? year, + string? letter, + string? position, + string? sex, + int? ageMin, + int? ageMax, + string? sort, + string? dir, + int? page, + int? pageSize, + string? lang, + GameLoopService loop) => + { + var published = loop.FindPeople(id); + if (published is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + if (!TryParsePeopleQuery(role, year, letter, position, sex, ageMin, ageMax, sort, dir, page, pageSize, out var query, out var error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var roster = published.Roster; + if (roster is null) + { + return Results.Ok(new PeopleListResponse(0, query.Page, query.PageSize, [], new PeopleFilterOptionsResponse([], [], []))); + } + + var locale = ParseLocale(lang); + var slice = RosterBrowser.Apply(roster, published.School.GameTime, query); + return Results.Ok(PeopleListMapper.From(roster, slice, published.School.GameTime, published.Catalog, locale, query)); + }) + .WithName("GetSchoolPeople"); + + schools.MapGet("/{id:int}/people/{personId}", async ( + int id, + string personId, + string? lang, + GameCommandQueue commands, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid."); + } + + var command = new GameCommand.GetPerson(id, personId, ParseLocale(lang), NewCompletion()); + commands.Enqueue(command); + + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return outcome.Error switch + { + PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card), + PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."), + _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."), + }; + }) + .WithName("GetSchoolPerson"); } /// The supervisor must never be blocked by a continuation of a waiting request. @@ -102,6 +167,118 @@ internal static class SchoolEndpoints ? SchoolNameLanguage.English : SchoolNameLanguage.Russian; + private static string ParseLocale(string? lang) => + string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru"; + + private static bool TryParsePeopleQuery( + string? role, + int? year, + string? letter, + string? position, + string? sex, + int? ageMin, + int? ageMax, + string? sort, + string? dir, + int? page, + int? pageSize, + out RosterQuery query, + out string error) + { + query = default; + error = string.Empty; + + if (!string.IsNullOrWhiteSpace(role) && !HSchool.Content.PersonRoles.IsKnown(role)) + { + error = "role must be student, staff or parent."; + return false; + } + + if (year is < 1) + { + error = "year must be a positive integer."; + return false; + } + + bool? female = null; + if (!string.IsNullOrWhiteSpace(sex)) + { + if (sex.Equals("female", StringComparison.OrdinalIgnoreCase)) + { + female = true; + } + else if (sex.Equals("male", StringComparison.OrdinalIgnoreCase)) + { + female = false; + } + else + { + error = "sex must be male or female."; + return false; + } + } + + if (ageMin is < 0 || ageMax is < 0) + { + error = "ageMin and ageMax must be zero or greater."; + return false; + } + + if (ageMin is int min && ageMax is int max && min > max) + { + error = "ageMin cannot be greater than ageMax."; + return false; + } + + if (!RosterBrowser.TryParseSort(sort, out var parsedSort)) + { + error = "sort must be surname, age, year or position."; + return false; + } + + var descending = false; + if (!string.IsNullOrWhiteSpace(dir)) + { + if (dir.Equals("desc", StringComparison.OrdinalIgnoreCase)) + { + descending = true; + } + else if (!dir.Equals("asc", StringComparison.OrdinalIgnoreCase)) + { + error = "dir must be asc or desc."; + return false; + } + } + + var parsedPage = page ?? 1; + if (parsedPage < 1) + { + error = "page must be 1 or greater."; + return false; + } + + var parsedPageSize = pageSize ?? RosterBrowser.DefaultPageSize; + if (parsedPageSize < 1 || parsedPageSize > RosterBrowser.MaxPageSize) + { + error = $"pageSize must be between 1 and {RosterBrowser.MaxPageSize}."; + return false; + } + + query = new RosterQuery( + string.IsNullOrWhiteSpace(role) ? null : role, + year, + string.IsNullOrWhiteSpace(letter) ? null : letter, + string.IsNullOrWhiteSpace(position) ? null : position, + female, + ageMin, + ageMax, + parsedSort, + descending, + parsedPage, + parsedPageSize); + return true; + } + private static IResult Problem(int statusCode, string code, string detail) => Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary { diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 0fa323e..63b24d8 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -1,4 +1,5 @@ using HSchool.Content; +using HSchool.Server.Api; using HSchool.Simulation; namespace HSchool.Server.Game; @@ -42,4 +43,11 @@ internal abstract record GameCommand /// it to drop the school instead of removing itself. /// internal sealed record WorkerFailed(int SchoolId) : GameCommand; + + /// One person's card, including live needs. Completes on that school's worker thread. + internal sealed record GetPerson( + int SchoolId, + string PersonId, + string Locale, + TaskCompletionSource Result) : GameCommand; } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 5afd8ea..92aed6f 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -1,5 +1,7 @@ using HSchool.Content; +using HSchool.People; using HSchool.Protocol; +using HSchool.Server.Api; using HSchool.Server.Net; using HSchool.Simulation; using Microsoft.Extensions.Options; @@ -52,6 +54,23 @@ internal sealed class GameLoopService( } } + /// + /// Menu-style read of one school's published roster and frozen catalog. Does not post to the + /// mailbox — the list is HTTP over a snapshot, the same way the menu reads clocks. + /// + public PublishedSchoolPeople? FindPeople(int schoolId) + { + foreach (var worker in Volatile.Read(ref _publishedWorkers)) + { + if (worker.Id == schoolId) + { + return new PublishedSchoolPeople(worker.Snapshot, worker.RosterSnapshot, worker.CatalogSnapshot); + } + } + + return null; + } + public Task ReloadFromDiskAsync(CancellationToken cancellationToken) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -148,6 +167,19 @@ internal sealed class GameLoopService( case GameCommand.WorkerFailed failed: HandleWorkerFailed(failed.SchoolId); break; + + case GameCommand.GetPerson getPerson: + HandleGetPerson(getPerson); + break; + } + } + + private void HandleGetPerson(GameCommand.GetPerson command) + { + if (!_workers.TryGetValue(command.SchoolId, out var worker) + || !worker.Post(new WorkerCommand.GetPerson(command.PersonId, command.Locale, command.Result))) + { + command.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); } } @@ -548,3 +580,5 @@ internal sealed class GameLoopService( } } } + +internal sealed record PublishedSchoolPeople(SchoolState School, Roster? Roster, DefCatalog? Catalog); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs new file mode 100644 index 0000000..e919683 --- /dev/null +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -0,0 +1,219 @@ +using Arch.Core; +using HSchool.Content; +using HSchool.People; +using HSchool.Server.Api; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +/// +/// Builds a person card on the school's worker thread so live need values come from the World. +/// +internal static class PersonCardReader +{ + private static readonly QueryDescription IdentityAndNeeds = + new QueryDescription().WithAll(); + + public static PersonCardResponse? Read(School school, string personId, string locale) + { + var roster = school.Roster; + if (roster is null) + { + return null; + } + + var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal)); + if (person is null) + { + return null; + } + + var catalog = school.Catalog; + var classes = roster.Classes.ToDictionary(schoolClass => schoolClass.Id, StringComparer.Ordinal); + int? year = null; + string? letter = null; + if (person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)) + { + year = schoolClass.Year; + letter = schoolClass.Letter; + } + + var needs = LiveNeeds(school.World, personId) ?? person.Needs; + return new PersonCardResponse( + person.Id, + person.Name.Full, + person.Name.Surname, + person.Name.Given, + person.Name.Patronymic, + person.Female, + person.AgeOn(school.Clock.Time), + person.BirthDate, + PeopleListMapper.RolesOf(person), + year, + letter, + person.Position, + PeopleListMapper.PositionLabel(catalog, locale, person.Position), + Body(person, catalog, locale), + Skills(person, catalog, locale), + Traits(person, catalog, locale), + Needs(needs, catalog, locale), + Family(roster, person)); + } + + private static IReadOnlyDictionary? LiveNeeds(World world, string personId) + { + Dictionary? found = null; + world.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds needs) => + { + if (identity.Id.Equals(personId, StringComparison.Ordinal)) + { + found = new Dictionary(needs.Values, StringComparer.Ordinal); + } + }); + return found; + } + + private static IReadOnlyList Body(Person person, DefCatalog? catalog, string locale) + { + var rows = new List(); + if (catalog is not null) + { + foreach (var def in catalog.BodyAttributes.Values) + { + if (def.Abstract) + { + continue; + } + + if (def.Kind == BodyAttributeKind.Number && person.Numbers.TryGetValue(def.DefName, out var number)) + { + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), number.ToString())); + } + else if (def.Kind == BodyAttributeKind.Choice && person.Choices.TryGetValue(def.DefName, out var choice)) + { + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), catalog.Text(locale, choice))); + } + } + } + else + { + foreach (var (id, number) in person.Numbers) + { + rows.Add(new LabeledStatResponse(id, id, number.ToString())); + } + + foreach (var (id, choice) in person.Choices) + { + rows.Add(new LabeledStatResponse(id, id, choice)); + } + } + + if (person.Choices.TryGetValue(BodyBuilds.Attribute, out var build) + && rows.TrueForAll(row => row.Id != BodyBuilds.Attribute)) + { + var label = catalog?.Text(locale, BodyBuilds.Attribute) ?? BodyBuilds.Attribute; + var value = catalog?.Text(locale, build) ?? build; + rows.Add(new LabeledStatResponse(BodyBuilds.Attribute, label, value)); + } + + return rows; + } + + private static IReadOnlyList Skills(Person person, DefCatalog? catalog, string locale) + { + if (catalog is null) + { + return person.Skills + .Select(pair => new LabeledStatResponse(pair.Key, pair.Key, pair.Value.ToString())) + .ToArray(); + } + + var rows = new List(); + foreach (var def in catalog.Skills.Values) + { + if (def.Abstract || !person.Skills.TryGetValue(def.DefName, out var value)) + { + continue; + } + + rows.Add(new LabeledStatResponse(def.DefName, catalog.Label(locale, def), value.ToString())); + } + + return rows; + } + + private static IReadOnlyList Traits(Person person, DefCatalog? catalog, string locale) + { + var rows = new List(person.Traits.Count); + foreach (var id in person.Traits) + { + var label = catalog is not null && catalog.Traits.TryGetValue(id, out var def) + ? catalog.Label(locale, def) + : id; + rows.Add(new DefLabelResponse(id, label)); + } + + return rows; + } + + private static IReadOnlyList Needs( + IReadOnlyDictionary values, + DefCatalog? catalog, + string locale) + { + if (catalog is null) + { + return values.Select(pair => new NeedStatResponse(pair.Key, pair.Key, pair.Value)).ToArray(); + } + + var rows = new List(); + foreach (var def in catalog.Needs.Values) + { + if (def.Abstract || !values.TryGetValue(def.DefName, out var value)) + { + continue; + } + + rows.Add(new NeedStatResponse(def.DefName, catalog.Label(locale, def), value)); + } + + return rows; + } + + private static PersonFamilyResponse Family(Roster roster, Person person) + { + var family = roster.Families.FirstOrDefault(candidate => candidate.Id.Equals(person.FamilyId, StringComparison.Ordinal)); + if (family is null) + { + return new PersonFamilyResponse([], [], [], []); + } + + var people = roster.People.ToDictionary(member => member.Id, StringComparer.Ordinal); + var inParents = family.ParentIds.Contains(person.Id, StringComparer.Ordinal); + var inChildren = family.ChildIds.Contains(person.Id, StringComparer.Ordinal); + return new PersonFamilyResponse( + inChildren ? Relatives(family.ParentIds, people, except: person.Id) : [], + inParents ? Relatives(family.ChildIds, people, except: person.Id) : [], + inChildren ? Relatives(family.ChildIds, people, except: person.Id) : [], + inParents ? Relatives(family.ParentIds, people, except: person.Id) : []); + } + + private static IReadOnlyList Relatives( + IReadOnlyList ids, + IReadOnlyDictionary people, + string except) + { + var rows = new List(); + foreach (var id in ids) + { + if (id.Equals(except, StringComparison.Ordinal) || !people.TryGetValue(id, out var relative)) + { + continue; + } + + rows.Add(new PersonRelResponse(relative.Id, relative.Name.Full, relative.Female)); + } + + return rows; + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 8a6dae2..55aa9b0 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using HSchool.Content; using HSchool.People; using HSchool.Protocol; +using HSchool.Server.Api; using HSchool.Server.Net; using HSchool.Simulation; @@ -41,6 +42,7 @@ internal sealed class SchoolWorker private SchoolState _snapshot; private Roster? _rosterSnapshot; + private DefCatalog? _catalogSnapshot; private School? _school; private Task? _run; private bool _persistOnStop = true; @@ -94,6 +96,9 @@ internal sealed class SchoolWorker /// Last roster composition. Published like ; needs live on entities. public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot); + /// Frozen catalog for this school. Safe to read from HTTP; it never mutates after load. + public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot); + public void Start() { _run = Task.Factory.StartNew( @@ -103,12 +108,15 @@ internal sealed class SchoolWorker TaskScheduler.Default); } - public void Post(WorkerCommand command) + public bool Post(WorkerCommand command) { - if (!_mailbox.Writer.TryWrite(command)) + if (_mailbox.Writer.TryWrite(command)) { - _logger.LogDebug("Dropped a command for school {SchoolId}: the mailbox is closed.", _id); + return true; } + + _logger.LogDebug("Dropped a command for school {SchoolId}: the mailbox is closed.", _id); + return false; } public async Task StopAsync(bool persist) @@ -185,6 +193,7 @@ internal sealed class SchoolWorker } var catalog = _mods.LoadCatalog(packIds, _logger); + Volatile.Write(ref _catalogSnapshot, catalog); var map = _mods.LoadMap(packIds, _savedMap); try { @@ -320,6 +329,14 @@ internal sealed class SchoolWorker var school = _school; if (school is null) { + while (_mailbox.Reader.TryRead(out var orphan)) + { + if (orphan is WorkerCommand.GetPerson getPerson) + { + getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); + } + } + return; } @@ -357,10 +374,23 @@ internal sealed class SchoolWorker school.Clock.SpeedIndex = setSpeed.SpeedIndex; dirty = true; break; + + case WorkerCommand.GetPerson getPerson: + var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale); + getPerson.Result.TrySetResult( + card is null + ? new PersonCardResult(null, PersonLookupError.UnknownPerson) + : new PersonCardResult(card, PersonLookupError.None)); + break; } } catch (Exception ex) { + if (command is WorkerCommand.GetPerson failed) + { + failed.Result.TrySetException(ex); + } + _logger.LogError( ex, "Command {Command} failed for school {SchoolId}; the school keeps running.", diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index d9a3e45..f3a3fb7 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -1,3 +1,4 @@ +using HSchool.Server.Api; using HSchool.Server.Net; namespace HSchool.Server.Game; @@ -15,4 +16,9 @@ internal abstract record WorkerCommand internal sealed record SetRunning(bool Running) : WorkerCommand; internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand; + + internal sealed record GetPerson( + string PersonId, + string Locale, + TaskCompletionSource Result) : WorkerCommand; } diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs new file mode 100644 index 0000000..3396841 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -0,0 +1,190 @@ +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class PeopleApiTests(AppHostFixture fixture) +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task List_FiltersByRoleAndYear() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Люди фильтр", Start); + + var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=100"); + + Assert.NotEmpty(page.People); + Assert.Equal(page.People.Count, page.Total); + Assert.All(page.People, person => + { + Assert.Contains("student", person.Roles); + Assert.Equal(5, person.ClassYear); + }); + Assert.Contains(5, page.Filters.Years); + } + + [Fact] + public async Task List_SortsBySurname() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Люди сорт", Start); + + var page = await GetPeopleAsync(client, school.Id, "sort=surname&pageSize=20"); + var surnames = page.People.Select(person => person.Surname).ToArray(); + Assert.Equal(surnames.OrderBy(name => name, StringComparer.Ordinal), surnames); + } + + [Fact] + public async Task List_PageBounds() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Люди страницы", Start); + + var first = await GetPeopleAsync(client, school.Id, "page=1&pageSize=10"); + Assert.True(first.Total > 10); + Assert.Equal(10, first.People.Count); + Assert.Equal(1, first.Page); + Assert.Equal(10, first.PageSize); + + var past = await GetPeopleAsync(client, school.Id, "page=999&pageSize=10"); + Assert.Equal(first.Total, past.Total); + Assert.Empty(past.People); + + using var zero = await client.GetAsync($"/api/schools/{school.Id}/people?page=0", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, zero.StatusCode); + Assert.Equal("invalid-query", await ProblemCodeAsync(zero)); + + using var huge = await client.GetAsync($"/api/schools/{school.Id}/people?pageSize=101", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, huge.StatusCode); + Assert.Equal("invalid-query", await ProblemCodeAsync(huge)); + } + + [Fact] + public async Task List_UnknownSchool_IsNotFound() + { + using var client = fixture.App.CreateHttpClient("server"); + + using var response = await client.GetAsync("/api/schools/999999/people", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown-school", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task Card_UnknownPerson_IsNotFound() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Люди карточка", Start); + + using var response = await client.GetAsync( + $"/api/schools/{school.Id}/people/nobody", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown-person", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task Card_IncludesFamilyAndLiveNeeds() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Люди семья", Start); + + var page = await GetPeopleAsync(client, school.Id, "role=student&year=5&pageSize=10"); + var pupil = page.People[0]; + + var card = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil.Id)}?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(card); + Assert.Equal(pupil.Id, card.Id); + Assert.Equal(2, card.Family.Parents.Count); + Assert.Contains(card.Needs, need => need.Id == "Sleep" && need.Value == 1f); + Assert.NotEmpty(card.Body); + Assert.NotEmpty(card.Skills); + + var mother = card.Family.Parents.Single(parent => parent.Female); + var motherCard = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(mother.Id)}?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(motherCard); + Assert.Contains(motherCard.Family.Children, child => child.Id == pupil.Id); + } + + private static async Task GetPeopleAsync(HttpClient client, int schoolId, string query) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?{query}", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + return page; + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record ProblemResponse(string? Code); + + private sealed record PeopleListResponse( + int Total, + int Page, + int PageSize, + IReadOnlyList People, + PeopleFilterOptionsResponse Filters); + + private sealed record PeopleFilterOptionsResponse( + IReadOnlyList Years, + IReadOnlyList Letters, + IReadOnlyList Positions); + + private sealed record DefLabelResponse(string DefName, string Label); + + private sealed record PersonListItemResponse( + string Id, + string FullName, + string Surname, + string Given, + string Patronymic, + bool Female, + int Age, + IReadOnlyList Roles, + int? ClassYear, + string? ClassLetter, + string? Position, + string? PositionLabel); + + private sealed record PersonCardResponse( + string Id, + string FullName, + bool Female, + int Age, + DateTime BirthDate, + IReadOnlyList Roles, + int? ClassYear, + string? ClassLetter, + IReadOnlyList Body, + IReadOnlyList Skills, + IReadOnlyList Traits, + IReadOnlyList Needs, + PersonFamilyResponse Family); + + private sealed record LabeledStatResponse(string Id, string Label, string Value); + + private sealed record NeedStatResponse(string Id, string Label, float Value); + + private sealed record PersonFamilyResponse( + IReadOnlyList Parents, + IReadOnlyList Children, + IReadOnlyList Siblings, + IReadOnlyList Partners); + + private sealed record PersonRelResponse(string Id, string FullName, bool Female); +} diff --git a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs index b42f646..5402c1b 100644 --- a/tests/HSchool.Content.Tests/CatalogLoaderTests.cs +++ b/tests/HSchool.Content.Tests/CatalogLoaderTests.cs @@ -57,6 +57,20 @@ public class CatalogLoaderTests Assert.Contains(log.Warnings, warning => warning.Contains("Sit") && warning.Contains("addon")); } + [Fact] + public void Text_FallsBackToTheKeyWhenMissing() + { + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def(CatalogLoader.CorePackId, "actions", "sit", """{ "defName": "Sit" }"""), + PackDocuments.Locale(CatalogLoader.CorePackId, "ru", """{ "Sit": "Сесть" }"""), + ]); + + Assert.Equal("Сесть", catalog.Text("ru", "Sit")); + Assert.Equal("Skinny", catalog.Text("en", "Skinny")); + } + [Fact] public void CoreIsAlwaysFirst_EvenIfOmittedFromThePackList() { diff --git a/tests/HSchool.People.Tests/RosterBrowserTests.cs b/tests/HSchool.People.Tests/RosterBrowserTests.cs new file mode 100644 index 0000000..95db9e3 --- /dev/null +++ b/tests/HSchool.People.Tests/RosterBrowserTests.cs @@ -0,0 +1,67 @@ +namespace HSchool.People.Tests; + +public class RosterBrowserTests +{ + [Fact] + public void FilterByRoleAndYear_KeepsOnlyThosePupils() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var page = RosterBrowser.Apply( + roster, + Fixtures.AsOf, + Query(role: PersonRoles.Student, year: 1, pageSize: RosterBrowser.MaxPageSize)); + + Assert.NotEmpty(page.People); + Assert.Equal(page.People.Count, page.Total); + Assert.All(page.People, person => + { + Assert.True(person.IsStudent); + var schoolClass = roster.Classes.Single(candidate => candidate.Id == person.ClassId); + Assert.Equal(1, schoolClass.Year); + }); + } + + [Fact] + public void SortBySurname_IsAscendingOrdinal() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var page = RosterBrowser.Apply(roster, Fixtures.AsOf, Query(pageSize: 20)); + + var surnames = page.People.Select(person => person.Name.Surname).ToArray(); + var expected = surnames.OrderBy(name => name, StringComparer.Ordinal).ToArray(); + Assert.Equal(expected, surnames); + } + + [Fact] + public void PagePastTheEnd_IsEmptyButKeepsTheTotal() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(2)); + var first = RosterBrowser.Apply(roster, Fixtures.AsOf, Query(page: 1, pageSize: 10)); + var past = RosterBrowser.Apply(roster, Fixtures.AsOf, Query(page: 999, pageSize: 10)); + + Assert.True(first.Total > 10); + Assert.Equal(10, first.People.Count); + Assert.Equal(first.Total, past.Total); + Assert.Empty(past.People); + } + + [Fact] + public void ParentFilter_IncludesStaffWhoAreAlsoParents() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(4)); + var page = RosterBrowser.Apply( + roster, + Fixtures.AsOf, + Query(role: PersonRoles.Parent, pageSize: RosterBrowser.MaxPageSize)); + + Assert.Contains(page.People, person => person.IsStaff && person.IsParent); + Assert.All(page.People, person => Assert.True(person.IsParent)); + } + + private static RosterQuery Query( + string? role = null, + int? year = null, + int page = 1, + int pageSize = RosterBrowser.DefaultPageSize) => + new(role, year, Letter: null, Position: null, Female: null, AgeMin: null, AgeMax: null, PersonSort.Surname, Descending: false, page, pageSize); +}