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:
@@ -0,0 +1,32 @@
|
||||
extends SceneTree
|
||||
|
||||
const Cloze := preload("res://scripts/ui/flashcard_cloze.gd")
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var text := "The capital of {{c1::France}} is {{c2::Paris::city}}."
|
||||
|
||||
_assert(Cloze.render_front(text, 1) == "The capital of [...] is Paris.",
|
||||
"front should hide the target cloze number and reveal the rest")
|
||||
_assert(Cloze.render_front(text, 2) == "The capital of France is [city].",
|
||||
"front should show the hint in brackets when one is provided")
|
||||
_assert(Cloze.render_back(text) == "The capital of France is Paris.",
|
||||
"back should reveal every cloze answer regardless of variant")
|
||||
|
||||
var multiline := "Line one\n{{c1::multi\nline\nanswer}}\nLine two"
|
||||
_assert(Cloze.render_back(multiline) == "Line one\nmulti\nline\nanswer\nLine two",
|
||||
"cloze content spanning multiple lines should still parse as one deletion")
|
||||
|
||||
_assert(Cloze.render_front("no markers here", 1) == "no markers here",
|
||||
"text without cloze markers should render unchanged")
|
||||
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1,23 @@
|
||||
extends SceneTree
|
||||
|
||||
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_assert(NavigationRoutes.key_for_screen("flashcards_hub") == "flashcards",
|
||||
"the flashcards library screen should activate the Flashcards nav entry")
|
||||
_assert(NavigationRoutes.key_for_screen("flashcards_study") == "flashcards",
|
||||
"the flashcards study screen should activate the Flashcards nav entry")
|
||||
_assert(NavigationRoutes.key_for_screen("dashboard") == "home",
|
||||
"unrelated routes should be unaffected by the new flashcards prefix rule")
|
||||
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1,51 @@
|
||||
extends SceneTree
|
||||
|
||||
# Exercises FlashcardService's offline-review-queue bookkeeping without touching the network
|
||||
# or user:// disk (mirrors the load(...).new() pattern used by review_queue_session_test.gd -
|
||||
# the instance is never added to the tree, so _ready() never runs and the real HTTPRequest
|
||||
# nodes/autoload wiring are never touched).
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var service = load("res://scripts/autoload/flashcard_service.gd").new()
|
||||
|
||||
var uuid: String = service._new_uuid()
|
||||
_assert(uuid.length() == 36, "a client review id should be a standard 36-character UUID")
|
||||
var segments := uuid.split("-")
|
||||
_assert(segments.size() == 5, "a UUID should have five dash-separated segments")
|
||||
_assert(segments[2].begins_with("4"), "the generated UUID should be version 4")
|
||||
_assert("89ab".contains(segments[3][0]), "the generated UUID should have a valid RFC 4122 variant nibble")
|
||||
|
||||
service.active_account_key = "account-a"
|
||||
service.outbox = [
|
||||
{"account": "account-b", "payload": {"cardId": "other-account-card"}, "retry_count": 0},
|
||||
{"account": "account-a", "payload": {"cardId": "card-1"}, "retry_count": 0}
|
||||
]
|
||||
_assert(service.has_pending_reviews(), "a queued review for the active account should be reported as pending")
|
||||
_assert(service._next_account_item_index() == 1,
|
||||
"outbox lookup should skip items queued under a different account (offline queue recovery must not leak across accounts)")
|
||||
|
||||
service.active_account_key = "account-c"
|
||||
_assert(not service.has_pending_reviews(),
|
||||
"switching to an account with no queued reviews should report nothing pending")
|
||||
|
||||
service.active_account_key = "account-a"
|
||||
service.outbox[1]["active"] = true
|
||||
_assert(service._active_item_index() == 1, "the in-flight retry item should be discoverable while marked active")
|
||||
|
||||
var iso: String = service.current_study_day_start_utc()
|
||||
_assert(iso.ends_with("Z"), "the study day boundary must be expressed as a UTC instant")
|
||||
_assert(Time.get_unix_time_from_datetime_string(iso.trim_suffix("Z")) > 0,
|
||||
"the study day boundary string should parse back into a valid datetime")
|
||||
|
||||
service.free()
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1,59 @@
|
||||
extends SceneTree
|
||||
|
||||
# Drives the flashcards study screen's reveal/grading render logic and keyboard mapping
|
||||
# directly (no network): the screen is added to the tree so _ready() builds its controls and
|
||||
# wires the FlashcardService signal connections, but no method that would send an HTTP
|
||||
# request (submit_review/request_next_card) is invoked.
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var Study := load("res://scripts/ui/flashcards_study.gd")
|
||||
var screen = Study.new()
|
||||
screen._build()
|
||||
|
||||
# Keyboard grading shortcuts: 1-4 map to Again/Hard/Good/Easy; everything else is inert.
|
||||
_assert(Study.rating_for_keycode(KEY_1) == "again", "1 should grade Again")
|
||||
_assert(Study.rating_for_keycode(KEY_2) == "hard", "2 should grade Hard")
|
||||
_assert(Study.rating_for_keycode(KEY_3) == "good", "3 should grade Good")
|
||||
_assert(Study.rating_for_keycode(KEY_4) == "easy", "4 should grade Easy")
|
||||
_assert(Study.rating_for_keycode(KEY_5) == "", "keys outside 1-4 should not grade a card")
|
||||
|
||||
# Basic card: front-first, then reveal shows answer + extra and the four graded options.
|
||||
screen.current_note = {"type": "basic", "prompt": "2 + 2 = ?", "answer": "4", "extra": "Basic arithmetic"}
|
||||
screen.current_card = {"id": "card-1", "variant": 0, "revision": 1}
|
||||
screen.rating_options = [
|
||||
{"rating": "again", "intervalLabel": "<1m"}, {"rating": "hard", "intervalLabel": "6m"},
|
||||
{"rating": "good", "intervalLabel": "1d"}, {"rating": "easy", "intervalLabel": "4d"}
|
||||
]
|
||||
screen._render_card()
|
||||
_assert(screen.front_label.text == "2 + 2 = ?", "the front should show only the prompt before reveal")
|
||||
_assert(screen.reveal_button.visible, "the reveal button should be visible before the answer is shown")
|
||||
_assert(not screen.grade_row.visible, "grading buttons should be hidden before reveal")
|
||||
_assert(not screen.back_label.visible, "the back should stay hidden before reveal")
|
||||
|
||||
screen._reveal()
|
||||
_assert(screen.back_label.text == "4\n\nBasic arithmetic", "reveal should show the answer plus extra")
|
||||
_assert(screen.grade_row.visible, "grading buttons should appear after reveal")
|
||||
_assert(not screen.reveal_button.visible, "the reveal button should hide once the card is revealed")
|
||||
_assert(screen.grade_buttons["good"].text.contains("1d"), "each grade button should show its computed next interval")
|
||||
|
||||
# Cloze card: only the card's own variant is hidden on the front; back reveals everything.
|
||||
screen.current_note = {"type": "cloze", "prompt": "{{c1::Paris}} is the capital of France", "answer": "", "extra": ""}
|
||||
screen.current_card = {"id": "card-2", "variant": 1, "revision": 1}
|
||||
screen.revealed = false
|
||||
screen._render_card()
|
||||
_assert(screen.front_label.text == "[...] is the capital of France", "cloze front should hide only its own variant")
|
||||
screen._reveal()
|
||||
_assert(screen.back_label.text == "Paris is the capital of France", "cloze back should reveal every deletion")
|
||||
|
||||
screen.free()
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -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