Show lesson marks and attendance on the person card.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,18 +12,18 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Блок/вкладка на карточке ученика: недавние оценки по предметам и явка
|
- [x] Блок/вкладка на карточке ученика: недавние оценки по предметам и явка
|
||||||
- [ ] Короткая сводка класса (средний след / частые отсутствия) в Управлении или у класса —
|
- [x] Короткая сводка класса (средний след / частые отсутствия) в Управлении или у класса —
|
||||||
без простыни на год
|
без простыни на год
|
||||||
- [ ] HTTP через мейлбокс; гость читает, не пишет
|
- [x] HTTP через мейлбокс; гость читает, не пишет
|
||||||
- [ ] Строки `t(...)`, обе локали
|
- [x] Строки `t(...)`, обе локали
|
||||||
- [ ] Список людей в панели журнал не раздувает
|
- [x] Список людей в панели журнал не раздувает
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Карточка отдаёт те же оценки/явку, что в мире
|
- [x] Карточка отдаёт те же оценки/явку, что в мире
|
||||||
- [ ] Нет права править оценку через API
|
- [x] Нет права править оценку через API
|
||||||
- [ ] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние
|
- [x] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
+36
-2
@@ -373,6 +373,14 @@ order as in the world and in `people.json`. Each row has `kind`, `kindLabel` (mo
|
|||||||
and which kinds write live on `BehaviorDef` (`offenseMemoryMax`, `offenseMemoryKinds`), not in
|
and which kinds write live on `BehaviorDef` (`offenseMemoryMax`, `offenseMemoryKinds`), not in
|
||||||
the client. HTTP JSON is additive — no protocol version bump.
|
the client. HTTP JSON is additive — no protocol version bump.
|
||||||
|
|
||||||
|
`lessonMarks` and `attendance` are the sparse gradebook on the person (stages A/B). Same order as
|
||||||
|
in the world and in `people.json`. Mark rows carry `subject`, `subjectLabel`, `value` (2–5),
|
||||||
|
`time`, `period`. Attendance rows carry `subject`, `subjectLabel`, `status`
|
||||||
|
(`present` / `late` / `absent`), `statusLabel` (mod locale), `time`, `period`, and optional
|
||||||
|
`absenceReason` (vanilla `truancy`; illness comes later). Empty is `[]`. Ceilings live on
|
||||||
|
`BehaviorDef` (`lessonMarkMax`, `attendanceMax`). There is **no** POST/PUT to set marks or
|
||||||
|
attendance — guests and owners only read. The people list does not include these fields.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "f0.c0",
|
"id": "f0.c0",
|
||||||
@@ -439,6 +447,26 @@ the client. HTTP JSON is additive — no protocol version bump.
|
|||||||
"otherFullName": "Иванов Кирилл Петрович"
|
"otherFullName": "Иванов Кирилл Петрович"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"lessonMarks": [
|
||||||
|
{
|
||||||
|
"subject": "Mathematics",
|
||||||
|
"subjectLabel": "Математика",
|
||||||
|
"value": 5,
|
||||||
|
"time": "2012-04-03T10:05:00Z",
|
||||||
|
"period": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attendance": [
|
||||||
|
{
|
||||||
|
"subject": "Mathematics",
|
||||||
|
"subjectLabel": "Математика",
|
||||||
|
"status": "present",
|
||||||
|
"statusLabel": "был",
|
||||||
|
"time": "2012-04-03T10:02:00Z",
|
||||||
|
"period": 1,
|
||||||
|
"absenceReason": null
|
||||||
|
}
|
||||||
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
"family": {
|
"family": {
|
||||||
"parents": [
|
"parents": [
|
||||||
@@ -720,14 +748,20 @@ one.
|
|||||||
"year": 5,
|
"year": 5,
|
||||||
"letter": "А",
|
"letter": "А",
|
||||||
"classTeacherId": "f3.p1",
|
"classTeacherId": "f3.p1",
|
||||||
"classTeacherName": "Иванова Ольга Михайловна"
|
"classTeacherName": "Иванова Ольга Михайловна",
|
||||||
|
"averageMark": 3.8,
|
||||||
|
"absentLessons": 4,
|
||||||
|
"frequentAbsentees": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`classes` lists every homeroom with an optional class-teacher slot. `classTeacherId` /
|
`classes` lists every homeroom with an optional class-teacher slot. `classTeacherId` /
|
||||||
`classTeacherName` are null when the slot is empty. Assign and clear (below) return this same
|
`classTeacherName` are null when the slot is empty. `averageMark` is the mean of recent lesson
|
||||||
|
marks across pupils in that class (`null` when none). `absentLessons` counts absent rows in
|
||||||
|
recent attendance memory; `frequentAbsentees` is how many pupils have at least three such
|
||||||
|
absences — a short Management summary, not a year sheet. Assign and clear (below) return this same
|
||||||
payload so the Management panel can refresh the slots without a second GET.
|
payload so the Management panel can refresh the slots without a second GET.
|
||||||
|
|
||||||
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ const ru = {
|
|||||||
peopleTabCarry: 'Ноша',
|
peopleTabCarry: 'Ноша',
|
||||||
peopleTabNow: 'Сейчас',
|
peopleTabNow: 'Сейчас',
|
||||||
peopleTabConnections: 'Связи',
|
peopleTabConnections: 'Связи',
|
||||||
|
peopleTabGradebook: 'Журнал',
|
||||||
peopleTabPortrait: 'Портрет',
|
peopleTabPortrait: 'Портрет',
|
||||||
peopleConnectionsFriends: 'Друзья',
|
peopleConnectionsFriends: 'Друзья',
|
||||||
peopleConnectionsEnemies: 'Враги',
|
peopleConnectionsEnemies: 'Враги',
|
||||||
@@ -235,6 +236,18 @@ const ru = {
|
|||||||
peopleOffenses: 'Проступки',
|
peopleOffenses: 'Проступки',
|
||||||
peopleOffensesEmpty: 'Недавних проступков нет.',
|
peopleOffensesEmpty: 'Недавних проступков нет.',
|
||||||
peopleOffenseWith: '{kind} · {other}',
|
peopleOffenseWith: '{kind} · {other}',
|
||||||
|
peopleGradebookMarks: 'Оценки',
|
||||||
|
peopleGradebookMarksEmpty: 'Недавних оценок нет.',
|
||||||
|
peopleGradebookAttendance: 'Явка',
|
||||||
|
peopleGradebookAttendanceEmpty: 'Отметок явки нет.',
|
||||||
|
peopleGradebookWhen: 'Когда',
|
||||||
|
peopleGradebookSubject: 'Предмет',
|
||||||
|
peopleGradebookMark: 'Оценка',
|
||||||
|
peopleGradebookStatus: 'Статус',
|
||||||
|
classGradebookAverage: 'средний {mark}',
|
||||||
|
classGradebookNoMarks: 'оценок пока нет',
|
||||||
|
classGradebookAbsences: 'прогулов {n}',
|
||||||
|
classGradebookFrequent: 'часто отсутствует: {n}',
|
||||||
|
|
||||||
peoplePortraitAvatar: 'Аватар',
|
peoplePortraitAvatar: 'Аватар',
|
||||||
peoplePortraitFull: 'В полный рост',
|
peoplePortraitFull: 'В полный рост',
|
||||||
@@ -621,6 +634,7 @@ const en: Messages = {
|
|||||||
peopleTabCarry: 'Carried',
|
peopleTabCarry: 'Carried',
|
||||||
peopleTabNow: 'Now',
|
peopleTabNow: 'Now',
|
||||||
peopleTabConnections: 'Connections',
|
peopleTabConnections: 'Connections',
|
||||||
|
peopleTabGradebook: 'Gradebook',
|
||||||
peopleTabPortrait: 'Portrait',
|
peopleTabPortrait: 'Portrait',
|
||||||
peopleConnectionsFriends: 'Friends',
|
peopleConnectionsFriends: 'Friends',
|
||||||
peopleConnectionsEnemies: 'Enemies',
|
peopleConnectionsEnemies: 'Enemies',
|
||||||
@@ -645,6 +659,18 @@ const en: Messages = {
|
|||||||
peopleOffenses: 'Misconduct',
|
peopleOffenses: 'Misconduct',
|
||||||
peopleOffensesEmpty: 'No recent misconduct.',
|
peopleOffensesEmpty: 'No recent misconduct.',
|
||||||
peopleOffenseWith: '{kind} · {other}',
|
peopleOffenseWith: '{kind} · {other}',
|
||||||
|
peopleGradebookMarks: 'Marks',
|
||||||
|
peopleGradebookMarksEmpty: 'No recent marks.',
|
||||||
|
peopleGradebookAttendance: 'Attendance',
|
||||||
|
peopleGradebookAttendanceEmpty: 'No attendance rows.',
|
||||||
|
peopleGradebookWhen: 'When',
|
||||||
|
peopleGradebookSubject: 'Subject',
|
||||||
|
peopleGradebookMark: 'Mark',
|
||||||
|
peopleGradebookStatus: 'Status',
|
||||||
|
classGradebookAverage: 'avg {mark}',
|
||||||
|
classGradebookNoMarks: 'no marks yet',
|
||||||
|
classGradebookAbsences: 'absences {n}',
|
||||||
|
classGradebookFrequent: 'often absent: {n}',
|
||||||
|
|
||||||
peoplePortraitAvatar: 'Avatar',
|
peoplePortraitAvatar: 'Avatar',
|
||||||
peoplePortraitFull: 'Full body',
|
peoplePortraitFull: 'Full body',
|
||||||
|
|||||||
@@ -359,6 +359,10 @@ export interface PersonCard {
|
|||||||
readonly orientation?: DefLabel | null;
|
readonly orientation?: DefLabel | null;
|
||||||
/** Recent misconduct from the person save — same order as the world list. */
|
/** Recent misconduct from the person save — same order as the world list. */
|
||||||
readonly offenses?: readonly OffenseEntry[] | null;
|
readonly offenses?: readonly OffenseEntry[] | null;
|
||||||
|
/** Recent lesson marks (2–5) — same order as the world list. */
|
||||||
|
readonly lessonMarks?: readonly LessonMarkEntry[] | null;
|
||||||
|
/** Recent attendance rows — same order as the world list. */
|
||||||
|
readonly attendance?: readonly AttendanceEntry[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OffenseEntry {
|
export interface OffenseEntry {
|
||||||
@@ -369,6 +373,24 @@ export interface OffenseEntry {
|
|||||||
readonly otherFullName: string | null;
|
readonly otherFullName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LessonMarkEntry {
|
||||||
|
readonly subject: string;
|
||||||
|
readonly subjectLabel: string;
|
||||||
|
readonly value: number;
|
||||||
|
readonly time: string;
|
||||||
|
readonly period: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceEntry {
|
||||||
|
readonly subject: string;
|
||||||
|
readonly subjectLabel: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly statusLabel: string;
|
||||||
|
readonly time: string;
|
||||||
|
readonly period: number;
|
||||||
|
readonly absenceReason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WornItem {
|
export interface WornItem {
|
||||||
readonly defName: string;
|
readonly defName: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -731,6 +753,12 @@ export interface ClassTeacherSlot {
|
|||||||
readonly letter: string;
|
readonly letter: string;
|
||||||
readonly classTeacherId: string | null;
|
readonly classTeacherId: string | null;
|
||||||
readonly classTeacherName: string | null;
|
readonly classTeacherName: string | null;
|
||||||
|
/** Mean of recent marks in the class; null when none. */
|
||||||
|
readonly averageMark?: number | null;
|
||||||
|
/** Absent rows in recent attendance memory. */
|
||||||
|
readonly absentLessons?: number;
|
||||||
|
/** Pupils with at least three recent absences. */
|
||||||
|
readonly frequentAbsentees?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Staffing {
|
export interface Staffing {
|
||||||
|
|||||||
@@ -836,6 +836,39 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.people__gradebook-block {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people__gradebook {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people__gradebook th,
|
||||||
|
.people__gradebook td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.people__gradebook-mark {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__class-teacher-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__class-gradebook {
|
||||||
|
margin: 0 0 0 2px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.people__tabs {
|
.people__tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -558,4 +558,31 @@ describe('ManagementPanel money and subjects', () => {
|
|||||||
select.dispatchEvent(new Event('change'));
|
select.dispatchEvent(new Event('change'));
|
||||||
await vi.waitFor(() => expect(clearClassTeacher).toHaveBeenCalledWith(2, 'c1', 'en'));
|
await vi.waitFor(() => expect(clearClassTeacher).toHaveBeenCalledWith(2, 'c1', 'en'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a short class gradebook summary next to each class teacher slot', async () => {
|
||||||
|
vi.mocked(fetchStaffing).mockResolvedValue({
|
||||||
|
...hiredTeacher(),
|
||||||
|
classes: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
year: 5,
|
||||||
|
letter: 'A',
|
||||||
|
classTeacherId: null,
|
||||||
|
classTeacherName: null,
|
||||||
|
averageMark: 3.5,
|
||||||
|
absentLessons: 4,
|
||||||
|
frequentAbsentees: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = new ManagementPanel();
|
||||||
|
document.body.append(panel.listElement, panel.cardElement);
|
||||||
|
panel.show(2);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(panel.listElement.textContent).toContain(t('classGradebookAverage', { mark: '3.5' }));
|
||||||
|
});
|
||||||
|
expect(panel.listElement.textContent).toContain(t('classGradebookAbsences', { n: 4 }));
|
||||||
|
expect(panel.listElement.textContent).toContain(t('classGradebookFrequent', { n: 1 }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
fetchStaffing,
|
fetchStaffing,
|
||||||
fetchTimetable,
|
fetchTimetable,
|
||||||
unassignSubject,
|
unassignSubject,
|
||||||
|
type ClassTeacherSlot,
|
||||||
type PersonCard,
|
type PersonCard,
|
||||||
type Staffing,
|
type Staffing,
|
||||||
type StaffMember,
|
type StaffMember,
|
||||||
@@ -446,10 +447,18 @@ export class ManagementPanel {
|
|||||||
|
|
||||||
this.classTeachersList.append(
|
this.classTeachersList.append(
|
||||||
el(
|
el(
|
||||||
'label',
|
'div',
|
||||||
{ class: 'people__field staffing__class-teacher' },
|
{ class: 'staffing__class-teacher-row' },
|
||||||
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
|
el(
|
||||||
select,
|
'label',
|
||||||
|
{ class: 'people__field staffing__class-teacher' },
|
||||||
|
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
|
||||||
|
select,
|
||||||
|
),
|
||||||
|
el('p', {
|
||||||
|
class: 'staffing__class-gradebook',
|
||||||
|
text: classGradebookSummary(schoolClass),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -775,3 +784,17 @@ export class ManagementPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function classGradebookSummary(schoolClass: ClassTeacherSlot): string {
|
||||||
|
const markBit =
|
||||||
|
schoolClass.averageMark !== null && schoolClass.averageMark !== undefined
|
||||||
|
? t('classGradebookAverage', { mark: String(schoolClass.averageMark) })
|
||||||
|
: t('classGradebookNoMarks');
|
||||||
|
const absences = schoolClass.absentLessons ?? 0;
|
||||||
|
const frequent = schoolClass.frequentAbsentees ?? 0;
|
||||||
|
return [
|
||||||
|
markBit,
|
||||||
|
t('classGradebookAbsences', { n: absences }),
|
||||||
|
t('classGradebookFrequent', { n: frequent }),
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|||||||
@@ -464,4 +464,70 @@ describe('renderPersonCard', () => {
|
|||||||
expect(items[0]).toContain('ссора');
|
expect(items[0]).toContain('ссора');
|
||||||
expect(items[1]).toContain('драка');
|
expect(items[1]).toContain('драка');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows empty gradebook placeholders when marks and attendance are missing', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderPersonCard(root, card({ lessonMarks: [], attendance: [] }), () => {}, options({ tab: 'gradebook' }));
|
||||||
|
|
||||||
|
expect(tabButton(root, 'gradebook').textContent).toBe(t('peopleTabGradebook'));
|
||||||
|
expect(root.textContent).toContain(t('peopleGradebookMarksEmpty'));
|
||||||
|
expect(root.textContent).toContain(t('peopleGradebookAttendanceEmpty'));
|
||||||
|
expect(root.querySelectorAll('.people__gradebook tr').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists recent marks and attendance on the gradebook tab', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderPersonCard(
|
||||||
|
root,
|
||||||
|
card({
|
||||||
|
lessonMarks: [
|
||||||
|
{
|
||||||
|
subject: 'Mathematics',
|
||||||
|
subjectLabel: 'Математика',
|
||||||
|
value: 4,
|
||||||
|
time: '2012-04-03T10:00:00Z',
|
||||||
|
period: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: 'Literature',
|
||||||
|
subjectLabel: 'Литература',
|
||||||
|
value: 5,
|
||||||
|
time: '2012-04-03T11:00:00Z',
|
||||||
|
period: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attendance: [
|
||||||
|
{
|
||||||
|
subject: 'Mathematics',
|
||||||
|
subjectLabel: 'Математика',
|
||||||
|
status: 'present',
|
||||||
|
statusLabel: 'был',
|
||||||
|
time: '2012-04-03T10:00:00Z',
|
||||||
|
period: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: 'Literature',
|
||||||
|
subjectLabel: 'Литература',
|
||||||
|
status: 'late',
|
||||||
|
statusLabel: 'опоздал',
|
||||||
|
time: '2012-04-03T11:00:00Z',
|
||||||
|
period: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
options({ tab: 'gradebook' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const panel = root.querySelector('[data-card-tab="gradebook"]');
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
expect((panel as HTMLElement).hidden).toBe(false);
|
||||||
|
expect(panel?.textContent).toContain('5');
|
||||||
|
expect(panel?.textContent).toContain('Математика');
|
||||||
|
expect(panel?.textContent).toContain('опоздал');
|
||||||
|
expect(panel?.textContent).toContain(t('peopleGradebookMarks'));
|
||||||
|
expect(panel?.textContent).toContain(t('peopleGradebookAttendance'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { el } from './dom.ts';
|
|||||||
import { fillApparel } from './personCardApparel.ts';
|
import { fillApparel } from './personCardApparel.ts';
|
||||||
import { fillCarry } from './personCardCarry.ts';
|
import { fillCarry } from './personCardCarry.ts';
|
||||||
import { fillConnections } from './personCardConnections.ts';
|
import { fillConnections } from './personCardConnections.ts';
|
||||||
|
import { fillGradebook } from './personCardGradebook.ts';
|
||||||
import { fillNow } from './personCardNow.ts';
|
import { fillNow } from './personCardNow.ts';
|
||||||
import { fillOverview } from './personCardOverview.ts';
|
import { fillOverview } from './personCardOverview.ts';
|
||||||
import { fillPortrait } from './personCardPortrait.ts';
|
import { fillPortrait } from './personCardPortrait.ts';
|
||||||
@@ -17,7 +18,14 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
|||||||
parent: 'peopleRoleParent',
|
parent: 'peopleRoleParent',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait';
|
export type PersonCardTab =
|
||||||
|
| 'overview'
|
||||||
|
| 'apparel'
|
||||||
|
| 'carry'
|
||||||
|
| 'now'
|
||||||
|
| 'connections'
|
||||||
|
| 'gradebook'
|
||||||
|
| 'portrait';
|
||||||
|
|
||||||
export interface RenderPersonCardOptions {
|
export interface RenderPersonCardOptions {
|
||||||
readonly place?: string;
|
readonly place?: string;
|
||||||
@@ -115,6 +123,7 @@ export function renderPersonCard(
|
|||||||
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
|
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
|
||||||
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
|
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
|
||||||
connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }),
|
connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }),
|
||||||
|
gradebook: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'gradebook' } }),
|
||||||
portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }),
|
portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +133,7 @@ export function renderPersonCard(
|
|||||||
carry: 'peopleTabCarry',
|
carry: 'peopleTabCarry',
|
||||||
now: 'peopleTabNow',
|
now: 'peopleTabNow',
|
||||||
connections: 'peopleTabConnections',
|
connections: 'peopleTabConnections',
|
||||||
|
gradebook: 'peopleTabGradebook',
|
||||||
portrait: 'peopleTabPortrait',
|
portrait: 'peopleTabPortrait',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,6 +159,7 @@ export function renderPersonCard(
|
|||||||
fillCarry(panels.carry, card);
|
fillCarry(panels.carry, card);
|
||||||
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
|
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
|
||||||
fillConnections(panels.connections, card, onRelative, options);
|
fillConnections(panels.connections, card, onRelative, options);
|
||||||
|
fillGradebook(panels.gradebook, card);
|
||||||
fillPortrait(panels.portrait, card, options);
|
fillPortrait(panels.portrait, card, options);
|
||||||
|
|
||||||
const showTab = (next: PersonCardTab): void => {
|
const showTab = (next: PersonCardTab): void => {
|
||||||
@@ -169,7 +180,16 @@ export function renderPersonCard(
|
|||||||
|
|
||||||
showTab(tab);
|
showTab(tab);
|
||||||
|
|
||||||
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait);
|
parent.append(
|
||||||
|
tabs,
|
||||||
|
panels.overview,
|
||||||
|
panels.apparel,
|
||||||
|
panels.carry,
|
||||||
|
panels.now,
|
||||||
|
panels.connections,
|
||||||
|
panels.gradebook,
|
||||||
|
panels.portrait,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardMeta(card: PersonCard): string {
|
function cardMeta(card: PersonCard): string {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { AttendanceEntry, LessonMarkEntry, PersonCard } from '../net/api.ts';
|
||||||
|
import { formatGameDate, formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { el } from './dom.ts';
|
||||||
|
|
||||||
|
export function fillGradebook(parent: HTMLElement, card: PersonCard): void {
|
||||||
|
parent.append(marksBlock(card.lessonMarks ?? []));
|
||||||
|
parent.append(attendanceBlock(card.attendance ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function marksBlock(marks: readonly LessonMarkEntry[]): HTMLElement {
|
||||||
|
const wrap = el('div', { class: 'people__gradebook-block' });
|
||||||
|
wrap.append(el('h4', { class: 'people__section-title', text: t('peopleGradebookMarks') }));
|
||||||
|
if (marks.length === 0) {
|
||||||
|
wrap.append(el('p', { class: 'panel__empty', text: t('peopleGradebookMarksEmpty') }));
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = el('table', { class: 'people__gradebook' });
|
||||||
|
const body = el('tbody');
|
||||||
|
table.append(
|
||||||
|
el(
|
||||||
|
'thead',
|
||||||
|
{},
|
||||||
|
el(
|
||||||
|
'tr',
|
||||||
|
{},
|
||||||
|
el('th', { text: t('peopleGradebookWhen') }),
|
||||||
|
el('th', { text: t('peopleGradebookSubject') }),
|
||||||
|
el('th', { text: t('peopleGradebookMark') }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of [...marks].reverse()) {
|
||||||
|
body.append(
|
||||||
|
el(
|
||||||
|
'tr',
|
||||||
|
{},
|
||||||
|
el('td', { text: formatWhen(row.time) }),
|
||||||
|
el('td', { text: row.subjectLabel }),
|
||||||
|
el('td', { class: 'people__gradebook-mark', text: String(row.value) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.append(table);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attendanceBlock(rows: readonly AttendanceEntry[]): HTMLElement {
|
||||||
|
const wrap = el('div', { class: 'people__gradebook-block' });
|
||||||
|
wrap.append(el('h4', { class: 'people__section-title', text: t('peopleGradebookAttendance') }));
|
||||||
|
if (rows.length === 0) {
|
||||||
|
wrap.append(el('p', { class: 'panel__empty', text: t('peopleGradebookAttendanceEmpty') }));
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = el('table', { class: 'people__gradebook' });
|
||||||
|
const body = el('tbody');
|
||||||
|
table.append(
|
||||||
|
el(
|
||||||
|
'thead',
|
||||||
|
{},
|
||||||
|
el(
|
||||||
|
'tr',
|
||||||
|
{},
|
||||||
|
el('th', { text: t('peopleGradebookWhen') }),
|
||||||
|
el('th', { text: t('peopleGradebookSubject') }),
|
||||||
|
el('th', { text: t('peopleGradebookStatus') }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of [...rows].reverse()) {
|
||||||
|
body.append(
|
||||||
|
el(
|
||||||
|
'tr',
|
||||||
|
{},
|
||||||
|
el('td', { text: formatWhen(row.time) }),
|
||||||
|
el('td', { text: row.subjectLabel }),
|
||||||
|
el('td', { text: row.statusLabel }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.append(table);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return `${formatGameDate(date)} ${formatGameTimeOfDay(date)}`;
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ const CARD_TABS: readonly PersonCardTab[] = [
|
|||||||
'carry',
|
'carry',
|
||||||
'now',
|
'now',
|
||||||
'connections',
|
'connections',
|
||||||
|
'gradebook',
|
||||||
'portrait',
|
'portrait',
|
||||||
];
|
];
|
||||||
const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc'];
|
const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc'];
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ public static class AttendanceStatuses
|
|||||||
public const string Late = "late";
|
public const string Late = "late";
|
||||||
|
|
||||||
public const string Absent = "absent";
|
public const string Absent = "absent";
|
||||||
|
|
||||||
|
public static string LocaleKey(string status) => status switch
|
||||||
|
{
|
||||||
|
Present => "AttendancePresent",
|
||||||
|
Late => "AttendanceLate",
|
||||||
|
Absent => "AttendanceAbsent",
|
||||||
|
_ => "Attendance" + char.ToUpperInvariant(status[0]) + status[1..],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ internal sealed record PersonCardResponse(
|
|||||||
string? TalkTopicId = null,
|
string? TalkTopicId = null,
|
||||||
string? ClassTeacherId = null,
|
string? ClassTeacherId = null,
|
||||||
string? ClassTeacherName = null,
|
string? ClassTeacherName = null,
|
||||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null);
|
IReadOnlyList<OffenseEntryResponse>? Offenses = null,
|
||||||
|
IReadOnlyList<LessonMarkEntryResponse>? LessonMarks = null,
|
||||||
|
IReadOnlyList<AttendanceEntryResponse>? Attendance = null);
|
||||||
|
|
||||||
internal sealed record OffenseEntryResponse(
|
internal sealed record OffenseEntryResponse(
|
||||||
string Kind,
|
string Kind,
|
||||||
@@ -97,6 +99,24 @@ internal sealed record OffenseEntryResponse(
|
|||||||
string? OtherPersonId,
|
string? OtherPersonId,
|
||||||
string? OtherFullName);
|
string? OtherFullName);
|
||||||
|
|
||||||
|
/// <summary>One recent lesson mark on the person card. Same order as the world list.</summary>
|
||||||
|
internal sealed record LessonMarkEntryResponse(
|
||||||
|
string Subject,
|
||||||
|
string SubjectLabel,
|
||||||
|
int Value,
|
||||||
|
DateTime Time,
|
||||||
|
int Period);
|
||||||
|
|
||||||
|
/// <summary>One recent attendance row on the person card. Same order as the world list.</summary>
|
||||||
|
internal sealed record AttendanceEntryResponse(
|
||||||
|
string Subject,
|
||||||
|
string SubjectLabel,
|
||||||
|
string Status,
|
||||||
|
string StatusLabel,
|
||||||
|
DateTime Time,
|
||||||
|
int Period,
|
||||||
|
string? AbsenceReason = null);
|
||||||
|
|
||||||
|
|
||||||
internal sealed record WornItemResponse(
|
internal sealed record WornItemResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
@@ -274,7 +294,13 @@ internal sealed record ClassTeacherSlotResponse(
|
|||||||
int Year,
|
int Year,
|
||||||
string Letter,
|
string Letter,
|
||||||
string? ClassTeacherId,
|
string? ClassTeacherId,
|
||||||
string? ClassTeacherName);
|
string? ClassTeacherName,
|
||||||
|
/// <summary>Mean of recent lesson marks across pupils in the class; null when none.</summary>
|
||||||
|
float? AverageMark = null,
|
||||||
|
/// <summary>Count of absent rows in recent attendance memory for the class.</summary>
|
||||||
|
int AbsentLessons = 0,
|
||||||
|
/// <summary>Pupils with at least three recent absences.</summary>
|
||||||
|
int FrequentAbsentees = 0);
|
||||||
|
|
||||||
internal sealed record UncoveredSubjectResponse(
|
internal sealed record UncoveredSubjectResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
@@ -390,12 +416,16 @@ internal static class StaffingMapper
|
|||||||
teacherName = teacher.Name.Full;
|
teacherName = teacher.Name.Full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (averageMark, absentLessons, frequentAbsentees) = ClassGradebook(roster, schoolClass.Id);
|
||||||
return new ClassTeacherSlotResponse(
|
return new ClassTeacherSlotResponse(
|
||||||
schoolClass.Id,
|
schoolClass.Id,
|
||||||
schoolClass.Year,
|
schoolClass.Year,
|
||||||
schoolClass.Letter,
|
schoolClass.Letter,
|
||||||
schoolClass.ClassTeacherId,
|
schoolClass.ClassTeacherId,
|
||||||
teacherName);
|
teacherName,
|
||||||
|
averageMark,
|
||||||
|
absentLessons,
|
||||||
|
frequentAbsentees);
|
||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -411,6 +441,65 @@ internal static class StaffingMapper
|
|||||||
classes);
|
classes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short class track for Management — mean mark and absence counts from sparse person memory,
|
||||||
|
/// not a year-long sheet.
|
||||||
|
/// </summary>
|
||||||
|
private static (float? AverageMark, int AbsentLessons, int FrequentAbsentees) ClassGradebook(
|
||||||
|
Roster roster,
|
||||||
|
string classId)
|
||||||
|
{
|
||||||
|
var markSum = 0;
|
||||||
|
var markCount = 0;
|
||||||
|
var absentLessons = 0;
|
||||||
|
var frequentAbsentees = 0;
|
||||||
|
const int frequentThreshold = 3;
|
||||||
|
|
||||||
|
foreach (var person in roster.People)
|
||||||
|
{
|
||||||
|
if (!person.IsStudent
|
||||||
|
|| person.ClassId is null
|
||||||
|
|| !person.ClassId.Equals(classId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.LessonMarks is { Count: > 0 } marks)
|
||||||
|
{
|
||||||
|
foreach (var row in marks)
|
||||||
|
{
|
||||||
|
markSum += row.Value;
|
||||||
|
markCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var absents = 0;
|
||||||
|
if (person.Attendance is { Count: > 0 } attendance)
|
||||||
|
{
|
||||||
|
foreach (var row in attendance)
|
||||||
|
{
|
||||||
|
if (row.Status != AttendanceStatuses.Absent)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
absents++;
|
||||||
|
absentLessons++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absents >= frequentThreshold)
|
||||||
|
{
|
||||||
|
frequentAbsentees++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float? average = markCount == 0
|
||||||
|
? null
|
||||||
|
: MathF.Round(markSum / (float)markCount, 1);
|
||||||
|
return (average, absentLessons, frequentAbsentees);
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
|
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
|
||||||
{
|
{
|
||||||
return person.Skills
|
return person.Skills
|
||||||
|
|||||||
@@ -110,7 +110,84 @@ internal static partial class PersonCardReader
|
|||||||
TalkTopicId: circle?.TopicId,
|
TalkTopicId: circle?.TopicId,
|
||||||
ClassTeacherId: classTeacherId,
|
ClassTeacherId: classTeacherId,
|
||||||
ClassTeacherName: classTeacherName,
|
ClassTeacherName: classTeacherName,
|
||||||
Offenses: Offenses(roster, person, catalog, locale));
|
Offenses: Offenses(roster, person, catalog, locale),
|
||||||
|
LessonMarks: LessonMarks(person, catalog, locale),
|
||||||
|
Attendance: Attendance(person, catalog, locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<LessonMarkEntryResponse> LessonMarks(
|
||||||
|
Person person,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var rows = person.LessonMarks;
|
||||||
|
if (rows is null || rows.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new LessonMarkEntryResponse[rows.Count];
|
||||||
|
for (var i = 0; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var row = rows[i];
|
||||||
|
result[i] = new LessonMarkEntryResponse(
|
||||||
|
row.Subject,
|
||||||
|
SubjectLabel(catalog, locale, row.Subject),
|
||||||
|
row.Value,
|
||||||
|
row.Time,
|
||||||
|
row.Period);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AttendanceEntryResponse> Attendance(
|
||||||
|
Person person,
|
||||||
|
DefCatalog? catalog,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var rows = person.Attendance;
|
||||||
|
if (rows is null || rows.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new AttendanceEntryResponse[rows.Count];
|
||||||
|
for (var i = 0; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
var row = rows[i];
|
||||||
|
result[i] = new AttendanceEntryResponse(
|
||||||
|
row.Subject,
|
||||||
|
SubjectLabel(catalog, locale, row.Subject),
|
||||||
|
row.Status,
|
||||||
|
StatusLabel(catalog, locale, row.Status),
|
||||||
|
row.Time,
|
||||||
|
row.Period,
|
||||||
|
row.AbsenceReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SubjectLabel(DefCatalog? catalog, string locale, string subject)
|
||||||
|
{
|
||||||
|
if (catalog is not null && catalog.Subjects.TryGetValue(subject, out var def))
|
||||||
|
{
|
||||||
|
return catalog.Label(locale, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StatusLabel(DefCatalog? catalog, string locale, string status)
|
||||||
|
{
|
||||||
|
var key = AttendanceStatuses.LocaleKey(status);
|
||||||
|
if (catalog is not null && catalog.HasText(locale, key))
|
||||||
|
{
|
||||||
|
return catalog.Text(locale, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<OffenseEntryResponse> Offenses(
|
private static IReadOnlyList<OffenseEntryResponse> Offenses(
|
||||||
|
|||||||
@@ -211,6 +211,9 @@
|
|||||||
"OffenseQuarrel": "quarrel",
|
"OffenseQuarrel": "quarrel",
|
||||||
"OffenseFight": "fight",
|
"OffenseFight": "fight",
|
||||||
"OffenseReprimand": "reprimand",
|
"OffenseReprimand": "reprimand",
|
||||||
|
"AttendancePresent": "present",
|
||||||
|
"AttendanceLate": "late",
|
||||||
|
"AttendanceAbsent": "absent",
|
||||||
"PrincipalHearing": "Principal's hearing",
|
"PrincipalHearing": "Principal's hearing",
|
||||||
"TopicStudy": "schoolwork",
|
"TopicStudy": "schoolwork",
|
||||||
"TopicGames": "games",
|
"TopicGames": "games",
|
||||||
|
|||||||
@@ -211,6 +211,9 @@
|
|||||||
"OffenseQuarrel": "ссора",
|
"OffenseQuarrel": "ссора",
|
||||||
"OffenseFight": "драка",
|
"OffenseFight": "драка",
|
||||||
"OffenseReprimand": "выговор",
|
"OffenseReprimand": "выговор",
|
||||||
|
"AttendancePresent": "был",
|
||||||
|
"AttendanceLate": "опоздал",
|
||||||
|
"AttendanceAbsent": "отсутствовал",
|
||||||
"PrincipalHearing": "Приём у директора",
|
"PrincipalHearing": "Приём у директора",
|
||||||
"TopicStudy": "учёбе",
|
"TopicStudy": "учёбе",
|
||||||
"TopicGames": "играх",
|
"TopicGames": "играх",
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace HSchool.AppHost.Tests;
|
||||||
|
|
||||||
|
[Collection(AppHostCollection.Name)]
|
||||||
|
public class GradebookApiTests(AppHostFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostAttendanceAndMarks_AreNotAccepted()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Журнал API", Start, seed: 75);
|
||||||
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&pageSize=1",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(page);
|
||||||
|
Assert.NotEmpty(page.People);
|
||||||
|
var personId = page.People[0].Id;
|
||||||
|
|
||||||
|
using var markPost = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/people/{personId}/marks",
|
||||||
|
new { subject = "Mathematics", value = 5, period = 1 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, markPost.StatusCode);
|
||||||
|
|
||||||
|
using var markPut = await client.PutAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/people/{personId}/marks",
|
||||||
|
new { subject = "Mathematics", value = 5, period = 1 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, markPut.StatusCode);
|
||||||
|
|
||||||
|
using var attendancePost = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/people/{personId}/attendance",
|
||||||
|
new { subject = "Mathematics", status = "absent", period = 1 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, attendancePost.StatusCode);
|
||||||
|
|
||||||
|
using var attendancePut = await client.PutAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/people/{personId}/attendance",
|
||||||
|
new { subject = "Mathematics", status = "present", period = 1 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, attendancePut.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PeopleList_DoesNotIncludeGradebookFields()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Список без журнала", Start, seed: 75);
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&pageSize=1&lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
|
||||||
|
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var person = doc.RootElement.GetProperty("people")[0];
|
||||||
|
Assert.False(person.TryGetProperty("lessonMarks", out _));
|
||||||
|
Assert.False(person.TryGetProperty("attendance", out _));
|
||||||
|
Assert.False(person.TryGetProperty("averageMark", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PersonCard_IncludesEmptyGradebookArrays()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Карточка журнал", Start, seed: 75);
|
||||||
|
var page = await client.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&pageSize=1",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(page);
|
||||||
|
var personId = page.People[0].Id;
|
||||||
|
|
||||||
|
var card = await client.GetFromJsonAsync<PersonCardDto>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.NotNull(card!.LessonMarks);
|
||||||
|
Assert.NotNull(card.Attendance);
|
||||||
|
Assert.Empty(card.LessonMarks);
|
||||||
|
Assert.Empty(card.Attendance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Guest_CanReadPersonCardGradebook_ButNotWrite()
|
||||||
|
{
|
||||||
|
using var owner = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(owner);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(owner, "Гость журнал", Start, seed: 75);
|
||||||
|
var page = await owner.GetFromJsonAsync<PeoplePage>(
|
||||||
|
$"/api/schools/{school.Id}/people?role=student&pageSize=1",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(page);
|
||||||
|
var personId = page.People[0].Id;
|
||||||
|
|
||||||
|
using var guest = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "GradebookGuest");
|
||||||
|
var card = await guest.GetFromJsonAsync<PersonCardDto>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.NotNull(card!.LessonMarks);
|
||||||
|
Assert.NotNull(card.Attendance);
|
||||||
|
|
||||||
|
using var write = await guest.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/people/{personId}/marks",
|
||||||
|
new { subject = "Mathematics", value = 5, period = 1 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, write.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PeoplePage(IReadOnlyList<PersonRow> People);
|
||||||
|
|
||||||
|
private sealed record PersonRow(string Id);
|
||||||
|
|
||||||
|
private sealed record PersonCardDto(
|
||||||
|
string Id,
|
||||||
|
IReadOnlyList<object> LessonMarks,
|
||||||
|
IReadOnlyList<object> Attendance);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Server.Api;
|
||||||
|
using HSchool.Server.Game;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Tests;
|
||||||
|
|
||||||
|
public class PersonCardReaderGradebookTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Start = new(2012, 4, 3, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Card_ReturnsLessonMarksAndAttendanceInSameOrderAsPerson()
|
||||||
|
{
|
||||||
|
var (catalog, map) = Vanilla();
|
||||||
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 75, "Russia", Start);
|
||||||
|
var pupil = roster.People.First(person => person.IsStudent && !person.IsParent);
|
||||||
|
var rules = catalog.BehaviorRules!;
|
||||||
|
var t0 = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
Assert.True(LessonMarkMemory.Record(pupil, "Mathematics", 5, t0, period: 1, rules));
|
||||||
|
Assert.True(LessonMarkMemory.Record(pupil, "Literature", 3, t0.AddHours(1), period: 2, rules));
|
||||||
|
Assert.True(AttendanceMemory.Record(
|
||||||
|
pupil, "Mathematics", AttendanceStatuses.Present, t0, period: 1, rules));
|
||||||
|
Assert.True(AttendanceMemory.Record(
|
||||||
|
pupil, "Literature", AttendanceStatuses.Late, t0.AddHours(1), period: 2, rules));
|
||||||
|
Assert.True(AttendanceMemory.Record(
|
||||||
|
pupil, "History", AttendanceStatuses.Absent, t0.AddHours(2), period: 3, rules));
|
||||||
|
|
||||||
|
var school = School.Create(75, "GradebookCard", Start, catalog, map);
|
||||||
|
using (school)
|
||||||
|
{
|
||||||
|
school.InstallPeople(roster, 75, "Russia", ApplicantPool.Empty);
|
||||||
|
var card = PersonCardReader.Read(school, pupil.Id, "ru");
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.NotNull(card!.LessonMarks);
|
||||||
|
Assert.NotNull(card.Attendance);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
pupil.LessonMarks!.Select(row => (row.Subject, row.Value, row.Period)).ToArray(),
|
||||||
|
card.LessonMarks!.Select(row => (row.Subject, row.Value, row.Period)).ToArray());
|
||||||
|
Assert.Equal(5, card.LessonMarks[0].Value);
|
||||||
|
Assert.Equal("Математика", card.LessonMarks[0].SubjectLabel);
|
||||||
|
Assert.Equal(3, card.LessonMarks[1].Value);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
pupil.Attendance!.Select(row => (row.Subject, row.Status, row.Period)).ToArray(),
|
||||||
|
card.Attendance!.Select(row => (row.Subject, row.Status, row.Period)).ToArray());
|
||||||
|
Assert.Equal(AttendanceStatuses.Present, card.Attendance[0].Status);
|
||||||
|
Assert.Equal("был", card.Attendance[0].StatusLabel);
|
||||||
|
Assert.Equal(AttendanceStatuses.Late, card.Attendance[1].Status);
|
||||||
|
Assert.Equal("опоздал", card.Attendance[1].StatusLabel);
|
||||||
|
Assert.Equal(AttendanceStatuses.Absent, card.Attendance[2].Status);
|
||||||
|
Assert.Equal("отсутствовал", card.Attendance[2].StatusLabel);
|
||||||
|
Assert.Equal(AbsenceReasons.Truancy, card.Attendance[2].AbsenceReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Staffing_ClassSlot_SummarisesMarksAndAbsences()
|
||||||
|
{
|
||||||
|
var (catalog, map) = Vanilla();
|
||||||
|
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 75, "Russia", Start);
|
||||||
|
var schoolClass = roster.Classes[0];
|
||||||
|
var pupils = roster.People
|
||||||
|
.Where(person => person.IsStudent
|
||||||
|
&& person.ClassId is { } id
|
||||||
|
&& id.Equals(schoolClass.Id, StringComparison.Ordinal))
|
||||||
|
.Take(2)
|
||||||
|
.ToArray();
|
||||||
|
Assert.True(pupils.Length >= 2);
|
||||||
|
|
||||||
|
var rules = catalog.BehaviorRules!;
|
||||||
|
var t0 = new DateTime(2012, 4, 3, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.True(LessonMarkMemory.Record(pupils[0], "Mathematics", 4, t0, period: 1, rules));
|
||||||
|
Assert.True(LessonMarkMemory.Record(pupils[1], "Mathematics", 2, t0, period: 1, rules));
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
Assert.True(AttendanceMemory.Record(
|
||||||
|
pupils[0],
|
||||||
|
"Mathematics",
|
||||||
|
AttendanceStatuses.Absent,
|
||||||
|
t0.AddDays(i),
|
||||||
|
period: 1,
|
||||||
|
rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
var staffing = StaffingMapper.From(roster, ApplicantPool.Empty, catalog, Start, allocated: 100_000, "ru");
|
||||||
|
var slot = Assert.Single(staffing.Classes, row => row.Id == schoolClass.Id);
|
||||||
|
Assert.Equal(3f, slot.AverageMark);
|
||||||
|
Assert.Equal(3, slot.AbsentLessons);
|
||||||
|
Assert.Equal(1, slot.FrequentAbsentees);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
Assert.True(Directory.Exists(root), $"Vanilla pack missing at {root}.");
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
documents.Add(new ContentDocument(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
Path.GetRelativePath(root, path).Replace('\\', '/'),
|
||||||
|
File.ReadAllText(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
|
||||||
|
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
|
||||||
|
Assert.NotNull(map);
|
||||||
|
return (catalog, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user