1use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Entry {
40 pub id: String,
41 #[serde(default)]
43 pub name: String,
44 #[serde(default)]
46 pub kind: String,
47 #[serde(default)]
51 pub version: String,
52 #[serde(default, skip_serializing_if = "String::is_empty")]
53 pub commit: String,
54 #[serde(default, skip_serializing_if = "is_zero")]
58 pub format: u32,
59 #[serde(default, skip_serializing_if = "is_zero")]
69 pub keeping: u32,
70 #[serde(default)]
73 pub started: i64,
74 #[serde(default)]
76 pub closed: i64,
77 pub working: bool,
80 #[serde(default)]
82 pub imported: i64,
83 #[serde(default)]
86 pub exported: i64,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub wrote: Option<(u32, u64)>,
90 #[serde(default)]
92 pub doses: bool,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub notifications: Option<bool>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub active: Option<bool>,
108 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
110 pub left: bool,
111 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
119 pub acked: std::collections::BTreeMap<String, i64>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub replaces: Vec<Replaced>,
129 #[serde(default, skip_serializing_if = "String::is_empty")]
131 pub pad: String,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Replaced {
141 pub id: String,
142 #[serde(default)]
143 pub started: i64,
144 #[serde(default)]
145 pub heard: i64,
146}
147
148impl Replaced {
149 pub fn holds(&self, started: Option<i64>, latest_ms: i64) -> bool {
153 started.is_none_or(|started| started == self.started) && latest_ms <= self.heard
154 }
155}
156
157fn is_zero(n: &u32) -> bool {
158 *n == 0
159}
160
161pub const PHONE: &str = "phone";
162pub const COMPUTER: &str = "computer";
163
164impl Entry {
165 pub fn start(&mut self, now: i64) {
167 self.started = now;
168 self.working = true;
169 self.left = false;
170 }
171
172 pub fn close(&mut self, now: i64) {
175 self.closed = now.max(self.started);
176 self.working = false;
177 }
178
179 pub fn exported(&mut self, looked_ms: i64, wrote: Option<(u32, u64)>, now: i64) {
183 if looked_ms <= 0 {
184 return;
185 }
186 self.imported = now;
187 self.exported = self.exported.max(looked_ms / 1000);
188 if wrote.is_some() {
189 self.wrote = wrote;
190 }
191 }
192
193 pub fn build(&self) -> String {
196 sioul_core::build::described(&self.version, &self.commit)
197 }
198
199 pub fn said(&self) -> sioul_core::health::Said {
201 sioul_core::health::Said { started: self.started, closed: self.closed, working: self.working, exported: self.exported, imported: self.imported, doses: self.doses, left: self.left, phone: self.kind == PHONE }
202 }
203}
204
205fn folder_of(folder: &Path) -> PathBuf {
206 folder.join("devices")
207}
208
209fn file_of(folder: &Path, id: &str) -> PathBuf {
210 folder_of(folder).join(format!("{id}.device"))
211}
212
213fn bound(id: &str) -> String {
214 format!("device\u{1f}{id}")
215}
216
217pub fn publish(folder: &Path, key: &[u8; 32], entry: &Entry) -> Result<(), String> {
220 let path = file_of(folder, &entry.id);
221 let sealed = |pad: usize| serde_json::to_vec(&Entry { pad: ".".repeat(pad), ..entry.clone() }).map(|plain| crate::share::seal(key, &bound(&entry.id), &plain)).map_err(|e| e.to_string());
222 let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
223 crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
224}
225
226pub fn all(folder: &Path, key: &[u8; 32]) -> (Vec<Entry>, Vec<String>) {
234 let (mut entries, mut unread) = (Vec::new(), Vec::new());
235 let fetched = crate::remote::overlay(folder);
236 let ids = |dir: &Path| -> Vec<String> {
237 let listing = std::fs::read_dir(folder_of(dir)).into_iter().flatten().filter_map(Result::ok);
238 listing.filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".device")).map(str::to_string)).filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()).collect()
239 };
240 let mut all: std::collections::BTreeSet<String> = ids(folder).into_iter().collect();
241 if let Some(fetched) = &fetched {
242 all.extend(ids(fetched));
243 }
244 let read = |dir: &Path, id: &str| crate::share::read_small(&file_of(dir, id)).and_then(|text| crate::share::open(key, &bound(id), text.trim())).and_then(|plain| serde_json::from_slice::<Entry>(&plain).ok()).filter(|entry| entry.id == id);
245 for id in all {
246 let entry = match (read(folder, &id), fetched.as_deref().and_then(|f| read(f, &id))) {
247 (Some(here), Some(fetched)) => Some(chosen(here, fetched)),
248 (here, fetched) => here.or(fetched),
249 };
250 match entry {
251 Some(entry) => entries.push(entry),
252 None => unread.push(id),
253 }
254 }
255 entries.sort_by(|a, b| a.id.cmp(&b.id));
256 unread.sort();
257 (entries, unread)
258}
259
260pub fn chosen(here: Entry, fetched: Entry) -> Entry {
271 let (mut chosen, other) = if written(&fetched) > written(&here) { (fetched, here) } else { (here, fetched) };
272 chosen.acked.retain(|computer, _| other.acked.contains_key(computer));
273 chosen
274}
275
276pub fn written(entry: &Entry) -> (i64, i64, i64, i64, i64) {
280 (entry.started.max(entry.closed).max(entry.imported), entry.closed, entry.started, entry.imported, entry.exported)
281}
282
283pub fn own_path(state: &Path) -> PathBuf {
285 state.join("share").join("device.toml")
286}
287
288pub fn own(path: &Path) -> Entry {
290 std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
291}
292
293pub(crate) fn in_use(memory: &Path) -> bool {
296 own(&memory.with_file_name("device.toml")).working
297}
298
299pub const RESTATED: i64 = 60 * 60;
306
307pub fn others_in_use(entries: &[Entry], own: &str, now: i64, asleep: bool) -> bool {
318 use sioul_core::health::{FRESH, SKEW};
319 entries.iter().any(|e| e.id != own && e.working && !e.left && now - e.exported <= FRESH + SKEW && (!asleep || e.active != Some(false)))
320}
321
322#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328struct Published {
329 said: String,
330 folder: String,
331 #[serde(default)]
332 key: String,
333 size: u64,
334 modified: u64,
335 at: i64,
336}
337
338fn sealed_with(key: &[u8; 32]) -> String {
341 use sha2::Digest;
342 let digest = sha2::Sha256::new().chain_update(b"sioul: the key a device entry was sealed with\x1f").chain_update(key).finalize();
343 digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
344}
345
346fn published_path(own: &Path) -> PathBuf {
347 own.with_file_name("device-published.toml")
348}
349
350pub(crate) fn steady(entry: &Entry) -> String {
353 let steady = Entry { imported: 0, exported: 0, pad: String::new(), ..entry.clone() };
354 serde_json::to_string(&steady).map(|text| crate::share::hash(&text)).unwrap_or_default()
355}
356
357fn stamp_of(path: &Path) -> Option<(u64, u64)> {
359 std::fs::metadata(path).ok().map(|meta| (meta.len(), crate::share::modified_ns(&meta)))
360}
361
362pub fn change(path: &Path, vault: Option<(&Path, &[u8; 32])>, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
367 change_at(path, vault, jiff::Timestamp::now().as_second(), change)
368}
369
370pub(crate) fn change_at(path: &Path, vault: Option<(&Path, &[u8; 32])>, now: i64, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
372 sioul_core::filelock::with_lock(path, || {
373 let mut entry = own(path);
374 change(&mut entry);
375 entry.pad.clear();
376 let text = toml::to_string(&entry).map_err(|e| e.to_string())?;
377 crate::share::write_atomically(path, format!("# This device, as it tells your other devices (docs/database.md, \"Devices\").\n{text}").as_bytes())?;
378 if let Some((folder, key)) = vault
379 && !entry.id.is_empty()
380 {
381 let file = file_of(folder, &entry.id);
382 let said = steady(&entry);
383 let there = folder.display().to_string();
384 let last: Option<Published> = std::fs::read_to_string(published_path(path)).ok().and_then(|t| toml::from_str(&t).ok());
385 let mark = sealed_with(key);
387 let current = last.is_some_and(|last| last.said == said && last.folder == there && last.key == mark && stamp_of(&file) == Some((last.size, last.modified)) && (0..RESTATED).contains(&(now - last.at)));
388 if entry.working || !current {
390 publish(folder, key, &entry)?;
391 let (size, modified) = stamp_of(&file).unwrap_or_default();
392 let published = Published { said, folder: there, key: mark, size, modified, at: now };
393 let _ = toml::to_string(&published).map_err(|e| e.to_string()).and_then(|text| crate::share::write_atomically(&published_path(path), text.as_bytes()));
395 }
396 }
397 Ok(entry)
398 })
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::share::{Roots, Sharing, Store};
405 use sioul_core::config::Config;
406 use sioul_core::doses::{Answer, Answered, DoseRecords, SKIPPED, TAKEN};
407 use sioul_core::health::{Doubt, HealthState, Peer, doubts_now};
408
409 fn scratch(name: &str) -> PathBuf {
410 let dir = std::env::temp_dir().join(format!("sioul-devices-{name}-{}", std::process::id()));
411 let _ = std::fs::remove_dir_all(&dir);
412 std::fs::create_dir_all(&dir).unwrap();
413 dir
414 }
415
416 const KEY: [u8; 32] = [7u8; 32];
417
418 #[test]
419 fn a_device_says_how_it_is() {
420 let dir = scratch("says");
421 let (folder, state) = (dir.join("shared"), dir.join("state"));
422 let id = uuid::Uuid::new_v4().to_string();
423 let own = own_path(&state);
424 let vault = Some((folder.as_path(), &KEY));
425 change(&own, vault, |e| {
427 e.id = id.clone();
428 e.kind = PHONE.into();
429 e.doses = true;
430 e.start(1_000);
431 })
432 .unwrap();
433 let raw = std::fs::read_to_string(file_of(&folder, &id)).unwrap();
434 assert!(!raw.contains("working") && !raw.contains(&id), "sealed");
435 let size = raw.len();
436 let (entries, unread) = all(&folder, &KEY);
437 assert!(unread.is_empty());
438 assert_eq!((entries[0].started, entries[0].working, entries[0].said().phone), (1_000, true, true));
439 change(&own, vault, |e| e.exported(1_060_000, Some((1, 4)), 1_061)).unwrap();
441 change(&own, vault, |e| e.close(1_120)).unwrap();
443 let (entries, _) = all(&folder, &KEY);
444 assert_eq!((entries[0].exported, entries[0].wrote, entries[0].closed, entries[0].working), (1_060, Some((1, 4)), 1_120, false));
445 assert_ne!(std::fs::metadata(file_of(&folder, &id)).unwrap().len() as usize, size, "its size changes at each writing");
446 assert_eq!(super::own(&own).closed, 1_120);
448 let (entries, unread) = all(&folder, &[1u8; 32]);
450 assert!(entries.is_empty() && unread == [id.clone()]);
451 std::fs::copy(file_of(&folder, &id), folder_of(&folder).join(format!("{id} (conflicted copy).device"))).unwrap();
453 let other = uuid::Uuid::new_v4().to_string();
454 std::fs::copy(file_of(&folder, &id), file_of(&folder, &other)).unwrap();
455 let (entries, unread) = all(&folder, &KEY);
456 assert_eq!((entries.len(), unread), (1, vec![other]));
457 let without = serde_json::json!({ "id": id, "started": 1_000, "closed": 2_000 }).to_string();
459 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), without.as_bytes())).unwrap();
460 assert!(all(&folder, &KEY).0.is_empty());
461 let _ = std::fs::remove_dir_all(&dir);
462 }
463
464 #[test]
467 fn an_entry_says_its_build_and_an_older_one_still_reads() {
468 let dir = scratch("build");
469 let (folder, state) = (dir.join("shared"), dir.join("state"));
470 let id = uuid::Uuid::new_v4().to_string();
471 let vault = Some((folder.as_path(), &KEY));
472 change(&own_path(&state), vault, |e| {
473 e.id = id.clone();
474 e.version = sioul_core::build::VERSION.into();
475 e.commit = sioul_core::build::COMMIT.into();
476 e.start(1_000);
477 })
478 .unwrap();
479 let (entries, _) = all(&folder, &KEY);
480 assert_eq!((entries[0].version.as_str(), entries[0].commit.as_str()), (sioul_core::build::VERSION, sioul_core::build::COMMIT));
481 assert_eq!(entries[0].build(), sioul_core::build::DESCRIBED);
482 assert!(!std::fs::read_to_string(file_of(&folder, &id)).unwrap().contains(sioul_core::build::COMMIT), "sealed: the server never learns it");
483 for (plain, said) in [(serde_json::json!({ "id": id, "version": "0.0.2", "started": 1_000, "working": true }), "0.0.2"), (serde_json::json!({ "id": id, "started": 1_000, "working": true }), "")] {
485 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), plain.to_string().as_bytes())).unwrap();
486 let (entries, unread) = all(&folder, &KEY);
487 assert!(unread.is_empty() && entries[0].working && entries[0].commit.is_empty(), "{entries:?}");
488 assert_eq!(entries[0].build(), said);
489 }
490 let _ = std::fs::remove_dir_all(&dir);
491 }
492
493 #[test]
497 fn a_phone_says_whether_it_holds_notifications() {
498 let dir = scratch("notifications");
499 let (folder, state) = (dir.join("shared"), dir.join("state"));
500 let id = uuid::Uuid::new_v4().to_string();
501 let vault = Some((folder.as_path(), &KEY));
502 change(&own_path(&state), vault, |e| {
503 e.id = id.clone();
504 e.kind = PHONE.into();
505 e.notifications = Some(true);
506 e.start(1_000);
507 })
508 .unwrap();
509 change(&own_path(&state), vault, |e| e.close(1_100)).unwrap();
510 assert_eq!(all(&folder, &KEY).0[0].notifications, Some(true), "kept by a change that did not look");
511 change(&own_path(&state), vault, |e| e.notifications = Some(false)).unwrap();
512 assert_eq!(all(&folder, &KEY).0[0].notifications, Some(false));
513 let older = serde_json::json!({ "id": id, "kind": "phone", "started": 1_000, "working": true });
514 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), older.to_string().as_bytes())).unwrap();
515 assert_eq!(all(&folder, &KEY).0[0].notifications, None, "an older Sioul's: unsaid");
516 let _ = std::fs::remove_dir_all(&dir);
517 }
518
519 #[test]
526 fn a_device_put_away_writes_its_entry_only_when_it_says_something_new() {
527 let dir = scratch("put-away");
528 let (folder, state) = (dir.join("shared"), dir.join("state"));
529 let own = own_path(&state);
530 let vault = Some((folder.as_path(), &KEY));
531 let id = uuid::Uuid::new_v4().to_string();
532 let file = file_of(&folder, &id);
533 let bytes = || std::fs::read(&file).unwrap();
534 let exported = |at: i64, wrote: (u32, u64)| change_at(&own, vault, at, |e| e.exported(at * 1000, Some(wrote), at)).unwrap();
535 let t = 1_800_000_000;
536 change_at(&own, vault, t, |e| {
537 e.id = id.clone();
538 e.kind = PHONE.into();
539 e.doses = true;
540 e.start(t);
541 })
542 .unwrap();
543 exported(t, (1, 4));
544 change_at(&own, vault, t + 5, |e| e.close(t + 5)).unwrap();
545 let closed = bytes();
546 for n in 1..=5 {
548 exported(t + 5 + n * 120, (1, 4));
549 }
550 assert_eq!(bytes(), closed, "nothing new: the folder's copy as it was");
551 assert_eq!(super::own(&own).exported, t + 605, "kept here");
552 let seen = &all(&folder, &KEY).0[0];
553 assert_eq!((seen.exported, seen.wrote, seen.working), (t, Some((1, 4)), false));
554 exported(t + 700, (2, 1));
556 let seen = all(&folder, &KEY).0[0].clone();
557 assert_eq!((seen.exported, seen.wrote, seen.working), (t + 700, Some((2, 1)), false));
558 let wrote = bytes();
560 exported(t + 700 + RESTATED - 1, (2, 1));
561 assert_eq!(bytes(), wrote);
562 exported(t + 700 + RESTATED, (2, 1));
563 assert_eq!(all(&folder, &KEY).0[0].exported, t + 700 + RESTATED, "restated after an hour");
564 std::fs::write(&file, &closed).unwrap();
566 exported(t + 800 + RESTATED, (2, 1));
567 assert_eq!(all(&folder, &KEY).0[0].wrote, Some((2, 1)), "the copy put back is replaced");
568 change_at(&own, vault, t + 900 + RESTATED, |e| e.doses = false).unwrap();
570 assert!(!all(&folder, &KEY).0[0].doses);
571 let other = [9u8; 32];
573 exported(t + 950 + RESTATED, (2, 1));
574 change_at(&own, Some((folder.as_path(), &other)), t + 960 + RESTATED, |e| e.exported((t + 960 + RESTATED) * 1000, Some((2, 1)), t + 960 + RESTATED)).unwrap();
575 assert_eq!(all(&folder, &other).0.len(), 1, "readable under the new key at once");
576 change_at(&own, vault, t + 1_000 + RESTATED, |e| e.start(t + 1_000 + RESTATED)).unwrap();
578 for n in 1..=3 {
579 let at = t + 1_000 + RESTATED + n * 60;
580 exported(at, (2, 1));
581 let seen = &all(&folder, &KEY).0[0];
582 assert_eq!((seen.exported, seen.working), (at, true), "in use: export {n} there at once");
583 }
584 let elsewhere = dir.join("elsewhere");
586 change_at(&own, Some((elsewhere.as_path(), &KEY)), t + 2_000 + RESTATED, |e| e.close(t + 2_000 + RESTATED)).unwrap();
587 exported(t + 2_100 + RESTATED, (2, 1));
588 let moved = dir.join("moved");
589 change_at(&own, Some((moved.as_path(), &KEY)), t + 2_200 + RESTATED, |e| e.exported((t + 2_200 + RESTATED) * 1000, Some((2, 1)), t + 2_200 + RESTATED)).unwrap();
590 assert_eq!(all(&moved, &KEY).0.len(), 1, "the new folder has it at once");
591 let _ = std::fs::remove_dir_all(&dir);
592 }
593
594 #[test]
600 fn a_computer_counts_in_use_for_a_phones_rhythm_while_someone_is_at_it() {
601 use sioul_core::health::{FRESH, SKEW};
602 let now = DUE;
603 let desk = |active: Option<bool>| Entry { id: "desk".into(), kind: COMPUTER.into(), working: true, started: now - 3_600, exported: now - 60, active, ..Entry::default() };
604 for asleep in [false, true] {
605 assert!(others_in_use(&[desk(Some(true))], "phone", now, asleep));
606 assert!(others_in_use(&[desk(None)], "phone", now, asleep), "unsaid: Sioul running counts, as before");
607 assert!(!others_in_use(&[Entry { exported: now - FRESH - SKEW - 1, ..desk(Some(true)) }], "phone", now, asleep), "its news stale");
608 assert!(!others_in_use(&[Entry { working: false, ..desk(Some(true)) }], "phone", now, asleep), "closed");
609 assert!(!others_in_use(&[Entry { left: true, ..desk(None) }], "phone", now, asleep), "no longer sharing");
610 assert!(!others_in_use(&[desk(Some(true))], "desk", now, asleep), "itself");
611 assert!(others_in_use(&[desk(Some(false)), Entry { id: "laptop".into(), ..desk(Some(true)) }], "phone", now, asleep), "someone at another computer");
612 assert!(!others_in_use(&[], "phone", now, asleep), "no other device");
613 }
614 assert!(others_in_use(&[desk(Some(false))], "phone", now, false), "awake: Sioul running counts");
616 assert!(!others_in_use(&[desk(Some(false))], "phone", now, true), "asleep: nobody at it, not in use");
617 }
618
619 #[test]
627 fn a_computer_writes_its_entry_at_one_cadence_whoever_is_at_it() {
628 use sioul_core::health::{FRESH, SKEW};
629 let dir = scratch("one-cadence");
630 let (folder, state) = (dir.join("shared"), dir.join("state"));
631 let own = own_path(&state);
632 let vault = Some((folder.as_path(), &KEY));
633 let id = uuid::Uuid::new_v4().to_string();
634 let file = file_of(&folder, &id);
635 let t = DUE - 3_600;
636 change_at(&own, vault, t, |e| {
637 e.id = id.clone();
638 e.kind = COMPUTER.into();
639 e.doses = true;
640 e.active = Some(true);
641 e.start(t);
642 })
643 .unwrap();
644 let export = |at: i64, active: Option<bool>| -> bool {
646 let before = std::fs::read(&file).unwrap();
647 change_at(&own, vault, at, |e| {
648 e.exported(at * 1000, Some((1, 4)), at);
649 e.active = active;
650 })
651 .unwrap();
652 std::fs::read(&file).unwrap() != before
653 };
654 let mut written = Vec::new();
656 let mut watched: Option<((i64, i64, bool, i64, bool), i64)> = None;
657 for at in (t + 60..=t + 90 * 60).step_by(10) {
658 let active = match (at - t) / 60 {
659 0..=10 => Some(true),
660 11..=80 => Some(false),
661 _ => None,
662 };
663 if (at - t) % 60 == 0 && export(at, active) {
664 written.push(at);
665 }
666 if (at - t) % 70 != 0 {
668 continue;
669 }
670 let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == id).unwrap();
671 assert!(at - entry.exported < 60, "the folder's copy from the last export: {} s at {at}", at - entry.exported);
672 let state = (entry.started, entry.closed, entry.working, entry.exported, entry.left);
673 if watched.is_none_or(|(last, _)| last != state) {
674 watched = Some((state, at));
675 }
676 let peer = Peer { id: id.clone(), said: Some(entry.said()), complete: true, seen: watched.map_or(at, |(_, seen)| seen), heard: entry.exported, ..Peer::default() };
677 let doubts = doubts_now(DUE, at, None, &[peer]);
678 if entry.exported >= DUE + SKEW {
679 assert!(doubts.is_empty(), "an export after the dose read: known at {at}: {doubts:?}");
680 } else {
681 assert!(matches!(doubts.as_slice(), [Doubt::Working { .. }]), "not shared since the dose: in doubt at {at}");
682 }
683 }
684 assert_eq!(written, (1..=90).map(|n| t + n * 60).collect::<Vec<_>>(), "one writing a minute, whoever is at it");
685 let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == id).unwrap();
687 let stopped = entry.exported + FRESH + 1;
688 let peer = Peer { id: id.clone(), said: Some(entry.said()), complete: true, seen: entry.exported, heard: entry.exported, ..Peer::default() };
689 assert!(matches!(doubts_now(DUE, stopped, None, &[peer]).as_slice(), [Doubt::Working { .. }]));
690 let _ = std::fs::remove_dir_all(&dir);
691 }
692
693 #[test]
700 fn an_older_device_reads_the_entry_that_says_whether_someone_is_at_it() {
701 use sioul_core::health::{FRESH, SKEW};
702 #[derive(Deserialize)]
704 struct Older {
705 id: String,
706 #[serde(default)]
707 kind: String,
708 #[serde(default)]
709 started: i64,
710 #[serde(default)]
711 closed: i64,
712 working: bool,
713 #[serde(default)]
714 exported: i64,
715 #[serde(default)]
716 wrote: Option<(u32, u64)>,
717 #[serde(default)]
718 doses: bool,
719 #[serde(default)]
720 left: bool,
721 }
722 let dir = scratch("older-reads");
723 let (folder, state) = (dir.join("shared"), dir.join("state"));
724 let id = uuid::Uuid::new_v4().to_string();
725 let vault = Some((folder.as_path(), &KEY));
726 change(&own_path(&state), vault, |e| {
727 e.id = id.clone();
728 e.kind = COMPUTER.into();
729 e.doses = true;
730 e.start(1_000);
731 e.exported(1_060_000, Some((1, 2)), 1_061);
732 e.active = Some(false);
733 })
734 .unwrap();
735 let sealed = std::fs::read_to_string(file_of(&folder, &id)).unwrap();
736 let plain = crate::share::open(&KEY, &bound(&id), sealed.trim()).unwrap();
737 assert!(String::from_utf8_lossy(&plain).contains("\"active\":false"));
738 let older: Older = serde_json::from_slice(&plain).expect("an older Sioul reads it");
739 assert_eq!((older.id.as_str(), older.kind.as_str(), older.started, older.closed, older.working, older.exported, older.wrote, older.doses, older.left), (id.as_str(), COMPUTER, 1_000, 0, true, 1_060, Some((1, 2)), true, false));
740 assert!(older.working && !older.left && 1_100 - older.exported <= FRESH + SKEW, "an older phone steps as before");
742 let said = all(&folder, &KEY).0[0].said();
744 change(&own_path(&state), vault, |e| e.active = Some(true)).unwrap();
745 assert_eq!(all(&folder, &KEY).0[0].said(), said);
746 let plain = serde_json::json!({ "id": id, "kind": "computer", "started": 1_000, "working": true, "exported": 1_060 }).to_string();
748 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), plain.as_bytes())).unwrap();
749 let (entries, _) = all(&folder, &KEY);
750 assert_eq!(entries[0].active, None);
751 assert!(others_in_use(&entries, "phone", 1_100, false) && others_in_use(&entries, "phone", 1_100, true));
752 let _ = std::fs::remove_dir_all(&dir);
753 }
754
755 #[test]
756 fn two_processes_never_undo_each_others_session() {
757 let dir = scratch("processes");
758 let (folder, state) = (dir.join("shared"), dir.join("state"));
759 let own = own_path(&state);
760 let vault = Some((folder.as_path(), &KEY));
761 let id = uuid::Uuid::new_v4().to_string();
762 change(&own, vault, |e| {
763 e.id = id.clone();
764 e.start(1_000);
765 })
766 .unwrap();
767 change(&own, vault, |e| e.close(1_100)).unwrap();
769 change(&own, vault, |e| e.exported(1_200_000, Some((1, 9)), 1_201)).unwrap();
770 let (entries, _) = all(&folder, &KEY);
771 assert_eq!((entries[0].working, entries[0].closed, entries[0].exported), (false, 1_100, 1_200));
772 change(&own, vault, |e| e.start(1_300)).unwrap();
774 change(&own, vault, |e| e.exported(1_310_000, Some((1, 9)), 1_311)).unwrap();
775 assert!(all(&folder, &KEY).0[0].working);
776 change(&own, vault, |e| e.exported(0, None, 1_400)).unwrap();
778 assert_eq!(all(&folder, &KEY).0[0].exported, 1_310);
779 let _ = std::fs::remove_dir_all(&dir);
780 }
781
782 struct Device {
786 roots: Roots,
787 id: String,
788 memory: PathBuf,
789 kind: &'static str,
790 }
791
792 impl Device {
793 fn new(base: &Path, name: &str, kind: &'static str) -> Device {
794 let root = base.join(name);
795 let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
796 for dir in [&roots.config, &roots.data, &roots.state] {
797 std::fs::create_dir_all(dir).unwrap();
798 }
799 let memory = roots.state.join("share").join("memory.json");
800 Device { roots, id: uuid::Uuid::new_v4().to_string(), memory, kind }
801 }
802
803 fn stores(&self) -> Vec<Store> {
804 crate::share::stores(&Config::default(), &self.roots)
805 }
806
807 fn exchange(&self, folder: &Path, now: i64) -> crate::share::Outcome {
810 let outcome = crate::share::exchange(&Sharing { folder, computer: &self.id, key: &KEY, memory: &self.memory, files: false, hurry: None }, &self.stores(), now * 1000).unwrap();
811 change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| e.exported(now * 1000, outcome.wrote, now)).unwrap();
813 outcome
814 }
815
816 fn session(&self, folder: &Path, f: impl FnOnce(&mut Entry)) {
817 let kind = self.kind;
818 let id = self.id.clone();
819 change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| {
820 e.id = id;
821 e.kind = kind.into();
822 e.doses = true;
823 f(e);
824 })
825 .unwrap();
826 }
827
828 fn records(&self) -> PathBuf {
829 self.roots.state.join(sioul_core::doses::FILE)
830 }
831
832 fn marks(&self) -> PathBuf {
833 self.roots.state.join("health-state.toml")
834 }
835
836 fn answer(&self, key: &str, state: &str, at: i64) {
839 DoseRecords::update(&self.records(), at, |r| r.answer(key, &self.id, Answer { state: state.into(), at, noted: at, name: self.kind.into(), kind: self.kind.into() })).unwrap();
840 HealthState::update(&self.marks(), at, |s| {
841 if state == TAKEN {
842 s.taken.insert(key.to_string(), at);
843 } else {
844 s.not_taken.insert(key.to_string(), at);
845 }
846 })
847 .unwrap();
848 }
849
850 fn sees(&self, folder: &Path, other: &Device, now: i64) -> Peer {
853 let entry = all(folder, &KEY).0.into_iter().find(|e| e.id == other.id).expect("its entry");
854 let heard = crate::share::heard(&self.memory, &self.id);
855 Peer { id: other.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: entry.wrote.is_none_or(|wrote| heard.complete(&other.id, wrote)), seen: now, heard: entry.exported, ..Peer::default() }
856 }
857
858 fn answered(&self, key: &str) -> Answered {
859 sioul_core::doses::answered(key, &HealthState::read(&self.marks()).unwrap(), &DoseRecords::read(&self.records()).unwrap())
860 }
861 }
862
863 const DUE: i64 = 1_800_000_000;
864 const DOSE: &str = "levo@1800000000";
865
866 fn pair(name: &str) -> (PathBuf, PathBuf, Device, Device) {
868 let base = scratch(name);
869 let folder = base.join("shared");
870 std::fs::create_dir_all(&folder).unwrap();
871 let desk = Device::new(&base, "desk", COMPUTER);
872 let phone = Device::new(&base, "phone", PHONE);
873 desk.session(&folder, |e| e.start(DUE - 7_200));
874 phone.session(&folder, |e| e.start(DUE - 7_200));
875 desk.exchange(&folder, DUE - 7_100);
876 phone.exchange(&folder, DUE - 7_090);
877 desk.exchange(&folder, DUE - 7_080);
878 (base, folder, desk, phone)
879 }
880
881 #[test]
882 fn the_phone_marks_taken_in_the_background_and_the_desktop_shows_taken() {
883 let (base, folder, desk, phone) = pair("background");
884 phone.exchange(&folder, DUE - 3_600);
886 phone.session(&folder, |e| e.close(DUE - 3_595));
887 desk.exchange(&folder, DUE - 3_500);
888 let at_dose = desk.sees(&folder, &phone, DUE + 30);
890 assert!(doubts_now(DUE, DUE + 30, None, &[at_dose]).is_empty());
891 assert_eq!(desk.answered(DOSE), Answered::Open);
892 phone.session(&folder, |e| e.start(DUE + 300));
895 phone.answer(DOSE, TAKEN, DUE + 300);
896 phone.exchange(&folder, DUE + 302);
897 phone.session(&folder, |e| e.close(DUE + 303));
898 desk.exchange(&folder, DUE + 360);
900 match desk.answered(DOSE) {
901 Answered::Taken(given) => assert_eq!((given.device.as_str(), given.at), (phone.id.as_str(), DUE + 300)),
902 other => panic!("{other:?}"),
903 }
904 assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
905 assert!(HealthState::read(&desk.marks()).unwrap().taken.contains_key(DOSE));
907 let _ = std::fs::remove_dir_all(&base);
908 }
909
910 #[test]
917 fn the_phone_put_away_and_quiet_is_known_without_rewriting_its_entry() {
918 let (base, folder, desk, phone) = pair("quiet-phone");
919 phone.exchange(&folder, DUE - 3_600);
920 phone.session(&folder, |e| e.close(DUE - 3_595));
921 let entry = std::fs::read(file_of(&folder, &phone.id)).unwrap();
922 for n in 0..12 {
923 phone.exchange(&folder, DUE - 3_500 + n * 300);
924 desk.exchange(&folder, DUE - 3_490 + n * 300);
925 }
926 assert_eq!(std::fs::read(file_of(&folder, &phone.id)).unwrap(), entry, "nothing new: its entry as it was");
927 for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
928 assert!(doubts_now(DUE, at, None, &[desk.sees(&folder, &phone, at)]).is_empty(), "known at {at}");
929 }
930 phone.session(&folder, |e| e.start(DUE + 300));
932 assert!(matches!(doubts_now(DUE, DUE + 310, None, &[desk.sees(&folder, &phone, DUE + 310)]).as_slice(), [Doubt::Working { .. }]));
933 phone.answer(DOSE, TAKEN, DUE + 300);
934 phone.exchange(&folder, DUE + 302);
935 phone.session(&folder, |e| e.close(DUE + 303));
936 desk.exchange(&folder, DUE + 360);
937 assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
938 assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
939 let _ = std::fs::remove_dir_all(&base);
940 }
941
942 #[test]
951 fn a_dose_is_known_with_seen_as_the_desktop_keeps_it() {
952 let (base, folder, desk, phone) = pair("seen-kept");
953 let mut watched: Option<((i64, i64, bool, i64, bool), i64)> = None;
954 let mut sees = |at: i64| -> Peer {
955 let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == phone.id).expect("its entry");
956 let state = (entry.started, entry.closed, entry.working, entry.exported, entry.left);
957 if watched.is_none_or(|(last, _)| last != state) {
958 watched = Some((state, at));
959 }
960 Peer { seen: watched.map_or(at, |(_, seen)| seen), ..desk.sees(&folder, &phone, at) }
961 };
962 phone.exchange(&folder, DUE - 3_600);
963 phone.session(&folder, |e| e.close(DUE - 3_595));
964 desk.exchange(&folder, DUE - 3_590);
965 let closed_seen = sees(DUE - 3_590).seen;
966 for n in 0..12 {
967 phone.exchange(&folder, DUE - 3_500 + n * 300);
968 desk.exchange(&folder, DUE - 3_490 + n * 300);
969 assert_eq!(sees(DUE - 3_490 + n * 300).seen, closed_seen, "put away and quiet: its entry not seen changing");
970 }
971 for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
972 let peer = sees(at);
973 assert!(at - peer.seen > sioul_core::health::FRESH);
974 assert!(doubts_now(DUE, at, None, &[peer]).is_empty(), "closed, its seen {} s old: known at {at}", at - closed_seen);
975 }
976 let later = DUE + 7 * 3_600;
978 phone.session(&folder, |e| e.start(later));
979 for n in 1..=5 {
980 phone.exchange(&folder, later + n * 60);
981 desk.exchange(&folder, later + n * 60 + 5);
982 assert!(doubts_now(later, later + n * 60 + 10, None, &[sees(later + n * 60 + 10)]).is_empty() || n * 60 < sioul_core::health::SKEW, "in use, exporting: known at minute {n}");
983 }
984 let stopped = later + 5 * 60 + 10 + sioul_core::health::FRESH + 60;
986 desk.exchange(&folder, stopped - 5);
987 assert!(matches!(doubts_now(later, stopped, None, &[sees(stopped)]).as_slice(), [Doubt::Working { .. }]), "a phone in use gone quiet: the doubt said");
988 let _ = std::fs::remove_dir_all(&base);
989 }
990
991 #[test]
992 fn the_phone_raised_but_not_exported_is_uncertain_and_its_records_behind_its_entry_too() {
993 let (base, folder, desk, phone) = pair("raised");
994 phone.session(&folder, |e| e.start(DUE + 300));
996 phone.answer(DOSE, TAKEN, DUE + 300);
997 desk.exchange(&folder, DUE + 400);
998 assert_eq!(desk.answered(DOSE), Answered::Open, "its answer not exported");
999 let doubts = doubts_now(DUE, DUE + 400, None, &[desk.sees(&folder, &phone, DUE + 400)]);
1000 assert!(matches!(doubts.as_slice(), [Doubt::Working { device, .. }] if device.id == phone.id), "{doubts:?}");
1001 let rounds = || -> Vec<PathBuf> { std::fs::read_dir(&folder).unwrap().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.file_name().unwrap().to_string_lossy().starts_with(&phone.id) && p.extension().is_some_and(|x| x == "jsonl")).collect() };
1004 let before: Vec<(PathBuf, usize)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap().len())).collect();
1005 phone.exchange(&folder, DUE + 500);
1006 phone.session(&folder, |e| e.close(DUE + 501));
1007 let held: Vec<(PathBuf, Vec<u8>)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap())).collect();
1008 for (path, bytes) in &held {
1009 let had = before.iter().find(|(p, _)| p == path).map_or(0, |(_, size)| *size);
1010 std::fs::write(path, &bytes[..had + (bytes.len() - had) / 2]).unwrap();
1011 }
1012 desk.exchange(&folder, DUE + 510);
1013 let behind = desk.sees(&folder, &phone, DUE + 520);
1014 assert!(!behind.complete);
1015 assert!(matches!(doubts_now(DUE, DUE + 520, None, &[behind]).as_slice(), [Doubt::Coming { .. }]));
1016 for (path, bytes) in &held {
1017 std::fs::write(path, bytes).unwrap();
1018 }
1019 desk.exchange(&folder, DUE + 560);
1020 assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
1021 assert!(doubts_now(DUE, DUE + 560, None, &[desk.sees(&folder, &phone, DUE + 560)]).is_empty());
1022 let _ = std::fs::remove_dir_all(&base);
1023 }
1024
1025 #[test]
1026 fn the_same_dose_answered_on_both_devices_keeps_both() {
1027 let (base, folder, desk, phone) = pair("both");
1028 phone.answer(DOSE, TAKEN, DUE + 120);
1030 desk.answer(DOSE, SKIPPED, DUE + 600);
1031 phone.exchange(&folder, DUE + 700);
1032 desk.exchange(&folder, DUE + 710);
1033 phone.exchange(&folder, DUE + 720);
1034 for device in [&desk, &phone] {
1036 match device.answered(DOSE) {
1037 Answered::Differ(given) => {
1038 let said: Vec<(String, sioul_core::doses::State)> = given.iter().map(|g| (g.device.clone(), g.state.clone())).collect();
1039 assert_eq!(said, [(phone.id.clone(), sioul_core::doses::State::Taken), (desk.id.clone(), sioul_core::doses::State::Skipped)]);
1040 }
1041 other => panic!("{other:?}"),
1042 }
1043 }
1044 DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: TAKEN.into(), at: DUE + 120, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
1046 HealthState::update(&desk.marks(), DUE + 800, |s| {
1048 s.not_taken.remove(DOSE);
1049 s.taken.insert(DOSE.into(), DUE + 120);
1050 })
1051 .unwrap();
1052 desk.exchange(&folder, DUE + 810);
1053 phone.exchange(&folder, DUE + 820);
1054 assert!(matches!(phone.answered(DOSE), Answered::Taken(ref g) if g.device == phone.id));
1055 let _ = std::fs::remove_dir_all(&base);
1056 }
1057
1058 #[test]
1059 fn a_choice_or_a_take_back_on_one_device_settles_both() {
1060 let (base, folder, desk, phone) = pair("settle");
1061 phone.answer(DOSE, TAKEN, DUE + 120);
1063 desk.answer(DOSE, SKIPPED, DUE + 600);
1064 phone.exchange(&folder, DUE + 700);
1065 desk.exchange(&folder, DUE + 710);
1066 phone.exchange(&folder, DUE + 720);
1067 assert!(matches!(phone.answered(DOSE), Answered::Differ(_)));
1068 DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: SKIPPED.into(), at: DUE + 800, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
1070 HealthState::update(&desk.marks(), DUE + 800, |s| {
1071 s.taken.remove(DOSE);
1072 s.not_taken.insert(DOSE.into(), DUE + 800);
1073 })
1074 .unwrap();
1075 desk.exchange(&folder, DUE + 810);
1076 phone.exchange(&folder, DUE + 820);
1077 desk.exchange(&folder, DUE + 830);
1078 for device in [&desk, &phone] {
1079 let answered = device.answered(DOSE);
1080 assert!(matches!(answered, Answered::Skipped(ref g) if g.device == desk.id), "{answered:?}");
1081 }
1082 let iron = "iron@1800000000";
1084 phone.answer(iron, TAKEN, DUE + 900);
1085 phone.exchange(&folder, DUE + 910);
1086 desk.exchange(&folder, DUE + 920);
1087 assert!(matches!(desk.answered(iron), Answered::Taken(_)));
1088 DoseRecords::update(&desk.records(), DUE + 1_000, |r| r.take_back(iron)).unwrap();
1089 HealthState::update(&desk.marks(), DUE + 1_000, |s| {
1090 s.taken.remove(iron);
1091 })
1092 .unwrap();
1093 desk.exchange(&folder, DUE + 1_010);
1094 phone.exchange(&folder, DUE + 1_020);
1095 desk.exchange(&folder, DUE + 1_030);
1096 for device in [&desk, &phone] {
1097 assert_eq!(device.answered(iron), Answered::Open);
1098 }
1099 let _ = std::fs::remove_dir_all(&base);
1100 }
1101
1102 #[test]
1103 fn a_canary_opened_on_two_devices_merges_into_one_record() {
1104 let (base, folder, desk, phone) = pair("canary");
1105 DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
1106 DoseRecords::update(&phone.records(), DUE + 20, |r| r.open(DOSE, &phone.id, DUE + 20)).unwrap();
1107 desk.exchange(&folder, DUE + 60);
1108 phone.exchange(&folder, DUE + 70);
1109 desk.exchange(&folder, DUE + 80);
1110 for device in [&desk, &phone] {
1111 let records = DoseRecords::read(&device.records()).unwrap();
1112 assert_eq!(records.dose.len(), 1);
1113 assert_eq!(records.dose[DOSE].opened.len(), 2, "both openings kept");
1114 assert_eq!(records.opened_first(DOSE), Some((phone.id.clone(), DUE + 20)));
1115 assert_eq!(device.answered(DOSE), Answered::Open, "not taken yet: the canary");
1116 }
1117 phone.answer(DOSE, TAKEN, DUE + 90);
1119 phone.exchange(&folder, DUE + 100);
1120 desk.exchange(&folder, DUE + 110);
1121 assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
1122 assert_eq!(DoseRecords::read(&desk.records()).unwrap().dose[DOSE].opened.len(), 2);
1123 let _ = std::fs::remove_dir_all(&base);
1124 }
1125
1126 #[test]
1127 fn an_older_sioul_takes_nothing_out_and_its_marks_still_count() {
1128 let base = scratch("older");
1129 let folder = base.join("shared");
1130 std::fs::create_dir_all(&folder).unwrap();
1131 let desk = Device::new(&base, "desk", COMPUTER);
1132 let old = Device::new(&base, "old", PHONE);
1133 let old_stores = || old.stores().into_iter().filter(|s| s.name != "state/health-doses.toml").collect::<Vec<_>>();
1135 let old_exchange = |now: i64| crate::share::exchange(&Sharing { folder: &folder, computer: &old.id, key: &KEY, memory: &old.memory, files: false, hurry: None }, &old_stores(), now * 1000).unwrap();
1136 desk.session(&folder, |e| e.start(DUE - 3_600));
1137 desk.exchange(&folder, DUE - 3_500);
1138 old_exchange(DUE - 3_400);
1139 DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
1141 desk.answer("iron@1800000000", SKIPPED, DUE + 60);
1142 desk.exchange(&folder, DUE + 90);
1143 for n in 0..3 {
1144 old_exchange(DUE + 100 + n);
1145 }
1146 desk.exchange(&folder, DUE + 200);
1147 let records = DoseRecords::read(&desk.records()).unwrap();
1148 assert!(records.dose.contains_key(DOSE) && records.dose.contains_key("iron@1800000000"), "nothing taken out by the older one");
1149 HealthState::update(&old.marks(), DUE + 300, |s| {
1151 s.taken.insert(DOSE.into(), DUE + 300);
1152 })
1153 .unwrap();
1154 old_exchange(DUE + 310);
1155 desk.exchange(&folder, DUE + 320);
1156 assert!(matches!(desk.answered(DOSE), Answered::Taken(ref g) if g.device.is_empty()));
1157 assert!(all(&folder, &KEY).0.iter().all(|e| e.id != old.id));
1159 let _ = std::fs::remove_dir_all(&base);
1160 }
1161
1162 #[test]
1172 fn of_two_copies_the_later_and_a_word_both_name() {
1173 let copy = |exported: i64, word: Option<i64>| Entry { id: "phone".into(), kind: PHONE.into(), working: true, started: 1_000, exported, imported: exported, acked: word.map(|opening| ("desk".to_string(), opening)).into_iter().collect(), ..Entry::default() };
1174 let picked = chosen(copy(9_000, Some(500)), copy(7_000, None));
1176 assert_eq!((picked.exported, picked.acked.get("desk")), (9_000, None), "withdrawn, though the copy naming it is dated later");
1177 assert!(chosen(copy(9_000, Some(500)), copy(9_000, None)).acked.is_empty());
1179 assert_eq!(chosen(copy(7_000, Some(500)), copy(9_000, Some(800))).acked.get("desk"), Some(&800));
1181 assert_eq!(chosen(copy(9_000, Some(800)), copy(7_000, Some(500))).acked.get("desk"), Some(&800));
1182 assert!(chosen(copy(7_000, None), copy(9_000, Some(800))).acked.is_empty());
1184 assert_eq!(chosen(copy(7_000, Some(800)), copy(9_000, Some(800))).acked.get("desk"), Some(&800));
1186 }
1187
1188 #[test]
1189 fn a_phones_word_on_an_opening_travels_sealed_in_its_entry() {
1190 let base = scratch("acknowledged");
1191 let folder = base.join("shared");
1192 std::fs::create_dir_all(&folder).unwrap();
1193 let desk = Device::new(&base, "desk", COMPUTER);
1194 let phone = Device::new(&base, "phone", PHONE);
1195 desk.session(&folder, |e| e.start(DUE - 30));
1196 let desk_id = desk.id.clone();
1197 phone.session(&folder, |e| {
1198 e.start(DUE - 3_600);
1199 e.acked.insert(desk_id.clone(), DUE - 30);
1200 });
1201 let read = |id: &str| all(&folder, &KEY).0.into_iter().find(|e| e.id == id);
1202 assert_eq!(read(&phone.id).and_then(|e| e.acked.get(&desk.id).copied()), Some(DUE - 30), "the phone's word, sealed");
1203 let forged = Entry { id: phone.id.clone(), kind: PHONE.into(), working: true, acked: [(desk.id.clone(), i64::MAX)].into_iter().collect(), ..Entry::default() };
1205 std::fs::write(file_of(&folder, &phone.id), serde_json::to_vec(&forged).unwrap()).unwrap();
1206 assert!(read(&phone.id).is_none() && all(&folder, &KEY).1.contains(&phone.id), "unsealed: unread");
1207 let _ = std::fs::remove_dir_all(&base);
1208 }
1209
1210 #[test]
1215 fn what_each_device_did_to_remind_travels_and_an_older_sioul_leaves_it() {
1216 use sioul_core::dose_reminders::{DoseReminders, Tried};
1217 let base = scratch("reminders");
1218 let folder = base.join("shared");
1219 std::fs::create_dir_all(&folder).unwrap();
1220 let desk = Device::new(&base, "desk", COMPUTER);
1221 let phone = Device::new(&base, "phone", PHONE);
1222 let old = Device::new(&base, "old", PHONE);
1223 let old_stores = || old.stores().into_iter().filter(|s| s.name != "state/health-doses.toml" && s.name != "state/health-reminders.toml").collect::<Vec<_>>();
1224 let old_exchange = |now: i64| crate::share::exchange(&Sharing { folder: &folder, computer: &old.id, key: &KEY, memory: &old.memory, files: false, hurry: None }, &old_stores(), now * 1000).unwrap();
1225 let reminders = |device: &Device| device.roots.state.join(sioul_core::dose_reminders::FILE);
1226 desk.session(&folder, |e| e.start(DUE - 3_600));
1227 phone.session(&folder, |e| e.start(DUE - 3_600));
1228 desk.exchange(&folder, DUE - 3_500);
1229 phone.exchange(&folder, DUE - 3_450);
1230 old_exchange(DUE - 3_400);
1231 let promised = Tried { unshown: DUE + 5, quiet: DUE + 900, ..Tried::default() };
1233 DoseReminders::update(&reminders(&desk), DUE + 5, &desk.id, |all| *all.entry(DOSE, &desk.id) = promised).unwrap();
1234 desk.exchange(&folder, DUE + 420);
1235 for n in 0..3 {
1236 old_exchange(DUE + 430 + n);
1237 }
1238 HealthState::update(&old.marks(), DUE + 440, |s| {
1239 s.reminded.insert("iron@1800000000".into(), DUE + 440);
1240 })
1241 .unwrap();
1242 old_exchange(DUE + 450);
1243 phone.exchange(&folder, DUE + 600);
1244 assert_eq!(DoseReminders::read(&reminders(&phone)).unwrap().of(DOSE, &desk.id), Some(promised), "whole, as the desk wrote it");
1245 DoseReminders::update(&reminders(&phone), DUE + 601, &phone.id, |all| all.entry(DOSE, &phone.id).shown = DUE + 601).unwrap();
1247 phone.exchange(&folder, DUE + 610);
1248 desk.exchange(&folder, DUE + 620);
1249 let read = DoseReminders::read(&reminders(&desk)).unwrap();
1250 assert!(read.shown(DOSE) && read.of(DOSE, &desk.id) == Some(promised), "both entries, nothing taken out");
1251 let _ = std::fs::remove_dir_all(&base);
1252 }
1253}