Show lesson marks and attendance on the person card.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 13:42:37 +03:00
co-authored by Cursor
parent 564776883a
commit db6e60929d
18 changed files with 800 additions and 20 deletions
+8 -8
View File
@@ -12,18 +12,18 @@
## Задачи ## Задачи
- [ ] Блок/вкладка на карточке ученика: недавние оценки по предметам и явка - [x] Блок/вкладка на карточке ученика: недавние оценки по предметам и явка
- [ ] Короткая сводка класса (средний след / частые отсутствия) в Управлении или у класса — - [x] Короткая сводка класса (средний след / частые отсутствия) в Управлении или у класса —
без простыни на год без простыни на год
- [ ] HTTP через мейлбокс; гость читает, не пишет - [x] HTTP через мейлбокс; гость читает, не пишет
- [ ] Строки `t(...)`, обе локали - [x] Строки `t(...)`, обе локали
- [ ] Список людей в панели журнал не раздувает - [x] Список людей в панели журнал не раздувает
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Карточка отдаёт те же оценки/явку, что в мире - [x] Карточка отдаёт те же оценки/явку, что в мире
- [ ] Нет права править оценку через API - [x] Нет права править оценку через API
- [ ] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние - [x] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние
## Критерий готовности ## Критерий готовности
+36 -2
View File
@@ -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` (25),
`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
+26
View File
@@ -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',
+28
View File
@@ -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 (25) — 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 {
+33
View File
@@ -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,
@@ -445,12 +446,20 @@ export class ManagementPanel {
}); });
this.classTeachersList.append( this.classTeachersList.append(
el(
'div',
{ class: 'staffing__class-teacher-row' },
el( el(
'label', 'label',
{ class: 'people__field staffing__class-teacher' }, { class: 'people__field staffing__class-teacher' },
el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }), el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }),
select, 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'));
});
}); });
+22 -2
View File
@@ -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)}`;
}
+1
View File
@@ -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'];
+8
View File
@@ -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>
+92 -3
View File
@@ -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
+78 -1
View File
@@ -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);
}
}