feat(trainer): P6 — учительская аналитика класса + общий прогресс

- GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ
- клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats
- лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress
- тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:24:05 +03:00
parent 7cc2a9d526
commit d003a0e100
6 changed files with 222 additions and 6 deletions
+47 -1
View File
@@ -7,7 +7,7 @@
*/
const { describe, it, before } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/practice on the shared test app (setup.js не монтирует новые роуты).
app.use('/api/practice', require('../src/routes/practice'));
@@ -114,3 +114,49 @@ describe('/api/practice progress', () => {
assert.equal(res.status, 400, `got ${res.status}`);
});
});
describe('/api/practice/class-stats (аналитика класса)', () => {
let teacher, other, s1, s2, classId;
before(async () => {
teacher = await getToken('teacher');
other = await getToken('teacher');
s1 = await getToken('student');
s2 = await getToken('student');
// класс учителя + два ученика в нём
const info = db.prepare("INSERT INTO classes (name, teacher_id, invite_code) VALUES ('P6 класс', ?, ?)").run(teacher.userId, 'P6CODE');
classId = info.lastInsertRowid;
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s1.userId);
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s2.userId);
// прогресс: s1 решил lin-basic верно, s2 ошибся на lin-basic
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: true }, s1.token);
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: false }, s2.token);
await inject('POST', '/api/practice/attempt', { skill: 'lin-paren', correct: true }, s1.token);
});
it('владелец класса видит агрегаты и матрицу', async () => {
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, teacher.token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.students.length, 2, 'два ученика');
assert.ok(res.body.skills.includes('lin-basic'), 'навык в списке');
const lb = res.body.perSkill.find(s => s.skill === 'lin-basic');
assert.ok(lb, 'агрегат по lin-basic есть');
assert.equal(lb.attempted, 2, 'оба пробовали lin-basic');
assert.equal(lb.accuracy, 50, '1 верный из 2 попыток → 50%');
});
it('чужой класс → 403', async () => {
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, other.token);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('ученику запрещено (требуется роль) → 403', async () => {
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, s1.token);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('без class_id → 400', async () => {
const res = await inject('GET', '/api/practice/class-stats', null, teacher.token);
assert.equal(res.status, 400, `got ${res.status}`);
});
});