Skip to main content

max / goingson

22.3 KB · 725 lines History Blame Raw
1 //! Integration tests for the task dependency graph.
2 //!
3 //! The cached columns (`block_depth`, `unblocks_count`, `in_cycle`,
4 //! `graph_urgency`) are a derivation, so most of what is worth asserting is that
5 //! they agree with the edges after every kind of write that can change either.
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 /// A repository over a fresh in-memory database, plus a user to own everything.
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 /// Re-read a task so the assertions see the cached columns as stored, not as
35 /// they were in whatever the write returned.
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 /// `a` blocks `b`: b cannot start until a is done.
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 // d waits on both b (one hop) and c (two hops, via a). It opens only when
88 // the slower chain clears, so its depth is 3 and not 1.
89 //
90 // a -> c -> d
91 // b ------> d
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 // A diamond: finishing `root` eventually frees three tasks, not four, even
114 // though there are two paths to the tip.
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 // A soft-deleted task will never be completed, so an edge to it would hold
151 // its dependent at depth forever.
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 // c already depends on a through b, so a depending on c closes the loop.
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 // Give the hub something else to free so it outranks the isolated task.
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 // Corrupt the cache the way a stray write or a restored backup would.
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 // The write path refuses cycles, so the only way to get one is for sync to
388 // merge two individually legal edges. Simulated here by inserting the second
389 // leg directly, which is exactly what a remote apply does.
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 /// Exercises the whole surface against a graph big enough that an accidental
464 /// exponential blowup would show up as a hang rather than a wrong number.
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 /// A guard on the type of the whole exercise: nothing here may make a task's
490 /// stored `urgency` depend on the graph. That column is synced, and the moment
491 /// it moves with the graph two devices contest it.
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 // Plan gates: a day plan widens "has this stopped gating" to include work
517 // already scheduled in that plan.
518
519 use chrono::{Duration, Utc};
520
521 /// Schedule `id` at `offset` from the window start.
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 // a -> b -> c. Scheduling a opens b, and only scheduling b opens c.
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 // The failure this prevents is a day that looks plannable and cannot be
634 // executed, because the thing it waits on happens next week.
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 // It has no start to be earlier than, and flagging it would put a warning
685 // on every item in the pool.
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 // No project on either, so the field is absent rather than wrong; the point
719 // is that the gate carries the field at all, since an unqualified title
720 // reads as if the blocker were local.
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