Add flashcards feature with spaced repetition scheduling
Adds a full flashcards system: FSRS-based scheduling, cloze/basic card types, CSV import/export, media storage, and study UI, wired into navigation and the dashboard alongside existing question flows.
This commit is contained in:
@@ -12,6 +12,23 @@ function requiredOrigin(name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function callMultipart(path, formData, token, expected = [200]) {
|
||||
const response = await fetch(`${API_ORIGIN}${path}`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData
|
||||
});
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = text; }
|
||||
}
|
||||
if (!expected.includes(response.status)) {
|
||||
throw new Error(`POST ${path} returned ${response.status}: ${text.slice(0, 500)}`);
|
||||
}
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
async function call(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 20_000);
|
||||
@@ -154,6 +171,72 @@ test("public API and disposable learner journey", async () => {
|
||||
method: "POST", token, expected: [204]
|
||||
});
|
||||
activeCcsAttempt = "";
|
||||
|
||||
// Flashcards: create a deck and card, study and grade it, verify it drops out of the due
|
||||
// queue, then round-trip it through CSV export/import (exercising the exact-content dedupe).
|
||||
const { data: deck } = await call("/api/flashcards/decks", {
|
||||
method: "POST", token, body: { name: `Smoke Deck ${suffix}`, description: "" }
|
||||
});
|
||||
expect(deck.id).toBeTruthy();
|
||||
|
||||
const { data: note } = await call("/api/flashcards/notes", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: {
|
||||
deckId: deck.id, stableId: "", type: "basic",
|
||||
prompt: "Smoke test prompt", answer: "Smoke test answer", extra: "",
|
||||
tags: ["smoke"], images: [], sourceQuestionExternalId: ""
|
||||
}
|
||||
});
|
||||
expect(note.id).toBeTruthy();
|
||||
|
||||
const studyDayStartUtc = new Date().toISOString();
|
||||
const { data: firstCard } = await call("/api/flashcards/study/next", {
|
||||
method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc }
|
||||
});
|
||||
expect(firstCard.note?.id).toBe(note.id);
|
||||
expect(firstCard.card?.state).toBe("new");
|
||||
|
||||
const { data: reviewResult } = await call("/api/flashcards/reviews", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: {
|
||||
reviews: [{
|
||||
clientReviewId: crypto.randomUUID(),
|
||||
cardId: firstCard.card.id,
|
||||
rating: "good",
|
||||
expectedRevision: firstCard.card.revision,
|
||||
reviewedUtc: new Date().toISOString(),
|
||||
durationSeconds: 5,
|
||||
studyDayStartUtc
|
||||
}]
|
||||
}
|
||||
});
|
||||
expect(reviewResult.results).toHaveLength(1);
|
||||
expect(reviewResult.results[0].card.state).not.toBe("new");
|
||||
expect(new Date(reviewResult.results[0].card.dueUtc).getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
const { data: secondCard } = await call("/api/flashcards/study/next", {
|
||||
method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc }
|
||||
});
|
||||
expect(secondCard.note).toBeFalsy();
|
||||
|
||||
const { response: csvResponse, data: csvText } = await call("/api/flashcards/export/csv", { token });
|
||||
expect(csvResponse.headers.get("content-type") || "").toContain("text/csv");
|
||||
expect(String(csvText)).toContain("Smoke test prompt");
|
||||
|
||||
const csvForm = new FormData();
|
||||
csvForm.append("file", new Blob([String(csvText)], { type: "text/csv" }), "flashcards.csv");
|
||||
const { data: csvPreview } = await callMultipart("/api/flashcards/import/csv/preview", csvForm, token);
|
||||
expect(csvPreview.importToken).toBeTruthy();
|
||||
expect(csvPreview.skipCount).toBeGreaterThan(0);
|
||||
const { data: csvCommit } = await call("/api/flashcards/import/commit", {
|
||||
method: "POST", token, body: { importToken: csvPreview.importToken }
|
||||
});
|
||||
expect(csvCommit.skipped).toBeGreaterThan(0);
|
||||
|
||||
const { data: jsonExport } = await call("/api/flashcards/export/json", { token });
|
||||
expect(jsonExport.notes.some((item) => item.note.stableId === note.stableId)).toBeTruthy();
|
||||
} catch (error) {
|
||||
mainError = error;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user