use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{answer::Mode, card::Card, depth::Reveal};
const CURRENT_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Format {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub front: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub back: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<Mode>,
}
fn reveal_from_suggested(mode: Mode) -> Option<Reveal> {
match mode {
Mode::Flip => Some(Reveal::Flip),
Mode::LineByLine => Some(Reveal::Line),
_ => None,
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Augmentation {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub distractors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub variants: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keypoints: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<Format>,
}
impl Augmentation {
fn is_empty(&self) -> bool {
self.distractors.is_empty()
&& self.note.is_none()
&& self.variants.is_empty()
&& self.keypoints.is_empty()
&& self.format.is_none()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Topology {
pub name: String,
pub principle: String,
pub edges: Vec<TopologyEdge>,
pub walk: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub regions: Vec<TopologyRegion>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologyEdge {
pub from: u64,
pub to: u64,
pub label: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologyRegion {
pub name: String,
pub cards: Vec<u64>,
}
impl Topology {
pub fn region_path(&self, card: u64) -> Option<(Vec<&str>, usize)> {
let current = self.regions.iter().position(|r| r.cards.contains(&card))?;
let names = self.regions.iter().map(|r| r.name.as_str()).collect();
Some((names, current))
}
pub fn covers(&self, deck_ids: &HashSet<u64>) -> bool {
self.walk.iter().any(|id| deck_ids.contains(id))
}
pub fn region_cards(&self, name: &str) -> Option<&[u64]> {
self.regions
.iter()
.find(|r| r.name.eq_ignore_ascii_case(name))
.map(|r| r.cards.as_slice())
}
}
#[derive(Clone, Debug, Default)]
pub struct TopologyOrder {
rank: HashMap<u64, usize>,
}
impl TopologyOrder {
pub fn from_walk(walk: &[u64]) -> Self {
Self {
rank: walk
.iter()
.copied()
.enumerate()
.map(|(i, id)| (id, i))
.collect(),
}
}
pub fn rank_of(&self, card_id: u64) -> Option<usize> {
self.rank.get(&card_id).copied()
}
}
#[derive(Serialize, Deserialize)]
struct AugmentFile {
#[serde(default = "default_version")]
version: u32,
cards: HashMap<String, Augmentation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
topologies: Vec<Topology>,
}
pub struct AugmentCache {
path: PathBuf,
cards: HashMap<u64, Augmentation>,
topologies: Vec<Topology>,
}
#[derive(Debug, Error)]
pub enum AugmentError {
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path}: {source}")]
Format {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
impl AugmentCache {
pub fn open(path: impl AsRef<Path>) -> Self {
let path = path.as_ref().to_path_buf();
let Loaded { cards, topologies } = load(&path).unwrap_or_default();
Self {
path,
cards,
topologies,
}
}
pub fn save(&self) -> Result<(), AugmentError> {
let io_err = |source| AugmentError::Io {
path: self.path.clone(),
source,
};
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(io_err)?;
}
let file = AugmentFile {
version: CURRENT_VERSION,
cards: self
.cards
.iter()
.map(|(id, aug)| (id.to_string(), aug.clone()))
.collect(),
topologies: self.topologies.clone(),
};
let json = serde_json::to_string_pretty(&file).map_err(|source| AugmentError::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 distractors(&self, card_id: u64) -> Option<&[String]> {
self.cards
.get(&card_id)
.map(|aug| aug.distractors.as_slice())
.filter(|d| !d.is_empty())
}
pub fn contains(&self, card_id: u64) -> bool {
self.cards.contains_key(&card_id)
}
pub fn set_distractors(&mut self, card_id: u64, distractors: Vec<String>) {
self.cards.entry(card_id).or_default().distractors = distractors;
}
pub fn note(&self, card_id: u64) -> Option<&str> {
self.cards.get(&card_id).and_then(|aug| aug.note.as_deref())
}
pub fn set_note(&mut self, card_id: u64, note: String) {
self.cards.entry(card_id).or_default().note = Some(note);
}
pub fn format(&self, card_id: u64) -> Option<&Format> {
self.cards.get(&card_id).and_then(|aug| aug.format.as_ref())
}
pub fn set_format(&mut self, card_id: u64, format: Format) {
self.cards.entry(card_id).or_default().format = Some(format);
}
pub fn variants(&self, card_id: u64) -> Option<&[String]> {
self.cards
.get(&card_id)
.map(|aug| aug.variants.as_slice())
.filter(|v| !v.is_empty())
}
pub fn pick_front(&self, card_id: u64, original: &str, seed: u64) -> Option<String> {
let variants = self.variants(card_id)?;
let pool_len = variants.len() + 1; let idx = (seed % pool_len as u64) as usize;
Some(if idx == 0 {
original.to_string()
} else {
variants[idx - 1].clone()
})
}
pub fn set_variants(&mut self, card_id: u64, variants: Vec<String>) {
self.cards.entry(card_id).or_default().variants = variants;
}
pub fn keypoints(&self, card_id: u64) -> Option<&[String]> {
self.cards
.get(&card_id)
.map(|aug| aug.keypoints.as_slice())
.filter(|k| !k.is_empty())
}
pub fn set_keypoints(&mut self, card_id: u64, keypoints: Vec<String>) {
self.cards.entry(card_id).or_default().keypoints = keypoints;
}
pub fn topologies(&self) -> &[Topology] {
&self.topologies
}
pub fn topologies_for(&self, deck_ids: &HashSet<u64>) -> Vec<&Topology> {
self.topologies
.iter()
.filter(|t| t.covers(deck_ids))
.collect()
}
pub fn has_topology_for(&self, deck_ids: &HashSet<u64>) -> bool {
self.topologies.iter().any(|t| t.covers(deck_ids))
}
pub fn topology(&self, name: &str) -> Option<&Topology> {
self.topologies.iter().find(|t| t.name == name)
}
pub fn add_topology(&mut self, topology: Topology) {
let ids: HashSet<u64> = topology.walk.iter().copied().collect();
match self
.topologies
.iter_mut()
.find(|t| t.name == topology.name && t.covers(&ids))
{
Some(existing) => *existing = topology,
None => self.topologies.push(topology),
}
}
pub fn len(&self) -> usize {
self.cards.len()
}
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
pub fn summarize(&self, cards: &[Card]) -> CoverageSummary {
let coverage = |eligible: &[&Card], covered: &dyn Fn(u64) -> bool| Coverage {
covered: eligible.iter().filter(|c| covered(c.id())).count(),
eligible: eligible.len(),
};
let all: Vec<&Card> = cards.iter().collect();
let plain: Vec<&Card> = cards.iter().filter(|c| c.hash_lines.is_none()).collect();
let deck_ids: HashSet<u64> = cards.iter().map(Card::id).collect();
CoverageSummary {
choices: coverage(&all, &|id| self.distractors(id).is_some()),
notes: coverage(&all, &|id| self.note(id).is_some()),
questions: coverage(&plain, &|id| self.variants(id).is_some()),
keypoints: coverage(&all, &|id| self.keypoints(id).is_some()),
format: coverage(&plain, &|id| self.format(id).is_some()),
topologies: self
.topologies_for(&deck_ids)
.iter()
.map(|t| t.name.clone())
.collect(),
}
}
fn missing(
&self,
cards: &[Card],
eligible: impl Fn(&Card) -> bool,
covered: impl Fn(u64) -> bool,
) -> Vec<WarmItem> {
cards
.iter()
.filter(|c| eligible(c) && !covered(c.id()))
.map(WarmItem::from_card)
.collect()
}
pub fn missing_choices(&self, cards: &[Card]) -> Vec<WarmItem> {
self.missing(cards, |_| true, |id| self.distractors(id).is_some())
}
pub fn missing_notes(&self, cards: &[Card]) -> Vec<WarmItem> {
self.missing(cards, |_| true, |id| self.note(id).is_some())
}
pub fn missing_questions(&self, cards: &[Card]) -> Vec<WarmItem> {
self.missing(
cards,
|c| c.hash_lines.is_none(),
|id| self.variants(id).is_some(),
)
}
pub fn missing_keypoints(&self, cards: &[Card]) -> Vec<WarmItem> {
self.missing(cards, |_| true, |id| self.keypoints(id).is_some())
}
pub fn missing_format(&self, cards: &[Card]) -> Vec<WarmItem> {
self.missing(
cards,
|c| c.hash_lines.is_none(),
|id| self.format(id).is_some(),
)
}
pub fn clear_distractors(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
if let Some(aug) = self.cards.get_mut(id) {
aug.distractors.clear();
}
}
self.prune_empty(deck_ids);
}
pub fn clear_notes(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
if let Some(aug) = self.cards.get_mut(id) {
aug.note = None;
}
}
self.prune_empty(deck_ids);
}
pub fn clear_variants(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
if let Some(aug) = self.cards.get_mut(id) {
aug.variants.clear();
}
}
self.prune_empty(deck_ids);
}
pub fn clear_keypoints(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
if let Some(aug) = self.cards.get_mut(id) {
aug.keypoints.clear();
}
}
self.prune_empty(deck_ids);
}
pub fn clear_format(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
if let Some(aug) = self.cards.get_mut(id) {
aug.format = None;
}
}
self.prune_empty(deck_ids);
}
pub fn apply_format(&self, card: &mut Card) {
let Some(fmt) = self.format(card.id()) else {
return;
};
if let Some(front) = &fmt.front {
card.front = front.clone();
}
if let Some(note) = &fmt.note {
card.note = Some(note.clone());
}
if !fmt.back.is_empty() {
card.display_back = Some(fmt.back.clone());
}
if card.reveal.is_none() {
card.reveal = fmt.mode.and_then(reveal_from_suggested);
}
}
fn prune_empty(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
if self.cards.get(id).is_some_and(Augmentation::is_empty) {
self.cards.remove(id);
}
}
}
pub fn remove_topology(&mut self, name: &str, deck_ids: &HashSet<u64>) -> bool {
let before = self.topologies.len();
self.topologies
.retain(|t| !(t.name == name && t.covers(deck_ids)));
self.topologies.len() != before
}
pub fn clear_all(&mut self, deck_ids: &HashSet<u64>) {
for id in deck_ids {
self.cards.remove(id);
}
self.topologies.retain(|t| !t.covers(deck_ids));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Coverage {
pub covered: usize,
pub eligible: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverageSummary {
pub choices: Coverage,
pub notes: Coverage,
pub questions: Coverage,
pub keypoints: Coverage,
pub format: Coverage,
pub topologies: Vec<String>,
}
#[derive(Default)]
struct Loaded {
cards: HashMap<u64, Augmentation>,
topologies: Vec<Topology>,
}
fn load(path: &Path) -> Option<Loaded> {
let text = std::fs::read_to_string(path).ok()?;
let file: AugmentFile = serde_json::from_str(&text).ok()?;
if file.version > CURRENT_VERSION {
return None;
}
let mut cards = HashMap::with_capacity(file.cards.len());
for (key, aug) in file.cards {
if let Ok(id) = key.parse::<u64>() {
cards.insert(id, aug);
}
}
Some(Loaded {
cards,
topologies: file.topologies,
})
}
fn default_version() -> u32 {
1
}
pub fn augment_path_for(store_path: &Path) -> PathBuf {
store_path.with_file_name("augment.json")
}
#[derive(Clone, Debug)]
pub struct WarmItem {
pub id: u64,
pub question: String,
pub answer: String,
pub note: Option<String>,
}
impl WarmItem {
pub fn from_card(card: &Card) -> Self {
Self {
id: card.id(),
question: card.front.clone(),
answer: card.back.join("\n"),
note: card.note.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reveal_from_suggested_maps_only_flip_and_line() {
assert_eq!(Some(Reveal::Flip), reveal_from_suggested(Mode::Flip));
assert_eq!(Some(Reveal::Line), reveal_from_suggested(Mode::LineByLine));
assert_eq!(None, reveal_from_suggested(Mode::Explain));
assert_eq!(None, reveal_from_suggested(Mode::Typing));
}
#[test]
fn open_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let cache = AugmentCache::open(dir.path().join("augment.json"));
assert!(cache.is_empty());
}
#[test]
fn save_and_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
let mut cache = AugmentCache::open(&path);
cache.set_distractors(42, vec!["wrong a".into(), "wrong b".into()]);
cache.save().unwrap();
let reloaded = AugmentCache::open(&path);
assert_eq!(1, reloaded.len());
assert_eq!(
Some(["wrong a".to_string(), "wrong b".to_string()].as_slice()),
reloaded.distractors(42)
);
}
#[test]
fn distractors_is_none_when_absent_or_empty() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
assert_eq!(None, cache.distractors(1)); cache.set_distractors(1, Vec::new());
assert_eq!(None, cache.distractors(1)); assert!(cache.contains(1));
}
#[test]
fn corrupt_file_yields_empty_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
std::fs::write(&path, "this is not json").unwrap();
let cache = AugmentCache::open(&path);
assert!(cache.is_empty());
}
#[test]
fn newer_version_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
std::fs::write(&path, r#"{"version":999,"cards":{}}"#).unwrap();
let cache = AugmentCache::open(&path);
assert!(cache.is_empty());
}
#[test]
fn a_single_bad_key_does_not_drop_the_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
std::fs::write(
&path,
r#"{"version":1,"cards":{"not-a-number":{"distractors":["x"]},"7":{"distractors":["y"]}}}"#,
)
.unwrap();
let cache = AugmentCache::open(&path);
assert_eq!(1, cache.len());
assert_eq!(Some(["y".to_string()].as_slice()), cache.distractors(7));
}
#[test]
fn file_without_version_field_loads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
std::fs::write(&path, r#"{"cards":{"3":{"distractors":["z"]}}}"#).unwrap();
let cache = AugmentCache::open(&path);
assert_eq!(Some(["z".to_string()].as_slice()), cache.distractors(3));
}
#[test]
fn augment_path_is_a_sibling_of_the_store() {
let p = augment_path_for(Path::new("/data/alix/progress.json"));
assert_eq!(Path::new("/data/alix/augment.json"), p);
}
#[test]
fn set_distractors_replaces_previous() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
cache.set_distractors(1, vec!["old".into()]);
cache.set_distractors(1, vec!["new a".into(), "new b".into()]);
assert_eq!(
Some(["new a".to_string(), "new b".to_string()].as_slice()),
cache.distractors(1)
);
}
#[test]
fn note_roundtrips_through_the_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
let mut cache = AugmentCache::open(&path);
cache.set_note(7, "a memorable fact".into());
cache.save().unwrap();
let reloaded = AugmentCache::open(&path);
assert_eq!(Some("a memorable fact"), reloaded.note(7));
assert_eq!(None, reloaded.note(8));
}
#[test]
fn variants_roundtrip_and_pick() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
let mut cache = AugmentCache::open(&path);
cache.set_variants(5, vec!["one".into(), "two".into(), "three".into()]);
cache.save().unwrap();
let reloaded = AugmentCache::open(&path);
assert_eq!(3, reloaded.variants(5).unwrap().len());
assert_eq!(Some("ORIG".to_string()), reloaded.pick_front(5, "ORIG", 0));
assert_eq!(Some("one".to_string()), reloaded.pick_front(5, "ORIG", 1));
assert_eq!(Some("ORIG".to_string()), reloaded.pick_front(5, "ORIG", 4)); assert_eq!(None, reloaded.pick_front(6, "ORIG", 0)); }
#[test]
fn keypoints_roundtrip_through_the_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
let mut cache = AugmentCache::open(&path);
cache.set_keypoints(9, vec!["claim a".into(), "claim b".into()]);
cache.save().unwrap();
let reloaded = AugmentCache::open(&path);
assert_eq!(
Some(["claim a".to_string(), "claim b".to_string()].as_slice()),
reloaded.keypoints(9)
);
assert_eq!(None, reloaded.keypoints(10)); }
fn topology(name: &str, walk: Vec<u64>) -> Topology {
Topology {
name: name.into(),
principle: format!("principle for {name}"),
edges: vec![TopologyEdge {
from: walk[0],
to: walk[1],
label: "x".into(),
}],
walk,
regions: Vec::new(),
}
}
fn region(name: &str, cards: Vec<u64>) -> TopologyRegion {
TopologyRegion {
name: name.into(),
cards,
}
}
fn topo_regions(regions: Vec<TopologyRegion>) -> Topology {
Topology {
name: "n".into(),
principle: "p".into(),
edges: Vec::new(),
walk: Vec::new(),
regions,
}
}
#[test]
fn topology_roundtrips_through_the_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("augment.json");
let mut cache = AugmentCache::open(&path);
assert!(cache.topologies().is_empty());
cache.add_topology(topology("auto", vec![1, 2]));
cache.save().unwrap();
let reloaded = AugmentCache::open(&path);
let t = reloaded.topology("auto").unwrap();
assert_eq!("principle for auto", t.principle);
assert_eq!(vec![1, 2], t.walk);
assert_eq!(1, t.edges.len());
}
#[test]
fn add_topology_appends_new_names_and_replaces_same_name() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
cache.add_topology(topology("north to south", vec![1, 2]));
cache.add_topology(topology("by continent", vec![3, 4]));
assert_eq!(2, cache.topologies().len());
cache.add_topology(topology("north to south", vec![1, 2, 7]));
assert_eq!(2, cache.topologies().len());
assert_eq!(
vec![1, 2, 7],
cache.topology("north to south").unwrap().walk
);
assert_eq!(vec![3, 4], cache.topology("by continent").unwrap().walk);
assert!(cache.topology("alphabetical").is_none());
}
#[test]
fn add_topology_keeps_like_named_topologies_for_different_decks() {
let mut cache = AugmentCache::open(std::path::Path::new("unused.json"));
cache.add_topology(topology("auto", vec![1, 2, 3])); cache.add_topology(topology("auto", vec![10, 20, 30])); assert_eq!(2, cache.topologies().len());
let a: HashSet<u64> = [1, 2, 3].into_iter().collect();
let b: HashSet<u64> = [10, 20, 30].into_iter().collect();
assert_eq!(vec![1, 2, 3], cache.topologies_for(&a)[0].walk);
assert_eq!(vec![10, 20, 30], cache.topologies_for(&b)[0].walk);
}
#[test]
fn topologies_for_keeps_only_the_decks_own() {
let mut cache = AugmentCache::open(std::path::Path::new("unused.json"));
cache.add_topology(topology("architecture", vec![1, 2, 3]));
cache.add_topology(topology("capitals", vec![10, 20, 30]));
let arch: HashSet<u64> = [1, 2, 3].into_iter().collect();
let mine = cache.topologies_for(&arch);
assert_eq!(1, mine.len());
assert_eq!("architecture", mine[0].name);
let other: HashSet<u64> = [99].into_iter().collect();
assert!(cache.topologies_for(&other).is_empty());
}
#[test]
fn has_topology_for_reports_presence_without_cross_deck_leak() {
let mut cache = AugmentCache::open(std::path::Path::new("unused.json"));
cache.add_topology(topology("architecture", vec![1, 2, 3]));
let mine: HashSet<u64> = [1, 2, 3].into_iter().collect();
assert!(cache.has_topology_for(&mine));
let other: HashSet<u64> = [99].into_iter().collect();
assert!(!cache.has_topology_for(&other));
}
#[test]
fn region_path_locates_the_card_and_lists_regions_in_walk_order() {
let t = topo_regions(vec![
region("Parsing", vec![1, 2]),
region("Session", vec![3, 4]),
region("Persistence", vec![5]),
]);
let (names, current) = t.region_path(3).unwrap();
assert_eq!(vec!["Parsing", "Session", "Persistence"], names);
assert_eq!(1, current); }
#[test]
fn region_cards_finds_by_name_case_insensitively() {
let t = topo_regions(vec![
region("Persistence", vec![10, 20]),
region("Engine", vec![30]),
]);
assert_eq!(Some([10, 20].as_slice()), t.region_cards("persistence"));
assert_eq!(Some([30].as_slice()), t.region_cards("Engine"));
assert_eq!(None, t.region_cards("nope"));
}
#[test]
fn region_path_none_when_card_absent_or_no_regions() {
let t = topo_regions(vec![region("A", vec![1])]);
assert!(t.region_path(99).is_none()); assert!(topo_regions(vec![]).region_path(1).is_none()); }
#[test]
fn topology_order_from_walk_ranks_present_and_misses_absent() {
let order = TopologyOrder::from_walk(&[10, 20, 30]);
assert_eq!(Some(0), order.rank_of(10));
assert_eq!(Some(2), order.rank_of(30));
assert_eq!(None, order.rank_of(99));
}
fn plain_card(back: &str) -> Card {
Card::plain("deck.txt".into(), "Q".into(), vec![back.into()], None, 1)
}
fn cloze_card(back: &str) -> Card {
let mut c = plain_card(back);
c.hash_lines = Some(vec![back.into()]);
c
}
fn topo_over(name: &str, card: u64) -> Topology {
Topology {
name: name.into(),
principle: String::new(),
edges: Vec::new(),
walk: vec![card],
regions: Vec::new(),
}
}
#[test]
fn summarize_counts_coverage_against_each_targets_eligible_cards() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
let cards = vec![
plain_card("a"),
plain_card("b"),
plain_card("c"),
cloze_card("z"),
];
cache.set_distractors(cards[0].id(), vec!["x".into()]);
cache.set_distractors(cards[1].id(), vec!["y".into()]);
cache.set_note(cards[0].id(), "n".into());
cache.set_variants(cards[0].id(), vec!["v".into()]);
cache.set_keypoints(cards[2].id(), vec!["k1".into(), "k2".into()]);
cache.add_topology(topo_over("auto", cards[0].id()));
let s = cache.summarize(&cards);
assert_eq!(
Coverage {
covered: 2,
eligible: 4
},
s.choices
);
assert_eq!(
Coverage {
covered: 1,
eligible: 4
},
s.notes
);
assert_eq!(
Coverage {
covered: 1,
eligible: 3
},
s.questions
);
assert_eq!(
Coverage {
covered: 1,
eligible: 4
},
s.keypoints
);
assert_eq!(vec!["auto".to_string()], s.topologies);
}
#[test]
fn missing_returns_only_uncovered_eligible_cards() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
let cards = vec![plain_card("a"), plain_card("b"), cloze_card("z")];
cache.set_distractors(cards[0].id(), vec!["x".into()]);
let miss: Vec<u64> = cache.missing_choices(&cards).iter().map(|w| w.id).collect();
assert_eq!(vec![cards[1].id(), cards[2].id()], miss);
let mq: Vec<u64> = cache
.missing_questions(&cards)
.iter()
.map(|w| w.id)
.collect();
assert_eq!(vec![cards[0].id(), cards[1].id()], mq);
}
#[test]
fn clear_distractors_is_deck_scoped_and_prunes_empty_entries() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
let mine = plain_card("a");
let other = plain_card("other-deck-card");
cache.set_distractors(mine.id(), vec!["x".into()]);
cache.set_distractors(other.id(), vec!["y".into()]);
let deck_ids: HashSet<u64> = [mine.id()].into_iter().collect();
cache.clear_distractors(&deck_ids);
assert_eq!(None, cache.distractors(mine.id()));
assert!(!cache.contains(mine.id())); assert_eq!(
Some(["y".to_string()].as_slice()),
cache.distractors(other.id())
);
}
#[test]
fn clear_notes_keeps_other_fields_and_does_not_prune() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
let c = plain_card("a");
cache.set_note(c.id(), "n".into());
cache.set_distractors(c.id(), vec!["x".into()]);
let deck_ids: HashSet<u64> = [c.id()].into_iter().collect();
cache.clear_notes(&deck_ids);
assert_eq!(None, cache.note(c.id()));
assert_eq!(
Some(["x".to_string()].as_slice()),
cache.distractors(c.id())
);
assert!(cache.contains(c.id())); }
#[test]
fn remove_topology_is_name_and_deck_scoped() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
let mine = plain_card("a");
let other = plain_card("other");
cache.add_topology(topo_over("auto", mine.id()));
cache.add_topology(topo_over("auto", other.id()));
let deck_ids: HashSet<u64> = [mine.id()].into_iter().collect();
assert!(cache.remove_topology("auto", &deck_ids));
assert_eq!(1, cache.topologies().len());
let other_ids: HashSet<u64> = [other.id()].into_iter().collect();
assert_eq!(1, cache.topologies_for(&other_ids).len()); assert!(!cache.remove_topology("nope", &deck_ids)); }
#[test]
fn clear_all_removes_only_this_decks_augmentations() {
let dir = tempfile::tempdir().unwrap();
let mut cache = AugmentCache::open(dir.path().join("augment.json"));
let mine = plain_card("a");
let other = plain_card("other");
cache.set_distractors(mine.id(), vec!["x".into()]);
cache.set_note(mine.id(), "n".into());
cache.add_topology(topo_over("auto", mine.id()));
cache.set_distractors(other.id(), vec!["y".into()]);
cache.add_topology(topo_over("auto", other.id()));
let deck_ids: HashSet<u64> = [mine.id()].into_iter().collect();
cache.clear_all(&deck_ids);
assert!(!cache.contains(mine.id()));
assert!(cache.topologies_for(&deck_ids).is_empty());
assert_eq!(
Some(["y".to_string()].as_slice()),
cache.distractors(other.id())
);
let other_ids: HashSet<u64> = [other.id()].into_iter().collect();
assert_eq!(1, cache.topologies_for(&other_ids).len());
}
#[test]
fn apply_format_reshapes_display_without_changing_identity() {
use std::sync::Arc;
let mut card = Card::plain(
Arc::from("d.txt"),
"List the parts".to_string(),
vec!["A, B, C".to_string()],
None,
1,
);
let id = card.id();
let mut cache = AugmentCache::open(std::env::temp_dir().join("nonexistent-augment.json"));
cache.set_format(
id,
Format {
front: Some("Name the parts".to_string()),
back: vec!["A".to_string(), "B".to_string(), "C".to_string()],
note: None,
mode: Some(Mode::LineByLine),
},
);
cache.apply_format(&mut card);
assert_eq!(card.front, "Name the parts");
assert_eq!(card.back_for_display(), ["A", "B", "C"]);
assert_eq!(card.reveal, Some(Reveal::Line));
assert_eq!(card.id(), id); }
#[test]
fn apply_format_respects_an_explicit_reveal() {
use std::sync::Arc;
let mut card = Card::plain(Arc::from("d.txt"), "f".into(), vec!["a".into()], None, 1);
card.reveal = Some(Reveal::Flip); let id = card.id();
let mut cache = AugmentCache::open(std::env::temp_dir().join("nonexistent-augment2.json"));
cache.set_format(
id,
Format {
front: None,
back: Vec::new(),
note: None,
mode: Some(Mode::LineByLine),
},
);
cache.apply_format(&mut card);
assert_eq!(card.reveal, Some(Reveal::Flip)); }
}