diff --git a/rslib/src/card.rs b/rslib/src/card.rs index 65b3d5b16..f7aa35263 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -43,7 +43,7 @@ pub enum CardQueue { SchedBuried = -3, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Card { pub(crate) id: CardID, pub(crate) nid: NoteID, diff --git a/rslib/src/deckconf/mod.rs b/rslib/src/deckconf/mod.rs index 459afe538..02101cd68 100644 --- a/rslib/src/deckconf/mod.rs +++ b/rslib/src/deckconf/mod.rs @@ -19,7 +19,7 @@ mod schema11; define_newtype!(DeckConfID, i64); -#[derive(Debug)] +#[derive(Debug, PartialEq, Clone)] pub struct DeckConf { pub id: DeckConfID, pub name: String, diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 6332cdeba..00dc32154 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -26,7 +26,7 @@ use std::{borrow::Cow, sync::Arc}; define_newtype!(DeckID, i64); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Deck { pub id: DeckID, pub name: String, diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index e657b0f58..6fa56f95f 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -33,7 +33,7 @@ pub(crate) struct TransformNoteOutput { pub mark_modified: bool, } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Note { pub id: NoteID, pub guid: String, diff --git a/rslib/src/notetype/fields.rs b/rslib/src/notetype/fields.rs index 6ba1ce03e..8ce31f6f4 100644 --- a/rslib/src/notetype/fields.rs +++ b/rslib/src/notetype/fields.rs @@ -3,7 +3,7 @@ use crate::backend_proto::{NoteField as NoteFieldProto, NoteFieldConfig, OptionalUInt32}; -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct NoteField { pub ord: Option, pub name: String, diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index e4f7fba4a..ad76b55ae 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -45,7 +45,7 @@ pub(crate) const DEFAULT_CSS: &str = include_str!("styling.css"); pub(crate) const DEFAULT_LATEX_HEADER: &str = include_str!("header.tex"); pub(crate) const DEFAULT_LATEX_FOOTER: &str = r"\end{document}"; -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct NoteType { pub id: NoteTypeID, pub name: String, diff --git a/rslib/src/notetype/templates.rs b/rslib/src/notetype/templates.rs index d9f3dffdd..309c446c8 100644 --- a/rslib/src/notetype/templates.rs +++ b/rslib/src/notetype/templates.rs @@ -9,7 +9,7 @@ use crate::{ types::Usn, }; -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct CardTemplate { pub ord: Option, pub mtime_secs: TimestampSecs, diff --git a/rslib/src/sync/mod.rs b/rslib/src/sync/mod.rs index afb90dc4d..6e2262c19 100644 --- a/rslib/src/sync/mod.rs +++ b/rslib/src/sync/mod.rs @@ -114,7 +114,7 @@ struct ChunkableIDs { notes: Vec, } -#[derive(Serialize_tuple, Deserialize, Debug)] +#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq)] pub struct ReviewLogEntry { pub id: TimestampMillis, pub cid: CardID, @@ -238,6 +238,7 @@ pub struct SyncOutput { pub host_number: u32, } +#[derive(Clone)] pub struct SyncAuth { pub hkey: String, pub host_number: u32, @@ -681,7 +682,7 @@ impl Collection { //---------------------------------------------------------------- fn local_unchunked_changes( - &self, + &mut self, pending_usn: Usn, new_usn: Option, local_is_newer: bool, @@ -704,7 +705,7 @@ impl Collection { } fn changed_notetypes( - &self, + &mut self, pending_usn: Usn, new_usn: Option, ) -> Result> { @@ -713,6 +714,7 @@ impl Collection { .objects_pending_sync("notetypes", pending_usn)?; self.storage .maybe_update_object_usns("notetypes", &ids, new_usn)?; + self.state.notetype_cache.clear(); ids.into_iter() .map(|id| { self.storage.get_notetype(id).map(|opt| { @@ -724,10 +726,15 @@ impl Collection { .collect() } - fn changed_decks(&self, pending_usn: Usn, new_usn: Option) -> Result> { + fn changed_decks( + &mut self, + pending_usn: Usn, + new_usn: Option, + ) -> Result> { let ids = self.storage.objects_pending_sync("decks", pending_usn)?; self.storage .maybe_update_object_usns("decks", &ids, new_usn)?; + self.state.deck_cache.clear(); ids.into_iter() .map(|id| { self.storage.get_deck(id).map(|opt| { @@ -1122,3 +1129,278 @@ impl From for SyncOutput { } } } + +#[cfg(test)] +mod test { + use super::*; + use crate::log; + use crate::{ + collection::open_collection, deckconf::DeckConf, decks::DeckKind, i18n::I18n, + notetype::all_stock_notetypes, search::SortMode, + }; + use tempfile::{tempdir, TempDir}; + use tokio::runtime::Runtime; + + fn norm_progress(_: NormalSyncProgress, _: bool) {} + + fn full_progress(_: FullSyncProgress, _: bool) {} + + struct TestContext { + dir: TempDir, + auth: SyncAuth, + col1: Option, + col2: Option, + } + + fn open_col(ctx: &TestContext, fname: &str) -> Result { + let path = ctx.dir.path().join(fname); + let i18n = I18n::new(&[""], "", log::terminal()); + open_collection( + path, + "".into(), + "".into(), + false, + i18n.clone(), + log::terminal(), + ) + } + + async fn upload_download(ctx: &mut TestContext) -> Result<()> { + // add a card + let mut col1 = open_col(ctx, "col1.anki2")?; + let nt = col1.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + note.fields[0] = "1".into(); + col1.add_note(&mut note, DeckID(1))?; + + let out: SyncOutput = col1.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert!(matches!( + out.required, + SyncActionRequired::FullSyncRequired { .. } + )); + + col1.full_upload(ctx.auth.clone(), full_progress).await?; + + // another collection + let mut col2 = open_col(ctx, "col2.anki2")?; + + // won't allow ankiweb clobber + let out: SyncOutput = col2.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!( + out.required, + SyncActionRequired::FullSyncRequired { + upload_ok: false, + download_ok: true + } + ); + + // fetch so we're in sync + col2.full_download(ctx.auth.clone(), full_progress).await?; + + // reopen the two collections + ctx.col1 = Some(open_col(ctx, "col1.anki2")?); + ctx.col2 = Some(open_col(ctx, "col2.anki2")?); + + Ok(()) + } + + async fn regular_sync(ctx: &mut TestContext) -> Result<()> { + let col1 = ctx.col1.as_mut().unwrap(); + let col2 = ctx.col2.as_mut().unwrap(); + + // add a deck + let mut deck = col1.get_or_create_normal_deck("new deck")?; + + // give it a new option group + let mut dconf = DeckConf::default(); + dconf.name = "new dconf".into(); + col1.add_or_update_deck_config(&mut dconf, false)?; + if let DeckKind::Normal(deck) = &mut deck.kind { + deck.config_id = dconf.id.0; + } + col1.add_or_update_deck(&mut deck)?; + + // and a new notetype + let mut nt = all_stock_notetypes(&col1.i18n).remove(0); + nt.name = "new".into(); + col1.add_notetype(&mut nt)?; + + // add another note+card+tag + let mut note = nt.new_note(); + note.fields[0] = "2".into(); + note.tags.push("tag".into()); + col1.add_note(&mut note, deck.id)?; + + // mock revlog entry + col1.storage.add_revlog_entry(&ReviewLogEntry { + id: TimestampMillis(123), + cid: CardID(456), + usn: Usn(-1), + interval: 10, + ..Default::default() + })?; + + // config + creation + col1.set_config("test", &"test1")?; + // bumping this will affect 'last studied at' on decks at the moment + // col1.storage.set_creation_stamp(TimestampSecs(12345))?; + + // and sync our changes + let out: SyncOutput = col1.get_sync_status(ctx.auth.clone()).await?; + assert_eq!(out.required, SyncActionRequired::NormalSyncRequired); + + let out: SyncOutput = col1.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!(out.required, SyncActionRequired::NoChanges); + + // sync the other collection + let out: SyncOutput = col2.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!(out.required, SyncActionRequired::NoChanges); + + let ntid = nt.id; + let deckid = deck.id; + let dconfid = dconf.id; + let noteid = note.id; + let cardid = col1.search_cards(&format!("nid:{}", note.id), SortMode::NoOrder)?[0]; + let revlogid = RevlogID(123); + + let compare_sides = |col1: &mut Collection, col2: &mut Collection| -> Result<()> { + assert_eq!( + col1.get_notetype(ntid)?.unwrap(), + col2.get_notetype(ntid)?.unwrap() + ); + assert_eq!( + col1.get_deck(deckid)?.unwrap(), + col2.get_deck(deckid)?.unwrap() + ); + assert_eq!( + col1.get_deck_config(dconfid, false)?.unwrap(), + col2.get_deck_config(dconfid, false)?.unwrap() + ); + assert_eq!( + col1.storage.get_note(noteid)?.unwrap(), + col2.storage.get_note(noteid)?.unwrap() + ); + assert_eq!( + col1.storage.get_card(cardid)?.unwrap(), + col2.storage.get_card(cardid)?.unwrap() + ); + assert_eq!( + col1.storage.get_revlog_entry(revlogid)?, + col2.storage.get_revlog_entry(revlogid)?, + ); + assert_eq!( + col1.storage.get_all_config()?, + col2.storage.get_all_config()? + ); + assert_eq!( + col1.storage.creation_stamp()?, + col1.storage.creation_stamp()? + ); + + // server doesn't send tag usns, so we can only compare tags, not usns, + // as the usns may not match + assert_eq!( + col1.storage + .all_tags()? + .into_iter() + .map(|t| t.0) + .collect::>(), + col2.storage + .all_tags()? + .into_iter() + .map(|t| t.0) + .collect::>() + ); + + Ok(()) + }; + + // make sure everything has been transferred across + compare_sides(col1, col2)?; + + // make some modifications + let mut note = col2.storage.get_note(note.id)?.unwrap(); + note.fields[1] = "new".into(); + note.tags.push("tag2".into()); + col2.update_note(&mut note)?; + + col2.get_and_update_card(cardid, |card| { + card.queue = CardQueue::Review; + Ok(()) + })?; + + let mut deck = col2.storage.get_deck(deck.id)?.unwrap(); + deck.name = "newer".into(); + col2.add_or_update_deck(&mut deck)?; + + let mut nt = col2.storage.get_notetype(nt.id)?.unwrap(); + nt.name = "newer".into(); + col2.update_notetype(&mut nt, false)?; + + // sync the changes back + let out: SyncOutput = col2.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!(out.required, SyncActionRequired::NoChanges); + let out: SyncOutput = col1.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!(out.required, SyncActionRequired::NoChanges); + + // should still match + compare_sides(col1, col2)?; + + // deletions should sync too + for table in &["cards", "notes", "decks"] { + assert_eq!( + col1.storage + .db_scalar::(&format!("select count() from {}", table))?, + 2 + ); + } + + // fixme: inconsistent usn arg + col1.remove_cards_inner(&[cardid])?; + col1.remove_note_only(noteid, col1.usn()?)?; + col1.remove_deck_and_child_decks(deckid)?; + + let out: SyncOutput = col1.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!(out.required, SyncActionRequired::NoChanges); + let out: SyncOutput = col2.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert_eq!(out.required, SyncActionRequired::NoChanges); + + for table in &["cards", "notes", "decks"] { + assert_eq!( + col2.storage + .db_scalar::(&format!("select count() from {}", table))?, + 1 + ); + } + + // removing things like a notetype forces a full sync + col2.remove_notetype(ntid)?; + let out: SyncOutput = col2.normal_sync(ctx.auth.clone(), norm_progress).await?; + assert!(matches!(out.required, SyncActionRequired::FullSyncRequired { .. })); + Ok(()) + } + + #[test] + fn collection_sync() -> Result<()> { + let hkey = match std::env::var("TEST_HKEY") { + Ok(s) => s, + Err(_) => { + return Ok(()); + } + }; + + let mut ctx = TestContext { + dir: tempdir()?, + auth: SyncAuth { + hkey, + host_number: 0, + }, + col1: None, + col2: None, + }; + + let mut rt = Runtime::new().unwrap(); + rt.block_on(upload_download(&mut ctx))?; + rt.block_on(regular_sync(&mut ctx)) + } +}