//! Tests for the storage encoding layer. //! //! These need no database: the codec is where a silently-dropped field would //! live, and a round-trip test here is far cheaper to run and to read than the //! same assertion through SQLite. #![allow( clippy::unwrap_used, clippy::expect_used, clippy::panic, reason = "test fixtures: a failing assertion or a broken test is not a runtime \ condition to handle gracefully" )] use super::*; use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow}; /// Every condition shape must survive encode -> decode unchanged. /// /// A field that is written but not read back is invisible in production and /// catastrophic here: the schedule quietly stops working the next time the app /// restarts. #[test] fn every_condition_round_trips() { let cases = vec![ Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 9 * 60, end: 17 * 60 }), // Overnight: start > end must not be normalised or dropped on the way // through the database. This is the representation the whole overnight // rule depends on. Condition::Time(TimeWindow { days: vec![6], start: 22 * 60, end: 6 * 60 }), Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily }, Condition::LaunchCount { limit: 5, window: UsageWindow::Hourly }, Condition::WifiSsid("CoffeeShop".into()), Condition::Location { latitude: 52.52, longitude: 13.405, radius_meters: 500, block_when_outside: true, }, ]; for original in cases { let rows = encode_conditions(std::slice::from_ref(&original)); let decoded = decode_conditions(&rows).expect("decode must succeed"); assert_eq!(decoded, vec![original], "round trip changed the condition"); } } /// A schedule's ordinal order must survive, since the user arranges targets /// deliberately and the block screen shows them in that order. #[test] fn target_order_is_preserved() { let targets = vec![ Target::App("b.app".into()), Target::Website("example.com".into()), Target::Keyword("casino".into()), Target::App("a.app".into()), ]; let mut rows = encode_targets(&targets); // Simulate the database returning rows in an arbitrary order. rows.reverse(); assert_eq!(decode_targets(&rows).unwrap(), targets); } /// Unknown discriminators must error, not default. /// /// Defaulting here would turn a typo or a newer-version row into a schedule /// that silently blocks the wrong thing. #[test] fn unknown_kinds_are_errors_not_defaults() { let bad_target = vec![TargetRow { ordinal: 0, kind: "applicaton".into(), value: "com.example".into() }]; assert_eq!(decode_targets(&bad_target), Err(DecodeError::UnknownKind("applicaton".into()))); let bad_condition = vec![ConditionRow { ordinal: 0, kind: "Timey".into(), days: None, start_min: None, end_min: None, limit_value: None, window: None, ssid: None, latitude: None, longitude: None, radius_m: None, outside: None, }]; assert_eq!(decode_conditions(&bad_condition), Err(DecodeError::UnknownKind("Timey".into()))); } /// A time of day outside 0..1440 must be rejected rather than wrapping. /// /// A wrapped minute would produce a window that never matches, or one that /// always matches — both look like "the app ignores my schedule". #[test] fn out_of_range_minutes_are_rejected() { let bad = ConditionRow { ordinal: 0, kind: "Time".into(), days: Some("0".into()), start_min: Some(-60), end_min: Some(1020), limit_value: None, window: None, ssid: None, latitude: None, longitude: None, radius_m: None, outside: None, }; assert_eq!(decode_conditions(&[bad]), Err(DecodeError::MinuteOutOfRange(-60))); } /// Weekday values outside 0..=6 must be rejected. #[test] fn out_of_range_weekdays_are_rejected() { assert_eq!(decode_days("0,1,9"), Err(DecodeError::BadDays("0,1,9".into()))); assert_eq!(decode_days("monday"), Err(DecodeError::BadDays("monday".into()))); assert_eq!(decode_days(""), Ok(vec![])); assert_eq!(decode_days("0,6"), Ok(vec![0, 6])); } /// A flag column holding something other than 0 or 1 must be rejected. #[test] fn bad_flags_are_rejected() { assert_eq!(decode_flag(0), Ok(false)); assert_eq!(decode_flag(1), Ok(true)); assert_eq!(decode_flag(2), Err(DecodeError::BadFlag(2))); assert_eq!(decode_flag(-1), Err(DecodeError::BadFlag(-1))); } /// Negative counts must be rejected — a negative usage limit would mean /// "block immediately", which is not what a corrupted row should do. #[test] fn negative_counts_are_rejected() { assert_eq!(require_count(Some(-5)), Err(DecodeError::NegativeCount(-5))); assert_eq!(require_count(Some(0)), Ok(0)); assert_eq!(require_count(None), Err(DecodeError::NegativeCount(-1))); } /// `HH:MM` parsing, as the golden tables use it. #[test] fn hhmm_parsing() { assert_eq!(parse_hhmm("09:00"), Ok(540)); assert_eq!(parse_hhmm("22:30"), Ok(1350)); assert_eq!(parse_hhmm("00:00"), Ok(0)); assert_eq!(parse_hhmm("9:5"), Ok(545)); assert_eq!(parse_hhmm("24:00"), Err(DecodeError::BadTime("24:00".into()))); assert_eq!(parse_hhmm("12:60"), Err(DecodeError::BadTime("12:60".into()))); assert_eq!(parse_hhmm("nine"), Err(DecodeError::BadTime("nine".into()))); assert_eq!(parse_hhmm("0900"), Err(DecodeError::BadTime("0900".into()))); } /// Lock level and mode round-trip through their string forms. #[test] fn level_and_mode_round_trip() { for level in [ blocker_core::lock::LockLevel::Normal, blocker_core::lock::LockLevel::Strict, blocker_core::lock::LockLevel::Stubborn, ] { assert_eq!(level_from_str(level_to_str(level)).unwrap(), level); } assert!(level_from_str("Paranoid").is_err()); for mode in [ScheduleMode::Blocklist, ScheduleMode::Allowlist] { assert_eq!(mode_from_str(mode_to_str(mode)).unwrap(), mode); } assert!(mode_from_str("Whitelist").is_err()); } /// A full schedule round-trips through its parts. #[test] fn schedule_round_trips_through_parts() { let original = Schedule { id: "s1".into(), name: "Evening Focus".into(), icon: Some("/icons/focus.png".into()), mode: ScheduleMode::Blocklist, targets: vec![Target::App("com.example.social".into())], conditions: vec![Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60, })], paused: false, }; let decoded = decode_schedule( &original.id, &original.name, original.icon.as_deref(), mode_to_str(original.mode), &encode_targets(&original.targets), &encode_conditions(&original.conditions), original.paused, ) .unwrap(); assert_eq!(decoded, original); }