use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use anyhow::{Context, Result as AnyResult, bail};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{card::Card, deck, depth::Depth, scheduler::Grade};
const HISTORY_CAP: usize = 50;
const CURRENT_VERSION: u32 = 1;
pub const FOREIGN_WRITE_WARN_WINDOW_MS: u64 = 60 * 60 * 1000;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Review {
pub ts_ms: u64,
#[serde(default = "default_review_grade")]
pub grade: Grade,
#[serde(default)]
pub depth: Depth,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub propagated: bool,
}
fn default_review_grade() -> Grade {
Grade::Pass
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct FsrsState {
pub stability: f64,
pub difficulty: f64,
pub reps: u32,
pub lapses: u32,
pub state: u8,
pub scheduled_days: u32,
pub last_review_ms: u64,
pub due_ms: u64,
#[serde(default)]
pub learning_goods: u8,
}
impl FsrsState {
pub fn graduated(&self) -> bool {
self.state >= 2
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CardState {
#[serde(default)]
pub acquired_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recall: Option<FsrsState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reconstruct: Option<FsrsState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recognized_ms: Option<u64>,
#[serde(default)]
pub total_reviews: u32,
#[serde(default)]
pub total_passes: u32,
#[serde(default)]
pub streak: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<Review>,
}
impl CardState {
pub fn new(now_ms: u64) -> Self {
Self {
acquired_ms: now_ms,
recall: None,
reconstruct: None,
recognized_ms: None,
total_reviews: 0,
total_passes: 0,
streak: 0,
history: Vec::new(),
}
}
pub fn schedule(&self, depth: Depth) -> Option<&FsrsState> {
match depth {
Depth::Recognize => None,
Depth::Recall => self.recall.as_ref(),
Depth::Reconstruct => self.reconstruct.as_ref(),
}
}
pub fn schedule_slot(&mut self, depth: Depth) -> Option<&mut Option<FsrsState>> {
match depth {
Depth::Recognize => None,
Depth::Recall => Some(&mut self.recall),
Depth::Reconstruct => Some(&mut self.reconstruct),
}
}
pub fn record_review(&mut self, ts_ms: u64, grade: Grade, depth: Depth, propagated: bool) {
self.total_reviews += 1;
if grade.passed() {
self.total_passes += 1;
self.streak += 1;
} else {
self.streak = 0;
}
self.history.push(Review {
ts_ms,
grade,
depth,
propagated,
});
if self.history.len() > HISTORY_CAP {
let excess = self.history.len() - HISTORY_CAP;
self.history.drain(..excess);
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeckProgress {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mastered_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exam_failed_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_depth: Option<Depth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recognized_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recalled_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reconstructed_at_ms: Option<u64>,
}
pub const MATURE_STABILITY_DAYS: f64 = 21.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum VirtualKind {
Remediation,
Tutor,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VirtualCard {
pub id: u64,
pub kind: VirtualKind,
pub parent: String,
pub text: String,
pub created_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Writer {
pub device: String,
pub at_ms: u64,
}
#[derive(Serialize, Deserialize)]
struct StoreFile {
#[serde(default = "default_version")]
version: u32,
cards: HashMap<String, CardState>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
decks: HashMap<String, DeckProgress>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
virtual_cards: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
writer: Option<Writer>,
}
pub struct Store {
path: PathBuf,
cards: HashMap<u64, CardState>,
decks: HashMap<String, DeckProgress>,
virtual_cards: HashMap<u64, VirtualCard>,
pub device: Option<String>,
last_writer: Option<Writer>,
}
#[derive(Debug, Error)]
pub enum StoreError {
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path}: {source}")]
Format {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
impl Store {
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
let path = path.as_ref().to_path_buf();
if !path.exists() {
return Ok(Self {
path,
cards: HashMap::new(),
decks: HashMap::new(),
virtual_cards: HashMap::new(),
device: None,
last_writer: None,
});
}
let text = std::fs::read_to_string(&path).map_err(|source| StoreError::Io {
path: path.clone(),
source,
})?;
let file: StoreFile = serde_json::from_str(&text).map_err(|source| StoreError::Format {
path: path.clone(),
source,
})?;
let mut cards = HashMap::with_capacity(file.cards.len());
for (key, state) in file.cards {
let hash = key.parse::<u64>().map_err(|e| StoreError::Format {
path: path.clone(),
source: serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("bad card key {key:?}: {e}"),
)),
})?;
cards.insert(hash, state);
}
let mut virtual_cards = HashMap::new();
for (key, val) in file.virtual_cards {
if let (Ok(id), Ok(vc)) = (
key.parse::<u64>(),
serde_json::from_value::<VirtualCard>(val),
) {
virtual_cards.insert(id, vc);
}
}
Ok(Self {
path,
cards,
decks: file.decks,
virtual_cards,
device: None,
last_writer: file.writer,
})
}
pub fn save(&self) -> Result<(), StoreError> {
let io_err = |source| StoreError::Io {
path: self.path.clone(),
source,
};
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(io_err)?;
}
let mut virtual_cards = HashMap::with_capacity(self.virtual_cards.len());
for (id, vc) in &self.virtual_cards {
let value = serde_json::to_value(vc).map_err(|source| StoreError::Format {
path: self.path.clone(),
source,
})?;
virtual_cards.insert(id.to_string(), value);
}
let file = StoreFile {
version: CURRENT_VERSION,
cards: self
.cards
.iter()
.map(|(hash, state)| (hash.to_string(), state.clone()))
.collect(),
decks: self.decks.clone(),
virtual_cards,
writer: self
.device
.clone()
.map(|device| Writer {
device,
at_ms: crate::time::now_ms(),
})
.or_else(|| self.last_writer.clone()),
};
let json = serde_json::to_string_pretty(&file).map_err(|source| StoreError::Format {
path: self.path.clone(),
source,
})?;
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(io_err)?;
std::fs::rename(&tmp, &self.path).map_err(io_err)?;
Ok(())
}
pub fn foreign_writer(&self, my_device: &str, now_ms: u64) -> Option<(String, u64)> {
let writer = self.last_writer.as_ref()?;
if writer.device == my_device {
return None;
}
Some((writer.device.clone(), now_ms.saturating_sub(writer.at_ms)))
}
pub fn recent_foreign_writer(&self, my_device: &str, now_ms: u64) -> Option<(String, u64)> {
self.foreign_writer(my_device, now_ms)
.filter(|(_, age_ms)| *age_ms < FOREIGN_WRITE_WARN_WINDOW_MS)
}
pub fn get(&self, card_id: u64) -> Option<&CardState> {
self.cards.get(&card_id)
}
pub fn last_review_ms(&self) -> Option<u64> {
self.cards
.values()
.filter_map(|state| state.history.last().map(|review| review.ts_ms))
.max()
}
pub fn get_or_insert(&mut self, card_id: u64, now_ms: u64) -> &mut CardState {
self.cards
.entry(card_id)
.or_insert_with(|| CardState::new(now_ms))
}
pub fn remove(&mut self, card_id: u64) -> bool {
self.cards.remove(&card_id).is_some()
}
pub fn get_virtual(&self, id: u64) -> Option<&VirtualCard> {
self.virtual_cards.get(&id)
}
pub fn is_virtual(&self, id: u64) -> bool {
self.virtual_cards.contains_key(&id)
}
pub fn insert_virtual(&mut self, card: VirtualCard) {
self.virtual_cards.insert(card.id, card);
}
pub fn remove_virtual(&mut self, id: u64) -> bool {
self.virtual_cards.remove(&id).is_some()
}
pub fn remove_virtual_block(&mut self, parent: &str, text: &str) -> usize {
let before = self.virtual_cards.len();
self.virtual_cards
.retain(|_, vc| !(vc.parent == parent && vc.text == text));
before - self.virtual_cards.len()
}
pub fn iter_virtual_cards(&self) -> impl Iterator<Item = &VirtualCard> {
self.virtual_cards.values()
}
pub fn virtual_cards_for(&self, subject: &str) -> Vec<&VirtualCard> {
self.virtual_cards
.values()
.filter(|v| v.parent == subject)
.collect()
}
pub fn deck_mastered(&self, subject: &str) -> bool {
self.deck_mastered_at(subject).is_some()
}
pub fn deck_mastered_at(&self, subject: &str) -> Option<u64> {
self.decks.get(subject).and_then(|d| d.mastered_at_ms)
}
pub fn set_deck_mastered(&mut self, subject: &str, now_ms: u64) {
let entry = self.decks.entry(subject.to_string()).or_default();
entry.mastered_at_ms = Some(now_ms);
entry.exam_failed_at_ms = None;
}
pub fn exam_failed_at(&self, subject: &str) -> Option<u64> {
self.decks.get(subject).and_then(|d| d.exam_failed_at_ms)
}
pub fn set_exam_failed(&mut self, subject: &str, now_ms: u64) {
self.decks
.entry(subject.to_string())
.or_default()
.exam_failed_at_ms = Some(now_ms);
}
pub fn clear_deck_mastered(&mut self, subject: &str) -> bool {
self.decks.remove(subject).is_some()
}
pub fn last_depth(&self, subject: &str) -> Option<Depth> {
self.decks.get(subject).and_then(|d| d.last_depth)
}
pub fn set_last_depth(&mut self, subject: &str, depth: Depth) {
self.decks
.entry(subject.to_string())
.or_default()
.last_depth = Some(depth);
}
pub fn badge_earned(&self, subject: &str, depth: Depth) -> Option<u64> {
let deck = self.decks.get(subject)?;
match depth {
Depth::Recognize => deck.recognized_at_ms,
Depth::Recall => deck.recalled_at_ms,
Depth::Reconstruct => deck.reconstructed_at_ms,
}
}
pub fn clear(&mut self) -> usize {
let n = self.cards.len();
self.cards.clear();
self.decks.clear();
self.virtual_cards.clear();
n
}
pub fn len(&self) -> usize {
self.cards.len()
}
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
pub fn virtual_len(&self) -> usize {
self.virtual_cards.len()
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, thiserror::Error)]
pub enum MintError {
#[error("the drafted card is malformed: {0}")]
Malformed(String),
#[error("a card with this content already exists in the deck")]
Duplicate,
}
pub fn mint_tutor_card(
store: &mut Store,
subject: &str,
front: &str,
back: &[String],
now_ms: u64,
deck_ids: &std::collections::HashSet<u64>,
) -> Result<u64, MintError> {
let front = front.trim();
let back: Vec<String> = back
.iter()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if front.is_empty() || back.is_empty() {
return Err(MintError::Malformed(
"front and back must both be non-empty".to_string(),
));
}
if front.contains('\n') || back.iter().any(|l| l.contains('\n')) {
return Err(MintError::Malformed(
"front and back must be single lines".to_string(),
));
}
let mut text = format!("# {front}\n");
for line in &back {
text.push('\t');
text.push_str(line);
text.push('\n');
}
let cards = crate::parser::parse_str(subject, &text)
.map_err(|e| MintError::Malformed(e.to_string()))?;
let [card] = cards.as_slice() else {
return Err(MintError::Malformed(
"expected exactly one card".to_string(),
));
};
let id = card.id();
if deck_ids.contains(&id) || store.is_virtual(id) {
return Err(MintError::Duplicate);
}
store.insert_virtual(VirtualCard {
id,
kind: VirtualKind::Tutor,
parent: subject.to_string(),
text,
created_ms: now_ms,
});
store.get_or_insert(id, now_ms);
Ok(id)
}
pub fn badge_solid(cards: &[Card], store: &Store, depth: Depth) -> bool {
if cards.is_empty() {
return false;
}
cards.iter().all(|card| {
let Some(state) = store.get(card.id()) else {
return false;
};
match depth {
Depth::Recognize => state.recognized_ms.is_some(),
Depth::Recall | Depth::Reconstruct => state
.schedule(depth)
.is_some_and(|fsrs| fsrs.stability >= MATURE_STABILITY_DAYS),
}
})
}
pub fn note_badges(store: &mut Store, subject: &str, cards: &[Card], now_ms: u64) {
for depth in [Depth::Recognize, Depth::Recall, Depth::Reconstruct] {
if store.badge_earned(subject, depth).is_some() || !badge_solid(cards, store, depth) {
continue;
}
let entry = store.decks.entry(subject.to_string()).or_default();
match depth {
Depth::Recognize => entry.recognized_at_ms = Some(now_ms),
Depth::Recall => entry.recalled_at_ms = Some(now_ms),
Depth::Reconstruct => entry.reconstructed_at_ms = Some(now_ms),
}
}
}
pub fn promote_virtual(store: &mut Store, id: u64, deck_path: &Path) -> AnyResult<()> {
let Some(vc) = store.get_virtual(id) else {
bail!("no virtual card with id {id} to promote");
};
let text = vc.text.clone();
let parent = vc.parent.clone();
deck::append_cards(deck_path, &text)
.with_context(|| format!("appending the promoted card to {}", deck_path.display()))?;
store.remove_virtual_block(&parent, &text);
store
.save()
.context("saving the store after promoting a virtual card")?;
Ok(())
}
fn default_version() -> u32 {
1
}
pub fn default_store_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "alix").map(|dirs| dirs.data_dir().join("progress.json"))
}
pub fn migrate_legacy_data_dir() {
let old = directories::ProjectDirs::from("", "", "flash").map(|d| d.data_dir().to_path_buf());
let new = directories::ProjectDirs::from("", "", "alix").map(|d| d.data_dir().to_path_buf());
if let (Some(old), Some(new)) = (old, new) {
adopt_legacy_dir(&old, &new);
}
}
fn adopt_legacy_dir(old: &Path, new: &Path) {
if new.exists() || !old.exists() {
return;
}
if let Some(parent) = new.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::rename(old, new);
}
pub fn sync_conflicts(store_path: &Path) -> Vec<PathBuf> {
let Some(dir) = store_path.parent() else {
return Vec::new();
};
let (Some(stem), Some(ext)) = (
store_path.file_stem().and_then(|s| s.to_str()),
store_path.extension().and_then(|e| e.to_str()),
) else {
return Vec::new();
};
let prefix = format!("{stem}.sync-conflict-");
let suffix = format!(".{ext}");
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.starts_with(&prefix) && name.ends_with(&suffix))
})
.collect();
out.sort();
out
}
pub fn device_label_in(dir: &Path) -> Option<String> {
let path = dir.join("device");
if let Ok(text) = std::fs::read_to_string(&path) {
let label = text.trim();
if !label.is_empty() {
return Some(label.to_string());
}
}
let label = generate_device_label();
std::fs::create_dir_all(dir).ok()?;
std::fs::write(&path, format!("{label}\n")).ok()?;
Some(label)
}
pub fn device_label() -> Option<String> {
let dirs = directories::ProjectDirs::from("", "", "alix")?;
device_label_in(dirs.data_dir())
}
fn generate_device_label() -> String {
use std::hash::{BuildHasher, Hasher};
let r = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
format!("alix-{:04x}", r & 0xffff)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_creates_empty_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
}
#[test]
fn save_stamps_the_writer_and_a_reopen_sees_it_as_foreign_elsewhere() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
store.device = Some("desk-1".into());
store.save().unwrap();
let reopened = Store::open(&path).unwrap();
let (device, _) = reopened
.foreign_writer("phone-1", crate::time::now_ms())
.expect("another device sees the marker");
assert_eq!(device, "desk-1");
assert!(
reopened
.foreign_writer("desk-1", crate::time::now_ms())
.is_none(),
"a device's own writes are not foreign"
);
}
#[test]
fn an_unnamed_save_preserves_the_existing_writer_marker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
store.device = Some("desk-1".into());
store.save().unwrap();
let unnamed = Store::open(&path).unwrap();
unnamed.save().unwrap();
let reopened = Store::open(&path).unwrap();
let (device, _) = reopened
.foreign_writer("phone-1", crate::time::now_ms())
.expect("the marker survives an unnamed save");
assert_eq!(device, "desk-1");
}
#[test]
fn a_store_without_a_writer_marker_loads_and_reports_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, r#"{"version":1,"cards":{}}"#).unwrap();
let store = Store::open(&path).unwrap();
assert!(store.foreign_writer("phone-1", 0).is_none());
}
#[test]
fn the_warn_window_separates_roaming_from_concurrent_writes() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
store.last_writer = Some(Writer {
device: "desk-1".into(),
at_ms: 1_000,
});
let just_inside = 1_000 + FOREIGN_WRITE_WARN_WINDOW_MS - 1;
let at_the_edge = 1_000 + FOREIGN_WRITE_WARN_WINDOW_MS;
assert!(
store
.recent_foreign_writer("phone-1", just_inside)
.is_some()
);
assert!(
store
.recent_foreign_writer("phone-1", at_the_edge)
.is_none(),
"an old write is ordinary roaming, not a warning"
);
assert!(store.recent_foreign_writer("desk-1", just_inside).is_none());
}
#[test]
fn sync_conflicts_finds_syncthing_copies_and_ignores_near_misses() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("progress.json");
std::fs::write(&store_path, "{}").unwrap();
let conflict = dir
.path()
.join("progress.sync-conflict-20260714-101112-ABCDEF7.json");
std::fs::write(&conflict, "{}").unwrap();
for near_miss in [
"recent.sync-conflict-20260714-101112-AAAAAAA.json",
"progress.sync-conflict-20260714.txt",
"progress.json.tmp",
] {
std::fs::write(dir.path().join(near_miss), "{}").unwrap();
}
assert_eq!(sync_conflicts(&store_path), vec![conflict]);
assert_eq!(
sync_conflicts(&dir.path().join("missing/progress.json")),
Vec::<PathBuf>::new()
);
}
#[test]
fn device_label_is_created_once_and_stays_editable() {
let dir = tempfile::tempdir().unwrap();
let first = device_label_in(dir.path()).unwrap();
assert!(
first.starts_with("alix-") && first.len() == 9,
"generated shape: {first}"
);
assert_eq!(
device_label_in(dir.path()).unwrap(),
first,
"stable across calls"
);
std::fs::write(dir.path().join("device"), "desktop\n").unwrap();
assert_eq!(device_label_in(dir.path()).unwrap(), "desktop");
}
#[test]
fn adopt_legacy_dir_moves_when_new_is_absent() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("flash");
let new = tmp.path().join("alix");
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("progress.json"), "{}").unwrap();
adopt_legacy_dir(&old, &new);
assert!(
new.join("progress.json").exists(),
"progress should have moved"
);
assert!(
!old.exists(),
"the legacy dir should be gone after the move"
);
}
#[test]
fn adopt_legacy_dir_leaves_an_existing_new_dir_untouched() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("flash");
let new = tmp.path().join("alix");
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("progress.json"), "OLD").unwrap();
std::fs::create_dir_all(&new).unwrap();
std::fs::write(new.join("progress.json"), "NEW").unwrap();
adopt_legacy_dir(&old, &new);
assert_eq!(
"NEW",
std::fs::read_to_string(new.join("progress.json")).unwrap()
);
assert!(old.exists(), "the legacy dir should be left in place");
}
#[test]
fn open_rejects_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "this is not json").unwrap();
assert!(Store::open(&path).is_err());
}
#[test]
fn open_rejects_a_non_numeric_card_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(
&path,
r#"{"version":1,"cards":{"not-a-number":{"acquired_ms":0}}}"#,
)
.unwrap();
let err = Store::open(&path).err().unwrap();
assert!(format!("{err}").contains("bad card key"));
}
#[test]
fn last_review_ms_is_the_latest_across_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
assert_eq!(None, store.last_review_ms());
store
.get_or_insert(1, 0)
.record_review(100, Grade::Pass, Depth::Recall, false);
store
.get_or_insert(2, 0)
.record_review(300, Grade::Pass, Depth::Recall, false);
assert_eq!(Some(300), store.last_review_ms());
}
#[test]
fn path_returns_the_store_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let store = Store::open(&path).unwrap();
assert_eq!(path.as_path(), store.path());
}
#[test]
fn save_and_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
let state = store.get_or_insert(42, 1000);
state.record_review(1000, Grade::Pass, Depth::Recall, false);
store.save().unwrap();
let reloaded = Store::open(&path).unwrap();
assert_eq!(1, reloaded.len());
let state = reloaded.get(42).unwrap();
assert_eq!(1, state.total_reviews);
assert_eq!(
vec![Review {
ts_ms: 1000,
grade: Grade::Pass,
depth: Depth::Recall,
propagated: false
}],
state.history
);
}
#[test]
fn propagated_flag_survives_save_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
let state = store.get_or_insert(42, 1000);
state.record_review(1000, Grade::Pass, Depth::Reconstruct, false);
state.record_review(1000, Grade::Pass, Depth::Recall, true);
store.save().unwrap();
let json = std::fs::read_to_string(&path).unwrap();
assert_eq!(1, json.matches("propagated").count());
let reloaded = Store::open(&path).unwrap();
let history = &reloaded.get(42).unwrap().history;
assert!(!history[0].propagated);
assert!(history[1].propagated);
assert_eq!(Depth::Recall, history[1].depth);
}
#[test]
fn deck_mastered_roundtrips_and_clears() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
assert!(!store.deck_mastered("rust.txt"));
assert_eq!(None, store.deck_mastered_at("rust.txt"));
store.set_deck_mastered("rust.txt", 1234);
assert!(store.deck_mastered("rust.txt"));
assert_eq!(Some(1234), store.deck_mastered_at("rust.txt"));
store.save().unwrap();
let mut reloaded = Store::open(&path).unwrap();
assert!(reloaded.deck_mastered("rust.txt"));
assert_eq!(Some(1234), reloaded.deck_mastered_at("rust.txt"));
assert!(reloaded.clear_deck_mastered("rust.txt"));
assert!(!reloaded.deck_mastered("rust.txt"));
assert!(!reloaded.clear_deck_mastered("rust.txt")); }
#[test]
fn exam_failed_records_and_a_pass_clears_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
assert_eq!(None, store.exam_failed_at("t.txt"));
store.set_exam_failed("t.txt", 5000);
assert_eq!(Some(5000), store.exam_failed_at("t.txt"));
assert!(!store.deck_mastered("t.txt"));
store.save().unwrap();
let mut reloaded = Store::open(&path).unwrap();
assert_eq!(Some(5000), reloaded.exam_failed_at("t.txt"));
reloaded.set_deck_mastered("t.txt", 9000);
assert!(reloaded.deck_mastered("t.txt"));
assert_eq!(None, reloaded.exam_failed_at("t.txt"));
}
#[test]
fn per_deck_clear_drops_the_cooldown_too() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.set_exam_failed("t.txt", 1);
assert!(store.clear_deck_mastered("t.txt"));
assert_eq!(None, store.exam_failed_at("t.txt"));
}
#[test]
fn loads_a_v1_deck_record_with_a_bare_mastered_timestamp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(
&path,
"{\"version\":1,\"cards\":{},\"decks\":{\"rust.txt\":{\"mastered_at_ms\":1234}}}",
)
.unwrap();
let store = Store::open(&path).unwrap();
assert!(store.deck_mastered("rust.txt"));
assert_eq!(Some(1234), store.deck_mastered_at("rust.txt"));
assert_eq!(None, store.exam_failed_at("rust.txt"));
}
#[test]
fn clear_also_drops_deck_mastered() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.set_deck_mastered("a.txt", 1);
store.clear();
assert!(!store.deck_mastered("a.txt"));
}
#[test]
fn loads_store_file_without_decks_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "{\"version\":1,\"cards\":{}}").unwrap();
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
assert!(!store.deck_mastered("anything.txt"));
}
#[test]
fn loads_a_store_file_without_a_version_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "{\"cards\":{}}").unwrap();
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
}
#[test]
fn loads_any_version_and_defaults_pre_grade_history() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(
&path,
r#"{"version":999,"cards":{"5":{"acquired_ms":7,"history":[{"ts_ms":100,"passed":false}]}}}"#,
)
.unwrap();
let store = Store::open(&path).unwrap();
let state = store.get(5).unwrap();
assert_eq!(7, state.acquired_ms); assert_eq!(100, state.history[0].ts_ms);
assert_eq!(Grade::Pass, state.history[0].grade); }
#[test]
fn save_writes_the_current_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
store.get_or_insert(1, 0);
store.save().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains(&format!("\"version\": {CURRENT_VERSION}")));
}
#[test]
fn remove_drops_the_entry() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(42, 1000);
assert!(store.remove(42));
assert!(store.get(42).is_none());
assert!(!store.remove(42));
}
#[test]
fn clear_empties_and_counts() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(1, 0);
store.get_or_insert(2, 0);
assert_eq!(2, store.clear());
assert!(store.is_empty());
assert_eq!(0, store.clear()); }
const BORROW_TEXT: &str = "# What does the borrow checker enforce?\n\tExactly one mutable borrow, or many shared ones\n";
fn virtual_card(parent: &str, text: &str) -> VirtualCard {
let id = crate::parser::parse_str(parent, text).unwrap()[0].id();
VirtualCard {
id,
kind: VirtualKind::Remediation,
parent: parent.to_string(),
text: text.to_string(),
created_ms: 1000,
}
}
#[test]
fn insert_virtual_then_get_virtual_returns_it_with_fields_intact() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let vc = virtual_card("rust.txt", BORROW_TEXT);
let id = vc.id;
store.insert_virtual(vc);
let got = store.get_virtual(id).unwrap();
assert_eq!("rust.txt", got.parent);
assert_eq!(VirtualKind::Remediation, got.kind);
assert_eq!(BORROW_TEXT, got.text);
assert!(store.is_virtual(id));
}
#[test]
fn virtual_card_survives_save_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
let vc = virtual_card("rust.txt", BORROW_TEXT);
let id = vc.id;
store.insert_virtual(vc.clone());
store.save().unwrap();
let reloaded = Store::open(&path).unwrap();
let got = reloaded.get_virtual(id).unwrap();
assert_eq!(&vc, got);
}
#[test]
fn virtual_cards_for_matches_on_parent_subject() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.insert_virtual(virtual_card("rust.txt", "# f\n\tback one\n"));
store.insert_virtual(virtual_card("rust.txt", "# f\n\tback two\n"));
store.insert_virtual(virtual_card("other.txt", "# f\n\tback one\n"));
let rust_cards = store.virtual_cards_for("rust.txt");
assert_eq!(2, rust_cards.len());
assert!(rust_cards.iter().all(|v| v.parent == "rust.txt"));
assert_eq!(1, store.virtual_cards_for("other.txt").len());
assert!(store.virtual_cards_for("nonexistent.txt").is_empty());
}
#[test]
fn loads_store_file_without_virtual_cards_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(&path, "{\"version\":1,\"cards\":{}}").unwrap();
let store = Store::open(&path).unwrap();
assert!(store.is_empty());
assert!(store.get_virtual(123).is_none());
}
#[test]
fn an_old_shape_virtual_cards_object_loads_leniently_dropping_stale_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
std::fs::write(
&path,
r#"{"version":1,
"cards":{"5":{"acquired_ms":7}},
"decks":{"rust.txt":{"mastered_at_ms":1234}},
"virtual_cards":{
"v:abc":{"id":"v:abc","kind":"Remediation","parent":"rust.txt",
"content":{"front":"f","back":["b"],"mode":null},
"state":{"acquired_ms":0},"created_ms":0}
}}"#,
)
.unwrap();
let store = Store::open(&path).unwrap();
assert_eq!(7, store.get(5).unwrap().acquired_ms);
assert!(store.deck_mastered("rust.txt"));
assert_eq!(0, store.iter_virtual_cards().count());
}
fn write_deck(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn promote_virtual_appends_one_card_and_drops_the_virtual_entry() {
let dir = tempfile::tempdir().unwrap();
let deck_path = write_deck(dir.path(), "rust.txt", "# existing\n\tanswer\n");
let store_path = dir.path().join("progress.json");
let mut store = Store::open(&store_path).unwrap();
let vc = virtual_card("rust.txt", BORROW_TEXT);
let id = vc.id;
store.insert_virtual(vc);
promote_virtual(&mut store, id, &deck_path).unwrap();
assert!(store.get_virtual(id).is_none());
let text = std::fs::read_to_string(&deck_path).unwrap();
let cards = crate::parser::parse_str("rust.txt", &text).unwrap();
assert_eq!(2, cards.len());
let promoted = cards
.iter()
.find(|c| c.front == "What does the borrow checker enforce?")
.expect("promoted card present");
assert_eq!(
vec!["Exactly one mutable borrow, or many shared ones".to_string()],
promoted.back
);
let reloaded = Store::open(&store_path).unwrap();
assert!(reloaded.get_virtual(id).is_none());
}
#[test]
fn promote_leaves_existing_deck_card_ids_untouched() {
let dir = tempfile::tempdir().unwrap();
let deck_path = write_deck(dir.path(), "rust.txt", "# one\n\t1\n\n# two\n\t2\n");
let before =
crate::parser::parse_str("rust.txt", &std::fs::read_to_string(&deck_path).unwrap())
.unwrap();
let ids_before: Vec<u64> = before.iter().map(|c| c.id()).collect();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let vc = virtual_card("rust.txt", BORROW_TEXT);
let id = vc.id;
store.insert_virtual(vc);
promote_virtual(&mut store, id, &deck_path).unwrap();
let after =
crate::parser::parse_str("rust.txt", &std::fs::read_to_string(&deck_path).unwrap())
.unwrap();
let ids_after: Vec<u64> = after.iter().take(2).map(|c| c.id()).collect();
assert_eq!(ids_before, ids_after);
assert_eq!(3, after.len()); }
#[test]
fn promote_unknown_id_errors_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let deck_path = write_deck(dir.path(), "d.txt", "# one\n\t1\n");
let deck_before = std::fs::read_to_string(&deck_path).unwrap();
let store_path = dir.path().join("progress.json");
let mut store = Store::open(&store_path).unwrap();
let result = promote_virtual(&mut store, 999, &deck_path);
assert!(result.is_err());
assert_eq!(deck_before, std::fs::read_to_string(&deck_path).unwrap());
assert!(!store_path.exists()); }
#[test]
fn promoting_one_hole_of_a_multi_hole_cloze_removes_every_holes_sidecar_entry() {
let dir = tempfile::tempdir().unwrap();
let deck_path = write_deck(dir.path(), "rust.txt", "# existing\n\tanswer\n");
let store_path = dir.path().join("progress.json");
let mut store = Store::open(&store_path).unwrap();
let text =
"# Complete the quote\n% reveal: cloze\n\tTo {{be}} or not to {{be}}\n\t! Hamlet\n";
let cards = crate::parser::parse_str("rust.txt", text).unwrap();
assert_eq!(2, cards.len());
let id0 = cards[0].id();
let id1 = cards[1].id();
assert_ne!(id0, id1, "the two holes must have distinct ids");
for id in [id0, id1] {
store.insert_virtual(VirtualCard {
id,
kind: VirtualKind::Remediation,
parent: "rust.txt".to_string(),
text: text.to_string(),
created_ms: 1000,
});
store
.get_or_insert(id, 1000)
.record_review(1000, Grade::Pass, Depth::Recall, false);
}
promote_virtual(&mut store, id0, &deck_path).unwrap();
assert!(store.get_virtual(id0).is_none());
assert!(store.get_virtual(id1).is_none());
assert!(store.get(id0).is_some());
assert!(store.get(id1).is_some());
let deck_text = std::fs::read_to_string(&deck_path).unwrap();
let deck_cards = crate::parser::parse_str("rust.txt", &deck_text).unwrap();
assert_eq!(3, deck_cards.len());
let deck_before_second = std::fs::read_to_string(&deck_path).unwrap();
let second = promote_virtual(&mut store, id1, &deck_path);
assert!(second.is_err());
assert_eq!(
deck_before_second,
std::fs::read_to_string(&deck_path).unwrap(),
"a bailed second promote must not touch the deck file"
);
}
#[test]
fn promote_preserves_the_schedule_for_free() {
let dir = tempfile::tempdir().unwrap();
let deck_path = write_deck(dir.path(), "rust.txt", "# existing\n\tanswer\n");
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let vc = virtual_card("rust.txt", BORROW_TEXT);
let id = vc.id;
store.insert_virtual(vc);
let mut state = CardState::new(1000);
state.record_review(1000, Grade::Pass, Depth::Recall, false);
state.record_review(2000, Grade::Pass, Depth::Recall, false);
state.recall = Some(FsrsState {
stability: 12.5,
difficulty: 4.2,
reps: 2,
lapses: 0,
state: 2,
scheduled_days: 10,
last_review_ms: 2000,
due_ms: 900_000,
learning_goods: 2,
});
*store.get_or_insert(id, 1000) = state.clone();
promote_virtual(&mut store, id, &deck_path).unwrap();
assert!(store.get_virtual(id).is_none());
let text = std::fs::read_to_string(&deck_path).unwrap();
let cards = crate::parser::parse_str("rust.txt", &text).unwrap();
let promoted = cards
.iter()
.find(|c| c.front == "What does the borrow checker enforce?")
.expect("promoted card present");
assert_eq!(id, promoted.id());
let carried = store.get(promoted.id()).expect("schedule carried over");
assert_eq!(&state, carried);
}
#[test]
fn a_tutor_virtual_card_round_trips_through_the_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
let text = "# capital of france\n\tParis\n".to_string();
let id = crate::parser::parse_str("geo.txt", &text).unwrap()[0].id();
store.insert_virtual(VirtualCard {
id,
kind: VirtualKind::Tutor,
parent: "geo.txt".to_string(),
text,
created_ms: 5,
});
store.save().unwrap();
let reopened = Store::open(&path).unwrap();
let vc = reopened.get_virtual(id).expect("tutor card should load");
assert_eq!(vc.kind, VirtualKind::Tutor);
}
#[test]
fn history_is_capped() {
let mut state = CardState::new(0);
for i in 0..(HISTORY_CAP as u64 + 10) {
state.record_review(i, Grade::Pass, Depth::Recall, false);
}
assert_eq!(HISTORY_CAP, state.history.len());
assert_eq!(10, state.history[0].ts_ms);
assert_eq!(HISTORY_CAP as u32 + 10, state.total_reviews);
}
#[test]
fn streak_resets_on_fail() {
let mut state = CardState::new(0);
state.record_review(1, Grade::Pass, Depth::Recall, false);
state.record_review(2, Grade::Pass, Depth::Recall, false);
assert_eq!(2, state.streak);
state.record_review(3, Grade::Fail, Depth::Recall, false);
assert_eq!(0, state.streak);
assert_eq!(2, state.total_passes);
assert_eq!(3, state.total_reviews);
}
#[test]
fn record_review_stores_the_grade_and_partial_counts_as_a_pass() {
let mut state = CardState::new(0);
state.record_review(10, Grade::Partial, Depth::Recall, false);
assert_eq!(Grade::Partial, state.history.last().unwrap().grade);
assert_eq!(1, state.total_reviews);
assert_eq!(1, state.total_passes); assert_eq!(1, state.streak);
}
#[test]
fn recall_and_reconstruct_schedules_are_independent() {
let mut s = CardState::new(1_000);
*s.schedule_slot(Depth::Recall).unwrap() = Some(FsrsState {
stability: 30.0,
..Default::default()
});
assert!(s.schedule(Depth::Recall).is_some());
assert!(
s.schedule(Depth::Reconstruct).is_none(),
"no cross-crediting: reconstruct starts empty"
);
assert!(
s.schedule(Depth::Recognize).is_none(),
"recognize is never scheduled"
);
}
#[test]
fn per_depth_schedules_and_recognized_flag_survive_save_reload() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("store.json")).unwrap();
let st = store.get_or_insert(7, 1_000);
*st.schedule_slot(Depth::Reconstruct).unwrap() = Some(FsrsState {
stability: 4.5,
..Default::default()
});
st.recognized_ms = Some(2_000);
st.record_review(2_000, Grade::Pass, Depth::Reconstruct, false);
store.save().unwrap();
let reloaded = Store::open(dir.path().join("store.json")).unwrap();
let st = reloaded.get(7).unwrap();
assert_eq!(
Some(4.5),
st.schedule(Depth::Reconstruct).map(|f| f.stability)
);
assert_eq!(Some(2_000), st.recognized_ms);
assert_eq!(Depth::Reconstruct, st.history[0].depth);
}
#[test]
fn a_pre_depth_store_loads_with_empty_schedules() {
let json = r#"{"version":1,"cards":{"7":{"acquired_ms":5,"fsrs":{"stability":9.0,"difficulty":5.0,"reps":3,"lapses":0,"state":2,"scheduled_days":9,"last_review_ms":1,"due_ms":2},"total_reviews":3}}}"#;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.json");
std::fs::write(&path, json).unwrap();
let store = Store::open(&path).unwrap();
let st = store.get(7).unwrap();
assert_eq!(5, st.acquired_ms, "known fields still load");
assert!(
st.schedule(Depth::Recall).is_none(),
"old fsrs key is dropped, not aliased"
);
}
#[test]
fn card_state_round_trips_without_a_stage_field() {
let json = r#"{"acquired_ms":1234,"fsrs":null}"#;
let s: CardState = serde_json::from_str(json).unwrap();
assert_eq!(s.acquired_ms, 1234);
assert!(s.recall.is_none());
let legacy = r#"{"stage":3,"acquired_ms":5}"#;
let s2: CardState = serde_json::from_str(legacy).unwrap();
assert_eq!(s2.acquired_ms, 5);
}
#[test]
fn history_grades_survive_save_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
let st = store.get_or_insert(7, 0);
st.record_review(100, Grade::Partial, Depth::Recall, false);
st.record_review(200, Grade::Fail, Depth::Recall, false);
store.save().unwrap();
let reloaded = Store::open(&path).unwrap();
let history = &reloaded.get(7).unwrap().history;
assert_eq!(Grade::Partial, history[0].grade);
assert_eq!(Grade::Fail, history[1].grade);
}
#[test]
fn fsrs_state_survives_save_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("progress.json");
let mut store = Store::open(&path).unwrap();
store.get_or_insert(9, 0).recall = Some(FsrsState {
stability: 12.5,
difficulty: 6.0,
reps: 3,
lapses: 1,
state: 2,
scheduled_days: 12,
last_review_ms: 1000,
due_ms: 2000,
learning_goods: 1,
});
store.save().unwrap();
let reloaded = Store::open(&path).unwrap();
let f = reloaded.get(9).unwrap().recall.unwrap();
assert_eq!(2000, f.due_ms);
assert_eq!(1, f.learning_goods);
}
fn two_cards() -> Vec<crate::card::Card> {
crate::parser::parse_str("t.txt", "# a\n\t1\n\n# b\n\t2\n").unwrap()
}
#[test]
fn a_deck_with_all_mature_recall_cards_is_recall_solid() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let cards = two_cards();
for card in &cards {
store.get_or_insert(card.id(), 0).recall = Some(FsrsState {
stability: 30.0,
..Default::default()
});
}
assert!(badge_solid(&cards, &store, Depth::Recall));
}
#[test]
fn badge_solid_on_an_empty_deck_is_never_solid() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path().join("p.json")).unwrap();
assert!(!badge_solid(&[], &store, Depth::Recall));
assert!(!badge_solid(&[], &store, Depth::Recognize));
}
#[test]
fn a_lapsed_card_drops_solid_but_keeps_the_earn_date() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let cards = two_cards();
for card in &cards {
store.get_or_insert(card.id(), 0).recall = Some(FsrsState {
stability: 30.0,
..Default::default()
});
}
note_badges(&mut store, "t.txt", &cards, 1_000);
assert_eq!(Some(1_000), store.badge_earned("t.txt", Depth::Recall));
store.get_or_insert(cards[0].id(), 0).recall = Some(FsrsState {
stability: 3.0,
..Default::default()
});
assert!(!badge_solid(&cards, &store, Depth::Recall));
assert_eq!(Some(1_000), store.badge_earned("t.txt", Depth::Recall));
}
#[test]
fn recognize_badge_needs_every_card_recognized() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let cards = two_cards();
store.get_or_insert(cards[0].id(), 0).recognized_ms = Some(500);
assert!(
!badge_solid(&cards, &store, Depth::Recognize),
"second card not yet recognized"
);
store.get_or_insert(cards[1].id(), 0).recognized_ms = Some(600);
assert!(badge_solid(&cards, &store, Depth::Recognize));
}
#[test]
fn last_depth_roundtrips_through_save() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("p.json");
let mut store = Store::open(&path).unwrap();
assert_eq!(None, store.last_depth("t.txt"));
store.set_last_depth("t.txt", Depth::Reconstruct);
assert_eq!(Some(Depth::Reconstruct), store.last_depth("t.txt"));
store.save().unwrap();
let reloaded = Store::open(&path).unwrap();
assert_eq!(Some(Depth::Reconstruct), reloaded.last_depth("t.txt"));
}
#[test]
fn mint_tutor_card_inserts_a_tutor_virtual_card() {
use std::collections::HashSet;
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let id = mint_tutor_card(
&mut store,
"geo.txt",
"capital of france",
&["Paris".to_string()],
100,
&HashSet::new(),
)
.unwrap();
assert!(store.is_virtual(id));
assert!(store.get_virtual(id).is_some());
}
#[test]
fn mint_tutor_card_rejects_a_duplicate_id() {
use std::collections::HashSet;
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let text = "# capital of france\n\tParis\n".to_string();
let existing = crate::parser::parse_str("geo.txt", &text).unwrap()[0].id();
let deck_ids: HashSet<u64> = [existing].into_iter().collect();
let err = mint_tutor_card(
&mut store,
"geo.txt",
"capital of france",
&["Paris".to_string()],
100,
&deck_ids,
)
.unwrap_err();
assert!(matches!(err, MintError::Duplicate));
}
#[test]
fn mint_tutor_card_rejects_an_empty_side() {
use std::collections::HashSet;
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let err = mint_tutor_card(
&mut store,
"geo.txt",
" ",
&["Paris".to_string()],
100,
&HashSet::new(),
)
.unwrap_err();
assert!(matches!(err, MintError::Malformed(_)));
}
#[test]
fn mint_tutor_card_rejects_an_embedded_newline() {
use std::collections::HashSet;
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let err = mint_tutor_card(
&mut store,
"geo.txt",
"capital?",
&["Paris\n% direction: reverse".to_string()],
100,
&HashSet::new(),
)
.unwrap_err();
assert!(matches!(err, MintError::Malformed(_)));
}
}