fix test scheduler undo + implement look-ahead

Instead of using a separate undo queue, the code now defers checking for
newly-due learning cards until the answering stage, and logs the updated
cutoff time as an undoable change, so that any newly-due learning cards
won't appear instead of a new/review card that was just undone.

Queue redo now uses a similar approach to undo, instead of rebuilding the
queues.
This commit is contained in:
Damien Elmes 2021-05-14 22:16:53 +10:00
parent 9990a10161
commit 390a8421aa
13 changed files with 278 additions and 241 deletions

View file

@ -134,8 +134,6 @@ class CollectionOp(Generic[ResultWithChanges]):
changes = result.changes changes = result.changes
# fire new hook # fire new hook
print("op changes:")
print(changes)
aqt.gui_hooks.operation_did_execute(changes, handler) aqt.gui_hooks.operation_did_execute(changes, handler)
# fire legacy hook so old code notices changes # fire legacy hook so old code notices changes
if mw.col.op_made_changes(changes): if mw.col.op_made_changes(changes):

View file

@ -111,7 +111,7 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec
fn update_state_after_modification(col: &mut Collection, sql: &str) { fn update_state_after_modification(col: &mut Collection, sql: &str) {
if !is_dql(sql) { if !is_dql(sql) {
println!("clearing undo+study due to {}", sql); // println!("clearing undo+study due to {}", sql);
col.update_state_after_dbproxy_modification(); col.update_state_after_dbproxy_modification();
} }
} }

View file

@ -177,7 +177,9 @@ impl SchedulingService for Backend {
} }
fn get_queued_cards(&self, input: pb::GetQueuedCardsIn) -> Result<pb::GetQueuedCardsOut> { fn get_queued_cards(&self, input: pb::GetQueuedCardsIn) -> Result<pb::GetQueuedCardsOut> {
self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only)) self.with_col(|col| {
col.get_queued_cards(input.fetch_limit as usize, input.intraday_learning_only)
})
} }
} }

View file

@ -41,3 +41,11 @@ pub mod timestamp;
pub mod types; pub mod types;
pub mod undo; pub mod undo;
pub mod version; pub mod version;
use std::env;
use lazy_static::lazy_static;
lazy_static! {
pub(crate) static ref PYTHON_UNIT_TESTS: bool = env::var("ANKI_TEST_MODE").is_ok();
}

View file

@ -414,7 +414,7 @@ impl Collection {
/// Return a consistent seed for a given card at a given number of reps. /// Return a consistent seed for a given card at a given number of reps.
/// If in test environment, disable fuzzing. /// If in test environment, disable fuzzing.
fn get_fuzz_seed(card: &Card) -> Option<u64> { fn get_fuzz_seed(card: &Card) -> Option<u64> {
if *crate::timestamp::TESTING || cfg!(test) { if *crate::PYTHON_UNIT_TESTS || cfg!(test) {
None None
} else { } else {
Some((card.id.0 as u64).wrapping_add(card.reps as u64)) Some((card.id.0 as u64).wrapping_add(card.reps as u64))

View file

@ -120,7 +120,8 @@ impl QueueBuilder {
// intraday learning // intraday learning
let learning = sort_learning(self.learning); let learning = sort_learning(self.learning);
let cutoff = TimestampSecs::now().adding_secs(learn_ahead_secs); let now = TimestampSecs::now();
let cutoff = now.adding_secs(learn_ahead_secs);
let learn_count = learning.iter().take_while(|e| e.due <= cutoff).count(); let learn_count = learning.iter().take_while(|e| e.due <= cutoff).count();
// merge interday learning into main, and cap to parent review count // merge interday learning into main, and cap to parent review count
@ -143,12 +144,12 @@ impl QueueBuilder {
review: review_count, review: review_count,
learning: learn_count, learning: learn_count,
}, },
undo: Vec::new(),
main: main_iter.collect(), main: main_iter.collect(),
learning, intraday_learning: learning,
learn_ahead_secs, learn_ahead_secs,
selected_deck, selected_deck,
current_day, current_day,
current_learning_cutoff: now,
} }
} }
} }

View file

