d003a0e100
- 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>
163 lines
8.1 KiB
JavaScript
163 lines
8.1 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Integration tests: /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
|
|
* Covers: auth-only (401); correct создаёт строку; wrong не растит solved, но
|
|
* растит attempts и обнуляет серию; серия из MASTERY_STREAK → mastered;
|
|
* прогресс per-user; валидация входа (400).
|
|
*/
|
|
const { describe, it, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
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'));
|
|
|
|
const { after } = require('node:test');
|
|
after(() => cleanup());
|
|
|
|
const SKILL = 'linear-basic';
|
|
|
|
describe('/api/practice progress', () => {
|
|
let token;
|
|
|
|
before(async () => {
|
|
token = (await getToken('student')).token;
|
|
});
|
|
|
|
it('GET /progress requires auth (401)', async () => {
|
|
const res = await inject('GET', '/api/practice/progress', null, null);
|
|
assert.equal(res.status, 401, `got ${res.status}`);
|
|
});
|
|
|
|
it('POST /attempt requires auth (401)', async () => {
|
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, null);
|
|
assert.equal(res.status, 401, `got ${res.status}`);
|
|
});
|
|
|
|
it('correct attempt creates a row (solved=1, streak=1)', async () => {
|
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, token);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.ok, true);
|
|
assert.equal(res.body.progress.skill, SKILL);
|
|
assert.equal(res.body.progress.solved, 1);
|
|
assert.equal(res.body.progress.attempts, 1);
|
|
assert.equal(res.body.progress.cur_streak, 1);
|
|
assert.equal(res.body.progress.best_streak, 1);
|
|
assert.equal(res.body.progress.mastered, 0);
|
|
});
|
|
|
|
it('GET /progress lists the row', async () => {
|
|
const res = await inject('GET', '/api/practice/progress', null, token);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.ok(Array.isArray(res.body.progress));
|
|
const row = res.body.progress.find(r => r.skill === SKILL);
|
|
assert.ok(row, 'skill row present');
|
|
assert.equal(row.solved, 1);
|
|
});
|
|
|
|
it('wrong attempt: attempts++, solved unchanged, streak resets to 0', async () => {
|
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: false }, token);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.progress.solved, 1, 'solved unchanged');
|
|
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
|
|
assert.equal(res.body.progress.cur_streak, 0, 'streak reset');
|
|
assert.equal(res.body.progress.best_streak, 1, 'best streak kept');
|
|
});
|
|
|
|
it('streak of 5 correct → mastered=1 (and stays mastered after a miss)', async () => {
|
|
const sk = 'mastery-skill';
|
|
let last;
|
|
for (let i = 0; i < 5; i++) {
|
|
last = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
|
|
}
|
|
assert.equal(last.body.progress.cur_streak, 5);
|
|
assert.equal(last.body.progress.best_streak, 5);
|
|
assert.equal(last.body.progress.mastered, 1, 'mastered after 5 in a row');
|
|
|
|
const miss = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
|
|
assert.equal(miss.body.progress.cur_streak, 0, 'streak reset on miss');
|
|
assert.equal(miss.body.progress.mastered, 1, 'mastered is sticky');
|
|
});
|
|
|
|
it('SR: box растёт на верный ответ и сбрасывается на ошибку; due отражает срок', async () => {
|
|
const sk = 'sr-skill';
|
|
const c1 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
|
|
assert.equal(c1.body.progress.box, 1, 'box=1 после первого верного');
|
|
assert.equal(c1.body.progress.due, 0, 'свежий навык не просрочен (срок в будущем)');
|
|
const c2 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
|
|
assert.equal(c2.body.progress.box, 2, 'box растёт на следующем верном');
|
|
const w = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
|
|
assert.equal(w.body.progress.box, 0, 'ошибка сбрасывает box в 0');
|
|
assert.equal(w.body.progress.due, 1, 'после ошибки навык сразу к повторению (due=1)');
|
|
});
|
|
|
|
it('progress is per-user (другой ученик начинает с нуля)', async () => {
|
|
const other = (await getToken('student')).token;
|
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, other);
|
|
assert.equal(res.status, 200, `got ${res.status}`);
|
|
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
|
|
assert.equal(res.body.progress.solved, 1);
|
|
});
|
|
|
|
it('validation: missing skill → 400', async () => {
|
|
const res = await inject('POST', '/api/practice/attempt', { correct: true }, token);
|
|
assert.equal(res.status, 400, `got ${res.status}`);
|
|
});
|
|
|
|
it('validation: correct not boolean → 400', async () => {
|
|
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: 'yes' }, token);
|
|
assert.equal(res.status, 400, `got ${res.status}`);
|
|
});
|
|
|
|
it('validation: skill too long → 400', async () => {
|
|
const res = await inject('POST', '/api/practice/attempt', { skill: 'x'.repeat(200), correct: true }, token);
|
|
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}`);
|
|
});
|
|
});
|