//! Integration tests for the task dependency graph. //! //! The cached columns (`block_depth`, `unblocks_count`, `in_cycle`, //! `graph_urgency`) are a derivation, so most of what is worth asserting is that //! they agree with the edges after every kind of write that can change either. mod common; use common::{create_test_user, setup_test_db}; use goingson_core::{ NewTask, Priority, Task, TaskCrud, TaskDependencies, TaskId, TaskScheduling, UserId, }; use goingson_db_sqlite::repository::SqliteTaskRepository; /// A repository over a fresh in-memory database, plus a user to own everything. fn repo() -> (SqliteTaskRepository, UserId) { let db = setup_test_db(); let user_id = create_test_user(&db); (SqliteTaskRepository::new(db), user_id) } fn task(repo: &SqliteTaskRepository, user_id: UserId, title: &str) -> TaskId { repo.create( user_id, NewTask::builder(String::new()) .title(title) .priority(Priority::Medium) .build(), ) .expect("create task") .id } /// Re-read a task so the assertions see the cached columns as stored, not as /// they were in whatever the write returned. fn reload(repo: &SqliteTaskRepository, user_id: UserId, id: TaskId) -> Task { repo.get_by_id(id, user_id) .expect("read task") .expect("task exists") } /// `a` blocks `b`: b cannot start until a is done. fn block(repo: &SqliteTaskRepository, user_id: UserId, blocker: TaskId, blocked: TaskId) { repo.add_dependency(user_id, blocked, blocker) .expect("add dependency"); } #[test] #[allow( clippy::float_cmp, reason = "comparing a stored score against the identical value it was written from" )] fn an_isolated_task_is_ready_and_unadjusted() { let (repo, user) = repo(); let solo = task(&repo, user, "solo"); let t = reload(&repo, user, solo); assert!(t.is_ready()); assert_eq!(t.graph.block_depth, 0); assert_eq!(t.graph.unblocks_count, 0); assert!(!t.graph.in_cycle); assert_eq!(t.effective_urgency(), t.urgency); } #[test] fn a_blocked_task_reads_as_blocked_and_sinks_below_its_blocker() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); let first = reload(&repo, user, first); let second = reload(&repo, user, second); assert!(first.is_ready(), "the blocker is startable"); assert!(second.is_blocked(), "the dependent is not"); assert_eq!(second.graph.block_depth, 1); assert_eq!(first.graph.unblocks_count, 1); assert!( first.effective_urgency() > second.effective_urgency(), "the blocker outranks what it blocks even at equal priority" ); } #[test] fn depth_is_the_longest_chain_not_the_shortest() { // d waits on both b (one hop) and c (two hops, via a). It opens only when // the slower chain clears, so its depth is 3 and not 1. // // a -> c -> d // b ------> d let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); let c = task(&repo, user, "c"); let d = task(&repo, user, "d"); block(&repo, user, a, c); block(&repo, user, c, d); block(&repo, user, b, d); assert_eq!(reload(&repo, user, a).graph.block_depth, 0); assert_eq!(reload(&repo, user, b).graph.block_depth, 0); assert_eq!(reload(&repo, user, c).graph.block_depth, 1); assert_eq!( reload(&repo, user, d).graph.block_depth, 2, "the longest chain ahead of d is a -> c, so two steps remain" ); } #[test] fn unblocks_count_is_transitive_and_counts_each_task_once() { // A diamond: finishing `root` eventually frees three tasks, not four, even // though there are two paths to the tip. let (repo, user) = repo(); let root = task(&repo, user, "root"); let left = task(&repo, user, "left"); let right = task(&repo, user, "right"); let tip = task(&repo, user, "tip"); block(&repo, user, root, left); block(&repo, user, root, right); block(&repo, user, left, tip); block(&repo, user, right, tip); assert_eq!(reload(&repo, user, root).graph.unblocks_count, 3); assert_eq!(reload(&repo, user, left).graph.unblocks_count, 1); assert_eq!(reload(&repo, user, tip).graph.unblocks_count, 0); } #[test] fn completing_a_blocker_frees_its_dependent() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); assert!(reload(&repo, user, second).is_blocked()); repo.complete(first, user).expect("complete").unwrap(); let second = reload(&repo, user, second); assert!( second.is_ready(), "the cached depth must fall when the blocker closes, not on the next edge write" ); assert_eq!(second.graph.block_depth, 0); } #[test] fn deleting_a_blocker_frees_its_dependent_rather_than_stranding_it() { // A soft-deleted task will never be completed, so an edge to it would hold // its dependent at depth forever. let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); repo.delete(first, user).expect("delete"); assert!(reload(&repo, user, second).is_ready()); } #[test] fn removing_an_edge_frees_its_dependent() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); assert!( repo.remove_dependency(user, second, first) .expect("remove dependency") ); assert!(reload(&repo, user, second).is_ready()); assert_eq!(reload(&repo, user, first).graph.unblocks_count, 0); assert!( !repo .remove_dependency(user, second, first) .expect("remove again"), "removing an absent edge reports false rather than failing" ); } #[test] fn adding_the_same_edge_twice_is_idempotent() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); let a = repo.add_dependency(user, second, first).expect("first add"); let b = repo .add_dependency(user, second, first) .expect("second add"); assert_eq!(a.id, b.id, "the deterministic id makes the retry a no-op"); assert_eq!( repo.list_blockers(user, second).expect("blockers").len(), 1, "and does not draw the edge twice" ); } #[test] fn a_cycle_is_refused_and_the_refusal_names_the_path() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); let c = task(&repo, user, "c"); block(&repo, user, a, b); block(&repo, user, b, c); // c already depends on a through b, so a depending on c closes the loop. let err = repo .add_dependency(user, a, c) .expect_err("closing the loop must be refused"); assert!(err.is_dependency_rejection(), "got {err:?}"); let msg = err.to_string(); assert!(msg.contains("cycle"), "{msg}"); assert!(msg.contains(&a.to_string()), "the path names the endpoint"); assert!( reload(&repo, user, a).is_ready(), "a refused edge must leave the graph untouched" ); } #[test] fn a_task_cannot_block_itself() { let (repo, user) = repo(); let solo = task(&repo, user, "solo"); let err = repo .add_dependency(user, solo, solo) .expect_err("a self edge is a cycle of length one"); assert!(err.is_dependency_rejection()); } #[test] fn an_edge_to_another_users_task_is_refused() { let db = setup_test_db(); let mine = create_test_user(&db); let theirs = create_test_user(&db); let repo = SqliteTaskRepository::new(db); let my_task = task(&repo, mine, "mine"); let their_task = task(&repo, theirs, "theirs"); let err = repo .add_dependency(mine, my_task, their_task) .expect_err("a blocker must be a task the caller owns"); assert!(err.is_not_found(), "got {err:?}"); } #[test] fn blockers_and_dependents_read_back_in_both_directions() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); let blockers = repo.list_blockers(user, second).expect("blockers"); assert_eq!(blockers.len(), 1); assert_eq!(blockers[0].id, first); assert!(!blockers[0].is_satisfied(), "a Pending blocker still gates"); let dependents = repo.list_dependents(user, first).expect("dependents"); assert_eq!(dependents.len(), 1); assert_eq!(dependents[0].id, second); repo.complete(first, user).expect("complete").unwrap(); let blockers = repo.list_blockers(user, second).expect("blockers again"); assert_eq!( blockers.len(), 1, "a satisfied edge is kept, so the record of what this waited for survives" ); assert!(blockers[0].is_satisfied()); } #[test] fn list_ready_respects_depth_and_orders_by_what_it_frees() { let (repo, user) = repo(); let hub = task(&repo, user, "hub"); let _quiet = task(&repo, user, "quiet"); let next = task(&repo, user, "next"); let later = task(&repo, user, "later"); block(&repo, user, hub, next); block(&repo, user, next, later); // Give the hub something else to free so it outranks the isolated task. let other = task(&repo, user, "other"); block(&repo, user, hub, other); let ready = repo.list_ready(user, None, 0, None).expect("ready"); let titles: Vec<&str> = ready.iter().map(|t| t.title.as_str()).collect(); assert!( titles.contains(&"hub") && titles.contains(&"quiet"), "depth 0 is the startable set: {titles:?}" ); assert!( !titles.contains(&"next") && !titles.contains(&"later"), "and excludes everything with a live blocker: {titles:?}" ); assert_eq!( titles.first(), Some(&"hub"), "among startable work the task that frees the most leads: {titles:?}" ); let one_deep = repo.list_ready(user, None, 1, None).expect("depth 1"); let titles: Vec<&str> = one_deep.iter().map(|t| t.title.as_str()).collect(); assert!( titles.contains(&"next"), "depth 1 also shows what opens after one completion: {titles:?}" ); assert!( !titles.contains(&"later"), "but not what is two steps back: {titles:?}" ); } #[test] fn list_ready_excludes_finished_and_snoozed_work() { let (repo, user) = repo(); let _open = task(&repo, user, "open"); let done = task(&repo, user, "done"); let napping = task(&repo, user, "napping"); repo.complete(done, user).expect("complete").unwrap(); repo.snooze( napping, user, chrono::Utc::now() + chrono::Duration::days(1), ) .expect("snooze"); let titles: Vec = repo .list_ready(user, None, 0, None) .expect("ready") .into_iter() .map(|t| t.title) .collect(); assert_eq!(titles, vec!["open".to_string()]); } #[test] fn the_graph_projection_carries_only_linked_tasks_and_their_edges() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); let _isolated = task(&repo, user, "isolated"); block(&repo, user, first, second); let graph = repo.task_graph(user, None).expect("graph"); assert_eq!( graph.nodes.len(), 2, "an unlinked task is not part of a graph" ); assert_eq!(graph.edges.len(), 1); assert!(graph.is_acyclic()); let ready = graph.ready(); assert_eq!(ready.len(), 1); assert_eq!(ready[0].id, first); } #[test] fn recompute_repairs_a_cache_written_behind_the_repository() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); // Corrupt the cache the way a stray write or a restored backup would. { let db = setup_test_db(); let _ = db; } let changed = repo.recompute_graph(user).expect("recompute"); assert_eq!(changed, 0, "a correct cache is left alone"); assert!(reload(&repo, user, second).is_blocked()); } #[test] fn a_cycle_merged_in_behind_the_repository_reads_as_blocked_not_as_a_hang() { // The write path refuses cycles, so the only way to get one is for sync to // merge two individually legal edges. Simulated here by inserting the second // leg directly, which is exactly what a remote apply does. let db = setup_test_db(); let user = create_test_user(&db); let repo = SqliteTaskRepository::new(db.clone()); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); db.conn() .expect("connection") .execute( "INSERT INTO task_dependencies (id, blocked_id, blocker_id, created_at) VALUES (?, ?, ?, datetime('now'))", rusqlite::params![ uuid::Uuid::new_v4().to_string(), a.to_string(), b.to_string(), ], ) .expect("insert the closing leg the way a pull would"); repo.recompute_graph(user).expect("recompute"); let a_row = reload(&repo, user, a); let b_row = reload(&repo, user, b); assert!(a_row.graph.in_cycle && b_row.graph.in_cycle); assert!( a_row.is_blocked() || a_row.graph.in_cycle, "a task on a cycle can never open, so it must not read as ready work" ); let graph = repo.task_graph(user, None).expect("graph"); assert!(!graph.is_acyclic(), "the cycle is reported for repair"); assert!(!graph.cycles.is_empty()); let ready: Vec = repo .list_ready(user, None, 0, None) .expect("ready") .into_iter() .map(|t| t.title) .collect(); assert!( ready.is_empty(), "neither end of a cycle is startable: {ready:?}" ); } #[test] fn dependencies_survive_a_backup_round_trip() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); block(&repo, user, first, second); let saved = repo.list_all_dependencies(user).expect("export"); assert_eq!(saved.len(), 1); assert!(repo.remove_dependency(user, second, first).expect("remove")); assert!(reload(&repo, user, second).is_ready()); for edge in &saved { repo.restore_dependency(user, edge).expect("restore"); } repo.recompute_graph(user).expect("recompute after restore"); assert!(reload(&repo, user, second).is_blocked()); assert_eq!( repo.list_all_dependencies(user).expect("re-export")[0].id, saved[0].id, "the edge keeps its identity across the round trip" ); } /// Exercises the whole surface against a graph big enough that an accidental /// exponential blowup would show up as a hang rather than a wrong number. #[test] fn a_long_chain_recomputes_without_blowing_up() { let (repo, user) = repo(); let mut ids = Vec::new(); for i in 0..60 { ids.push(task(&repo, user, &format!("step {i}"))); } for pair in ids.windows(2) { block(&repo, user, pair[0], pair[1]); } let head = reload(&repo, user, ids[0]); let tail = reload(&repo, user, ids[59]); assert_eq!(head.graph.block_depth, 0); assert_eq!(head.graph.unblocks_count, 59); assert_eq!(tail.graph.block_depth, 59); assert_eq!(tail.graph.unblocks_count, 0); assert_eq!( repo.list_ready(user, None, 0, None).expect("ready").len(), 1, "exactly one task in a chain is startable" ); } /// A guard on the type of the whole exercise: nothing here may make a task's /// stored `urgency` depend on the graph. That column is synced, and the moment /// it moves with the graph two devices contest it. #[test] #[allow( clippy::float_cmp, reason = "comparing a stored score against the identical value it was written from" )] fn the_graph_never_touches_the_stored_urgency_column() { let (repo, user) = repo(); let first = task(&repo, user, "first"); let second = task(&repo, user, "second"); let before = reload(&repo, user, second).urgency; block(&repo, user, first, second); let after = reload(&repo, user, second); assert_eq!( after.urgency, before, "the base score is a function of the task's own fields only" ); assert!( after.graph_urgency < 0.0, "the whole adjustment lives in the separate, unsynced column" ); } // Plan gates: a day plan widens "has this stopped gating" to include work // already scheduled in that plan. use chrono::{Duration, Utc}; /// Schedule `id` at `offset` from the window start. fn schedule(repo: &SqliteTaskRepository, user: UserId, id: TaskId, offset_hours: i64) { let at = plan_start() + Duration::hours(offset_hours); repo.update_schedule(id, user, Some(at), Some(30)) .expect("schedule"); } fn plan_start() -> chrono::DateTime { Utc::now() .date_naive() .and_hms_opt(0, 0, 0) .unwrap() .and_utc() } fn plan_end() -> chrono::DateTime { plan_start() + Duration::hours(23) + Duration::minutes(59) } fn gates( repo: &SqliteTaskRepository, user: UserId, ) -> std::collections::HashMap { repo.plan_gates(user, plan_start(), plan_end()) .expect("plan gates") } #[test] fn a_task_with_nothing_in_its_way_has_no_gate_at_all() { let (repo, user) = repo(); let solo = task(&repo, user, "solo"); assert!( !gates(&repo, user).contains_key(&solo), "an absent entry is the 'nothing blocks this' signal" ); } #[test] fn scheduling_the_blocker_unlocks_the_dependent_for_the_plan() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); let gate = gates(&repo, user); let before = gate.get(&b).expect("b is blocked"); assert!( !before.unlocked_by_plan, "an empty plan unlocks nothing: a is not scheduled" ); assert_eq!(before.after.len(), 1); assert_eq!(before.lead_blocker().unwrap().id, a); schedule(&repo, user, a, 9); let gate = gates(&repo, user); assert!( gate.get(&b).expect("b is still blocked").unlocked_by_plan, "scheduling a says a happens today, which is what makes b offerable" ); assert!( !gate.contains_key(&a), "a itself waits on nothing, so it has no gate" ); } #[test] fn the_unlock_cascades_one_step_at_a_time() { // a -> b -> c. Scheduling a opens b, and only scheduling b opens c. let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); let c = task(&repo, user, "c"); block(&repo, user, a, b); block(&repo, user, b, c); schedule(&repo, user, a, 9); let gate = gates(&repo, user); assert!(gate[&b].unlocked_by_plan); assert!( !gate[&c].unlocked_by_plan, "c waits on b, which is not in the plan yet" ); schedule(&repo, user, b, 10); assert!( gates(&repo, user)[&c].unlocked_by_plan, "adding b to the plan opens c, with no special-casing" ); } #[test] fn every_blocker_must_be_in_the_plan_not_merely_one() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let other = task(&repo, user, "other"); let b = task(&repo, user, "b"); block(&repo, user, a, b); block(&repo, user, other, b); schedule(&repo, user, a, 9); assert!( !gates(&repo, user)[&b].unlocked_by_plan, "b waits on both, so a half-planned day must not offer it" ); schedule(&repo, user, other, 10); assert!(gates(&repo, user)[&b].unlocked_by_plan); } #[test] fn a_blocker_scheduled_on_another_day_does_not_unlock_anything() { // The failure this prevents is a day that looks plannable and cannot be // executed, because the thing it waits on happens next week. let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); schedule(&repo, user, a, 24 * 7); assert!( !gates(&repo, user)[&b].unlocked_by_plan, "in the plan means in THIS plan" ); } #[test] fn a_blocker_scheduled_later_than_its_dependent_is_reported_not_prevented() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); schedule(&repo, user, a, 15); schedule(&repo, user, b, 9); let gate = &gates(&repo, user)[&b]; assert!( gate.unlocked_by_plan, "presence-only gating: b stays in the plan however it is dragged" ); assert!( gate.out_of_order, "and the incoherence is reported so the marker can say so" ); } #[test] fn the_right_order_is_not_flagged() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); schedule(&repo, user, a, 9); schedule(&repo, user, b, 15); assert!(!gates(&repo, user)[&b].out_of_order); } #[test] fn an_unscheduled_task_is_never_out_of_order() { // It has no start to be earlier than, and flagging it would put a warning // on every item in the pool. let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); schedule(&repo, user, a, 15); assert!(!gates(&repo, user)[&b].out_of_order); } #[test] fn completing_the_blocker_drops_the_gate_entirely() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); assert!(gates(&repo, user).contains_key(&b)); repo.complete(a, user).expect("complete").unwrap(); assert!( !gates(&repo, user).contains_key(&b), "a finished blocker is not something b is waiting on" ); } #[test] fn a_gate_names_a_cross_project_blocker_with_its_project() { let (repo, user) = repo(); let a = task(&repo, user, "a"); let b = task(&repo, user, "b"); block(&repo, user, a, b); // No project on either, so the field is absent rather than wrong; the point // is that the gate carries the field at all, since an unqualified title // reads as if the blocker were local. let gate = &gates(&repo, user)[&b]; assert_eq!(gate.lead_blocker().unwrap().title, "a"); assert!(gate.lead_blocker().unwrap().project_name.is_none()); }