whiskers.git / crates / whiskers-store / tests / log_by_day.rs

The parents' log is read a day at a time through an index, never all at once, and the household can have its old lines cleared: their content goes, their place and time stay, the pictures only they showed are deleted, and nothing is cleared without a line that says so.

5use std::path::{Path, PathBuf};
7use rusqlite::Connection;
8use whiskers_core::{Entry, Event, Log, Outcome, PictureId};
9use whiskers_store::{EXCHANGE_SLACK_MS, LogDay, SqliteLog, SqlitePictures, Store};
10
11const DAY: u64 = 86_400_000;
12const HOUR: u64 = 3_600_000;
13const OLD_WORDS: &str = "OLD-WORDS-1357-QX";
14const OLD_PICTURE: &[u8] = b"OLD-PICTURE-BYTES-2468-QX-0123456789-0123456789";
15
16fn dir(name: &str) -> PathBuf {
17    let d = std::env::temp_dir().join(format!("whiskers-store-days-{name}-{}", std::process::id()));
18    let _ = std::fs::remove_dir_all(&d);
19    std::fs::create_dir_all(&d).unwrap();
20    d
21}
22
23fn contains_on_disk(db: &Path, needle: &[u8]) -> bool {
24    let wal = db.with_file_name(format!("{}-wal", db.file_name().unwrap().to_string_lossy()));
25    [std::fs::read(db).unwrap(), std::fs::read(wal).unwrap_or_default()].iter().any(|b| b.windows(needle.len()).any(|w| w == needle))
26}
27
28fn heard(at: u64, text: &str) -> Entry {
29    Entry::new(at, Event::Heard { text: text.into(), pictures: vec![] })
30}
31
32fn said(at: u64) -> Entry {
33    Entry::new(at, Event::Said { text: "ok".into(), outcome: Outcome::Answered })
34}

A log of five days, at an hour or two into each of days 100 to 104 of the epoch (UTC).

