diff --git a/docs/phases/13-grades/75-gradebook-ui.md b/docs/phases/13-grades/75-gradebook-ui.md index c8d5035..64c8c31 100644 --- a/docs/phases/13-grades/75-gradebook-ui.md +++ b/docs/phases/13-grades/75-gradebook-ui.md @@ -12,18 +12,18 @@ ## Задачи -- [ ] Блок/вкладка на карточке ученика: недавние оценки по предметам и явка -- [ ] Короткая сводка класса (средний след / частые отсутствия) в Управлении или у класса — +- [x] Блок/вкладка на карточке ученика: недавние оценки по предметам и явка +- [x] Короткая сводка класса (средний след / частые отсутствия) в Управлении или у класса — без простыни на год -- [ ] HTTP через мейлбокс; гость читает, не пишет -- [ ] Строки `t(...)`, обе локали -- [ ] Список людей в панели журнал не раздувает +- [x] HTTP через мейлбокс; гость читает, не пишет +- [x] Строки `t(...)`, обе локали +- [x] Список людей в панели журнал не раздувает ## Тесты, без которых фаза не закрыта -- [ ] Карточка отдаёт те же оценки/явку, что в мире -- [ ] Нет права править оценку через API -- [ ] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние +- [x] Карточка отдаёт те же оценки/явку, что в мире +- [x] Нет права править оценку через API +- [x] Клиентский тест панели/вкладки (vitest) на пустое и заполненное состояние ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 3c21c9d..b23af74 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index cc2df11..fcc4e13 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index ae94fed..68c46a5 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -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 { diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 739cfb4..4ac7c63 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -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; diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index 3cc03ea..fe8c834 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -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 })); + }); }); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 5c433da..e472c73 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -6,6 +6,7 @@ import { fetchStaffing, fetchTimetable, unassignSubject, + type ClassTeacherSlot, type PersonCard, type Staffing, type StaffMember, @@ -446,10 +447,18 @@ export class ManagementPanel { this.classTeachersList.append( el( - 'label', - { class: 'people__field staffing__class-teacher' }, - el('span', { class: 'people__label', text: `${schoolClass.year}${schoolClass.letter}` }), - select, + '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(' · '); +} diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index 514bb21..86751bd 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -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')); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index c89d03b..8be01fe 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -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 = { 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 { diff --git a/src/HSchool.Client/src/ui/personCardGradebook.ts b/src/HSchool.Client/src/ui/personCardGradebook.ts new file mode 100644 index 0000000..22780b0 --- /dev/null +++ b/src/HSchool.Client/src/ui/personCardGradebook.ts @@ -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)}`; +} diff --git a/src/HSchool.Client/src/ui/viewState.ts b/src/HSchool.Client/src/ui/viewState.ts index dce132e..7ec41b5 100644 --- a/src/HSchool.Client/src/ui/viewState.ts +++ b/src/HSchool.Client/src/ui/viewState.ts @@ -93,6 +93,7 @@ const CARD_TABS: readonly PersonCardTab[] = [ 'carry', 'now', 'connections', + 'gradebook', 'portrait', ]; const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc']; diff --git a/src/HSchool.People/Attendance.cs b/src/HSchool.People/Attendance.cs index 3b3075a..c1b587d 100644 --- a/src/HSchool.People/Attendance.cs +++ b/src/HSchool.People/Attendance.cs @@ -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..], + }; } /// diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index ac5e597..2afaa87 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -88,7 +88,9 @@ internal sealed record PersonCardResponse( string? TalkTopicId = null, string? ClassTeacherId = null, string? ClassTeacherName = null, - IReadOnlyList? Offenses = null); + IReadOnlyList? Offenses = null, + IReadOnlyList? LessonMarks = null, + IReadOnlyList? Attendance = null); internal sealed record OffenseEntryResponse( string Kind, @@ -97,6 +99,24 @@ internal sealed record OffenseEntryResponse( string? OtherPersonId, string? OtherFullName); +/// One recent lesson mark on the person card. Same order as the world list. +internal sealed record LessonMarkEntryResponse( + string Subject, + string SubjectLabel, + int Value, + DateTime Time, + int Period); + +/// One recent attendance row on the person card. Same order as the world list. +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, + /// Mean of recent lesson marks across pupils in the class; null when none. + float? AverageMark = null, + /// Count of absent rows in recent attendance memory for the class. + int AbsentLessons = 0, + /// Pupils with at least three recent absences. + 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); } + /// + /// Short class track for Management — mean mark and absence counts from sparse person memory, + /// not a year-long sheet. + /// + 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 SkillsOf(Person person, DefCatalog? catalog, string locale) { return person.Skills diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 841a966..631953b 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -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 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 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 Offenses( diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index a5df004..07ea7da 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -211,6 +211,9 @@ "OffenseQuarrel": "quarrel", "OffenseFight": "fight", "OffenseReprimand": "reprimand", + "AttendancePresent": "present", + "AttendanceLate": "late", + "AttendanceAbsent": "absent", "PrincipalHearing": "Principal's hearing", "TopicStudy": "schoolwork", "TopicGames": "games", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 36bb33b..07c6fd9 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -211,6 +211,9 @@ "OffenseQuarrel": "ссора", "OffenseFight": "драка", "OffenseReprimand": "выговор", + "AttendancePresent": "был", + "AttendanceLate": "опоздал", + "AttendanceAbsent": "отсутствовал", "PrincipalHearing": "Приём у директора", "TopicStudy": "учёбе", "TopicGames": "играх", diff --git a/tests/HSchool.AppHost.Tests/GradebookApiTests.cs b/tests/HSchool.AppHost.Tests/GradebookApiTests.cs new file mode 100644 index 0000000..6c2b688 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/GradebookApiTests.cs @@ -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( + $"/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( + $"/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( + $"/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( + $"/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( + $"/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 People); + + private sealed record PersonRow(string Id); + + private sealed record PersonCardDto( + string Id, + IReadOnlyList LessonMarks, + IReadOnlyList Attendance); +} diff --git a/tests/HSchool.Server.Tests/PersonCardReaderGradebookTests.cs b/tests/HSchool.Server.Tests/PersonCardReaderGradebookTests.cs new file mode 100644 index 0000000..16740ed --- /dev/null +++ b/tests/HSchool.Server.Tests/PersonCardReaderGradebookTests.cs @@ -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(); + 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); + } +}