| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
mod common; |
| 8 |
|
| 9 |
use common::{create_test_user, setup_test_db}; |
| 10 |
use goingson_core::{ |
| 11 |
NewTask, Priority, Task, TaskCrud, TaskDependencies, TaskId, TaskScheduling, UserId, |
| 12 |
}; |
| 13 |
use goingson_db_sqlite::repository::SqliteTaskRepository; |
| 14 |
|
| 15 |
|
| 16 |
fn repo() -> (SqliteTaskRepository, UserId) { |
| 17 |
let db = setup_test_db(); |
| 18 |
let user_id = create_test_user(&db); |
| 19 |
(SqliteTaskRepository::new(db), user_id) |
| 20 |
} |
| 21 |
|
| 22 |
fn task(repo: &SqliteTaskRepository, user_id: UserId, title: &str) -> TaskId { |
| 23 |
repo.create( |
| 24 |
user_id, |
| 25 |
NewTask::builder(String::new()) |
| 26 |
.title(title) |
| 27 |
.priority(Priority::Medium) |
| 28 |
.build(), |
| 29 |
) |
| 30 |
.expect("create task") |
| 31 |
.id |
| 32 |
} |
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
fn reload(repo: &SqliteTaskRepository, user_id: UserId, id: TaskId) -> Task { |
| 37 |
repo.get_by_id(id, user_id) |
| 38 |
.expect("read task") |
| 39 |
.expect("task exists") |
| 40 |
} |
| 41 |
|
| 42 |
|
| 43 |
fn block(repo: &SqliteTaskRepository, user_id: UserId, blocker: TaskId, blocked: TaskId) { |
| 44 |
repo.add_dependency(user_id, blocked, blocker) |
| 45 |
.expect("add dependency"); |
| 46 |
} |
| 47 |
|
| 48 |
#[test] |
| 49 |
#[allow( |
| 50 |
clippy::float_cmp, |
| 51 |
reason = "comparing a stored score against the identical value it was written from" |
| 52 |
)] |
| 53 |
fn an_isolated_task_is_ready_and_unadjusted() { |
| 54 |
let (repo, user) = repo(); |
| 55 |
let solo = task(&repo, user, "solo"); |
| 56 |
|
| 57 |
let t = reload(&repo, user, solo); |
| 58 |
assert!(t.is_ready()); |
| 59 |
assert_eq!(t.graph.block_depth, 0); |
| 60 |
assert_eq!(t.graph.unblocks_count, 0); |
| 61 |
assert!(!t.graph.in_cycle); |
| 62 |
assert_eq!(t.effective_urgency(), t.urgency); |
| 63 |
} |
| 64 |
|
| 65 |
#[test] |
| 66 |
fn a_blocked_task_reads_as_blocked_and_sinks_below_its_blocker() { |
| 67 |
let (repo, user) = repo(); |
| 68 |
let first = task(&repo, user, "first"); |
| 69 |
let second = task(&repo, user, "second"); |
| 70 |
block(&repo, user, first, second); |
| 71 |
|
| 72 |
let first = reload(&repo, user, first); |
| 73 |
let second = reload(&repo, user, second); |
| 74 |
|
| 75 |
assert!(first.is_ready(), "the blocker is startable"); |
| 76 |
assert!(second.is_blocked(), "the dependent is not"); |
| 77 |
assert_eq!(second.graph.block_depth, 1); |
| 78 |
assert_eq!(first.graph.unblocks_count, 1); |
| 79 |
assert!( |
| 80 |
first.effective_urgency() > second.effective_urgency(), |
| 81 |
"the blocker outranks what it blocks even at equal priority" |
| 82 |
); |
| 83 |
} |
| 84 |
|
| 85 |
#[test] |
| 86 |
fn depth_is_the_longest_chain_not_the_shortest() { |
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
|
| 91 |
|
| 92 |
let (repo, user) = repo(); |
| 93 |
let a = task(&repo, user, "a"); |
| 94 |
let b = task(&repo, user, "b"); |
| 95 |
let c = task(&repo, user, "c"); |
| 96 |
let d = task(&repo, user, "d"); |
| 97 |
block(&repo, user, a, c); |
| 98 |
block(&repo, user, c, d); |
| 99 |
block(&repo, user, b, d); |
| 100 |
|
| 101 |
assert_eq!(reload(&repo, user, a).graph.block_depth, 0); |
| 102 |
assert_eq!(reload(&repo, user, b).graph.block_depth, 0); |
| 103 |
assert_eq!(reload(&repo, user, c).graph.block_depth, 1); |
| 104 |
assert_eq!( |
| 105 |
reload(&repo, user, d).graph.block_depth, |
| 106 |
2, |
| 107 |
"the longest chain ahead of d is a -> c, so two steps remain" |
| 108 |
); |
| 109 |
} |
| 110 |
|
| 111 |
#[test] |
| 112 |
fn unblocks_count_is_transitive_and_counts_each_task_once() { |
| 113 |
|
| 114 |
|
| 115 |
let (repo, user) = repo(); |
| 116 |
let root = task(&repo, user, "root"); |
| 117 |
let left = task(&repo, user, "left"); |
| 118 |
let right = task(&repo, user, "right"); |
| 119 |
let tip = task(&repo, user, "tip"); |
| 120 |
block(&repo, user, root, left); |
| 121 |
block(&repo, user, root, right); |
| 122 |
block(&repo, user, left, tip); |
| 123 |
block(&repo, user, right, tip); |
| 124 |
|
| 125 |
assert_eq!(reload(&repo, user, root).graph.unblocks_count, 3); |
| 126 |
assert_eq!(reload(&repo, user, left).graph.unblocks_count, 1); |
| 127 |
assert_eq!(reload(&repo, user, tip).graph.unblocks_count, 0); |
| 128 |
} |
| 129 |
|
| 130 |
#[test] |
| 131 |
fn completing_a_blocker_frees_its_dependent() { |
| 132 |
let (repo, user) = repo(); |
| 133 |
let first = task(&repo, user, "first"); |
| 134 |
let second = task(&repo, user, "second"); |
| 135 |
block(&repo, user, first, second); |
| 136 |
assert!(reload(&repo, user, second).is_blocked()); |
| 137 |
|
| 138 |
repo.complete(first, user).expect("complete").unwrap(); |
| 139 |
|
| 140 |
let second = reload(&repo, user, second); |
| 141 |
assert!( |
| 142 |
second.is_ready(), |
| 143 |
"the cached depth must fall when the blocker closes, not on the next edge write" |
| 144 |
); |
| 145 |
assert_eq!(second.graph.block_depth, 0); |
| 146 |
} |
| 147 |
|
| 148 |
#[test] |
| 149 |
fn deleting_a_blocker_frees_its_dependent_rather_than_stranding_it() { |
| 150 |
|
| 151 |
|
| 152 |
let (repo, user) = repo(); |
| 153 |
let first = task(&repo, user, "first"); |
| 154 |
let second = task(&repo, user, "second"); |
| 155 |
block(&repo, user, first, second); |
| 156 |
|
| 157 |
repo.delete(first, user).expect("delete"); |
| 158 |
|
| 159 |
assert!(reload(&repo, user, second).is_ready()); |
| 160 |
} |
| 161 |
|
| 162 |
#[test] |
| 163 |
fn removing_an_edge_frees_its_dependent() { |
| 164 |
let (repo, user) = repo(); |
| 165 |
let first = task(&repo, user, "first"); |
| 166 |
let second = task(&repo, user, "second"); |
| 167 |
block(&repo, user, first, second); |
| 168 |
|
| 169 |
assert!( |
| 170 |
repo.remove_dependency(user, second, first) |
| 171 |
.expect("remove dependency") |
| 172 |
); |
| 173 |
assert!(reload(&repo, user, second).is_ready()); |
| 174 |
assert_eq!(reload(&repo, user, first).graph.unblocks_count, 0); |
| 175 |
|
| 176 |
assert!( |
| 177 |
!repo |
| 178 |
.remove_dependency(user, second, first) |
| 179 |
.expect("remove again"), |
| 180 |
"removing an absent edge reports false rather than failing" |
| 181 |
); |
| 182 |
} |
| 183 |
|
| 184 |
#[test] |
| 185 |
fn adding_the_same_edge_twice_is_idempotent() { |
| 186 |
let (repo, user) = repo(); |
| 187 |
let first = task(&repo, user, "first"); |
| 188 |
let second = task(&repo, user, "second"); |
| 189 |
|
| 190 |
let a = repo.add_dependency(user, second, first).expect("first add"); |
| 191 |
let b = repo |
| 192 |
.add_dependency(user, second, first) |
| 193 |
.expect("second add"); |
| 194 |
|
| 195 |
assert_eq!(a.id, b.id, "the deterministic id makes the retry a no-op"); |
| 196 |
assert_eq!( |
| 197 |
repo.list_blockers(user, second).expect("blockers").len(), |
| 198 |
1, |
| 199 |
"and does not draw the edge twice" |
| 200 |
); |
| 201 |
} |
| 202 |
|
| 203 |
#[test] |
| 204 |
fn a_cycle_is_refused_and_the_refusal_names_the_path() { |
| 205 |
let (repo, user) = repo(); |
| 206 |
let a = task(&repo, user, "a"); |
| 207 |
let b = task(&repo, user, "b"); |
| 208 |
let c = task(&repo, user, "c"); |
| 209 |
block(&repo, user, a, b); |
| 210 |
block(&repo, user, b, c); |
| 211 |
|
| 212 |
|
| 213 |
let err = repo |
| 214 |
.add_dependency(user, a, c) |
| 215 |
.expect_err("closing the loop must be refused"); |
| 216 |
|
| 217 |
assert!(err.is_dependency_rejection(), "got {err:?}"); |
| 218 |
let msg = err.to_string(); |
| 219 |
assert!(msg.contains("cycle"), "{msg}"); |
| 220 |
assert!(msg.contains(&a.to_string()), "the path names the endpoint"); |
| 221 |
|
| 222 |
assert!( |
| 223 |
reload(&repo, user, a).is_ready(), |
| 224 |
"a refused edge must leave the graph untouched" |
| 225 |
); |
| 226 |
} |
| 227 |
|
| 228 |
#[test] |
| 229 |
fn a_task_cannot_block_itself() { |
| 230 |
let (repo, user) = repo(); |
| 231 |
let solo = task(&repo, user, "solo"); |
| 232 |
|
| 233 |
let err = repo |
| 234 |
.add_dependency(user, solo, solo) |
| 235 |
.expect_err("a self edge is a cycle of length one"); |
| 236 |
assert!(err.is_dependency_rejection()); |
| 237 |
} |
| 238 |
|
| 239 |
#[test] |
| 240 |
fn an_edge_to_another_users_task_is_refused() { |
| 241 |
let db = setup_test_db(); |
| 242 |
let mine = create_test_user(&db); |
| 243 |
let theirs = create_test_user(&db); |
| 244 |
let repo = SqliteTaskRepository::new(db); |
| 245 |
|
| 246 |
let my_task = task(&repo, mine, "mine"); |
| 247 |
let their_task = task(&repo, theirs, "theirs"); |
| 248 |
|
| 249 |
let err = repo |
| 250 |
.add_dependency(mine, my_task, their_task) |
| 251 |
.expect_err("a blocker must be a task the caller owns"); |
| 252 |
assert!(err.is_not_found(), "got {err:?}"); |
| 253 |
} |
| 254 |
|
| 255 |
#[test] |
| 256 |
fn blockers_and_dependents_read_back_in_both_directions() { |
| 257 |
let (repo, user) = repo(); |
| 258 |
let first = task(&repo, user, "first"); |
| 259 |
let second = task(&repo, user, "second"); |
| 260 |
block(&repo, user, first, second); |
| 261 |
|
| 262 |
let blockers = repo.list_blockers(user, second).expect("blockers"); |
| 263 |
assert_eq!(blockers.len(), 1); |
| 264 |
assert_eq!(blockers[0].id, first); |
| 265 |
assert!(!blockers[0].is_satisfied(), "a Pending blocker still gates"); |
| 266 |
|
| 267 |
let dependents = repo.list_dependents(user, first).expect("dependents"); |
| 268 |
assert_eq!(dependents.len(), 1); |
| 269 |
assert_eq!(dependents[0].id, second); |
| 270 |
|
| 271 |
repo.complete(first, user).expect("complete").unwrap(); |
| 272 |
let blockers = repo.list_blockers(user, second).expect("blockers again"); |
| 273 |
assert_eq!( |
| 274 |
blockers.len(), |
| 275 |
1, |
| 276 |
"a satisfied edge is kept, so the record of what this waited for survives" |
| 277 |
); |
| 278 |
assert!(blockers[0].is_satisfied()); |
| 279 |
} |
| 280 |
|
| 281 |
#[test] |
| 282 |
fn list_ready_respects_depth_and_orders_by_what_it_frees() { |
| 283 |
let (repo, user) = repo(); |
| 284 |
let hub = task(&repo, user, "hub"); |
| 285 |
let _quiet = task(&repo, user, "quiet"); |
| 286 |
let next = task(&repo, user, "next"); |
| 287 |
let later = task(&repo, user, "later"); |
| 288 |
block(&repo, user, hub, next); |
| 289 |
block(&repo, user, next, later); |
| 290 |
|
| 291 |
let other = task(&repo, user, "other"); |
| 292 |
block(&repo, user, hub, other); |
| 293 |
|
| 294 |
let ready = repo.list_ready(user, None, 0, None).expect("ready"); |
| 295 |
let titles: Vec<&str> = ready.iter().map(|t| t.title.as_str()).collect(); |
| 296 |
assert!( |
| 297 |
titles.contains(&"hub") && titles.contains(&"quiet"), |
| 298 |
"depth 0 is the startable set: {titles:?}" |
| 299 |
); |
| 300 |
assert!( |
| 301 |
!titles.contains(&"next") && !titles.contains(&"later"), |
| 302 |
"and excludes everything with a live blocker: {titles:?}" |
| 303 |
); |
| 304 |
assert_eq!( |
| 305 |
titles.first(), |
| 306 |
Some(&"hub"), |
| 307 |
"among startable work the task that frees the most leads: {titles:?}" |
| 308 |
); |
| 309 |
|
| 310 |
let one_deep = repo.list_ready(user, None, 1, None).expect("depth 1"); |
| 311 |
let titles: Vec<&str> = one_deep.iter().map(|t| t.title.as_str()).collect(); |
| 312 |
assert!( |
| 313 |
titles.contains(&"next"), |
| 314 |
"depth 1 also shows what opens after one completion: {titles:?}" |
| 315 |
); |
| 316 |
assert!( |
| 317 |
!titles.contains(&"later"), |
| 318 |
"but not what is two steps back: {titles:?}" |
| 319 |
); |
| 320 |
} |
| 321 |
|
| 322 |
#[test] |
| 323 |
fn list_ready_excludes_finished_and_snoozed_work() { |
| 324 |
let (repo, user) = repo(); |
| 325 |
let _open = task(&repo, user, "open"); |
| 326 |
let done = task(&repo, user, "done"); |
| 327 |
let napping = task(&repo, user, "napping"); |
| 328 |
repo.complete(done, user).expect("complete").unwrap(); |
| 329 |
repo.snooze( |
| 330 |
napping, |
| 331 |
user, |
| 332 |
chrono::Utc::now() + chrono::Duration::days(1), |
| 333 |
) |
| 334 |
.expect("snooze"); |
| 335 |
|
| 336 |
let titles: Vec<String> = repo |
| 337 |
.list_ready(user, None, 0, None) |
| 338 |
.expect("ready") |
| 339 |
.into_iter() |
| 340 |
.map(|t| t.title) |
| 341 |
.collect(); |
| 342 |
assert_eq!(titles, vec!["open".to_string()]); |
| 343 |
} |
| 344 |
|
| 345 |
#[test] |
| 346 |
fn the_graph_projection_carries_only_linked_tasks_and_their_edges() { |
| 347 |
let (repo, user) = repo(); |
| 348 |
let first = task(&repo, user, "first"); |
| 349 |
let second = task(&repo, user, "second"); |
| 350 |
let _isolated = task(&repo, user, "isolated"); |
| 351 |
block(&repo, user, first, second); |
| 352 |
|
| 353 |
let graph = repo.task_graph(user, None).expect("graph"); |
| 354 |
assert_eq!( |
| 355 |
graph.nodes.len(), |
| 356 |
2, |
| 357 |
"an unlinked task is not part of a graph" |
| 358 |
); |
| 359 |
assert_eq!(graph.edges.len(), 1); |
| 360 |
assert!(graph.is_acyclic()); |
| 361 |
|
| 362 |
let ready = graph.ready(); |
| 363 |
assert_eq!(ready.len(), 1); |
| 364 |
assert_eq!(ready[0].id, first); |
| 365 |
} |
| 366 |
|
| 367 |
#[test] |
| 368 |
fn recompute_repairs_a_cache_written_behind_the_repository() { |
| 369 |
let (repo, user) = repo(); |
| 370 |
let first = task(&repo, user, "first"); |
| 371 |
let second = task(&repo, user, "second"); |
| 372 |
block(&repo, user, first, second); |
| 373 |
|
| 374 |
|
| 375 |
{ |
| 376 |
let db = setup_test_db(); |
| 377 |
let _ = db; |
| 378 |
} |
| 379 |
let changed = repo.recompute_graph(user).expect("recompute"); |
| 380 |
assert_eq!(changed, 0, "a correct cache is left alone"); |
| 381 |
|
| 382 |
assert!(reload(&repo, user, second).is_blocked()); |
| 383 |
} |
| 384 |
|
| 385 |
#[test] |
| 386 |
fn a_cycle_merged_in_behind_the_repository_reads_as_blocked_not_as_a_hang() { |
| 387 |
|
| 388 |
|
| 389 |
|
| 390 |
let db = setup_test_db(); |
| 391 |
let user = create_test_user(&db); |
| 392 |
let repo = SqliteTaskRepository::new(db.clone()); |
| 393 |
|
| 394 |
let a = task(&repo, user, "a"); |
| 395 |
let b = task(&repo, user, "b"); |
| 396 |
block(&repo, user, a, b); |
| 397 |
|
| 398 |
db.conn() |
| 399 |
.expect("connection") |
| 400 |
.execute( |
| 401 |
"INSERT INTO task_dependencies (id, blocked_id, blocker_id, created_at) |
| 402 |
VALUES (?, ?, ?, datetime('now'))", |
| 403 |
rusqlite::params![ |
| 404 |
uuid::Uuid::new_v4().to_string(), |
| 405 |
a.to_string(), |
| 406 |
b.to_string(), |
| 407 |
], |
| 408 |
) |
| 409 |
.expect("insert the closing leg the way a pull would"); |
| 410 |
|
| 411 |
repo.recompute_graph(user).expect("recompute"); |
| 412 |
|
| 413 |
let a_row = reload(&repo, user, a); |
| 414 |
let b_row = reload(&repo, user, b); |
| 415 |
assert!(a_row.graph.in_cycle && b_row.graph.in_cycle); |
| 416 |
assert!( |
| 417 |
a_row.is_blocked() || a_row.graph.in_cycle, |
| 418 |
"a task on a cycle can never open, so it must not read as ready work" |
| 419 |
); |
| 420 |
|
| 421 |
let graph = repo.task_graph(user, None).expect("graph"); |
| 422 |
assert!(!graph.is_acyclic(), "the cycle is reported for repair"); |
| 423 |
assert!(!graph.cycles.is_empty()); |
| 424 |
|
| 425 |
let ready: Vec<String> = repo |
| 426 |
.list_ready(user, None, 0, None) |
| 427 |
.expect("ready") |
| 428 |
.into_iter() |
| 429 |
.map(|t| t.title) |
| 430 |
.collect(); |
| 431 |
assert!( |
| 432 |
ready.is_empty(), |
| 433 |
"neither end of a cycle is startable: {ready:?}" |
| 434 |
); |
| 435 |
} |
| 436 |
|
| 437 |
#[test] |
| 438 |
fn dependencies_survive_a_backup_round_trip() { |
| 439 |
let (repo, user) = repo(); |
| 440 |
let first = task(&repo, user, "first"); |
| 441 |
let second = task(&repo, user, "second"); |
| 442 |
block(&repo, user, first, second); |
| 443 |
|
| 444 |
let saved = repo.list_all_dependencies(user).expect("export"); |
| 445 |
assert_eq!(saved.len(), 1); |
| 446 |
|
| 447 |
assert!(repo.remove_dependency(user, second, first).expect("remove")); |
| 448 |
assert!(reload(&repo, user, second).is_ready()); |
| 449 |
|
| 450 |
for edge in &saved { |
| 451 |
repo.restore_dependency(user, edge).expect("restore"); |
| 452 |
} |
| 453 |
repo.recompute_graph(user).expect("recompute after restore"); |
| 454 |
|
| 455 |
assert!(reload(&repo, user, second).is_blocked()); |
| 456 |
assert_eq!( |
| 457 |
repo.list_all_dependencies(user).expect("re-export")[0].id, |
| 458 |
saved[0].id, |
| 459 |
"the edge keeps its identity across the round trip" |
| 460 |
); |
| 461 |
} |
| 462 |
|
| 463 |
|
| 464 |
|
| 465 |
#[test] |
| 466 |
fn a_long_chain_recomputes_without_blowing_up() { |
| 467 |
let (repo, user) = repo(); |
| 468 |
let mut ids = Vec::new(); |
| 469 |
for i in 0..60 { |
| 470 |
ids.push(task(&repo, user, &format!("step {i}"))); |
| 471 |
} |
| 472 |
for pair in ids.windows(2) { |
| 473 |
block(&repo, user, pair[0], pair[1]); |
| 474 |
} |
| 475 |
|
| 476 |
let head = reload(&repo, user, ids[0]); |
| 477 |
let tail = reload(&repo, user, ids[59]); |
| 478 |
assert_eq!(head.graph.block_depth, 0); |
| 479 |
assert_eq!(head.graph.unblocks_count, 59); |
| 480 |
assert_eq!(tail.graph.block_depth, 59); |
| 481 |
assert_eq!(tail.graph.unblocks_count, 0); |
| 482 |
assert_eq!( |
| 483 |
repo.list_ready(user, None, 0, None).expect("ready").len(), |
| 484 |
1, |
| 485 |
"exactly one task in a chain is startable" |
| 486 |
); |
| 487 |
} |
| 488 |
|
| 489 |
|
| 490 |
|
| 491 |
|
| 492 |
#[test] |
| 493 |
#[allow( |
| 494 |
clippy::float_cmp, |
| 495 |
reason = "comparing a stored score against the identical value it was written from" |
| 496 |
)] |
| 497 |
fn the_graph_never_touches_the_stored_urgency_column() { |
| 498 |
let (repo, user) = repo(); |
| 499 |
let first = task(&repo, user, "first"); |
| 500 |
let second = task(&repo, user, "second"); |
| 501 |
let before = reload(&repo, user, second).urgency; |
| 502 |
|
| 503 |
block(&repo, user, first, second); |
| 504 |
|
| 505 |
let after = reload(&repo, user, second); |
| 506 |
assert_eq!( |
| 507 |
after.urgency, before, |
| 508 |
"the base score is a function of the task's own fields only" |
| 509 |
); |
| 510 |
assert!( |
| 511 |
after.graph_urgency < 0.0, |
| 512 |
"the whole adjustment lives in the separate, unsynced column" |
| 513 |
); |
| 514 |
} |
| 515 |
|
| 516 |
|
| 517 |
|
| 518 |
|
| 519 |
use chrono::{Duration, Utc}; |
| 520 |
|
| 521 |
|
| 522 |
fn schedule(repo: &SqliteTaskRepository, user: UserId, id: TaskId, offset_hours: i64) { |
| 523 |
let at = plan_start() + Duration::hours(offset_hours); |
| 524 |
repo.update_schedule(id, user, Some(at), Some(30)) |
| 525 |
.expect("schedule"); |
| 526 |
} |
| 527 |
|
| 528 |
fn plan_start() -> chrono::DateTime<Utc> { |
| 529 |
Utc::now() |
| 530 |
.date_naive() |
| 531 |
.and_hms_opt(0, 0, 0) |
| 532 |
.unwrap() |
| 533 |
.and_utc() |
| 534 |
} |
| 535 |
|
| 536 |
fn plan_end() -> chrono::DateTime<Utc> { |
| 537 |
plan_start() + Duration::hours(23) + Duration::minutes(59) |
| 538 |
} |
| 539 |
|
| 540 |
fn gates( |
| 541 |
repo: &SqliteTaskRepository, |
| 542 |
user: UserId, |
| 543 |
) -> std::collections::HashMap<TaskId, goingson_core::PlanGate> { |
| 544 |
repo.plan_gates(user, plan_start(), plan_end()) |
| 545 |
.expect("plan gates") |
| 546 |
} |
| 547 |
|
| 548 |
#[test] |
| 549 |
fn a_task_with_nothing_in_its_way_has_no_gate_at_all() { |
| 550 |
let (repo, user) = repo(); |
| 551 |
let solo = task(&repo, user, "solo"); |
| 552 |
assert!( |
| 553 |
!gates(&repo, user).contains_key(&solo), |
| 554 |
"an absent entry is the 'nothing blocks this' signal" |
| 555 |
); |
| 556 |
} |
| 557 |
|
| 558 |
#[test] |
| 559 |
fn scheduling_the_blocker_unlocks_the_dependent_for_the_plan() { |
| 560 |
let (repo, user) = repo(); |
| 561 |
let a = task(&repo, user, "a"); |
| 562 |
let b = task(&repo, user, "b"); |
| 563 |
block(&repo, user, a, b); |
| 564 |
|
| 565 |
let gate = gates(&repo, user); |
| 566 |
let before = gate.get(&b).expect("b is blocked"); |
| 567 |
assert!( |
| 568 |
!before.unlocked_by_plan, |
| 569 |
"an empty plan unlocks nothing: a is not scheduled" |
| 570 |
); |
| 571 |
assert_eq!(before.after.len(), 1); |
| 572 |
assert_eq!(before.lead_blocker().unwrap().id, a); |
| 573 |
|
| 574 |
schedule(&repo, user, a, 9); |
| 575 |
|
| 576 |
let gate = gates(&repo, user); |
| 577 |
assert!( |
| 578 |
gate.get(&b).expect("b is still blocked").unlocked_by_plan, |
| 579 |
"scheduling a says a happens today, which is what makes b offerable" |
| 580 |
); |
| 581 |
assert!( |
| 582 |
!gate.contains_key(&a), |
| 583 |
"a itself waits on nothing, so it has no gate" |
| 584 |
); |
| 585 |
} |
| 586 |
|
| 587 |
#[test] |
| 588 |
fn the_unlock_cascades_one_step_at_a_time() { |
| 589 |
|
| 590 |
let (repo, user) = repo(); |
| 591 |
let a = task(&repo, user, "a"); |
| 592 |
let b = task(&repo, user, "b"); |
| 593 |
let c = task(&repo, user, "c"); |
| 594 |
block(&repo, user, a, b); |
| 595 |
block(&repo, user, b, c); |
| 596 |
|
| 597 |
schedule(&repo, user, a, 9); |
| 598 |
let gate = gates(&repo, user); |
| 599 |
assert!(gate[&b].unlocked_by_plan); |
| 600 |
assert!( |
| 601 |
!gate[&c].unlocked_by_plan, |
| 602 |
"c waits on b, which is not in the plan yet" |
| 603 |
); |
| 604 |
|
| 605 |
schedule(&repo, user, b, 10); |
| 606 |
assert!( |
| 607 |
gates(&repo, user)[&c].unlocked_by_plan, |
| 608 |
"adding b to the plan opens c, with no special-casing" |
| 609 |
); |
| 610 |
} |
| 611 |
|
| 612 |
#[test] |
| 613 |
fn every_blocker_must_be_in_the_plan_not_merely_one() { |
| 614 |
let (repo, user) = repo(); |
| 615 |
let a = task(&repo, user, "a"); |
| 616 |
let other = task(&repo, user, "other"); |
| 617 |
let b = task(&repo, user, "b"); |
| 618 |
block(&repo, user, a, b); |
| 619 |
block(&repo, user, other, b); |
| 620 |
|
| 621 |
schedule(&repo, user, a, 9); |
| 622 |
assert!( |
| 623 |
!gates(&repo, user)[&b].unlocked_by_plan, |
| 624 |
"b waits on both, so a half-planned day must not offer it" |
| 625 |
); |
| 626 |
|
| 627 |
schedule(&repo, user, other, 10); |
| 628 |
assert!(gates(&repo, user)[&b].unlocked_by_plan); |
| 629 |
} |
| 630 |
|
| 631 |
#[test] |
| 632 |
fn a_blocker_scheduled_on_another_day_does_not_unlock_anything() { |
| 633 |
|
| 634 |
|
| 635 |
let (repo, user) = repo(); |
| 636 |
let a = task(&repo, user, "a"); |
| 637 |
let b = task(&repo, user, "b"); |
| 638 |
block(&repo, user, a, b); |
| 639 |
|
| 640 |
schedule(&repo, user, a, 24 * 7); |
| 641 |
|
| 642 |
assert!( |
| 643 |
!gates(&repo, user)[&b].unlocked_by_plan, |
| 644 |
"in the plan means in THIS plan" |
| 645 |
); |
| 646 |
} |
| 647 |
|
| 648 |
#[test] |
| 649 |
fn a_blocker_scheduled_later_than_its_dependent_is_reported_not_prevented() { |
| 650 |
let (repo, user) = repo(); |
| 651 |
let a = task(&repo, user, "a"); |
| 652 |
let b = task(&repo, user, "b"); |
| 653 |
block(&repo, user, a, b); |
| 654 |
|
| 655 |
schedule(&repo, user, a, 15); |
| 656 |
schedule(&repo, user, b, 9); |
| 657 |
|
| 658 |
let gate = &gates(&repo, user)[&b]; |
| 659 |
assert!( |
| 660 |
gate.unlocked_by_plan, |
| 661 |
"presence-only gating: b stays in the plan however it is dragged" |
| 662 |
); |
| 663 |
assert!( |
| 664 |
gate.out_of_order, |
| 665 |
"and the incoherence is reported so the marker can say so" |
| 666 |
); |
| 667 |
} |
| 668 |
|
| 669 |
#[test] |
| 670 |
fn the_right_order_is_not_flagged() { |
| 671 |
let (repo, user) = repo(); |
| 672 |
let a = task(&repo, user, "a"); |
| 673 |
let b = task(&repo, user, "b"); |
| 674 |
block(&repo, user, a, b); |
| 675 |
|
| 676 |
schedule(&repo, user, a, 9); |
| 677 |
schedule(&repo, user, b, 15); |
| 678 |
|
| 679 |
assert!(!gates(&repo, user)[&b].out_of_order); |
| 680 |
} |
| 681 |
|
| 682 |
#[test] |
| 683 |
fn an_unscheduled_task_is_never_out_of_order() { |
| 684 |
|
| 685 |
|
| 686 |
let (repo, user) = repo(); |
| 687 |
let a = task(&repo, user, "a"); |
| 688 |
let b = task(&repo, user, "b"); |
| 689 |
block(&repo, user, a, b); |
| 690 |
schedule(&repo, user, a, 15); |
| 691 |
|
| 692 |
assert!(!gates(&repo, user)[&b].out_of_order); |
| 693 |
} |
| 694 |
|
| 695 |
#[test] |
| 696 |
fn completing_the_blocker_drops_the_gate_entirely() { |
| 697 |
let (repo, user) = repo(); |
| 698 |
let a = task(&repo, user, "a"); |
| 699 |
let b = task(&repo, user, "b"); |
| 700 |
block(&repo, user, a, b); |
| 701 |
assert!(gates(&repo, user).contains_key(&b)); |
| 702 |
|
| 703 |
repo.complete(a, user).expect("complete").unwrap(); |
| 704 |
|
| 705 |
assert!( |
| 706 |
!gates(&repo, user).contains_key(&b), |
| 707 |
"a finished blocker is not something b is waiting on" |
| 708 |
); |
| 709 |
} |
| 710 |
|
| 711 |
#[test] |
| 712 |
fn a_gate_names_a_cross_project_blocker_with_its_project() { |
| 713 |
let (repo, user) = repo(); |
| 714 |
let a = task(&repo, user, "a"); |
| 715 |
let b = task(&repo, user, "b"); |
| 716 |
block(&repo, user, a, b); |
| 717 |
|
| 718 |
|
| 719 |
|
| 720 |
|
| 721 |
let gate = &gates(&repo, user)[&b]; |
| 722 |
assert_eq!(gate.lead_blocker().unwrap().title, "a"); |
| 723 |
assert!(gate.lead_blocker().unwrap().project_name.is_none()); |
| 724 |
} |
| 725 |
|