Files
Learn_System/backend/tests/practice.test.js
T
Maxim Dolgolyov 48a73d9f8e feat(trainer): P2 — умная тренировка, интервальное повторение, итог сессии
- adaptive.js (TrainerAdaptive): nextSkill (in-session повтор → серверный due → прогрессия → удержание), onWrong/onCorrect (очередь повторения), sessionStats
- умная тренировка на странице (тумблер, по умолч. вкл): авто-подбор навыка от простого к сложному, возврат ошибок
- сессия из 10 задач + экран «Итог сессии» (верно/точность/навыки/стоит повторить); неверный ответ авто-показывает решение
- сервер: SR-поля box+due_at на practice_progress (мигр.082, Leitner 0/1/3/7/16/30 дн), listProgress отдаёт box/due_at/due
- смоуки: adaptive 12/12, страница 23/23, practice.test.js 11/11 (+SR box/due); план P2 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:46:29 +03:00

117 lines
5.6 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, 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}`);
});
});