@ -78,3 +78,15 @@ impl From<MainQueueEntry> for QueueEntry {
Self::Main(e) Self::Main(e)
} }
} }
impl From<&LearningQueueEntry> for QueueEntry {
fn from(e: &LearningQueueEntry) -> Self {
Self::IntradayLearning(*e)
}
}
impl From<&MainQueueEntry> for QueueEntry {
fn from(e: &MainQueueEntry) -> Self {
Self::Main(*e)
}
}

View file

@ -3,7 +3,7 @@
use std::{cmp::Ordering, collections::VecDeque}; use std::{cmp::Ordering, collections::VecDeque};
use super::CardQueues; use super::{undo::CutoffSnapshot, CardQueues};
use crate::{prelude::*, scheduler::timing::SchedTimingToday}; use crate::{prelude::*, scheduler::timing::SchedTimingToday};
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord)] #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord)]
@ -15,36 +15,43 @@ pub(crate) struct LearningQueueEntry {
} }
impl CardQueues { impl CardQueues {
/// Check for any newly due cards, and then return the first, if any, /// Intraday learning cards that can be shown immediately.
/// that is due before now. pub(super) fn intraday_now_iter(&self) -> impl Iterator<Item = &LearningQueueEntry> {
pub(super) fn next_learning_entry_due_before_now( let cutoff = self.current_learning_cutoff;
&mut self, self.intraday_learning
now: TimestampSecs, .iter()
) -> Option<LearningQueueEntry> { .take_while(move |e| e.due <= cutoff)
let learn_ahead_cutoff = now.adding_secs(self.learn_ahead_secs);
self.check_for_newly_due_learning_cards(learn_ahead_cutoff);
self.next_learning_entry_learning_ahead()
.filter(|c| c.due <= now)
} }
/// Check for due learning cards up to the learn ahead limit. /// Intraday learning cards that can be shown after the main queue is empty.
/// Does not check for newly due cards, as that is already done by pub(super) fn intraday_ahead_iter(&self) -> impl Iterator<Item = &LearningQueueEntry> {
/// next_learning_entry_due_before_now() let cutoff = self.current_learning_cutoff;
pub(super) fn next_learning_entry_learning_ahead(&self) -> Option<LearningQueueEntry> { let ahead_cutoff = self.current_learn_ahead_cutoff();
self.learning.front().copied() self.intraday_learning
.iter()
.skip_while(move |e| e.due <= cutoff)
.take_while(move |e| e.due <= ahead_cutoff)
} }
pub(super) fn pop_learning_entry(&mut self, id: CardId) -> Option<LearningQueueEntry> { /// Increase the cutoff to the current time, and increase the learning count
if let Some(top) = self.learning.front() { /// for any new cards that now fall within the cutoff.
if top.id == id { pub(super) fn check_for_newly_due_intraday_learning(&mut self) -> Box<CutoffSnapshot> {
// under normal circumstances this should not go below 0, but currently let change = CutoffSnapshot {
// the Python unit tests answer learning cards before they're due learning_count: self.counts.learning,
self.counts.learning = self.counts.learning.saturating_sub(1); learning_cutoff: self.current_learning_cutoff,
return self.learning.pop_front(); };
} let cutoff = self.current_learning_cutoff;
} let ahead_cutoff = self.current_learn_ahead_cutoff();
let new_learning_cards = self
.intraday_learning
.iter()
.skip(self.counts.learning)
.take_while(|e| e.due <= ahead_cutoff)
.count();
self.counts.learning += new_learning_cards;
self.current_learning_cutoff = cutoff;
None Box::new(change)
} }
/// Given the just-answered `card`, place it back in the learning queues if it's still /// Given the just-answered `card`, place it back in the learning queues if it's still
@ -65,18 +72,31 @@ impl CardQueues {
mtime: card.mtime, mtime: card.mtime,
}; };
Some(self.requeue_learning_entry(entry, timing)) Some(self.requeue_learning_entry(entry))
}
pub(super) fn cutoff_snapshot(&self) -> Box<CutoffSnapshot> {
Box::new(CutoffSnapshot {
learning_count: self.counts.learning,
learning_cutoff: self.current_learning_cutoff,
})
}
pub(super) fn restore_cutoff(&mut self, change: &CutoffSnapshot) -> Box<CutoffSnapshot> {
let current = self.cutoff_snapshot();
self.counts.learning = change.learning_count;
self.current_learning_cutoff = change.learning_cutoff;
current
} }
/// Caller must have validated learning entry is due today. /// Caller must have validated learning entry is due today.
pub(super) fn requeue_learning_entry( pub(super) fn requeue_learning_entry(
&mut self, &mut self,
mut entry: LearningQueueEntry, mut entry: LearningQueueEntry,
timing: SchedTimingToday,
) -> LearningQueueEntry { ) -> LearningQueueEntry {
let cutoff = timing.now.adding_secs(self.learn_ahead_secs); let cutoff = self.current_learn_ahead_cutoff();
if entry.due <= cutoff && self.learning_collapsed() { if entry.due <= cutoff && self.learning_collapsed() {
if let Some(next) = self.learning.front() { if let Some(next) = self.intraday_learning.front() {
// ensure the card is scheduled after the next due card // ensure the card is scheduled after the next due card
if next.due >= entry.due { if next.due >= entry.due {
if next.due < cutoff { if next.due < cutoff {
@ -94,7 +114,7 @@ impl CardQueues {
} }
} }
self.push_learning_card(entry); self.insert_intraday_learning_card(entry);
entry entry
} }
@ -103,41 +123,60 @@ impl CardQueues {
self.main.is_empty() self.main.is_empty()
} }
/// Adds card, maintaining correct sort order, and increments learning count if /// Remove the head of the intraday learning queue, and update counts.
/// card is due within cutoff. pub(super) fn pop_intraday_learning(&mut self) -> Option<LearningQueueEntry> {
pub(super) fn push_learning_card(&mut self, entry: LearningQueueEntry) { self.intraday_learning.pop_front().map(|head| {
let target_idx = // FIXME:
binary_search_by(&self.learning, |e| e.due.cmp(&entry.due)).unwrap_or_else(|e| e); // under normal circumstances this should not go below 0, but currently
if target_idx < self.counts.learning { // the Python unit tests answer learning cards before they're due
self.counts.learning = self.counts.learning.saturating_sub(1);
head
})
}
/// Add an undone entry to the top of the intraday learning queue.
pub(super) fn push_intraday_learning(&mut self, entry: LearningQueueEntry) {
self.intraday_learning.push_front(entry);
self.counts.learning += 1;
}
/// Adds an intraday learning card to the correct position of the queue, and
/// increments learning count if card is due.
pub(super) fn insert_intraday_learning_card(&mut self, entry: LearningQueueEntry) {
if entry.due <= self.current_learn_ahead_cutoff() {
self.counts.learning += 1; self.counts.learning += 1;
} }
self.learning.insert(target_idx, entry);
let target_idx = binary_search_by(&self.intraday_learning, |e| e.due.cmp(&entry.due))
.unwrap_or_else(|e| e);
self.intraday_learning.insert(target_idx, entry);
} }
fn check_for_newly_due_learning_cards(&mut self, cutoff: TimestampSecs) { /// Remove an inserted intraday learning card after a lapse is undone, adjusting
self.counts.learning += self /// counts.
.learning pub(super) fn remove_intraday_learning_card(
.iter() &mut self,
.skip(self.counts.learning) card_id: CardId,
.take_while(|e| e.due <= cutoff) ) -> Option<LearningQueueEntry> {
.count(); if let Some(position) = self.intraday_learning.iter().position(|e| e.id == card_id) {
} let entry = self.intraday_learning.remove(position).unwrap();
if entry.due
pub(super) fn remove_requeued_learning_card_after_undo(&mut self, id: CardId) { <= self
let due_idx = self.learning.iter().enumerate().find_map(|(idx, entry)| { .current_learning_cutoff
if entry.id == id { .adding_secs(self.learn_ahead_secs)
Some(idx) {
} else { self.counts.learning -= 1;
None
} }
}); Some(entry)
if let Some(idx) = due_idx { } else {
// FIXME: if we remove the saturating_sub from pop_learning(), maybe None
// this can go too?
self.counts.learning = self.counts.learning.saturating_sub(1);
self.learning.remove(idx);
} }
} }
fn current_learn_ahead_cutoff(&self) -> TimestampSecs {
self.current_learning_cutoff
.adding_secs(self.learn_ahead_secs)
}
} }
/// Adapted from the Rust stdlib VecDeque implementation; we can drop this when the following /// Adapted from the Rust stdlib VecDeque implementation; we can drop this when the following

View file

@ -18,21 +18,23 @@ pub(crate) enum MainQueueEntryKind {
} }
impl CardQueues { impl CardQueues {
pub(super) fn next_main_entry(&self) -> Option<MainQueueEntry> { /// Remove the head of the main queue, and update counts.
self.main.front().copied() pub(super) fn pop_main(&mut self) -> Option<MainQueueEntry> {
self.main.pop_front().map(|head| {
match head.kind {
MainQueueEntryKind::New => self.counts.new -= 1,
MainQueueEntryKind::Review => self.counts.review -= 1,
};
head
})
} }
pub(super) fn pop_main_entry(&mut self, id: CardId) -> Option<MainQueueEntry> { /// Add an undone entry to the top of the main queue.
if let Some(last) = self.main.front() { pub(super) fn push_main(&mut self, entry: MainQueueEntry) {
if last.id == id { match entry.kind {
match last.kind { MainQueueEntryKind::New => self.counts.new += 1,
MainQueueEntryKind::New => self.counts.new -= 1, MainQueueEntryKind::Review => self.counts.review += 1,
MainQueueEntryKind::Review => self.counts.review -= 1, };
} self.main.push_front(entry);
return self.main.pop_front();
}
}
None
} }
} }