37fn five_days(store: &Store) -> SqliteLog {
38    let mut log = SqliteLog::new(store.clone(), "phone");
39    for day in 100..105 {
40        log.append(&heard(day * DAY + HOUR, &format!("day {day}"))).unwrap();
41        log.append(&said(day * DAY + HOUR + 1000)).unwrap();
42    }
43    log
44}
46#[test]
47fn a_day_is_read_through_the_index_and_nothing_else_is() {
48    let store = Store::open_in_memory().unwrap();
49    let log = five_days(&store);
50    let (groups, _) = log.read_range(102 * DAY, 103 * DAY).unwrap();
51    let texts: Vec<String> = groups[0].iter().filter_map(|e| if let Event::Heard { text, .. } = &e.event { Some(text.clone()) } else { None }).collect();
52    assert_eq!(texts, ["day 102"], "one day, and not the other four");
53    assert_eq!(groups[0].len(), 2);
54    // The query reads the index, not the table: asked the way the store asks it, SQLite says so.
55    let plan: Vec<String> = {
56        let conn = store_conn(&store);
57        let mut st = conn.prepare("EXPLAIN QUERY PLAN SELECT device, entry FROM journal_line WHERE at_ms >= 1 AND at_ms < 2 ORDER BY device, seq").unwrap();
58        let rows = st.query_map([], |r| r.get::<_, String>(3)).unwrap();
59        rows.map(Result::unwrap).collect()
60    };
61    assert!(plan.iter().any(|p| p.contains("journal_line_by_time")), "{plan:?}");
62}
63
64fn store_conn(store: &Store) -> Connection {
65    // A second connection to the same in-memory database is not possible; use a file for the plan.
66    let d = dir("plan");
67    let path = d.join("p.db");
68    let s = Store::open(&path).unwrap();
69    drop(s);
70    let _ = store;
71    Connection::open(path).unwrap()
72}
73
74#[test]
75fn a_page_of_days_is_newest_first_and_continues_where_it_left_off() {
76    let store = Store::open_in_memory().unwrap();
77    let log = five_days(&store);
78    let first = log.days(0, u64::MAX, 2).unwrap();
79    assert_eq!(first, [LogDay { start_ms: 104 * DAY, lines: 2, cleared: 0 }, LogDay { start_ms: 103 * DAY, lines: 2, cleared: 0 }]);
80    let next = log.days(0, first.last().unwrap().start_ms, 10).unwrap();
81    assert_eq!(next.iter().map(|d| d.start_ms / DAY).collect::<Vec<_>>(), [102, 101, 100], "from the day before the last one on, and no further than there is");
82    assert!(log.days(0, 100 * DAY, 10).unwrap().is_empty());
83    assert_eq!(log.days(0, u64::MAX, 0).unwrap(), []);
84    assert_eq!(log.days(0, u64::MAX, 100_000).unwrap().len(), 5, "a page is bounded");
85}
86
87#[test]
88fn a_day_is_the_households_own() {
89    let store = Store::open_in_memory().unwrap();
90    let mut log = SqliteLog::new(store.clone(), "phone");
91    // 02:00 UTC on day 101 is 22:00 on day 100 four hours behind UTC, and 07:30 on day 101 five and a half hours ahead.
92    log.append(&heard(101 * DAY + 2 * HOUR, "late")).unwrap();
93    log.append(&heard(101 * DAY + 5 * HOUR, "morning")).unwrap();
94    let behind = log.days(-240, u64::MAX, 5).unwrap();
95    assert_eq!(behind.iter().map(|d| (d.start_ms, d.lines)).collect::<Vec<_>>(), [(101 * DAY + 4 * HOUR, 1), (100 * DAY + 4 * HOUR, 1)]);
96    let ahead = log.days(330, u64::MAX, 5).unwrap();
97    assert_eq!(ahead.iter().map(|d| (d.start_ms, d.lines)).collect::<Vec<_>>(), [(100 * DAY + 18 * HOUR + 30 * 60_000, 2)]);
98}
99
100#[test]
101fn the_pictures_the_log_shows_are_listed_from_their_index() {
102    let store = Store::open_in_memory().unwrap();
103    let pictures = SqlitePictures::new(store.clone());
104    let (a, b) = (PictureId("0000000000000001-0000.jpg".into()), PictureId("0000000000000002-0000.jpg".into()));
105    pictures.store(&a, b"a").unwrap();
106    pictures.store(&b, b"b").unwrap();
107    let mut log = SqliteLog::new(store, "phone");
108    log.append(&Entry::new(1, Event::Heard { text: "x".into(), pictures: vec![b.clone(), a.clone()] })).unwrap();
109    log.append(&Entry::new(2, Event::Heard { text: "y".into(), pictures: vec![a.clone()] })).unwrap();
110    assert_eq!(log.pictures_shown().unwrap(), [a.0, b.0]);
111}
112
113#[test]
114fn old_lines_are_emptied_their_pictures_deleted_and_the_counts_sync_is_made_of_do_not_move() {
115    let d = dir("expire");
116    let db = d.join("w.db");
117    let store = Store::open(&db).unwrap();
118    let pictures = SqlitePictures::new(store.clone());
119    let (old, recent) = (PictureId("0000000000000001-0000.jpg".into()), PictureId("0000000000000002-0000.jpg".into()));
120    pictures.store(&old, OLD_PICTURE).unwrap();
121    pictures.store(&recent, b"RECENT-PICTURE").unwrap();
122    let mut log = SqliteLog::new(store.clone(), "phone");
123    log.append(&Entry::new(100 * DAY, Event::Heard { text: OLD_WORDS.into(), pictures: vec![old.clone()] })).unwrap();
124    log.append(&said(100 * DAY + 5)).unwrap();
125    log.append(&Entry::new(103 * DAY, Event::Heard { text: "recent words".into(), pictures: vec![recent.clone()] })).unwrap();
126    assert!(contains_on_disk(&db, OLD_WORDS.as_bytes()) && contains_on_disk(&db, OLD_PICTURE));
127
128    let cleared = log.expire_before(101 * DAY).unwrap();
129    assert_eq!(cleared, 2);
130    assert!(!contains_on_disk(&db, OLD_WORDS.as_bytes()), "the words are gone from the file and its log");
131    assert!(!contains_on_disk(&db, OLD_PICTURE), "and so is the picture only they showed");
132    assert!(!pictures.has(&old) && pictures.has(&recent));
133    let kept = log.own_entries().unwrap();
134    assert_eq!(kept.len(), 3, "no line was removed: sync's counts and cursors do not move");
135    assert_eq!((kept[0].at_ms, &kept[0].event), (100 * DAY, &Event::Expired));
136    assert!(matches!(&kept[2].event, Event::Heard { text, .. } if text == "recent words"));
137    assert_eq!(log.own_count().unwrap(), 3);
138    // Once more: nothing left to clear, so nothing is reported.
139    assert_eq!(log.expire_before(101 * DAY).unwrap(), 0);
140    assert_eq!(log.expire_before(0).unwrap(), 0);
141    // A day with cleared lines says so in the page of days.
142    let days = log.days(0, u64::MAX, 5).unwrap();
143    assert_eq!((days[1].start_ms, days[1].lines, days[1].cleared), (100 * DAY, 0, 2));
144    // The index of the lines that still have content does the finding, not a walk over the ones already cleared.
145    let conn = Connection::open(&db).unwrap();
146    let mut st = conn.prepare("EXPLAIN QUERY PLAN UPDATE journal_line SET entry = entry WHERE at_ms < 5 AND live").unwrap();
147    let plan: Vec<String> = st.query_map([], |r| r.get::<_, String>(3)).unwrap().map(Result::unwrap).collect();
148    assert!(plan.iter().any(|p| p.contains("journal_line_live_by_time")), "{plan:?}");
149}
150
151#[test]
152fn a_picture_a_memory_or_a_newer_line_still_shows_is_kept_when_the_old_line_that_showed_it_is_cleared() {
153    let store = Store::open_in_memory().unwrap();
154    let pictures = SqlitePictures::new(store.clone());
155    let shared = PictureId("0000000000000001-0000.jpg".into());
156    pictures.store(&shared, b"shared").unwrap();
157    let mut log = SqliteLog::new(store, "phone");
158    log.append(&Entry::new(10, Event::Heard { text: "a".into(), pictures: vec![shared.clone()] })).unwrap();
159    log.append(&Entry::new(10 * DAY, Event::Heard { text: "b".into(), pictures: vec![shared.clone()] })).unwrap();
160    assert_eq!(log.expire_before(DAY).unwrap(), 1);
161    assert!(pictures.has(&shared), "a newer line still shows it");
162}
163
164#[test]
165fn the_slack_past_the_end_of_a_range_is_what_a_turn_needs_to_finish() {
166    let store = Store::open_in_memory().unwrap();
167    let mut log = SqliteLog::new(store, "phone");
168    log.append(&heard(DAY - 1000, "just before midnight")).unwrap();
169    log.append(&said(DAY + 2000)).unwrap();
170    let (g, _) = log.read_range(0, DAY + EXCHANGE_SLACK_MS).unwrap();
171    assert_eq!(g[0].len(), 2, "the closing line of a turn that began before midnight is read with it");
172}