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 через мейлбокс; гость читает, не пишет
|
||||
- [ ] Строки `t(...)`, обе локали
|
||||
- [ ] Список людей в панели журнал не раздувает
|
||||
- [x] HTTP через мейлбокс; гость читает, не пишет
|
||||
- [x] Строки `t(...)`, обе локали
|
||||
- [x] Список людей в панели журнал не раздувает
|
||||
|
||||
## Тесты, без которых фаза не закрыта
|
||||
|
||||
- [ ] Карточка отдаёт те же оценки/явку, что в мире
|
||||
- [ ] Нет права править оценку через API
|
||||
- [ ] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние
|
||||
- [x] Карточка отдаёт те же оценки/явку, что в мире
|
||||
- [x] Нет права править оценку через API
|
||||
- [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
|
||||
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
|
||||
{
|
||||
"id": "f0.c0",
|
||||
@@ -439,6 +447,26 @@ the client. HTTP JSON is additive — no protocol version bump.
|
||||
"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": {
|
||||
"family": {
|
||||
"parents": [
|
||||
@@ -720,14 +748,20 @@ one.
|
||||
"year": 5,
|
||||
"letter": "А",
|
||||
"classTeacherId": "f3.p1",
|
||||
"classTeacherName": "Иванова Ольга Михайловна"
|
||||
"classTeacherName": "Иванова Ольга Михайловна",
|
||||
"averageMark": 3.8,
|
||||
"absentLessons": 4,
|
||||
"frequentAbsentees": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
||||
|
||||
@@ -211,6 +211,7 @@ const ru = {
|
||||
peopleTabCarry: 'Ноша',
|
||||
peopleTabNow: 'Сейчас',
|
||||
peopleTabConnections: 'Связи',
|
||||
peopleTabGradebook: 'Журнал',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peopleConnectionsFriends: 'Друзья',
|
||||
peopleConnectionsEnemies: 'Враги',
|
||||
@@ -235,6 +236,18 @@ const ru = {
|
||||
peopleOffenses: 'Проступки',
|
||||
peopleOffensesEmpty: 'Недавних проступков нет.',
|
||||
peopleOffenseWith: '{kind} · {other}',
|
||||
peopleGradebookMarks: 'Оценки',
|
||||
peopleGradebookMarksEmpty: 'Недавних оценок нет.',
|
||||
peopleGradebookAttendance: 'Явка',
|
||||
peopleGradebookAttendanceEmpty: 'Отметок явки нет.',
|
||||
peopleGradebookWhen: 'Когда',
|
||||
peopleGradebookSubject: 'Предмет',
|
||||
peopleGradebookMark: 'Оценка',
|
||||
peopleGradebookStatus: 'Статус',
|
||||
classGradebookAverage: 'средний {mark}',
|
||||
classGradebookNoMarks: 'оценок пока нет',
|
||||
classGradebookAbsences: 'прогулов {n}',
|
||||
classGradebookFrequent: 'часто отсутствует: {n}',
|
||||
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
@@ -621,6 +634,7 @@ const en: Messages = {
|
||||
peopleTabCarry: 'Carried',
|
||||
peopleTabNow: 'Now',
|
||||
peopleTabConnections: 'Connections',
|
||||
peopleTabGradebook: 'Gradebook',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peopleConnectionsFriends: 'Friends',
|
||||
peopleConnectionsEnemies: 'Enemies',
|
||||
@@ -645,6 +659,18 @@ const en: Messages = {
|
||||
peopleOffenses: 'Misconduct',
|
||||
peopleOffensesEmpty: 'No recent misconduct.',
|
||||
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',
|
||||
peoplePortraitFull: 'Full body',
|
||||
|
||||
@@ -359,6 +359,10 @@ export interface PersonCard {
|
||||
readonly orientation?: DefLabel | null;
|
||||
/** Recent misconduct from the person save — same order as the world list. */
|
||||
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 {
|
||||
@@ -369,6 +373,24 @@ export interface OffenseEntry {
|
||||
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 {
|
||||
readonly defName: string;
|
||||
readonly label: string;
|
||||
@@ -731,6 +753,12 @@ export interface ClassTeacherSlot {
|
||||
readonly letter: string;
|
||||
readonly classTeacherId: 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 {
|
||||
|
||||
@@ -836,6 +836,39 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -558,4 +558,31 @@ describe('ManagementPanel money and subjects', () => {
|
||||
select.dispatchEvent(new Event('change'));
|
||||
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,
|
||||
fetchTimetable,
|
||||
unassignSubject,
|
||||
type ClassTeacherSlot,
|
||||
type PersonCard,
|
||||
type Staffing,
|
||||
type StaffMember,
|
||||
@@ -445,12 +446,20 @@ export class ManagementPanel {
|
||||
});
|
||||
|
||||
this.classTeachersList.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'staffing__class-teacher-row' },
|
||||
el(
|
||||
'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[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 { fillCarry } from './personCardCarry.ts';
|
||||
import { fillConnections } from './personCardConnections.ts';
|
||||
import { fillGradebook } from './personCardGradebook.ts';
|
||||
import { fillNow } from './personCardNow.ts';
|
||||
import { fillOverview } from './personCardOverview.ts';
|
||||
import { fillPortrait } from './personCardPortrait.ts';
|
||||
@@ -17,7 +18,14 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
parent: 'peopleRoleParent',
|
||||
};
|
||||
|
||||
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait';
|
||||
export type PersonCardTab =
|
||||
| 'overview'
|
||||
| 'apparel'
|
||||
| 'carry'
|
||||
| 'now'
|
||||
| 'connections'
|
||||
| 'gradebook'
|
||||
| 'portrait';
|
||||
|
||||
export interface RenderPersonCardOptions {
|
||||
readonly place?: string;
|
||||
@@ -115,6 +123,7 @@ export function renderPersonCard(
|
||||
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
|
||||
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
|
||||
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' } }),
|
||||
};
|
||||
|
||||
@@ -124,6 +133,7 @@ export function renderPersonCard(
|
||||
carry: 'peopleTabCarry',
|
||||
now: 'peopleTabNow',
|
||||
connections: 'peopleTabConnections',
|
||||
gradebook: 'peopleTabGradebook',
|
||||
portrait: 'peopleTabPortrait',
|
||||
};
|
||||
|
||||
@@ -149,6 +159,7 @@ export function renderPersonCard(
|
||||
fillCarry(panels.carry, card);
|
||||
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
|
||||
fillConnections(panels.connections, card, onRelative, options);
|
||||
fillGradebook(panels.gradebook, card);
|
||||
fillPortrait(panels.portrait, card, options);
|
||||
|
||||
const showTab = (next: PersonCardTab): void => {
|
||||
@@ -169,7 +180,16 @@ export function renderPersonCard(
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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',
|
||||
'now',
|
||||
'connections',
|
||||
'gradebook',
|
||||
'portrait',
|
||||
];
|
||||
const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc'];
|
||||
|
||||
@@ -11,6 +11,14 @@ public static class AttendanceStatuses
|
||||
public const string Late = "late";
|
||||
|
||||
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>
|
||||
|
||||
@@ -88,7 +88,9 @@ internal sealed record PersonCardResponse(
|
||||
string? TalkTopicId = null,
|
||||
string? ClassTeacherId = null,
|
||||
string? ClassTeacherName = null,
|
||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null);
|
||||
IReadOnlyList<OffenseEntryResponse>? Offenses = null,
|
||||
IReadOnlyList<LessonMarkEntryResponse>? LessonMarks = null,
|
||||
IReadOnlyList<AttendanceEntryResponse>? Attendance = null);
|
||||
|
||||
internal sealed record OffenseEntryResponse(
|
||||
string Kind,
|
||||
@@ -97,6 +99,24 @@ internal sealed record OffenseEntryResponse(
|
||||
string? OtherPersonId,
|
||||
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(
|
||||
string DefName,
|
||||
@@ -274,7 +294,13 @@ internal sealed record ClassTeacherSlotResponse(
|
||||
int Year,
|
||||
string Letter,
|
||||
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(
|
||||
string DefName,
|
||||
@@ -390,12 +416,16 @@ internal static class StaffingMapper
|
||||
teacherName = teacher.Name.Full;
|
||||
}
|
||||
|
||||
var (averageMark, absentLessons, frequentAbsentees) = ClassGradebook(roster, schoolClass.Id);
|
||||
return new ClassTeacherSlotResponse(
|
||||
schoolClass.Id,
|
||||
schoolClass.Year,
|
||||
schoolClass.Letter,
|
||||
schoolClass.ClassTeacherId,
|
||||
teacherName);
|
||||
teacherName,
|
||||
averageMark,
|
||||
absentLessons,
|
||||
frequentAbsentees);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
@@ -411,6 +441,65 @@ internal static class StaffingMapper
|
||||
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)
|
||||
{
|
||||
return person.Skills
|
||||
|
||||
@@ -110,7 +110,84 @@ internal static partial class PersonCardReader
|
||||
TalkTopicId: circle?.TopicId,
|
||||
ClassTeacherId: classTeacherId,
|
||||
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(
|
||||
|
||||
@@ -211,6 +211,9 @@
|
||||
"OffenseQuarrel": "quarrel",
|
||||
"OffenseFight": "fight",
|
||||
"OffenseReprimand": "reprimand",
|
||||
"AttendancePresent": "present",
|
||||
"AttendanceLate": "late",
|
||||
"AttendanceAbsent": "absent",
|
||||
"PrincipalHearing": "Principal's hearing",
|
||||
"TopicStudy": "schoolwork",
|
||||
"TopicGames": "games",
|
||||
|
||||
@@ -211,6 +211,9 @@
|
||||
"OffenseQuarrel": "ссора",
|
||||
"OffenseFight": "драка",
|
||||
"OffenseReprimand": "выговор",
|
||||
"AttendancePresent": "был",
|
||||
"AttendanceLate": "опоздал",
|
||||
"AttendanceAbsent": "отсутствовал",
|
||||
"PrincipalHearing": "Приём у директора",
|
||||
"TopicStudy": "учёбе",
|
||||
"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