Implement people management features by adding API endpoints for retrieving school rosters and individual person cards. Enhance the UI to support a people browser with filtering and pagination capabilities. Update localization strings for improved user experience and ensure robust handling of person data. Revise documentation to reflect new API functionalities and update tests to validate the new features.
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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: фильтр по роли и параллели, сортировка, границы страниц, неизвестная школа,
|
||||
неизвестный человек
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
@@ -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) | ⬜ | Первое сентября: переход, выпуск, набор |
|
||||
|
||||
+89
-2
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Locale, Messages> = { ru, en };
|
||||
|
||||
@@ -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<void> {
|
||||
await request<void>(`/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<PeoplePage> {
|
||||
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<PeoplePage>(`/api/schools/${schoolId}/people?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchPerson(schoolId: number, personId: string, lang: string): Promise<PersonCard> {
|
||||
const params = new URLSearchParams({ lang });
|
||||
return request<PersonCard>(
|
||||
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<string, HTMLButtonElement>();
|
||||
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 {
|
||||
|
||||
@@ -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<PersonRole, MessageKey> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -112,11 +112,10 @@ public sealed class DefCatalog
|
||||
/// <summary>Locale string for a def, walking <c>parent</c> when the key is missing. Falls back to <c>defName</c>.</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw locale lookup for keys that are not defs — body-build bands, hair colours, and so on.
|
||||
/// Missing keys fall back to <paramref name="key"/> so a card never goes blank.
|
||||
/// </summary>
|
||||
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,
|
||||
|
||||
@@ -49,7 +49,11 @@ public sealed record PersonName(
|
||||
string Patronymic,
|
||||
CaseTable GivenCases,
|
||||
CaseTable SurnameCases,
|
||||
CaseTable PatronymicCases);
|
||||
CaseTable PatronymicCases)
|
||||
{
|
||||
/// <summary>Nominative ФИО as the list and cards show it: surname, given, patronymic.</summary>
|
||||
public string Full => string.Join(' ', new[] { Surname, Given, Patronymic }.Where(part => part.Length > 0));
|
||||
}
|
||||
|
||||
public sealed record Family(
|
||||
string Id,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
namespace HSchool.People;
|
||||
|
||||
public enum PersonSort
|
||||
{
|
||||
Surname,
|
||||
Age,
|
||||
Year,
|
||||
Position,
|
||||
}
|
||||
|
||||
/// <summary>Filter, sort and page a published roster. Does not touch a World.</summary>
|
||||
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<Person> 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<Person>();
|
||||
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<Person> SortByYear(
|
||||
List<Person> filtered,
|
||||
IReadOnlyDictionary<string, SchoolClass> 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<string, SchoolClass> 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<string, SchoolClass> classes) =>
|
||||
person.ClassId is { } classId && classes.TryGetValue(classId, out var schoolClass)
|
||||
? schoolClass.Year
|
||||
: null;
|
||||
|
||||
private static string? LetterOf(Person person, IReadOnlyDictionary<string, SchoolClass> 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;
|
||||
}
|
||||
@@ -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<PersonListItemResponse> People,
|
||||
PeopleFilterOptionsResponse Filters);
|
||||
|
||||
internal sealed record PeopleFilterOptionsResponse(
|
||||
IReadOnlyList<int> Years,
|
||||
IReadOnlyList<string> Letters,
|
||||
IReadOnlyList<DefLabelResponse> 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<string> 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<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? Position,
|
||||
string? PositionLabel,
|
||||
IReadOnlyList<LabeledStatResponse> Body,
|
||||
IReadOnlyList<LabeledStatResponse> Skills,
|
||||
IReadOnlyList<DefLabelResponse> Traits,
|
||||
IReadOnlyList<NeedStatResponse> 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<PersonRelResponse> Parents,
|
||||
IReadOnlyList<PersonRelResponse> Children,
|
||||
IReadOnlyList<PersonRelResponse> Siblings,
|
||||
IReadOnlyList<PersonRelResponse> 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<string, SchoolClass> 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<string>(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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PersonCardResult>());
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
|
||||
@@ -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<string, object?>
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
internal sealed record WorkerFailed(int SchoolId) : GameCommand;
|
||||
|
||||
/// <summary>One person's card, including live needs. Completes on that school's worker thread.</summary>
|
||||
internal sealed record GetPerson(
|
||||
int SchoolId,
|
||||
string PersonId,
|
||||
string Locale,
|
||||
TaskCompletionSource<PersonCardResult> Result) : GameCommand;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
using HSchool.Server.Api;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a person card on the school's worker thread so live need values come from the World.
|
||||
/// </summary>
|
||||
internal static class PersonCardReader
|
||||
{
|
||||
private static readonly QueryDescription IdentityAndNeeds =
|
||||
new QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
|
||||
|
||||
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<string, float>? LiveNeeds(World world, string personId)
|
||||
{
|
||||
Dictionary<string, float>? found = null;
|
||||
world.Query(in IdentityAndNeeds, (ref PersonIdentity identity, ref PersonNeeds needs) =>
|
||||
{
|
||||
if (identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
found = new Dictionary<string, float>(needs.Values, StringComparer.Ordinal);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LabeledStatResponse> Body(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var rows = new List<LabeledStatResponse>();
|
||||
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<LabeledStatResponse> 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<LabeledStatResponse>();
|
||||
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<DefLabelResponse> Traits(Person person, DefCatalog? catalog, string locale)
|
||||
{
|
||||
var rows = new List<DefLabelResponse>(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<NeedStatResponse> Needs(
|
||||
IReadOnlyDictionary<string, float> 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<NeedStatResponse>();
|
||||
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<PersonRelResponse> Relatives(
|
||||
IReadOnlyList<string> ids,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
string except)
|
||||
{
|
||||
var rows = new List<PersonRelResponse>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
||||
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
||||
|
||||
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
|
||||
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.",
|
||||
|
||||
@@ -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<PersonCardResult> Result) : WorkerCommand;
|
||||
}
|
||||
|
||||
@@ -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<PersonCardResponse>(
|
||||
$"/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<PersonCardResponse>(
|
||||
$"/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<PeopleListResponse> GetPeopleAsync(HttpClient client, int schoolId, string query)
|
||||
{
|
||||
var page = await client.GetFromJsonAsync<PeopleListResponse>(
|
||||
$"/api/schools/{schoolId}/people?{query}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
|
||||
return problem?.Code;
|
||||
}
|
||||
|
||||
private sealed record ProblemResponse(string? Code);
|
||||
|
||||
private sealed record PeopleListResponse(
|
||||
int Total,
|
||||
int Page,
|
||||
int PageSize,
|
||||
IReadOnlyList<PersonListItemResponse> People,
|
||||
PeopleFilterOptionsResponse Filters);
|
||||
|
||||
private sealed record PeopleFilterOptionsResponse(
|
||||
IReadOnlyList<int> Years,
|
||||
IReadOnlyList<string> Letters,
|
||||
IReadOnlyList<DefLabelResponse> 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<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? Position,
|
||||
string? PositionLabel);
|
||||
|
||||
private sealed record PersonCardResponse(
|
||||
string Id,
|
||||
string FullName,
|
||||
bool Female,
|
||||
int Age,
|
||||
DateTime BirthDate,
|
||||
IReadOnlyList<string> Roles,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
IReadOnlyList<LabeledStatResponse> Body,
|
||||
IReadOnlyList<LabeledStatResponse> Skills,
|
||||
IReadOnlyList<DefLabelResponse> Traits,
|
||||
IReadOnlyList<NeedStatResponse> 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<PersonRelResponse> Parents,
|
||||
IReadOnlyList<PersonRelResponse> Children,
|
||||
IReadOnlyList<PersonRelResponse> Siblings,
|
||||
IReadOnlyList<PersonRelResponse> Partners);
|
||||
|
||||
private sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user