5c01a5c7ed
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>
134 lines
6.5 KiB
JavaScript
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);
|
|
});
|
|
});
|