Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505#[cfg(test)]mod tests { use std::collections::BTreeMap;
use crate::{ ids::ThingsId, store::{ThingsStore, fold_items}, wire::{ checklist::{ChecklistItemPatch, ChecklistItemProps}, recurrence::{FrequencyUnit, RecurrenceRule, RecurrenceType}, tags::{TagPatch, TagProps}, task::{TaskPatch, TaskProps, TaskStart, TaskStatus}, wire_object::{EntityType, OperationType, Properties, WireItem, WireObject}, }, };
fn id(s: &str) -> ThingsId { s.parse::<ThingsId>() .expect("test id should parse as ThingsId") }
const ID_A: &str = "A7h5eCi24RvAWKC3Hv3muf"; const ID_B: &str = "MpkEei6ybkFS2n6SXvwfLf"; const ID_C: &str = "JFdhhhp37fpryAKu8UXwzK";
#[test] fn an_envelope_that_does_not_parse_fails_its_object_and_not_the_line() { let create = format!(r#"{{"{ID_A}":{{"t":0,"e":"Task7","p":{{"tt":"Kept"}}}}}}"#); // a null payload is an empty one let deleted: WireItem = serde_json::from_str(&format!(r#"{{"{ID_B}":{{"t":2,"e":"Task7","p":null}}}}"#)) .expect("a null payload"); assert_eq!(deleted[ID_B].operation_type, OperationType::Delete); // an entity that is no string reads as an operation this CLI does not know, and marks the object let odd: WireItem = serde_json::from_str(&format!(r#"{{"{ID_A}":{{"t":1,"e":7,"p":{{}}}}}}"#)) .expect("an odd envelope"); assert_eq!(odd[ID_A].operation_type, OperationType::Unknown(-1)); let state = fold_items([serde_json::from_str(&create).expect("create"), odd]); assert!(state[&id(ID_A)].degraded); // a checklist patch names its task as one id or many // a create does too let patch: ChecklistItemPatch = serde_json::from_str(&format!(r#"{{"ts":"{ID_C}"}}"#)).expect("one task id"); assert_eq!(patch.task_ids, Some(vec![id(ID_C)])); }
#[test] fn wire_object_deserializes_with_wire_keys() { let json = r#"{ "abc-123": { "t": 1, "e": "Task6", "p": {"tt": "Title", "ss": 0} } }"#;
let item: WireItem = serde_json::from_str(json).expect("valid wire item"); let object = item.get("abc-123").expect("object exists");
assert_eq!(object.operation_type, OperationType::Update); assert_eq!(object.entity_type, Some(EntityType::Task6)); assert_eq!( object .properties_map() .get("tt") .and_then(|v| v.as_str()) .map(str::to_string) .as_deref(), Some("Title") ); }
#[test] fn task_props_maps_readable_fields_to_wire_names() { let props = TaskProps { title: "Ship v1".to_string(), status: TaskStatus::Completed, start_location: TaskStart::Anytime, parent_project_ids: vec![id(ID_A)], area_ids: vec![id(ID_B)], tag_ids: vec![id(ID_C)], evening_bit: 1, ..TaskProps::default() };
let encoded = serde_json::to_value(props).expect("serialize task props");
assert_eq!(encoded.get("tt").and_then(|v| v.as_str()), Some("Ship v1")); assert_eq!(encoded.get("ss").and_then(|v| v.as_i64()), Some(3)); assert_eq!(encoded.get("st").and_then(|v| v.as_i64()), Some(1)); assert_eq!(encoded.get("sb").and_then(|v| v.as_i64()), Some(1)); assert_eq!(encoded.get("pr"), Some(&serde_json::json!([ID_A]))); assert_eq!(encoded.get("ar"), Some(&serde_json::json!([ID_B]))); assert_eq!(encoded.get("tg"), Some(&serde_json::json!([ID_C]))); assert_eq!(encoded.get("rmd"), Some(&serde_json::Value::Null)); assert_eq!(encoded.get("rp"), Some(&serde_json::Value::Null)); assert!(encoded.get("title").is_none()); assert!(encoded.get("status").is_none()); }
#[test] fn task_props_accepts_null_for_defaulted_scalar_fields() { let json = r#"{ "tt": "Personal Website", "tp": 1, "ss": 0, "st": 1, "pr": [], "ar": [], "agr": [], "tg": [], "ix": -7069, "ti": null, "do": null, "rt": [], "icc": null, "icp": true, "sb": null, "lt": null, "tr": false, "dl": [] }"#;
let parsed: TaskProps = serde_json::from_str(json).expect("valid task props with nulls"); assert_eq!(parsed.today_sort_index, 0); assert_eq!(parsed.due_date_offset, 0); assert_eq!(parsed.instance_creation_count, 0); assert_eq!(parsed.evening_bit, 0); assert!(!parsed.leaves_tombstone); }
#[test] fn task_props_maps_legacy_instance_creation_fields() { let json = r#"{ "icsd": 1700000000, "acrd": 1700086400, "icc": 12, "icp": true }"#;
let parsed: TaskProps = serde_json::from_str(json).expect("valid task props");
assert_eq!(parsed.instance_creation_start_date, Some(1_700_000_000)); assert_eq!(parsed.after_completion_reference_date, Some(1_700_086_400)); assert_eq!(parsed.instance_creation_count, 12); assert!(parsed.instance_creation_paused);
let encoded = serde_json::to_value(parsed).expect("serialize task props"); assert_eq!( encoded.get("icsd").and_then(|v| v.as_i64()), Some(1_700_000_000) ); assert_eq!(encoded.get("icc").and_then(|v| v.as_i64()), Some(12)); }
#[test] fn checklist_item_accepts_task_ids_list_wire_shape() { let json = r#"{ "tt": "One", "ss": 0, "ts": ["A7h5eCi24RvAWKC3Hv3muf"], "ix": 9 }"#;
let parsed: ChecklistItemProps = serde_json::from_str(json).expect("valid checklist item props"); assert_eq!(parsed.title, "One"); assert_eq!(parsed.task_ids, vec![id(ID_A)]); assert_eq!(parsed.sort_index, 9); }
#[test] fn checklist_item_accepts_single_task_id_wire_shape() { let json = r#"{ "tt": "One", "ss": 0, "ts": "A7h5eCi24RvAWKC3Hv3muf", "ix": 9 }"#;
let parsed: ChecklistItemProps = serde_json::from_str(json).expect("valid checklist item props"); assert_eq!(parsed.title, "One"); assert_eq!(parsed.task_ids, vec![id(ID_A)]); assert_eq!(parsed.sort_index, 9); }
#[test] fn checklist_item_accepts_null_lt_as_default_false() { let json = r#"{ "tt": "One", "ss": 0, "ts": ["A7h5eCi24RvAWKC3Hv3muf"], "ix": 9, "lt": null }"#;
let parsed: ChecklistItemProps = serde_json::from_str(json).expect("valid checklist item props with null lt"); assert!(!parsed.leaves_tombstone); }
#[test] fn checklist_item_create_omits_unset_optional_fields() { let props = ChecklistItemProps { title: "One".to_string(), status: TaskStatus::Incomplete, task_ids: vec![id(ID_A)], sort_index: 9, creation_date: Some(1.0), modification_date: Some(2.0), ..ChecklistItemProps::default() };
let encoded = serde_json::to_value(props).expect("serialize checklist props");
assert_eq!(encoded.get("tt").and_then(|v| v.as_str()), Some("One")); assert_eq!(encoded.get("ss").and_then(|v| v.as_i64()), Some(0)); assert_eq!(encoded.get("ix").and_then(|v| v.as_i64()), Some(9)); assert!(encoded.get("sp").is_none()); assert!(encoded.get("lt").is_none()); assert!(encoded.get("xx").is_none()); }
#[test] fn checklist_patch_preserves_explicit_null_stop_date() { let patch: ChecklistItemPatch = serde_json::from_str(r#"{"sp":null}"#).expect("deserialize checklist patch"); assert_eq!(patch.stop_date, Some(None));
let absent: ChecklistItemPatch = serde_json::from_str("{}").expect("deserialize empty checklist patch"); assert_eq!(absent.stop_date, None); }
#[test] fn recurrence_rule_defaults_match_protocol() { let parsed: RecurrenceRule = serde_json::from_str("{}") .expect("empty recurrence should deserialize with protocol defaults");
assert_eq!(parsed.frequency_unit, FrequencyUnit::Weekly); assert_eq!(parsed.frequency_amount, 1); assert_eq!(parsed.end_date, Some(64_092_211_200)); assert_eq!(parsed.version, 4); }
#[test] fn recurrence_rule_maps_all_legacy_wire_fields() { let json = r#"{ "tp": 1, "fu": 8, "fa": 2, "of": [{"weekday": 3}], "sr": 1700000000, "ia": 1700086400, "ed": 1700172800, "rc": 5, "ts": -1, "rrv": 3 }"#;
let parsed: RecurrenceRule = serde_json::from_str(json).expect("valid recurrence rule");
assert_eq!(parsed.recurrence_type, RecurrenceType::AfterCompletion); assert_eq!(parsed.frequency_unit, FrequencyUnit::Monthly); assert_eq!(parsed.frequency_amount, 2); assert_eq!( parsed.offsets, vec![BTreeMap::from([( "weekday".to_string(), serde_json::json!(3), )])] ); assert_eq!(parsed.start_date, Some(1_700_000_000)); assert_eq!(parsed.interval_anchor, Some(1_700_086_400)); assert_eq!(parsed.end_date, Some(1_700_172_800)); assert_eq!(parsed.repeat_count, 5); assert_eq!(parsed.time_span_in_days, -1); assert_eq!(parsed.version, 3);
let encoded = serde_json::to_value(parsed).expect("serialize recurrence rule"); assert_eq!(encoded.get("tp").and_then(|v| v.as_i64()), Some(1)); assert_eq!(encoded.get("fu").and_then(|v| v.as_i64()), Some(8)); assert_eq!( encoded.get("sr").and_then(|v| v.as_i64()), Some(1_700_000_000) ); assert_eq!( encoded.get("ia").and_then(|v| v.as_i64()), Some(1_700_086_400) ); assert_eq!(encoded.get("ts").and_then(|v| v.as_i64()), Some(-1)); assert_eq!(encoded.get("rrv").and_then(|v| v.as_i64()), Some(3)); }
#[test] fn recurrence_frequency_units_match_wire_values() { for (wire_value, expected) in [ (4, FrequencyUnit::Yearly), (8, FrequencyUnit::Monthly), (16, FrequencyUnit::Daily), (256, FrequencyUnit::Weekly), ] { let parsed: FrequencyUnit = serde_json::from_value(serde_json::json!(wire_value)).expect("frequency unit"); assert_eq!(parsed, expected); assert_eq!( serde_json::to_value(parsed).expect("serialize frequency unit"), serde_json::json!(wire_value) ); } }
#[test] fn operation_enum_serializes_to_wire_integer() { let object = WireObject { operation_type: OperationType::Delete, entity_type: None, payload: Properties::Delete, }; let json = serde_json::to_string(&object).expect("serialize wire object"); assert!(json.contains("\"t\":2")); }
#[test] fn unknown_numeric_enum_values_round_trip() { let parsed: WireObject = serde_json::from_str(r#"{"t":99,"e":"Task6","p":{}}"#) .expect("deserialize with unknown op type");
assert_eq!(parsed.operation_type, OperationType::Unknown(99));
let json = serde_json::to_string(&parsed).expect("serialize unknown op type"); assert!(json.contains("\"t\":99")); }
#[test] fn unknown_entity_values_round_trip() { let parsed: WireObject = serde_json::from_str(r#"{"t":1,"e":"Task8","p":{}}"#).expect("deserialize"); assert_eq!( parsed.entity_type, Some(EntityType::Unknown("Task8".to_string())) );
let json = serde_json::to_string(&parsed).expect("serialize unknown entity"); assert!(json.contains("\"e\":\"Task8\"")); }
#[test] fn typed_properties_dispatch_for_task_create() { let parsed: WireObject = serde_json::from_str(r#"{"t":0,"e":"Task6","p":{"tt":"A","ss":0,"tp":0,"st":0}}"#) .expect("deserialize");
let typed = parsed.properties().expect("typed properties"); match typed { Properties::TaskCreate(props) => { assert_eq!(props.title, "A"); assert_eq!(props.status, TaskStatus::Incomplete); } other => panic!("unexpected variant: {other:?}"), } }
#[test] fn task7_dispatches_to_typed_task_properties() { let parsed: WireObject = serde_json::from_str(r#"{"t":1,"e":"Task7","p":{"tt":"Current","rp":{"version":1}}}"#) .expect("deserialize Task7 update");
assert_eq!(parsed.entity_type, Some(EntityType::Task7)); let Properties::TaskUpdate(patch) = parsed.payload else { panic!("Task7 should use typed task properties"); }; assert_eq!(patch.title.as_deref(), Some("Current")); assert_eq!( patch.repeater, Some(Some(serde_json::json!({"version": 1}))) ); }
#[test] fn task7_create_materializes_in_the_store() { let item: WireItem = serde_json::from_str( r#"{"A7h5eCi24RvAWKC3Hv3muf":{"t":0,"e":"Task7","p":{"tt":"Current","ss":0}}}"#, ) .expect("deserialize Task7 create"); let store = ThingsStore::from_raw_state(&fold_items([item])); let task = store .get_task("A7h5eCi24RvAWKC3Hv3muf") .expect("materialized Task7 task");
assert_eq!(task.title, "Current"); assert_eq!(task.entity, EntityType::Task7); }
#[test] fn a_malformed_payload_of_any_kind_is_kept_opaque() { let object = serde_json::from_str::<WireObject>(r#"{"t":1,"e":"Area3","p":{"ix":"future"}}"#) .expect("the object deserializes");
assert!(matches!( object.payload, crate::wire::wire_object::Properties::Unknown(_) )); assert!(object.properties().is_err()); }
#[test] fn entity_type_distinguishes_known_and_future_tasks() { assert!(EntityType::Task7.is_task()); assert!(EntityType::Task6.can_upgrade_to_task7()); assert!(EntityType::Task7.can_upgrade_to_task7()); assert!(EntityType::Task7.is_task_family()); assert!(!EntityType::from("Task4".to_string()).is_task()); assert!(!EntityType::Unknown("Task8".to_string()).is_task()); assert!(!EntityType::Unknown("Task8".to_string()).can_upgrade_to_task7()); assert!(EntityType::Unknown("Task8".to_string()).is_task_family()); assert!(!EntityType::Unknown("TaskFuture".to_string()).is_task_family()); }
#[test] fn typed_properties_dispatch_for_delete() { let parsed: WireObject = serde_json::from_str(r#"{"t":2,"e":"Task6","p":{}}"#).expect("deserialize"); let typed = parsed.properties().expect("typed properties"); assert!(matches!(typed, Properties::Delete)); }
#[test] fn task_patch_preserves_explicit_null_for_clearable_fields() { let patch: TaskPatch = serde_json::from_str(r#"{"sr":null,"tir":null,"sp":null,"rp":null,"md":null}"#) .expect("deserialize patch with nulls");
assert_eq!(patch.scheduled_date, Some(None)); assert_eq!(patch.today_index_reference, Some(None)); assert_eq!(patch.stop_date, Some(None)); assert_eq!(patch.repeater, Some(None)); assert_eq!(patch.modification_date, Some(None));
let absent: TaskPatch = serde_json::from_str("{}").expect("deserialize empty patch"); assert_eq!(absent.modification_date, None); }
#[test] fn task_update_wire_object_keeps_null_clears() { let parsed: WireObject = serde_json::from_str(r#"{"t":1,"e":"Task6","p":{"sr":null,"tir":null}}"#) .expect("deserialize wire update");
let typed = parsed.properties().expect("typed properties"); match typed { Properties::TaskUpdate(patch) => { assert_eq!(patch.scheduled_date, Some(None)); assert_eq!(patch.today_index_reference, Some(None)); } other => panic!("unexpected variant: {other:?}"), } }
#[test] fn tag_patch_preserves_explicit_null_for_shortcut() { let patch: TagPatch = serde_json::from_str(r#"{"sh":null}"#).expect("deserialize tag patch with null"); assert_eq!(patch.shortcut, Some(None)); }
#[test] fn tag_update_wire_object_keeps_null_shortcut_clear() { let parsed: WireObject = serde_json::from_str(r#"{"t":1,"e":"Tag4","p":{"sh":null}}"#) .expect("deserialize tag wire update");
let typed = parsed.properties().expect("typed properties"); match typed { Properties::TagUpdate(patch) => { assert_eq!(patch.shortcut, Some(None)); } other => panic!("unexpected variant: {other:?}"), } }
#[test] fn tag_create_wire_object_matches_historical_shape() { let object = WireObject::create( EntityType::Tag4, TagProps { title: "Test Tag".to_string(), sort_index: 0, conflict_overrides: Some(serde_json::json!({"_t": "oo", "sn": {}})), ..Default::default() }, );
let value = serde_json::to_value(object).expect("serialize tag create"); assert_eq!(value["p"]["tt"], "Test Tag"); assert_eq!(value["p"]["ix"], 0); assert_eq!(value["p"]["pn"], serde_json::json!([])); assert_eq!(value["p"]["sh"], serde_json::Value::Null); assert_eq!(value["p"]["xx"], serde_json::json!({"_t": "oo", "sn": {}})); }}