mod shuffle;
use super::card::{Card, Score};
use super::deck::{Deck, IntervalCoefficients};
use anyhow::Result;
use custom_error::custom_error;
use std::collections::VecDeque;
custom_error! {
#[derive(PartialEq)]
pub HandError
EmptyDeck { name: String } = "Deck '{name}' contains no cards",
NoDueCards { name: String } = "No due cards in Deck '{name}'",
}
#[derive(Debug)]
pub enum Outcome {
Scored(Score),
Undo,
Replace(Card),
Skip,
Bury,
Quit,
}
#[derive(Debug)]
pub struct Hand<'h> {
queue: VecDeque<Card>,
interval_coefficients: &'h IntervalCoefficients,
}
impl<'h> Hand<'h> {
pub fn from(deck: &'h Deck, cards: Vec<&'h Card>) -> Result<Hand<'h>, HandError> {
let deck_cards = Hand::filter_cards_in_deck(deck, cards);
let n_cards_in_deck = deck_cards.len();
let due_cards = Hand::filter_due_cards(deck_cards);
let n_due_cards = due_cards.len();
let hand_cards = shuffle::shuffle_cards(due_cards);
let name = deck.name.to_owned();
match (n_cards_in_deck, n_due_cards) {
(0, _) => Err(HandError::EmptyDeck { name })?,
(_, 0) => Err(HandError::NoDueCards { name })?,
_ => Ok(Self {
queue: hand_cards.into_iter().cloned().collect(),
interval_coefficients: &deck.interval_coefficients,
}),
}
}
pub fn from_due(deck: &'h Deck, cards: Vec<&'h Card>) -> Result<Hand<'h>, HandError> {
let due_cards = Hand::filter_due_cards(cards);
let n_due_cards = due_cards.len();
let hand_cards = shuffle::shuffle_cards(due_cards);
match n_due_cards {
0 => Err(HandError::NoDueCards {
name: deck.name.to_owned(),
}),
_ => Ok(Self {
queue: hand_cards.into_iter().cloned().collect(),
interval_coefficients: &deck.interval_coefficients,
}),
}
}
pub fn revise_until_none_fail<F>(mut self, mut read: F) -> Result<Vec<Card>>
where
F: FnMut(&Card, usize) -> Result<Outcome>,
{
use Score::*;
let mut output: Vec<Card> = Vec::new();
let mut undo_snapshot: Option<(VecDeque<Card>, Vec<Card>)> = None;
while !self.queue.is_empty() {
let n_remaining = self.queue.len();
let card = self.queue.front().unwrap().clone();
let today = chrono::Local::now().date_naive();
let transform = |card: Card, score| {
card.transform(score, self.interval_coefficients)
.with_review_recorded(today, score)
};
match read(&card, n_remaining)? {
Outcome::Scored(Fail) => {
undo_snapshot = Some((self.queue.clone(), output.clone()));
self.queue.pop_front();
self.queue.push_back(transform(card, Fail));
}
Outcome::Scored(other) => {
undo_snapshot = Some((self.queue.clone(), output.clone()));
self.queue.pop_front();
output.push(transform(card, other));
}
Outcome::Undo => {
if let Some((q, o)) = undo_snapshot.take() {
self.queue = q;
output = o;
}
}
Outcome::Replace(new_card) => {
self.queue[0] = new_card;
}
Outcome::Skip => {
let card = self.queue.pop_front().unwrap();
self.queue.push_back(card);
}
Outcome::Bury => {
undo_snapshot = Some((self.queue.clone(), output.clone()));
self.queue.pop_front();
let mut buried = card;
buried.revision_settings.due =
chrono::Utc::now() + chrono::Duration::days(1);
output.push(buried);
}
Outcome::Quit => {
output.extend(self.queue.iter().cloned());
return Ok(output);
}
}
}
Ok(output)
}
pub fn number_of_due_cards(&self) -> usize {
self.queue.len()
}
pub fn apply_limit(&mut self, limit: Option<usize>) {
let Some(n) = limit else { return };
if self.queue.len() <= n {
return;
}
let mut cards: Vec<Card> = self.queue.drain(..).collect();
cards.sort_by(|a, b| a.revision_settings.due.cmp(&b.revision_settings.due));
cards.truncate(n);
self.queue = cards.into();
}
fn filter_cards_in_deck(deck: &'h Deck, cards: Vec<&'h Card>) -> Vec<&'h Card> {
cards
.into_iter()
.filter(|c| c.in_deck(&deck.name))
.collect()
}
fn filter_due_cards(cards: Vec<&'h Card>) -> Vec<&'h Card> {
cards.into_iter().filter(|c| c.is_due()).collect()
}
}
#[cfg(test)]
pub mod assertions {
use super::*;
use crate::state::card::assertions::assert_cards_near;
use crate::state::tools::test_tools::{assertions::assert_length_matches, Expect};
pub fn assert_hands_near(a: &[Card], b: &[Card]) {
assert!(a.len() == b.len());
for (x, y) in a.iter().zip(b.iter()) {
assert_cards_near(x, y);
}
}
pub fn assert_hand_contains(
hand: &Hand,
expected_coefficients: &IntervalCoefficients,
expected_queued_items: &[Expect<Card>],
) {
assert_eq!(hand.interval_coefficients, expected_coefficients);
assert_length_matches(&hand.queue, expected_queued_items);
for comparator in expected_queued_items.iter() {
match comparator {
Expect::DoesContain(item) => assert!(hand.queue.contains(item)),
Expect::DoesNotContain(item) => assert!(!hand.queue.contains(item)),
_ => panic!("BAD TEST"),
}
}
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use crate::state::card::revision_settings::test_tools::make_expected_revision_settings;
use crate::state::{card::RevisionSettings, deck::IntervalCoefficients};
use chrono::{Duration, Utc};
use rstest::*;
const FAKE_DECK_NAME: &str = "cephelapoda";
fn make_card(path: &str, deck: &str) -> Card {
Card::new(
path.to_string(),
vec![deck.to_string()],
format!("{:?}?", path),
format!("yes, {:?}", path),
RevisionSettings::default(),
)
}
fn make_future_card(path: &str, deck: &str) -> Card {
Card::new(
path.to_string(),
vec![deck.to_string()],
format!("{:?}?", path),
format!("yes, {:?}", path),
RevisionSettings::new(Utc::now() + chrono::Duration::days(10), 0.0, 1300.0),
)
}
fn make_card_with_revision_settings(
path: &str,
deck: &str,
revision_settings: &RevisionSettings,
) -> Card {
let mut card = make_card(path, deck);
card.revision_settings = revision_settings.to_owned();
card
}
fn make_deck(name: &str, card_paths: &[&str]) -> Deck {
Deck::new(name, card_paths.to_owned(), IntervalCoefficients::default())
}
fn make_cards(deck_id: &str, card_paths: &[&str]) -> Vec<Card> {
card_paths.iter().map(|p| make_card(p, deck_id)).collect()
}
fn concat_cards(a: Vec<Card>, b: Vec<Card>) -> Vec<Card> {
[a, b].concat()
}
fn fake_future_card(path: &str) -> Card {
let mut card = make_card(path, FAKE_DECK_NAME);
card.revision_settings.due = Utc::now() + Duration::days(4);
card
}
fn fake_cards(paths: Vec<&str>) -> Vec<Card> {
make_cards(FAKE_DECK_NAME, &paths)
}
#[test]
fn apply_limit_keeps_only_the_n_most_overdue_cards() {
let deck = make_deck(FAKE_DECK_NAME, &["a", "b", "c", "d"]);
let now = Utc::now();
let mut cards: Vec<Card> = (1..=4)
.map(|i| {
let mut card = make_card(&format!("card_{i}"), FAKE_DECK_NAME);
card.revision_settings.due = now - Duration::days(i);
card
})
.collect();
let card_refs: Vec<&Card> = cards.iter_mut().map(|c| &*c).collect();
let mut hand = Hand::from(&deck, card_refs).unwrap();
hand.apply_limit(Some(2));
assert_eq!(2, hand.queue.len());
let kept_paths: Vec<&str> = hand.queue.iter().map(|c| c.path.as_str()).collect();
assert!(kept_paths.contains(&"card_3"));
assert!(kept_paths.contains(&"card_4"));
}
#[test]
fn apply_limit_no_op_when_limit_is_none_or_exceeds_queue() {
let deck = make_deck(FAKE_DECK_NAME, &["a", "b"]);
let cards = make_cards(FAKE_DECK_NAME, &["a", "b"]);
let card_refs: Vec<&Card> = cards.iter().collect();
let mut hand = Hand::from(&deck, card_refs).unwrap();
let original = hand.queue.len();
hand.apply_limit(None);
assert_eq!(original, hand.queue.len());
hand.apply_limit(Some(99));
assert_eq!(original, hand.queue.len());
}
#[test]
fn from_due_includes_due_cards_from_any_deck() {
let aux_deck = "auxiliary";
let cross_deck_card = make_card("crossdeck.md", aux_deck);
let same_deck_card = make_card("samedeck.md", FAKE_DECK_NAME);
let future_card = fake_future_card("future.md");
let cards = vec![&cross_deck_card, &same_deck_card, &future_card];
let placeholder_deck = make_deck("placeholder", &[]);
let hand = Hand::from_due(&placeholder_deck, cards).expect("expected a hand");
let queue: Vec<String> = hand.queue.iter().map(|c| c.path.clone()).collect();
assert_eq!(2, queue.len(), "future card should be filtered out");
assert!(
queue.contains(&"crossdeck.md".to_string()),
"card from auxiliary deck should still be queued"
);
assert!(queue.contains(&"samedeck.md".to_string()));
assert!(!queue.contains(&"future.md".to_string()));
}
#[rstest]
#[case::creates_shuffled_card_queue_from_deck_and_cards(
fake_cards(vec!["octopus", "squid", "cuttlefish", "nautilus"]),
Ok(vec!["squid", "cuttlefish", "nautilus", "octopus"])
)]
#[case::creates_shuffled_card_queue_containing_due_cards_only(
concat_cards(fake_cards(vec!["squid", "cuttlefish", "nautilus"]), vec![fake_future_card("octopus")]),
Ok(vec!["cuttlefish", "nautilus", "squid"])
)]
#[case::creates_shuffled_card_queue_containing_cards_in_deck_only(
concat_cards(fake_cards(vec!["octopus", "squid", "cuttlefish", "nautilus"]), vec![make_card("clam", "bivalvia")]),
Ok(vec!["squid", "cuttlefish", "nautilus", "octopus"])
)]
#[case::returns_empty_deck_error_if_no_cards_exist_for_deck(vec![make_card("clam", "bivalvia")], Err(HandError::EmptyDeck{name: FAKE_DECK_NAME.to_owned()}))]
#[case::returns_no_due_cards_error_if_no_cards_due_in_deck(vec![make_future_card("squid", FAKE_DECK_NAME)], Err(HandError::NoDueCards{name: FAKE_DECK_NAME.to_owned()}))]
fn from(#[case] cards: Vec<Card>, #[case] expected: Result<Vec<&str>, HandError>) {
let card_paths: Vec<&str> = cards.iter().map(|c| c.path.as_str()).collect();
let deck = make_deck(FAKE_DECK_NAME, &card_paths);
let hand = Hand::from(&deck, cards.iter().collect());
match hand {
Ok(hand) => {
let expected = make_cards(FAKE_DECK_NAME, &expected.expect("BAD TEST. Expected"));
let actual: Vec<Card> = hand.queue.into_iter().collect();
assertions::assert_hands_near(&expected, &actual);
}
Err(err) => {
assert_eq!(expected.unwrap_err(), err);
}
}
}
#[test]
fn revise_until_none_fail_with_empty_queue() {
let interval_coefficients = IntervalCoefficients::default();
let hand = Hand {
queue: VecDeque::new(),
interval_coefficients: &interval_coefficients,
};
let actual = hand.revise_until_none_fail(|_, _| Ok(Outcome::Scored(Score::Easy)));
assert!(actual.expect("Expected empty vec").is_empty());
}
#[test]
fn revise_until_none_fail_transforms_cards_based_on_their_score() {
let deck_id = "some_deck";
let in_date = Utc::now() - Duration::days(4);
let input_revision_settings = RevisionSettings::new(in_date, 1.0, 2000.0);
let input_card_paths = vec!["hard", "pass", "easy"];
let cards: Vec<Card> = input_card_paths
.iter()
.map(|path| make_card_with_revision_settings(path, deck_id, &input_revision_settings))
.collect();
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, input_card_paths.to_owned(), interval_coefficients);
let hand = Hand::from(&deck, cards.iter().collect()).unwrap();
let expected_specs = vec![
("pass", 6.0, 2000.0),
("easy", 20.0, 2150.0),
("hard", 2.4, 1850.0),
];
let expected: Vec<Card> = expected_specs
.into_iter()
.map(|(p, i, f)| {
let revision_settings = make_expected_revision_settings(&in_date, i, f);
make_card_with_revision_settings(p, deck_id, &revision_settings)
})
.collect();
let mut expected_remaining = cards.len();
let actual = hand
.revise_until_none_fail(|card, n_remaining| {
assert_eq!(expected_remaining, n_remaining);
expected_remaining -= 1;
match &card.path[..] {
"hard" => Ok(Outcome::Scored(Score::Hard)),
"pass" => Ok(Outcome::Scored(Score::Pass)),
"easy" => Ok(Outcome::Scored(Score::Easy)),
_ => panic!("IMPOSSIBLE"),
}
})
.expect("Expected vec of cards");
assertions::assert_hands_near(&expected, &actual);
}
#[test]
fn number_of_due_cards() {
let deck_id = "some_deck";
let due_card_date = Utc::now() - Duration::days(4);
let due_card_rs = RevisionSettings::new(due_card_date, 1.0, 2000.0);
let not_due_card_date = Utc::now() + Duration::days(4);
let not_due_card_rs = RevisionSettings::new(not_due_card_date, 1.0, 2000.0);
let (path_1, path_2) = ("path_1", "path_2");
let due_card = make_card_with_revision_settings(path_1, deck_id, &due_card_rs);
let not_due_card = make_card_with_revision_settings(path_2, deck_id, ¬_due_card_rs);
let cards = vec![&due_card, ¬_due_card];
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, vec![path_1, path_2], interval_coefficients);
let hand = Hand::from(&deck, cards).unwrap();
assert_eq!(1, hand.number_of_due_cards());
}
#[test]
fn revise_until_none_fail_cycles_for_failed_cards() {
let deck_id = "some_deck";
let in_date = Utc::now() - Duration::days(4);
let in_rs = RevisionSettings::new(in_date, 1.0, 2000.0);
let path = "fail";
let card = make_card_with_revision_settings(path, deck_id, &in_rs);
let cards = vec![&card];
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, vec![path], interval_coefficients);
let hand = Hand::from(&deck, cards).unwrap();
let out_rs = make_expected_revision_settings(&in_date, 2.6, 1300.0);
let expected = vec![make_card_with_revision_settings(path, deck_id, &out_rs)];
let mut total_number_of_cycles = 0;
let actual = hand
.revise_until_none_fail(|card, _| match &card.path[..] {
"fail" => {
let number_of_cycles_so_far = total_number_of_cycles;
if number_of_cycles_so_far < 5 {
total_number_of_cycles += 1;
Ok(Outcome::Scored(Score::Fail))
} else {
Ok(Outcome::Scored(Score::Pass))
}
}
_ => panic!("IMPOSSIBLE"),
})
.expect("Expected vec of cards");
assert_eq!(total_number_of_cycles, 5);
assertions::assert_hands_near(&expected, &actual);
}
#[test]
fn revise_until_none_fail_undoes_the_last_score() {
let deck_id = "some_deck";
let in_date = Utc::now() - Duration::days(4);
let in_rs = RevisionSettings::new(in_date, 1.0, 2000.0);
let card_paths = vec!["card_1", "card_2", "card_3"];
let cards: Vec<Card> = card_paths
.iter()
.map(|path| make_card_with_revision_settings(path, deck_id, &in_rs))
.collect();
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, card_paths.to_owned(), interval_coefficients);
let hand = Hand::from(&deck, cards.iter().collect()).unwrap();
let mut step = 0;
let mut undone_card_path: Option<String> = None;
let actual = hand
.revise_until_none_fail(|card, _| {
step += 1;
match step {
1 => {
undone_card_path = Some(card.path.clone());
Ok(Outcome::Scored(Score::Pass))
}
2 => Ok(Outcome::Undo),
_ => Ok(Outcome::Scored(Score::Easy)),
}
})
.expect("expected a vec of cards");
assert_eq!(3, actual.len(), "all three cards should land in output");
for card in &actual {
assert_eq!(
20.0, card.revision_settings.interval,
"every card should reflect the Easy score that came after the undo"
);
}
let undone_path = undone_card_path.expect("first card was inspected");
assert!(
actual.iter().any(|c| c.path == undone_path),
"the undone card should still appear in the final output"
);
}
#[test]
fn revise_until_none_fail_replaces_current_card_without_scoring() {
let deck_id = "some_deck";
let in_date = Utc::now() - Duration::days(4);
let in_rs = RevisionSettings::new(in_date, 1.0, 2000.0);
let card_paths = vec!["card_1", "card_2"];
let cards: Vec<Card> = card_paths
.iter()
.map(|path| make_card_with_revision_settings(path, deck_id, &in_rs))
.collect();
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, card_paths.to_owned(), interval_coefficients);
let hand = Hand::from(&deck, cards.iter().collect()).unwrap();
let mut step = 0;
let mut first_card_path: Option<String> = None;
let actual = hand
.revise_until_none_fail(|card, _| {
step += 1;
match step {
1 => {
first_card_path = Some(card.path.clone());
let mut edited = card.clone();
edited.question = "edited".to_string();
Ok(Outcome::Replace(edited))
}
2 => {
assert_eq!(
"edited", card.question,
"second prompt should see the replaced card"
);
Ok(Outcome::Scored(Score::Pass))
}
_ => Ok(Outcome::Scored(Score::Pass)),
}
})
.expect("expected a vec of cards");
assert_eq!(2, actual.len());
let first_path = first_card_path.expect("first card was inspected");
let edited_card = actual
.iter()
.find(|c| c.path == first_path)
.expect("edited card should appear in output");
assert_eq!("edited", edited_card.question);
}
#[test]
fn revise_until_none_fail_skip_rotates_card_to_back_untransformed() {
let deck_id = "some_deck";
let in_date = Utc::now() - Duration::days(4);
let in_rs = RevisionSettings::new(in_date, 1.0, 2000.0);
let card_paths = vec!["card_1", "card_2"];
let cards: Vec<Card> = card_paths
.iter()
.map(|path| make_card_with_revision_settings(path, deck_id, &in_rs))
.collect();
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, card_paths.to_owned(), interval_coefficients);
let hand = Hand::from(&deck, cards.iter().collect()).unwrap();
let mut step = 0;
let mut prompts: Vec<String> = Vec::new();
let actual = hand
.revise_until_none_fail(|card, _| {
prompts.push(card.path.clone());
step += 1;
if step == 1 {
Ok(Outcome::Skip)
} else {
Ok(Outcome::Scored(Score::Pass))
}
})
.expect("expected a vec of cards");
assert_eq!(2, actual.len());
assert_eq!(3, prompts.len(), "skipped card should be re-prompted");
assert_eq!(prompts[0], prompts[2], "skipped card returns at the back");
for card in &actual {
assert_eq!(
6.0, card.revision_settings.interval,
"all cards should reflect the Pass score (interval 6.0), not the skip"
);
}
}
#[test]
fn revise_until_none_fail_bury_defers_card_to_tomorrow_without_score() {
let deck_id = "some_deck";
let original_due = Utc::now() - Duration::days(2);
let original_factor = 1700.0;
let original_interval = 4.0;
let in_rs = RevisionSettings::new(original_due, original_interval, original_factor);
let card_paths = vec!["only"];
let cards = [make_card_with_revision_settings(
card_paths[0],
deck_id,
&in_rs,
)];
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, card_paths.to_owned(), interval_coefficients);
let hand = Hand::from(&deck, cards.iter().collect()).unwrap();
let actual = hand
.revise_until_none_fail(|_, _| Ok(Outcome::Bury))
.expect("expected a vec of cards");
assert_eq!(1, actual.len());
let buried = &actual[0];
assert_eq!(original_factor, buried.revision_settings.memorisation_factor);
assert_eq!(original_interval, buried.revision_settings.interval);
let now = Utc::now();
assert!(buried.revision_settings.due > now);
let secs_until_due = buried
.revision_settings
.due
.signed_duration_since(now)
.num_seconds();
assert!(
(86_390..=86_410).contains(&secs_until_due),
"expected ~24h until due, got {secs_until_due}s"
);
}
#[test]
fn revise_until_none_fail_handles_quit_outcome() {
let deck_id = "some_deck";
let in_date = Utc::now() - Duration::days(4);
let in_rs = RevisionSettings::new(in_date, 1.0, 2000.0);
let card_paths = vec!["card_1", "card_2", "card_3"];
let cards: Vec<Card> = card_paths
.iter()
.map(|path| make_card_with_revision_settings(path, deck_id, &in_rs))
.collect();
let interval_coefficients = IntervalCoefficients::new(1.0, 2.0, 0.0);
let deck = Deck::new(deck_id, card_paths.to_owned(), interval_coefficients);
let hand = Hand::from(&deck, cards.iter().collect()).unwrap();
let mut call_count = 0;
let actual = hand
.revise_until_none_fail(|_, _| {
call_count += 1;
if call_count == 2 {
Ok(Outcome::Quit)
} else {
Ok(Outcome::Scored(Score::Pass))
}
})
.expect("Expected vec of cards");
assert_eq!(actual.len(), 3);
assert_eq!(call_count, 2);
}
}