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).
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}