use std::collections::VecDeque;
use crate::{
augment::TopologyOrder,
card::Card,
scheduler::{Grade, Scheduler, SchedulerKind},
store::{MAX_STAGE, Store},
time,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum Order {
#[default]
Scheduled,
Sequential,
}
#[derive(Clone, Debug)]
pub struct SessionOptions {
pub max_new: usize,
pub limit: Option<usize>,
pub cram: bool,
pub order: Order,
pub topology: Option<TopologyOrder>,
}
impl Default for SessionOptions {
fn default() -> Self {
Self {
max_new: 10,
limit: None,
cram: false,
order: Order::Scheduled,
topology: None,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SessionStats {
pub reviews: usize,
pub passed: usize,
pub failed: usize,
pub acquired: usize,
}
pub type StageHistogram = [usize; 6];
pub struct Session {
cards: Vec<Card>,
queue: VecDeque<usize>,
scheduler: Box<dyn Scheduler>,
kind: SchedulerKind,
options: SessionOptions,
pub initial_size: usize,
pub stats: SessionStats,
}
impl Session {
pub fn new(
cards: Vec<Card>,
store: &Store,
kind: SchedulerKind,
options: SessionOptions,
now_ms: u64,
) -> Self {
let scheduler = kind.scheduler();
let queue = build_queue(&cards, store, &*scheduler, kind, &options, now_ms);
let initial_size = queue.len();
Self {
cards,
queue,
scheduler,
kind,
options,
initial_size,
stats: SessionStats::default(),
}
}
pub fn restart(&mut self, store: &Store, now_ms: u64) -> bool {
let queue = build_queue(
&self.cards,
store,
&*self.scheduler,
self.kind,
&self.options,
now_ms,
);
if queue.is_empty() {
return false;
}
self.initial_size = queue.len();
self.queue = queue;
self.stats = SessionStats::default();
true
}
pub fn has_due_now(&self, store: &Store, now_ms: u64) -> bool {
!build_queue(
&self.cards,
store,
&*self.scheduler,
self.kind,
&self.options,
now_ms,
)
.is_empty()
}
pub fn next_due_at(&self, store: &Store) -> Option<u64> {
self.cards
.iter()
.filter_map(|card| store.get(card.id()))
.map(|state| self.scheduler.due_at(state))
.min()
}
pub fn current(&self) -> Option<&Card> {
self.queue.front().map(|&i| &self.cards[i])
}
pub fn current_mut(&mut self) -> Option<&mut Card> {
let i = *self.queue.front()?;
Some(&mut self.cards[i])
}
pub fn current_unseen(&self, store: &Store) -> bool {
self.current()
.is_some_and(|card| store.get(card.id()).is_none())
}
pub fn cards(&self) -> &[Card] {
&self.cards
}
pub fn remaining(&self) -> usize {
self.queue.len()
}
pub fn is_finished(&self) -> bool {
self.queue.is_empty()
}
pub fn grade(&mut self, store: &mut Store, grade: Grade, now_ms: u64) {
let Some(index) = self.queue.pop_front() else {
return;
};
let card = &self.cards[index];
let state = store.get_or_insert(card.id(), now_ms);
self.scheduler.apply(state, grade, now_ms);
if state.stage > MAX_STAGE {
state.stage = MAX_STAGE;
}
self.stats.reviews += 1;
if grade.passed() {
self.stats.passed += 1;
} else {
self.stats.failed += 1;
self.queue.push_back(index);
}
}
pub fn acquire_current(&mut self, store: &mut Store, now_ms: u64) {
let Some(index) = self.queue.pop_front() else {
return;
};
store.get_or_insert(self.cards[index].id(), now_ms);
self.stats.acquired += 1;
}
pub fn skip(&mut self) {
if let Some(index) = self.queue.pop_front() {
self.queue.push_back(index);
}
}
pub fn remove_current(&mut self) -> Vec<Card> {
let Some(index) = self.queue.pop_front() else {
return Vec::new();
};
let group = sibling_group(&self.cards[index]);
let mut removed = vec![self.cards[index].clone()];
let mut kept = VecDeque::with_capacity(self.queue.len());
for &i in &self.queue {
if sibling_group(&self.cards[i]) == group {
removed.push(self.cards[i].clone());
} else {
kept.push_back(i);
}
}
self.queue = kept;
removed
}
pub fn stage_histogram(&self, store: &Store) -> StageHistogram {
histogram(&self.cards, store)
}
pub fn top_stage(&self) -> u8 {
MAX_STAGE
}
}
fn build_queue(
cards: &[Card],
store: &Store,
scheduler: &dyn Scheduler,
kind: SchedulerKind,
options: &SessionOptions,
now_ms: u64,
) -> VecDeque<usize> {
let mut due: Vec<usize> = Vec::new();
let mut fresh: Vec<usize> = Vec::new();
for (i, card) in cards.iter().enumerate() {
match store.get(card.id()) {
Some(_) if is_retired(card, store) => {}
Some(state) => {
if options.cram || scheduler.is_due(state, now_ms) {
due.push(i);
}
}
None => fresh.push(i),
}
}
match kind {
SchedulerKind::Leitner => {
due.sort_by_key(|&i| {
let state = store.get(cards[i].id()).unwrap();
(std::cmp::Reverse(state.stage), state.stage_entered_ms)
});
}
SchedulerKind::Sm2 => {
due.sort_by_key(|&i| {
let state = store.get(cards[i].id()).unwrap();
scheduler.due_at(state)
});
}
}
let mut fresh: Vec<usize> = fresh.into_iter().take(options.max_new).collect();
if let Some(topo) = &options.topology {
let rank = |&i: &usize| topo.rank_of(cards[i].id()).unwrap_or(usize::MAX);
due.sort_by_key(rank);
fresh.sort_by_key(rank);
}
let mut order: Vec<usize> = due;
order.extend(fresh);
if options.order == Order::Sequential {
order.sort_unstable();
}
if let Some(limit) = options.limit {
order.truncate(limit);
}
if options.topology.is_some() {
order.into()
} else {
separate_siblings(order, cards)
}
}
fn sibling_group(card: &Card) -> (&str, usize) {
(card.subject.as_ref(), card.line)
}
fn separate_siblings(order: Vec<usize>, cards: &[Card]) -> VecDeque<usize> {
let mut remaining: VecDeque<usize> = order.into();
let mut queue = VecDeque::with_capacity(remaining.len());
let mut last: Option<usize> = None;
while !remaining.is_empty() {
let pos = remaining
.iter()
.position(|&i| {
last.is_none_or(|l| sibling_group(&cards[i]) != sibling_group(&cards[l]))
})
.unwrap_or(0);
let index = remaining.remove(pos).unwrap();
last = Some(index);
queue.push_back(index);
}
queue
}
pub fn is_retired(card: &Card, store: &Store) -> bool {
is_retired_id(card.id(), store)
}
pub fn is_retired_id(card_id: u64, store: &Store) -> bool {
store
.get(card_id)
.is_some_and(|s| s.stage >= MAX_STAGE && s.streak >= 1)
}
pub fn card_strengths(card_ids: &[u64], store: &Store) -> Vec<f32> {
card_ids
.iter()
.map(|&id| f32::from(store.get(id).map_or(0, |s| s.stage)) / f32::from(MAX_STAGE))
.collect()
}
pub fn is_reviewable(card: &Card, store: &Store, scheduler: &dyn Scheduler, now_ms: u64) -> bool {
match store.get(card.id()) {
Some(_) if is_retired(card, store) => false,
Some(state) => scheduler.is_due(state, now_ms),
None => true,
}
}
pub fn has_reviewable(
cards: &[Card],
store: &Store,
scheduler: &dyn Scheduler,
now_ms: u64,
) -> bool {
cards
.iter()
.any(|card| is_reviewable(card, store, scheduler, now_ms))
}
pub fn count_reviewable(
cards: &[&Card],
store: &Store,
scheduler: &dyn Scheduler,
now_ms: u64,
) -> usize {
cards
.iter()
.filter(|card| is_reviewable(card, store, scheduler, now_ms))
.count()
}
pub fn histogram(cards: &[Card], store: &Store) -> StageHistogram {
let mut h = [0usize; 6];
for card in cards {
match store.get(card.id()) {
Some(state) => h[state.stage.clamp(1, 5) as usize] += 1,
None => h[0] += 1,
}
}
h
}
pub fn now_ms() -> u64 {
time::now_ms()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::store::Store;
fn card(subject: &str, n: usize) -> Card {
Card::plain(
Arc::from(subject),
format!("front {n}"),
vec![format!("back {n}")],
None,
n,
)
}
fn cards(n: usize) -> Vec<Card> {
(0..n).map(|i| card("deck.txt", i)).collect()
}
fn empty_store() -> (Store, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path().join("p.json")).unwrap();
(store, dir)
}
#[test]
fn new_cards_enter_up_to_max_new() {
let (store, _dir) = empty_store();
let session = Session::new(
cards(20),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 5,
..Default::default()
},
1000,
);
assert_eq!(5, session.initial_size);
}
#[test]
fn acquire_current_introduces_a_stage_one_card_without_a_review() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let id = all[0].id();
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert!(session.current_unseen(&store));
session.acquire_current(&mut store, 1000);
let state = store.get(id).expect("acquired card is recorded");
assert_eq!(1, state.stage);
assert!(state.history.is_empty()); assert_eq!(0, state.total_reviews);
assert_eq!(1, session.stats.acquired);
assert_eq!(0, session.stats.reviews);
assert!(session.is_finished()); }
#[test]
fn acquired_cards_are_not_due_until_the_relearn_cooldown() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(1),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
session.acquire_current(&mut store, 1000);
assert!(!session.has_due_now(&store, 1000));
assert!(!session.has_due_now(&store, 1000 + 5 * 60 * 1000 - 1));
assert!(session.has_due_now(&store, 1000 + 5 * 60 * 1000));
}
#[test]
fn failed_card_reappears_in_the_same_session_despite_the_cooldown() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let id = all[0].id();
store.get_or_insert(id, 0); let now = 5 * 60 * 1000; let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
now,
);
assert_eq!(1, session.remaining());
session.grade(&mut store, Grade::Fail, now);
assert_eq!(1, session.remaining());
assert!(!session.is_finished());
assert_eq!(id, session.current().unwrap().id());
}
#[test]
fn due_cards_take_priority_over_new_under_limit() {
let (mut store, _dir) = empty_store();
let all = cards(10);
for c in &all[7..] {
store.get_or_insert(c.id(), 0);
}
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 10,
limit: Some(3),
cram: false,
order: Order::Scheduled,
topology: None,
},
5 * 60 * 1000,
);
assert_eq!(3, session.initial_size);
assert_eq!("front 7", session.current().unwrap().front);
}
#[test]
fn leitner_orders_higher_stages_first() {
let (mut store, _dir) = empty_store();
let all = cards(3);
store.get_or_insert(all[0].id(), 0).stage = 2;
store.get_or_insert(all[1].id(), 0).stage = 5;
let now = 2 * 604_800_000; let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
now,
);
assert_eq!("front 1", session.current().unwrap().front); session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 0", session.current().unwrap().front); session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 2", session.current().unwrap().front); }
#[test]
fn sequential_order_follows_deck_order() {
let (mut store, _dir) = empty_store();
let all = cards(3);
store.get_or_insert(all[0].id(), 0).stage = 2;
store.get_or_insert(all[1].id(), 0).stage = 5;
let now = 2 * 604_800_000;
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
order: Order::Sequential,
..Default::default()
},
now,
);
assert_eq!("front 0", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 1", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, now);
assert_eq!("front 2", session.current().unwrap().front);
}
#[test]
fn cards_on_cooldown_are_not_due() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let now = 5_000_000;
store.get_or_insert(all[0].id(), now).stage = 2;
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 0,
..Default::default()
},
now + 1,
);
assert!(session.is_finished());
let session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 0,
limit: None,
cram: true,
order: Order::Scheduled,
topology: None,
},
now + 1,
);
assert_eq!(1, session.initial_size);
}
#[test]
fn failed_card_is_requeued_until_passed() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(2),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert_eq!(2, session.remaining());
let first = session.current().unwrap().front.clone();
session.grade(&mut store, Grade::Fail, 1000);
assert_eq!(2, session.remaining());
session.grade(&mut store, Grade::Pass, 1001);
assert_eq!(1, session.remaining());
assert_eq!(first, session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, 1002);
assert!(session.is_finished());
assert_eq!(3, session.stats.reviews);
assert_eq!(2, session.stats.passed);
assert_eq!(1, session.stats.failed);
}
#[test]
fn grading_updates_store_stages() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let id = all[0].id();
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
session.grade(&mut store, Grade::Pass, 1000);
assert_eq!(2, store.get(id).unwrap().stage);
}
#[test]
fn skip_rotates_queue() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(2),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
let first = session.current().unwrap().front.clone();
session.skip();
assert_ne!(first, session.current().unwrap().front);
assert_eq!(2, session.remaining());
session.skip();
assert_eq!(first, session.current().unwrap().front);
assert!(store.is_empty());
let _ = &mut store;
}
#[test]
fn remove_current_drops_card_without_grading() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(2),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
let removed = session.remove_current();
assert_eq!(1, removed.len());
assert_eq!(1, session.remaining());
assert_ne!(removed[0].front, session.current().unwrap().front);
assert!(store.is_empty());
let _ = &mut store;
}
#[test]
fn remove_current_also_drops_cloze_siblings() {
let (store, _dir) = empty_store();
let mut all = vec![
card("deck.txt", 1),
card("deck.txt", 1),
card("deck.txt", 2),
];
all[0].back = vec!["hole a".into()];
all[1].back = vec!["hole b".into()];
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
0,
);
assert_eq!(3, session.remaining());
let removed = session.remove_current();
assert_eq!(2, removed.len());
assert_eq!(1, session.remaining());
assert_eq!(2, session.current().unwrap().line);
}
#[test]
fn cloze_siblings_are_separated() {
let (store, _dir) = empty_store();
let mut all = Vec::new();
for (line, name) in [(1, "A"), (2, "B")] {
for hole in 1..=2 {
let mut c = card("deck.txt", line);
c.front = format!("{name}{hole}");
c.back = vec![format!("{name} answer {hole}")];
all.push(c);
}
}
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
0,
);
let mut fronts = Vec::new();
for _ in 0..session.remaining() {
fronts.push(session.current().unwrap().front.clone());
session.skip();
}
assert_eq!(4, fronts.len());
for pair in fronts.windows(2) {
assert_ne!(
pair[0].chars().next(),
pair[1].chars().next(),
"siblings adjacent in queue: {fronts:?}"
);
}
}
#[test]
fn lone_sibling_group_still_fully_queued() {
let (store, _dir) = empty_store();
let mut all = Vec::new();
for hole in 1..=3 {
let mut c = card("deck.txt", 1);
c.back = vec![format!("answer {hole}")];
all.push(c);
}
let session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
0,
);
assert_eq!(3, session.initial_size);
}
#[test]
fn restart_picks_up_newly_due_and_new_cards() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(4),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 2,
..Default::default()
},
1000,
);
assert_eq!(2, session.initial_size);
session.grade(&mut store, Grade::Pass, 1000);
session.grade(&mut store, Grade::Pass, 1001);
assert!(session.is_finished());
assert_eq!(2, session.stats.reviews);
assert!(session.restart(&store, 1002));
assert_eq!(2, session.initial_size);
assert_eq!(0, session.stats.reviews);
assert!(!session.is_finished());
}
#[test]
fn restart_with_nothing_due_returns_false_and_keeps_stats() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(1),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
session.grade(&mut store, Grade::Pass, 1000);
assert!(session.is_finished());
assert!(!session.restart(&store, 1001));
assert!(session.is_finished());
assert_eq!(1, session.stats.reviews);
}
#[test]
fn has_due_now_tracks_what_restart_would_find() {
let (mut store, _dir) = empty_store();
let mut session = Session::new(
cards(1),
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert!(session.has_due_now(&store, 1000));
session.grade(&mut store, Grade::Pass, 1000);
assert!(!session.has_due_now(&store, 1001));
assert!(!session.restart(&store, 1001));
assert!(session.has_due_now(&store, 1000 + 3_600_000));
}
#[test]
fn next_due_at_reports_earliest_due_time() {
let (mut store, _dir) = empty_store();
let all = cards(2);
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions::default(),
1000,
);
assert_eq!(None, session.next_due_at(&store)); session.grade(&mut store, Grade::Pass, 1000);
assert_eq!(Some(1000 + 3_600_000), session.next_due_at(&store));
}
#[test]
fn histogram_counts_stages() {
let (mut store, _dir) = empty_store();
let all = cards(4);
store.get_or_insert(all[0].id(), 0).stage = 1;
store.get_or_insert(all[1].id(), 0).stage = 5;
let h = histogram(&all, &store);
assert_eq!([2, 1, 0, 0, 0, 1], h);
}
#[test]
fn is_retired_needs_top_stage_reached_by_passing() {
let (mut store, _dir) = empty_store();
let c = card("deck.txt", 0);
assert!(!is_retired(&c, &store)); let s = store.get_or_insert(c.id(), 0);
s.stage = MAX_STAGE;
s.streak = 1;
assert!(is_retired(&c, &store)); let s = store.get_or_insert(c.id(), 0);
s.stage = MAX_STAGE - 1;
assert!(!is_retired(&c, &store)); }
#[test]
fn has_reviewable_counts_new_and_due_not_cooldown_or_retired() {
let (mut store, _dir) = empty_store();
let sched = SchedulerKind::Leitner.scheduler();
let now = 10_000_000;
assert!(has_reviewable(&cards(1), &store, sched.as_ref(), now));
let c = card("deck.txt", 0);
let s = store.get_or_insert(c.id(), now);
s.stage = 2;
s.streak = 1;
s.stage_entered_ms = now;
let one = std::slice::from_ref(&c);
assert!(!has_reviewable(one, &store, sched.as_ref(), now));
assert!(has_reviewable(one, &store, sched.as_ref(), now + 3_600_000));
store.get_or_insert(c.id(), now).stage = MAX_STAGE;
assert!(!has_reviewable(
std::slice::from_ref(&c),
&store,
sched.as_ref(),
now + 3_600_000
));
}
#[test]
fn retired_card_excluded_even_under_cram() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let s = store.get_or_insert(all[0].id(), 0);
s.stage = MAX_STAGE;
s.streak = 1;
let session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 10,
limit: None,
cram: true,
order: Order::Scheduled,
topology: None,
},
1000,
);
assert!(session.is_finished());
}
fn topology_order(walk: &[&Card]) -> TopologyOrder {
let ids: Vec<u64> = walk.iter().map(|c| c.id()).collect();
TopologyOrder::from_walk(&ids)
}
#[test]
fn topology_reorders_the_due_set() {
let (mut store, _dir) = empty_store();
let all = cards(3);
for c in &all {
store.get_or_insert(c.id(), 0);
}
let topo = topology_order(&[&all[2], &all[1], &all[0]]);
let mut session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
topology: Some(topo),
..Default::default()
},
1_000_000,
);
assert_eq!("front 2", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, 1_000_000);
assert_eq!("front 1", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, 1_000_000);
assert_eq!("front 0", session.current().unwrap().front);
}
#[test]
fn topology_only_reorders_does_not_readmit_a_card_that_is_not_due() {
let (mut store, _dir) = empty_store();
let all = cards(2);
let now = 5_000_000;
store.get_or_insert(all[0].id(), 0);
store.get_or_insert(all[1].id(), now).stage = 2;
let topo = topology_order(&[&all[1], &all[0]]);
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 0,
topology: Some(topo),
..Default::default()
},
now + 1,
);
assert_eq!(1, session.initial_size);
assert_eq!("front 0", session.current().unwrap().front);
}
#[test]
fn cards_not_in_walk_append_in_scheduler_order() {
let (mut store, _dir) = empty_store();
let all = cards(3);
for c in &all {
store.get_or_insert(c.id(), 0);
}
let topo = topology_order(&[&all[1]]);
let mut session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
topology: Some(topo),
..Default::default()
},
1_000_000,
);
assert_eq!("front 1", session.current().unwrap().front); session.grade(&mut store, Grade::Pass, 1_000_000);
assert_eq!("front 0", session.current().unwrap().front); session.grade(&mut store, Grade::Pass, 1_000_000);
assert_eq!("front 2", session.current().unwrap().front);
}
#[test]
fn retired_card_excluded_even_with_a_topology() {
let (mut store, _dir) = empty_store();
let all = cards(1);
let s = store.get_or_insert(all[0].id(), 0);
s.stage = MAX_STAGE;
s.streak = 1; let topo = topology_order(&[&all[0]]);
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
topology: Some(topo),
..Default::default()
},
1000,
);
assert!(session.is_finished());
}
#[test]
fn topology_keeps_cloze_siblings_in_walk_order_skipping_separation() {
let (mut store, _dir) = empty_store();
let sib_a = Card::plain(
Arc::from("d.txt"),
"front a".into(),
vec!["a".into()],
None,
7,
);
let sib_b = Card::plain(
Arc::from("d.txt"),
"front b".into(),
vec!["b".into()],
None,
7,
);
let other = card("d.txt", 3);
let all = vec![sib_a.clone(), sib_b.clone(), other.clone()];
for c in &all {
store.get_or_insert(c.id(), 0);
}
let topo = topology_order(&[&sib_a, &sib_b, &other]);
let mut session = Session::new(
all,
&store,
SchedulerKind::Leitner,
SessionOptions {
topology: Some(topo),
..Default::default()
},
1_000_000,
);
assert_eq!("front a", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, 1_000_000);
assert_eq!("front b", session.current().unwrap().front);
session.grade(&mut store, Grade::Pass, 1_000_000);
assert_eq!("front 3", session.current().unwrap().front);
}
#[test]
fn topology_keeps_due_ahead_of_new_under_a_limit() {
let (mut store, _dir) = empty_store();
let all = cards(4);
store.get_or_insert(all[0].id(), 0);
store.get_or_insert(all[1].id(), 0);
let topo = topology_order(&[&all[3], &all[1], &all[0], &all[2]]);
let session = Session::new(
all.clone(),
&store,
SchedulerKind::Leitner,
SessionOptions {
max_new: 10,
limit: Some(2),
topology: Some(topo),
..Default::default()
},
1_000_000,
);
assert_eq!(2, session.initial_size);
assert_eq!("front 1", session.current().unwrap().front);
}
#[test]
fn card_strengths_normalizes_each_stage() {
let (mut store, _dir) = empty_store();
let all = cards(3);
store.get_or_insert(all[0].id(), 0).stage = MAX_STAGE; store.get_or_insert(all[1].id(), 0).stage = 1; let ids: Vec<u64> = all.iter().map(Card::id).collect();
let s = card_strengths(&ids, &store);
assert_eq!(3, s.len());
assert!((s[0] - 1.0).abs() < 1e-6);
assert!((s[1] - 0.2).abs() < 1e-6);
assert_eq!(0.0, s[2]);
assert!(card_strengths(&[], &store).is_empty());
}
}