View file

@ -22,13 +22,14 @@ use crate::{backend_proto as pb, prelude::*, timestamp::TimestampSecs};
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct CardQueues { pub(crate) struct CardQueues {
counts: Counts, counts: Counts,
/// Any undone items take precedence.
undo: Vec<QueueEntry>,
main: VecDeque<MainQueueEntry>, main: VecDeque<MainQueueEntry>,
learning: VecDeque<LearningQueueEntry>, intraday_learning: VecDeque<LearningQueueEntry>,
selected_deck: DeckId, selected_deck: DeckId,
current_day: u32, current_day: u32,
learn_ahead_secs: i64, learn_ahead_secs: i64,
/// Updated each time a card is answered. Ensures we don't show a newly-due
/// learning card after a user returns from editing a review card.
current_learning_cutoff: TimestampSecs,
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
@ -53,26 +54,43 @@ pub(crate) struct QueuedCards {
} }
impl CardQueues { impl CardQueues {
/// Get the next due card, if there is one. /// An iterator over the card queues, in the order the cards will
fn next_entry(&mut self, now: TimestampSecs) -> Option<QueueEntry> { /// be presented.
self.next_undo_entry() fn iter(&self) -> impl Iterator<Item = QueueEntry> + '_ {
self.intraday_now_iter()
.map(Into::into) .map(Into::into)
.or_else(|| self.next_learning_entry_due_before_now(now).map(Into::into)) .chain(self.main.iter().map(Into::into))
.or_else(|| self.next_main_entry().map(Into::into)) .chain(self.intraday_ahead_iter().map(Into::into))
.or_else(|| self.next_learning_entry_learning_ahead().map(Into::into))
} }
/// Remove the provided card from the top of the queues. /// Remove the provided card from the top of the queues and
/// If it was not at the top, return an error. /// adjust the counts. If it was not at the top, return an error.
fn pop_answered(&mut self, id: CardId) -> Result<QueueEntry> { fn pop_entry(&mut self, id: CardId) -> Result<QueueEntry> {
if let Some(entry) = self.pop_undo_entry(id) { let mut entry = self.iter().next();
Ok(entry) if entry.is_none() && *crate::PYTHON_UNIT_TESTS {
} else if let Some(entry) = self.pop_main_entry(id) { // the existing Python tests answer learning cards
Ok(entry.into()) // before they're due; try the first non-due learning
} else if let Some(entry) = self.pop_learning_entry(id) { // card as a backup
Ok(entry.into()) entry = self.intraday_learning.front().map(Into::into)
}
if let Some(entry) = entry {
match entry {
QueueEntry::IntradayLearning(e) if e.id == id => {
self.pop_intraday_learning().map(Into::into)
}
QueueEntry::Main(e) if e.id == id => self.pop_main().map(Into::into),
_ => None,
}
.ok_or_else(|| AnkiError::invalid_input("not at top of queue"))
} else { } else {
Err(AnkiError::invalid_input("not at top of queue")) Err(AnkiError::invalid_input("queues are empty"))
}
}
fn push_undo_entry(&mut self, entry: QueueEntry) {
match entry {
QueueEntry::IntradayLearning(entry) => self.push_intraday_learning(entry),
QueueEntry::Main(entry) => self.push_main(entry),
} }
} }
@ -83,26 +101,12 @@ impl CardQueues {
fn is_stale(&self, current_day: u32) -> bool { fn is_stale(&self, current_day: u32) -> bool {
self.current_day != current_day self.current_day != current_day
} }
fn update_after_answering_card(
&mut self,
card: &Card,
timing: SchedTimingToday,
) -> Result<Box<QueueUpdate>> {
let entry = self.pop_answered(card.id)?;
let requeued_learning = self.maybe_requeue_learning_card(card, timing);
Ok(Box::new(QueueUpdate {
entry,
learning_requeue: requeued_learning,
}))
}
} }
impl Collection { impl Collection {
pub(crate) fn get_queued_cards( pub(crate) fn get_queued_cards(
&mut self, &mut self,
fetch_limit: u32, fetch_limit: usize,
intraday_learning_only: bool, intraday_learning_only: bool,
) -> Result<pb::GetQueuedCardsOut> { ) -> Result<pb::GetQueuedCardsOut> {
if let Some(next_cards) = self.next_cards(fetch_limit, intraday_learning_only)? { if let Some(next_cards) = self.next_cards(fetch_limit, intraday_learning_only)? {
@ -139,25 +143,34 @@ impl Collection {
timing: SchedTimingToday, timing: SchedTimingToday,
) -> Result<()> { ) -> Result<()> {
if let Some(queues) = &mut self.state.card_queues { if let Some(queues) = &mut self.state.card_queues {
let mutation = queues.update_after_answering_card(card, timing)?; let entry = queues.pop_entry(card.id)?;
self.save_queue_update_undo(mutation); let requeued_learning = queues.maybe_requeue_learning_card(card, timing);
Ok(()) let cutoff_change = queues.check_for_newly_due_intraday_learning();
self.save_queue_update_undo(Box::new(QueueUpdate {
entry,
learning_requeue: requeued_learning,
}));
self.save_cutoff_change(cutoff_change);
} else { } else {
// we currenly allow the queues to be empty for unit tests // we currenly allow the queues to be empty for unit tests
Ok(())
} }
Ok(())
} }
pub(crate) fn get_queues(&mut self) -> Result<&mut CardQueues> { pub(crate) fn get_queues(&mut self) -> Result<&mut CardQueues> {
let timing = self.timing_today()?; let timing = self.timing_today()?;
let deck = self.get_current_deck_id(); let deck = self.get_current_deck_id();
let need_rebuild = self let day_rolled_over = self
.state .state
.card_queues .card_queues
.as_ref() .as_ref()
.map(|q| q.is_stale(timing.days_elapsed)) .map(|q| q.is_stale(timing.days_elapsed))
.unwrap_or(true); .unwrap_or(false);
if need_rebuild { if day_rolled_over {
self.discard_undo_and_study_queues();
}
if self.state.card_queues.is_none() {
self.state.card_queues = Some(self.build_queues(deck)?); self.state.card_queues = Some(self.build_queues(deck)?);
} }
@ -166,36 +179,46 @@ impl Collection {
fn next_cards( fn next_cards(
&mut self, &mut self,
_fetch_limit: u32, fetch_limit: usize,
_intraday_learning_only: bool, intraday_learning_only: bool,
) -> Result<Option<QueuedCards>> { ) -> Result<Option<QueuedCards>> {
let queues = self.get_queues()?; let queues = self.get_queues()?;
let mut cards = vec![]; let counts = queues.counts();
if let Some(entry) = queues.next_entry(TimestampSecs::now()) { let entries: Vec<_> = if intraday_learning_only {
let card = self queues
.storage .intraday_now_iter()
.get_card(entry.card_id())? .chain(queues.intraday_ahead_iter())
.ok_or(AnkiError::NotFound)?; .map(Into::into)
if card.mtime != entry.mtime() { .collect()
return Err(AnkiError::invalid_input( } else {
"bug: card modified without updating queue", queues.iter().take(fetch_limit).collect()
)); };
} if entries.is_empty() {
// fixme: pass in card instead of id
let next_states = self.get_next_card_states(card.id)?;
cards.push(QueuedCard {
card,
next_states,
kind: entry.kind(),
});
}
if cards.is_empty() {
Ok(None) Ok(None)
} else { } else {
let counts = self.get_queues()?.counts(); let cards: Vec<_> = entries
.into_iter()
.map(|entry| {
let card = self
.storage
.get_card(entry.card_id())?
.ok_or(AnkiError::NotFound)?;
if card.mtime != entry.mtime() {
return Err(AnkiError::invalid_input(
"bug: card modified without updating queue",
));
}
// fixme: pass in card instead of id
let next_states = self.get_next_card_states(card.id)?;
Ok(QueuedCard {
card,
next_states,
kind: entry.kind(),
})
})
.collect::<Result<_>>()?;
Ok(Some(QueuedCards { Ok(Some(QueuedCards {
cards, cards,
new_count: counts.new, new_count: counts.new,
@ -215,7 +238,7 @@ impl Collection {
.map(|mut resp| resp.cards.pop().unwrap())) .map(|mut resp| resp.cards.pop().unwrap()))
} }
pub(crate) fn get_queue_single(&mut self) -> Result<QueuedCards> { fn get_queue_single(&mut self) -> Result<QueuedCards> {
self.next_cards(1, false)?.ok_or(AnkiError::NotFound) self.next_cards(1, false)?.ok_or(AnkiError::NotFound)
} }

View file

@ -1,13 +1,14 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{CardQueues, LearningQueueEntry, QueueEntry, QueueEntryKind}; use super::{LearningQueueEntry, QueueEntry};
use crate::prelude::*; use crate::prelude::*;
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum UndoableQueueChange { pub(crate) enum UndoableQueueChange {
CardAnswered(Box<QueueUpdate>), CardAnswered(Box<QueueUpdate>),
CardAnswerUndone(Box<QueueUpdate>), CardAnswerUndone(Box<QueueUpdate>),
CutoffChange(Box<CutoffSnapshot>),
} }
#[derive(Debug)] #[derive(Debug)]
@ -16,13 +17,21 @@ pub(crate) struct QueueUpdate {
pub learning_requeue: Option<LearningQueueEntry>, pub learning_requeue: Option<LearningQueueEntry>,
} }
/// Stores the old learning count and cutoff prior to the
/// cutoff being adjusted after answering a card.
#[derive(Debug)]
pub(crate) struct CutoffSnapshot {
pub learning_count: usize,
pub learning_cutoff: TimestampSecs,
}
impl Collection { impl Collection {
pub(crate) fn undo_queue_change(&mut self, change: UndoableQueueChange) -> Result<()> { pub(crate) fn undo_queue_change(&mut self, change: UndoableQueueChange) -> Result<()> {
match change { match change {
UndoableQueueChange::CardAnswered(update) => { UndoableQueueChange::CardAnswered(update) => {
let queues = self.get_queues()?; let queues = self.get_queues()?;
if let Some(learning) = &update.learning_requeue { if let Some(learning) = &update.learning_requeue {
queues.remove_requeued_learning_card_after_undo(learning.id); queues.remove_intraday_learning_card(learning.id);
} }
queues.push_undo_entry(update.entry); queues.push_undo_entry(update.entry);
self.save_undo(UndoableQueueChange::CardAnswerUndone(update)); self.save_undo(UndoableQueueChange::CardAnswerUndone(update));
@ -30,12 +39,20 @@ impl Collection {
Ok(()) Ok(())
} }
UndoableQueueChange::CardAnswerUndone(update) => { UndoableQueueChange::CardAnswerUndone(update) => {
// don't try to update existing queue when redoing; just let queues = self.get_queues()?;
// rebuild it instead if let Some(learning) = update.learning_requeue {
self.clear_study_queues(); queues.insert_intraday_learning_card(learning);
// but preserve undo state for a subsequent undo }
queues.pop_entry(update.entry.card_id())?;
self.save_undo(UndoableQueueChange::CardAnswered(update)); self.save_undo(UndoableQueueChange::CardAnswered(update));
Ok(())
}
UndoableQueueChange::CutoffChange(change) => {
let queues = self.get_queues()?;
let current = queues.restore_cutoff(&change);
self.save_cutoff_change(current);
Ok(()) Ok(())
} }
} }
@ -44,37 +61,9 @@ impl Collection {
pub(super) fn save_queue_update_undo(&mut self, change: Box<QueueUpdate>) { pub(super) fn save_queue_update_undo(&mut self, change: Box<QueueUpdate>) {
self.save_undo(UndoableQueueChange::CardAnswered(change)) self.save_undo(UndoableQueueChange::CardAnswered(change))
} }
}
impl CardQueues { pub(super) fn save_cutoff_change(&mut self, change: Box<CutoffSnapshot>) {
pub(super) fn next_undo_entry(&self) -> Option<QueueEntry> { self.save_undo(UndoableQueueChange::CutoffChange(change))
self.undo.last().copied()
}
pub(super) fn pop_undo_entry(&mut self, id: CardId) -> Option<QueueEntry> {
if let Some(last) = self.undo.last() {
if last.card_id() == id {
match last.kind() {
QueueEntryKind::New => self.counts.new -= 1,
QueueEntryKind::Review => self.counts.review -= 1,
QueueEntryKind::Learning => self.counts.learning -= 1,
}
return self.undo.pop();
}
}
None
}
/// Add an undone card back to the 'front' of the list, and update
/// the counts.
pub(super) fn push_undo_entry(&mut self, entry: QueueEntry) {
match entry.kind() {
QueueEntryKind::New => self.counts.new += 1,
QueueEntryKind::Review => self.counts.review += 1,
QueueEntryKind::Learning => self.counts.learning += 1,
}
self.undo.push(entry);
} }
} }
@ -188,11 +177,8 @@ mod test {
let deck = col.get_deck(DeckId(1))?.unwrap(); let deck = col.get_deck(DeckId(1))?.unwrap();
assert_eq!(deck.common.review_studied, 0); assert_eq!(deck.common.review_studied, 0);
assert_eq!(col.next_card()?.is_some(), true); assert_eq!(col.next_card()?.is_some(), true);
assert_eq!(col.counts(), [0, 0, 1]);
let q = col.get_queue_single()?;
assert_eq!(&[q.new_count, q.learning_count, q.review_count], &[0, 0, 1]);
Ok(()) Ok(())
}; };
@ -212,19 +198,23 @@ mod test {
} }
#[test] #[test]
#[ignore = "undo code is currently broken"] fn undo_counts() -> Result<()> {
fn undo_counts1() -> Result<()> {
let mut col = open_test_collection(); let mut col = open_test_collection();
assert_eq!(col.counts(), [0, 0, 0]); assert_eq!(col.counts(), [0, 0, 0]);
add_note(&mut col, true)?; add_note(&mut col, true)?;
assert_eq!(col.counts(), [2, 0, 0]); assert_eq!(col.counts(), [2, 0, 0]);
col.answer_good(); col.answer_again();
assert_eq!(col.counts(), [1, 1, 0]); assert_eq!(col.counts(), [1, 1, 0]);
col.answer_good(); col.answer_good();
assert_eq!(col.counts(), [0, 2, 0]); assert_eq!(col.counts(), [0, 2, 0]);
col.answer_again();
assert_eq!(col.counts(), [0, 2, 0]);
// first card graduates
col.answer_good(); col.answer_good();
assert_eq!(col.counts(), [0, 1, 0]); assert_eq!(col.counts(), [0, 1, 0]);
// other card is all that is left, so the
// last step is deferred
col.answer_good(); col.answer_good();
assert_eq!(col.counts(), [0, 0, 0]); assert_eq!(col.counts(), [0, 0, 0]);
@ -234,57 +224,26 @@ mod test {
col.undo()?; col.undo()?;
assert_eq!(col.counts(), [0, 2, 0]); assert_eq!(col.counts(), [0, 2, 0]);
col.undo()?; col.undo()?;
assert_eq!(col.counts(), [0, 2, 0]);
col.undo()?;
assert_eq!(col.counts(), [1, 1, 0]); assert_eq!(col.counts(), [1, 1, 0]);
col.undo()?; col.undo()?;
assert_eq!(col.counts(), [2, 0, 0]); assert_eq!(col.counts(), [2, 0, 0]);
col.undo()?; col.undo()?;
assert_eq!(col.counts(), [0, 0, 0]); assert_eq!(col.counts(), [0, 0, 0]);
Ok(()) // and forwards again
} col.redo()?;
#[test]
fn undo_counts2() -> Result<()> {
let mut col = open_test_collection();
assert_eq!(col.counts(), [0, 0, 0]);
add_note(&mut col, true)?;
assert_eq!(col.counts(), [2, 0, 0]); assert_eq!(col.counts(), [2, 0, 0]);
col.answer_again(); col.redo()?;
assert_eq!(col.counts(), [1, 1, 0]); assert_eq!(col.counts(), [1, 1, 0]);
col.answer_again(); col.redo()?;
assert_eq!(col.counts(), [0, 2, 0]); assert_eq!(col.counts(), [0, 2, 0]);
col.answer_again(); col.redo()?;
assert_eq!(col.counts(), [0, 2, 0]); assert_eq!(col.counts(), [0, 2, 0]);
col.answer_again(); col.redo()?;
assert_eq!(col.counts(), [0, 2, 0]);
col.answer_good();
assert_eq!(col.counts(), [0, 2, 0]);
col.answer_easy();
assert_eq!(col.counts(), [0, 1, 0]); assert_eq!(col.counts(), [0, 1, 0]);
col.answer_good(); col.redo()?;
// last card, due in a minute
assert_eq!(col.counts(), [0, 0, 0]);
Ok(())
}
#[test]
fn undo_counts_relearn() -> Result<()> {
let mut col = open_test_collection();
add_note(&mut col, true)?;
col.storage
.db
.execute_batch("update cards set due=0,queue=2,type=2")?;
assert_eq!(col.counts(), [0, 0, 2]);
col.answer_again();
assert_eq!(col.counts(), [0, 1, 1]);
col.answer_again();
assert_eq!(col.counts(), [0, 2, 0]);
col.answer_easy();
assert_eq!(col.counts(), [0, 1, 0]);
col.answer_easy();
assert_eq!(col.counts(), [0, 0, 0]); assert_eq!(col.counts(), [0, 0, 0]);
Ok(()) Ok(())

View file

@ -1,10 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::{env, time}; use std::time;
use chrono::prelude::*; use chrono::prelude::*;
use lazy_static::lazy_static;
use crate::define_newtype; use crate::define_newtype;
@ -69,12 +68,8 @@ impl TimestampMillis {
} }
} }
lazy_static! {
pub(crate) static ref TESTING: bool = env::var("ANKI_TEST_MODE").is_ok();
}
fn elapsed() -> time::Duration { fn elapsed() -> time::Duration {
if *TESTING { if *crate::PYTHON_UNIT_TESTS {
// shift clock around rollover time to accomodate Python tests that make bad assumptions. // shift clock around rollover time to accomodate Python tests that make bad assumptions.
// we should update the tests in the future and remove this hack. // we should update the tests in the future and remove this hack.
let mut elap = time::SystemTime::now() let mut elap = time::SystemTime::now()

View file

@ -84,7 +84,6 @@ impl UndoManager {
} }
fn begin_step(&mut self, op: Option<Op>) { fn begin_step(&mut self, op: Option<Op>) {
println!("begin: {:?}", op);
if op.is_none() { if op.is_none() {
self.undo_steps.clear(); self.undo_steps.clear();
self.redo_steps.clear(); self.redo_steps.clear();
@ -116,7 +115,6 @@ impl UndoManager {
println!("no undo changes, discarding step"); println!("no undo changes, discarding step");
} }
} }
println!("ended, undo steps count now {}", self.undo_steps.len());
} }
fn can_undo(&self) -> Option<&Op> { fn can_undo(&self) -> Option<&Op> {