undo.rsannotatedundo.rssource164 lines · 8.0 KB · raw
1//! Undoing a soft delete: she puts a memory away and has `UNDO_WINDOW_MS` to take it back. The memory comes
2//! back as it was; after the window, twice, for an action that does not exist, or once the parents have forgotten
3//! the memory, it is refused and nothing changes.
4
5use std::path::PathBuf;
6
7use whiskers_core::*;
8use whiskers_store::{SqliteMemory, Store};
9
10fn dir(name: &str) -> PathBuf {
11    use std::sync::atomic::{AtomicUsize, Ordering};
12    static N: AtomicUsize = AtomicUsize::new(0);
13    let d = std::env::temp_dir().join(format!("whiskers-store-undo-{name}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst)));
14    let _ = std::fs::remove_dir_all(&d);
15    std::fs::create_dir_all(&d).unwrap();
16    d
17}
18
19/// A memory with everything that hangs off one: mentions, an older wording, an embedding, two pictures and a star.
20fn rich(m: &mut SqliteMemory) -> Fact {
21    let (a, b) = (PictureId("0000000000000001-0000.jpg".into()), PictureId("0000000000000002-0000.jpg".into()));
22    let f = m.add(NewFact { text: "a toy cat".into(), pictures: vec![a.clone(), b.clone()], ..NewFact::default() }, 10).unwrap();
23    m.mention(f.id, 500, "").unwrap();
24    m.reword(f.id, "a big toy cat".into(), 800).unwrap();
25    m.set_embedding(f.id, vec![0.5, 0.25]).unwrap();
26    m.choose_cover(f.id, &b, 900).unwrap();
27    m.facts().into_iter().find(|x| x.id == f.id).unwrap()
28}
29
30/// The fact with the register's stamp taken out, which is the one thing a hide and an undo are meant to change.
31fn without_visibility(mut f: Fact) -> Fact {
32    f.visibility = Visibility::default();
33    f
34}
35
36#[test]
37fn an_undo_inside_the_window_puts_the_memory_back_exactly() {
38    let d = dir("exact");
39    let mut m = SqliteMemory::open(Store::open(&d.join("d.db")).unwrap(), "phone").unwrap();
40    let before = rich(&mut m);
41    m.hide(before.id, 100_000).unwrap();
42    assert!(m.facts()[0].visibility.is_hidden());
43    let actions = m.soft_actions();
44    assert_eq!(actions.len(), 1);
45    assert_eq!((actions[0].kind, actions[0].gid.as_str(), actions[0].at_ms, actions[0].undone), (SoftKind::PutAway, before.gid.as_str(), 100_000, false));
46
47    let back = m.undo(actions[0].id, 100_000 + UNDO_WINDOW_MS - 1).unwrap();
48    assert!(!back.visibility.is_hidden());
49    let now = m.facts().remove(0);
50    assert_eq!(without_visibility(now.clone()), without_visibility(before.clone()), "its mentions, older words, embedding, pictures and star are what they were");
51    assert_eq!((now.mentions.len(), now.revisions.len(), now.pictures.len(), now.embedding.len()), (2, 1, 2, 2));
52    assert!(now.visibility.at_ms() > 100_000, "the restore is stamped later than the hide, so it wins when devices meet");
53    assert!(m.soft_actions()[0].undone);
54    // It is on disk, not only in the process.
55    drop(m);
56    let again = SqliteMemory::open(Store::open(&d.join("d.db")).unwrap(), "phone").unwrap();
57    assert!(!again.facts()[0].visibility.is_hidden());
58    assert!(again.soft_actions()[0].undone);
59}
60
61#[test]
62fn an_undo_after_the_window_is_refused_and_changes_nothing() {
63    let d = dir("late");
64    let mut m = SqliteMemory::open(Store::open(&d.join("d.db")).unwrap(), "phone").unwrap();
65    let f = rich(&mut m);
66    m.hide(f.id, 50_000).unwrap();
67    let id = m.soft_actions()[0].id;
68    let hidden = m.facts();
69    assert_eq!(m.undo(id, 50_000 + UNDO_WINDOW_MS), Err(UndoRefusal::Closed), "the window is closed at its end");
70    assert_eq!(m.undo(id, 5_000_000), Err(UndoRefusal::Closed));
71    assert_eq!(m.facts(), hidden, "still put away, nothing else touched");
72    assert!(!m.soft_actions()[0].undone, "and it stays unspent, for the record");
73}
74
75#[test]
76fn an_undo_is_spent_once() {
77    let d = dir("twice");
78    let mut m = SqliteMemory::open(Store::open(&d.join("d.db")).unwrap(), "phone").unwrap();
79    let f = rich(&mut m);
80    m.hide(f.id, 1_000).unwrap();
81    let id = m.soft_actions()[0].id;
82    m.undo(id, 2_000).unwrap();
83    assert_eq!(m.undo(id, 2_500), Err(UndoRefusal::Spent));
84    // She puts it away again: a new action, with its own window.
85    m.hide(f.id, 20_000).unwrap();
86    let actions = m.soft_actions();
87    assert_eq!(actions.len(), 2);
88    assert!(actions[1].id > id && !actions[1].undone);
89    assert_eq!(m.undo(id, 21_000), Err(UndoRefusal::Spent), "the first is still spent");
90    m.undo(actions[1].id, 21_000).unwrap();
91    assert!(!m.facts()[0].visibility.is_hidden());
92}
93
94#[test]
95fn what_is_not_a_soft_action_cannot_be_undone() {
96    let d = dir("not-soft");
97    let mut m = SqliteMemory::open(Store::open(&d.join("d.db")).unwrap(), "phone").unwrap();
98    let f = rich(&mut m);
99    // A number nobody was given, a memory's own id (a fact is not an action), and the forgetting of another memory.
100    assert_eq!(m.undo(1, 10), Err(UndoRefusal::Gone));
101    assert_eq!(m.undo(f.id, 10), Err(UndoRefusal::Gone));
102    let other = m.add(NewFact { text: "other".into(), ..NewFact::default() }, 20).unwrap();
103    m.forget(other.id, 30).unwrap();
104    assert_eq!(m.undo(2, 40), Err(UndoRefusal::Gone));
105    assert!(m.soft_actions().is_empty(), "nothing was put away, so there is nothing to offer");
106    assert!(!m.facts()[0].visibility.is_hidden());
107}
108
109#[test]
110fn putting_away_what_is_away_already_adds_no_action() {
111    let d = dir("twice-away");
112    let mut m = SqliteMemory::open(Store::open(&d.join("d.db")).unwrap(), "phone").unwrap();
113    let f = rich(&mut m);
114    m.hide(f.id, 1_000).unwrap();
115    m.hide(f.id, 2_000).unwrap();
116    assert_eq!(m.soft_actions().len(), 1);
117    assert!(m.hide(999, 3_000).is_err());
118}
119
120#[test]
121fn the_parents_forgetting_a_memory_leaves_nothing_to_undo_and_cannot_be_undone() {
122    let d = dir("forget");
123    let db = d.join("d.db");
124    let store = Store::open(&db).unwrap();
125    let mut m = SqliteMemory::open(store.clone(), "phone").unwrap();
126    let f = rich(&mut m);
127    let keep = m.add(NewFact { text: "stays".into(), ..NewFact::default() }, 20).unwrap();
128    m.hide(f.id, 1_000).unwrap();
129    m.hide(keep.id, 1_100).unwrap();
130    let (id_forgotten, id_kept) = (m.soft_actions()[0].id, m.soft_actions()[1].id);
131    m.forget(f.id, 1_500).unwrap();
132    // Inside the window, and still refused: the action went with the memory.
133    assert_eq!(m.undo(id_forgotten, 1_600), Err(UndoRefusal::Gone));
134    assert_eq!(m.soft_actions().iter().map(|a| a.id).collect::<Vec<_>>(), vec![id_kept], "only the other memory's action is left");
135    // The forget itself is not a soft action: it never appears in the log, whatever its number.
136    assert!(m.soft_actions().iter().all(|a| a.kind == SoftKind::PutAway));
137    // And the schema cannot hold an action for a memory that is gone, whatever code asks.
138    drop((m, store));
139    let conn = rusqlite::Connection::open(&db).unwrap();
140    conn.pragma_update(None, "foreign_keys", "ON").unwrap();
141    let err = conn.execute("INSERT INTO soft_action (kind, gid, at_ms) VALUES ('put_away', ?1, 1)", [&f.gid]).unwrap_err();
142    assert!(err.to_string().to_lowercase().contains("foreign key"), "{err}");
143    let count: i64 = conn.query_row("SELECT COUNT(*) FROM soft_action WHERE gid = ?1", [&f.gid], |r| r.get(0)).unwrap();
144    assert_eq!(count, 0);
145}
146
147#[test]
148fn an_undo_reaches_the_other_devices_and_beats_the_hide_that_got_there_first() {
149    let d = dir("sync");
150    let mut phone = SqliteMemory::open(Store::open(&d.join("phone.db")).unwrap(), "phone").unwrap();
151    let mut tablet = SqliteMemory::open(Store::open(&d.join("tablet.db")).unwrap(), "tablet").unwrap();
152    let f = rich(&mut phone);
153    tablet.merge(phone.snapshot()).unwrap();
154    phone.hide(f.id, 100_000).unwrap();
155    tablet.merge(phone.snapshot()).unwrap();
156    assert!(tablet.facts()[0].visibility.is_hidden(), "the tablet heard of the hide");
157    let id = phone.soft_actions()[0].id;
158    phone.undo(id, 102_000).unwrap();
159    tablet.merge(phone.snapshot()).unwrap();
160    assert!(!tablet.facts()[0].visibility.is_hidden(), "and then of the undo");
161    phone.merge(tablet.snapshot()).unwrap();
162    assert!(!phone.facts()[0].visibility.is_hidden(), "which does not come back round as a hide");
163    assert!(tablet.soft_actions().is_empty(), "the tablet's child did not put it away, so it has nothing to undo");
164}