Files
Learn_System/backend/tests/flashcards-srs.test.js
T
Maxim Dolgolyov 5c01a5c7ed feat(flashcards): learning-steps SR — повторный показ «Снова» в сессии, лимит новых карт/день
Tier-1 апгрейд интервального повторения:
- schedule() с состояниями learning/relearning/review вместо плоского sm2():
  новая карта проходит шаги [1,10] мин, «Снова» возвращает на шаг 0 (минуты),
  «Знаю» продвигает шаг → выпуск (1д), «Легко» выпускает сразу (4д); зрелая
  «Снова» = lapse → relearning (ef−0.2, ×0.5).
- study-сессия: динамическая очередь — недоученная карта (graduated=false)
  возвращается через 3 карты и показывается снова в той же сессии.
- лимит новых карт/день (decks.new_per_day, деф.20) в getStudySession и бейдже.
- превью кнопок fcPreview() показывает минуты/дни, зеркало серверной логики.
- миграция 074: state/learning_step/lapses/created_at + new_per_day + индексы.
- тесты SRS 9/9 (шаги, lapse, лимит новых).

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

134 lines
6.5 KiB
JavaScript

'use strict';
/**
* Integration tests: интервальное повторение флешкарт (Tier-1 апгрейд).
* Covers: learning-steps (новая карта проходит шаги в минутах), выпуск в review
* через «Знаю»/«Легко», lapse зрелой карты → relearning, флаг graduated для
* клиентского re-queue, и лимит новых карт/день в study-сессии.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Маршрут флешкарт setup.js не монтирует (как и custom-sims) — монтируем сами.
app.use('/api/flashcards', require('../src/routes/flashcards'));
after(() => cleanup());
/* helpers */
async function mkDeck(token, title = 'Колода') {
const r = await inject('POST', '/api/flashcards/decks', { title }, token);
assert.equal(r.status, 200, `deck create: ${r.status} ${JSON.stringify(r.body)}`);
return r.body.id;
}
async function mkCard(token, deckId, front = 'Q', back = 'A') {
const r = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front, back }, token);
assert.equal(r.status, 200, `card create: ${r.status}`);
return r.body.id;
}
function review(token, cardId, quality) {
return inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality }, token);
}
describe('flashcards SRS — learning steps & limits', () => {
let token;
before(async () => { token = (await getToken('student')).token; });
it('review validates quality range (0..5)', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
assert.equal((await review(token, card, 7)).status, 400);
assert.equal((await review(token, card, -1)).status, 400);
});
it('new card + «Снова» (q0) → learning, due через 1 минуту, не выпущена', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
const r = await review(token, card, 0);
assert.equal(r.status, 200);
assert.equal(r.body.graduated, false, 'не выпущена');
assert.equal(r.body.due_in_sec, 60, '1 минута');
assert.equal(r.body.next.state, 'learning');
assert.equal(r.body.next.learning_step, 0);
});
it('new card + «Знаю» (q4) → второй шаг 10 минут, всё ещё learning', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
const r = await review(token, card, 4);
assert.equal(r.body.graduated, false);
assert.equal(r.body.due_in_sec, 600, '10 минут');
assert.equal(r.body.next.state, 'learning');
assert.equal(r.body.next.learning_step, 1);
});
it('two «Знаю» подряд выпускают карту в review (interval 1 день)', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
await review(token, card, 4); // шаг 0 → 1
const r = await review(token, card, 4); // шаг 1 → выпуск
assert.equal(r.body.graduated, true, 'выпущена в review');
assert.equal(r.body.next.state, 'review');
assert.equal(r.body.interval_days, 1);
assert.equal(r.body.due_in_sec, 86400);
});
it('«Легко» (q5) на новой карте выпускает сразу (interval 4 дня)', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
const r = await review(token, card, 5);
assert.equal(r.body.graduated, true);
assert.equal(r.body.next.state, 'review');
assert.equal(r.body.interval_days, 4);
});
it('зрелая карта + «Снова» → lapse: relearning, lapses=1, due 10 минут', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
await review(token, card, 5); // сразу в review (iv=4)
const r = await review(token, card, 0); // провал
assert.equal(r.body.graduated, false, 'ушла на переучивание');
assert.equal(r.body.next.state, 'relearning');
assert.equal(r.body.due_in_sec, 600, 'relearn-шаг 10 минут');
// lapses фиксируется в БД
const row = db.prepare('SELECT lapses FROM flashcard_reviews WHERE card_id=?').get(card);
assert.equal(row.lapses, 1);
});
it('зрелая карта + «Знаю» растёт по дням (≥ предыдущего интервала)', async () => {
const deck = await mkDeck(token);
const card = await mkCard(token, deck);
await review(token, card, 5); // iv=4, review
const r = await review(token, card, 4); // Знаю на зрелой
assert.equal(r.body.next.state, 'review');
assert.ok(r.body.interval_days > 4, `интервал вырос: ${r.body.interval_days}`);
});
it('study-сессия уважает лимит новых карт/день (new_per_day)', async () => {
const deck = await mkDeck(token, 'Большая');
db.prepare('UPDATE flashcard_decks SET new_per_day=? WHERE id=?').run(2, deck);
for (let i = 0; i < 5; i++) await mkCard(token, deck, 'Q' + i, 'A' + i);
const s1 = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token);
assert.equal(s1.status, 200);
assert.equal(s1.body.cards.length, 2, 'не больше лимита новых');
assert.ok(s1.body.cards.every(c => c.state === 'new'), 'все — новые');
// «вводим» обе карты (review) — бюджет на сегодня исчерпан
for (const c of s1.body.cards) await review(token, c.id, 4);
const s2 = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token);
// введённые карты теперь learning (due через минуты, не сейчас) → сессия пуста
assert.equal(s2.body.cards.length, 0, 'бюджет новых исчерпан, learning ещё не due');
});
it('study-сессия возвращает state/learning_step для превью кнопок', async () => {
const deck = await mkDeck(token, 'Превью');
const card = await mkCard(token, deck);
const s = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token);
const c = s.body.cards.find(x => x.id === card);
assert.ok(c, 'карта в сессии');
assert.equal(c.state, 'new');
assert.equal(c.learning_step, 0);
assert.equal(c.seen, 0);
});
});