1#[cfg(test)]
2mod tests {
3 use std::collections::{BTreeSet, HashMap};
4 use std::fs;
5 use std::path::Path;
6
7 use super::super::board::read_workflow_metadata;
8 use super::super::capability::{
9 CapabilityMap, CapabilitySubject, WorkflowCapability, resolve_capability_map,
10 };
11 use super::super::completion::ingest_completion_message;
12 use super::super::config::{RoleType, TeamConfig, WorkflowMode, WorkflowPolicy};
13 use super::super::hierarchy::{MemberInstance, resolve_hierarchy};
14 use super::super::nudge::compute_nudges;
15 use super::super::policy::{check_wip_limit, is_review_stale, should_escalate};
16 use super::super::resolver::{ResolutionStatus, resolve_board, runnable_tasks};
17 use super::super::review::{MergeDisposition, ReviewState, apply_review};
18 use super::super::standup::MemberState;
19 use super::super::team_config_dir;
20 use super::super::workflow::{TaskState, WorkflowMeta};
21
22 #[derive(Debug)]
23 struct TemplateExpectation {
24 users: usize,
25 architects: usize,
26 managers: usize,
27 engineers: usize,
28 role_capabilities: Vec<(&'static str, &'static [WorkflowCapability])>,
29 operator_caps: &'static [WorkflowCapability],
30 }
31
32 fn capability_set(values: &[WorkflowCapability]) -> BTreeSet<WorkflowCapability> {
33 values.iter().copied().collect()
34 }
35
36 fn capability_subject_set(
37 map: &CapabilityMap,
38 subject: CapabilitySubject,
39 ) -> BTreeSet<WorkflowCapability> {
40 map.get(&subject).cloned().unwrap_or_default()
41 }
42
43 fn member_capabilities(map: &CapabilityMap, member_name: &str) -> BTreeSet<WorkflowCapability> {
44 capability_subject_set(map, CapabilitySubject::Member(member_name.to_string()))
45 }
46
47 fn load_template(yaml: &str) -> TeamConfig {
48 serde_yaml::from_str(yaml).unwrap()
49 }
50
51 fn assert_template_topology(
52 yaml: &str,
53 expectation: &TemplateExpectation,
54 ) -> Vec<MemberInstance> {
55 let config = load_template(yaml);
56 assert_eq!(config.workflow_mode, WorkflowMode::Hybrid);
57 assert!(config.orchestrator_enabled());
58
59 let members = resolve_hierarchy(&config).unwrap();
60 let capability_map = resolve_capability_map(&members);
61
62 let users = members
63 .iter()
64 .filter(|member| member.role_type == RoleType::User)
65 .count();
66 let architects = members
67 .iter()
68 .filter(|member| member.role_type == RoleType::Architect)
69 .count();
70 let managers = members
71 .iter()
72 .filter(|member| member.role_type == RoleType::Manager)
73 .count();
74 let engineers = members
75 .iter()
76 .filter(|member| member.role_type == RoleType::Engineer)
77 .count();
78
79 assert_eq!(users, expectation.users);
80 assert_eq!(architects, expectation.architects);
81 assert_eq!(managers, expectation.managers);
82 assert_eq!(engineers, expectation.engineers);
83
84 for (role_name, expected_caps) in &expectation.role_capabilities {
85 let expected = capability_set(expected_caps);
86 let role_members: Vec<_> = members
87 .iter()
88 .filter(|member| member.role_name == *role_name)
89 .collect();
90 assert!(
91 !role_members.is_empty(),
92 "expected at least one member for role `{role_name}`"
93 );
94 for member in role_members {
95 assert_eq!(
96 member_capabilities(&capability_map, &member.name),
97 expected,
98 "unexpected capabilities for {}",
99 member.name
100 );
101 }
102 }
103
104 assert_eq!(
105 capability_subject_set(&capability_map, CapabilitySubject::Operator),
106 capability_set(expectation.operator_caps)
107 );
108 assert_eq!(
109 capability_subject_set(&capability_map, CapabilitySubject::Orchestrator),
110 capability_set(&[WorkflowCapability::Orchestrator])
111 );
112
113 members
114 }
115
116 fn idle_states(members: &[MemberInstance]) -> HashMap<String, MemberState> {
117 members
118 .iter()
119 .filter(|member| member.role_type != RoleType::User)
120 .map(|member| (member.name.clone(), MemberState::Idle))
121 .collect()
122 }
123
124 fn write_task(tasks_dir: &Path, id: u32, extra_frontmatter: &str) {
125 fs::write(
126 tasks_dir.join(format!("{id:03}-task-{id}.md")),
127 format!(
128 "---\nid: {id}\ntitle: Task {id}\npriority: medium\n{extra_frontmatter}class: standard\n---\n\nBody.\n"
129 ),
130 )
131 .unwrap();
132 }
133
134 fn create_task(project_root: &Path, id: u32, extra_frontmatter: &str) -> std::path::PathBuf {
135 let tasks_dir = team_config_dir(project_root).join("board").join("tasks");
136 fs::create_dir_all(&tasks_dir).unwrap();
137 let task_path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
138 fs::write(
139 &task_path,
140 format!(
141 "---\nid: {id}\ntitle: Task {id}\nstatus: review\npriority: medium\n{extra_frontmatter}class: standard\n---\n\nBody.\n"
142 ),
143 )
144 .unwrap();
145 task_path
146 }
147
148 #[test]
149 fn team_solo_template_validation_uses_real_capabilities() {
150 let members = assert_template_topology(
151 include_str!("templates/team_solo.yaml"),
152 &TemplateExpectation {
153 users: 0,
154 architects: 0,
155 managers: 0,
156 engineers: 1,
157 role_capabilities: vec![(
158 "engineer",
159 &[
160 WorkflowCapability::Planner,
161 WorkflowCapability::Dispatcher,
162 WorkflowCapability::Executor,
163 ],
164 )],
165 operator_caps: &[WorkflowCapability::Operator, WorkflowCapability::Reviewer],
166 },
167 );
168
169 assert_eq!(members[0].name, "engineer");
170 assert!(!members[0].use_worktrees);
171 }
172
173 #[test]
174 fn team_pair_template_validation_uses_real_capabilities() {
175 let members = assert_template_topology(
176 include_str!("templates/team_pair.yaml"),
177 &TemplateExpectation {
178 users: 0,
179 architects: 1,
180 managers: 0,
181 engineers: 1,
182 role_capabilities: vec![
183 (
184 "architect",
185 &[
186 WorkflowCapability::Planner,
187 WorkflowCapability::Dispatcher,
188 WorkflowCapability::Reviewer,
189 ],
190 ),
191 ("engineer", &[WorkflowCapability::Executor]),
192 ],
193 operator_caps: &[WorkflowCapability::Operator],
194 },
195 );
196
197 assert!(members.iter().any(|member| member.name == "architect"));
198 assert!(members.iter().any(|member| member.name == "engineer"));
199 }
200
201 #[test]
202 fn team_simple_template_validation_uses_real_capabilities() {
203 let members = assert_template_topology(
204 include_str!("templates/team_simple.yaml"),
205 &TemplateExpectation {
206 users: 1,
207 architects: 1,
208 managers: 1,
209 engineers: 3,
210 role_capabilities: vec![
211 (
212 "architect",
213 &[WorkflowCapability::Planner, WorkflowCapability::Reviewer],
214 ),
215 (
216 "manager",
217 &[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer],
218 ),
219 ("engineer", &[WorkflowCapability::Executor]),
220 ],
221 operator_caps: &[WorkflowCapability::Operator],
222 },
223 );
224
225 assert!(members.iter().any(|member| member.name == "human"));
226 assert_eq!(
227 members
228 .iter()
229 .filter(|member| member.role_name == "engineer")
230 .count(),
231 3
232 );
233 }
234
235 #[test]
236 fn team_squad_template_validation_uses_real_capabilities() {
237 let members = assert_template_topology(
238 include_str!("templates/team_squad.yaml"),
239 &TemplateExpectation {
240 users: 0,
241 architects: 1,
242 managers: 1,
243 engineers: 5,
244 role_capabilities: vec![
245 (
246 "architect",
247 &[WorkflowCapability::Planner, WorkflowCapability::Reviewer],
248 ),
249 (
250 "manager",
251 &[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer],
252 ),
253 ("engineer", &[WorkflowCapability::Executor]),
254 ],
255 operator_caps: &[WorkflowCapability::Operator],
256 },
257 );
258
259 assert_eq!(
260 members
261 .iter()
262 .filter(|member| member.role_name == "engineer")
263 .count(),
264 5
265 );
266 }
267
268 #[test]
269 fn team_research_template_validation_uses_real_capabilities() {
270 let members = assert_template_topology(
271 include_str!("templates/team_research.yaml"),
272 &TemplateExpectation {
273 users: 0,
274 architects: 1,
275 managers: 3,
276 engineers: 6,
277 role_capabilities: vec![
278 (
279 "principal",
280 &[WorkflowCapability::Planner, WorkflowCapability::Reviewer],
281 ),
282 (
283 "sub-lead",
284 &[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer],
285 ),
286 ("researcher", &[WorkflowCapability::Executor]),
287 ],
288 operator_caps: &[WorkflowCapability::Operator],
289 },
290 );
291
292 assert!(members.iter().any(|member| member.name == "principal"));
293 assert_eq!(
294 members
295 .iter()
296 .filter(|member| member.role_name == "researcher")
297 .count(),
298 6
299 );
300 }
301
302 #[test]
303 fn team_software_template_validation_uses_real_capabilities() {
304 let members = assert_template_topology(
305 include_str!("templates/team_software.yaml"),
306 &TemplateExpectation {
307 users: 1,
308 architects: 1,
309 managers: 2,
310 engineers: 8,
311 role_capabilities: vec![
312 (
313 "tech-lead",
314 &[WorkflowCapability::Planner, WorkflowCapability::Reviewer],
315 ),
316 (
317 "backend-mgr",
318 &[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer],
319 ),
320 (
321 "frontend-mgr",
322 &[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer],
323 ),
324 ("developer", &[WorkflowCapability::Executor]),
325 ],
326 operator_caps: &[WorkflowCapability::Operator],
327 },
328 );
329
330 assert!(members.iter().any(|member| member.name == "human"));
331 assert_eq!(
332 members
333 .iter()
334 .filter(|member| member.role_name == "developer")
335 .count(),
336 8
337 );
338 }
339
340 #[test]
341 fn team_cleanroom_template_validation_uses_real_capabilities() {
342 let members = assert_template_topology(
343 include_str!("templates/team_cleanroom.yaml"),
344 &TemplateExpectation {
345 users: 0,
346 architects: 1,
347 managers: 1,
348 engineers: 2,
349 role_capabilities: vec![
350 (
351 "decompiler",
352 &[WorkflowCapability::Planner, WorkflowCapability::Reviewer],
353 ),
354 (
355 "spec-writer",
356 &[WorkflowCapability::Dispatcher, WorkflowCapability::Reviewer],
357 ),
358 ("test-writer", &[WorkflowCapability::Executor]),
359 ("implementer", &[WorkflowCapability::Executor]),
360 ],
361 operator_caps: &[WorkflowCapability::Operator],
362 },
363 );
364
365 assert!(members.iter().any(|member| member.name == "decompiler"));
366 assert!(members.iter().any(|member| member.name == "spec-writer"));
367 assert!(
368 members
369 .iter()
370 .any(|member| member.name == "test-writer-1-1")
371 );
372 assert!(
373 members
374 .iter()
375 .any(|member| member.name == "implementer-1-1")
376 );
377 }
378
379 #[test]
380 fn orchestrator_enabled_uses_real_workflow_modes() {
381 let legacy: TeamConfig = serde_yaml::from_str(
382 r#"
383name: legacy
384workflow_mode: legacy
385orchestrator_pane: true
386roles:
387 - name: architect
388 role_type: architect
389 agent: claude
390"#,
391 )
392 .unwrap();
393 let hybrid: TeamConfig = serde_yaml::from_str(
394 r#"
395name: hybrid
396workflow_mode: hybrid
397orchestrator_pane: true
398roles:
399 - name: architect
400 role_type: architect
401 agent: claude
402"#,
403 )
404 .unwrap();
405 let workflow_first: TeamConfig = serde_yaml::from_str(
406 r#"
407name: wf
408workflow_mode: workflow_first
409orchestrator_pane: true
410roles:
411 - name: architect
412 role_type: architect
413 agent: claude
414"#,
415 )
416 .unwrap();
417 let workflow_first_hidden: TeamConfig = serde_yaml::from_str(
418 r#"
419name: wf-hidden
420workflow_mode: workflow_first
421orchestrator_pane: false
422roles:
423 - name: architect
424 role_type: architect
425 agent: claude
426"#,
427 )
428 .unwrap();
429 let board_first: TeamConfig = serde_yaml::from_str(
430 r#"
431name: board-first
432workflow_mode: board_first
433orchestrator_pane: true
434roles:
435 - name: architect
436 role_type: architect
437 agent: claude
438"#,
439 )
440 .unwrap();
441
442 assert!(!legacy.orchestrator_enabled());
443 assert!(hybrid.orchestrator_enabled());
444 assert!(workflow_first.orchestrator_enabled());
445 assert!(board_first.orchestrator_enabled());
446 assert!(!workflow_first_hidden.orchestrator_enabled());
447 }
448
449 #[test]
450 fn resolve_board_and_runnable_tasks_use_real_workflow_resolver() {
451 let tmp = tempfile::tempdir().unwrap();
452 let tasks_dir = tmp.path().join("tasks");
453 fs::create_dir_all(&tasks_dir).unwrap();
454
455 write_task(&tasks_dir, 1, "status: done\n");
456 write_task(
457 &tasks_dir,
458 2,
459 "status: todo\nexecution_owner: eng-1-1\nclaimed_by: eng-1-1\n",
460 );
461 write_task(&tasks_dir, 3, "status: review\nreview_owner: manager\n");
462 write_task(&tasks_dir, 4, "status: todo\nblocked_on: waiting for api\n");
463 write_task(&tasks_dir, 5, "status: todo\ndepends_on: [6]\n");
464 write_task(&tasks_dir, 6, "status: backlog\n");
465
466 let members = resolve_hierarchy(
467 &serde_yaml::from_str::<TeamConfig>(
468 r#"
469name: team
470roles:
471 - name: manager
472 role_type: manager
473 agent: claude
474 - name: engineer
475 role_type: engineer
476 agent: codex
477"#,
478 )
479 .unwrap(),
480 )
481 .unwrap();
482
483 let resolutions = resolve_board(tmp.path(), &members).unwrap();
484 let runnable = runnable_tasks(&resolutions);
485
486 assert_eq!(
487 runnable.iter().map(|task| task.task_id).collect::<Vec<_>>(),
488 vec![2, 6]
489 );
490 assert_eq!(
491 resolutions
492 .iter()
493 .find(|task| task.task_id == 2)
494 .map(|task| task.status),
495 Some(ResolutionStatus::Runnable)
496 );
497 assert_eq!(
498 resolutions
499 .iter()
500 .find(|task| task.task_id == 3)
501 .map(|task| task.status),
502 Some(ResolutionStatus::NeedsReview)
503 );
504 assert_eq!(
505 resolutions
506 .iter()
507 .find(|task| task.task_id == 4)
508 .and_then(|task| task.blocking_reason.as_deref()),
509 Some("waiting for api")
510 );
511 assert_eq!(
512 resolutions
513 .iter()
514 .find(|task| task.task_id == 5)
515 .and_then(|task| task.blocking_reason.as_deref()),
516 Some("unmet dependency #6")
517 );
518 }
519
520 #[test]
521 fn compute_nudges_uses_real_planner_path_for_each_shipped_topology() {
522 let templates = [
523 include_str!("templates/team_solo.yaml"),
524 include_str!("templates/team_pair.yaml"),
525 include_str!("templates/team_simple.yaml"),
526 include_str!("templates/team_squad.yaml"),
527 include_str!("templates/team_research.yaml"),
528 include_str!("templates/team_software.yaml"),
529 include_str!("templates/team_cleanroom.yaml"),
530 ];
531
532 for yaml in templates {
533 let config = load_template(yaml);
534 let members = resolve_hierarchy(&config).unwrap();
535 let capability_map = resolve_capability_map(&members);
536
537 let tmp = tempfile::tempdir().unwrap();
538 let tasks_dir = tmp.path().join("tasks");
539 fs::create_dir_all(&tasks_dir).unwrap();
540 write_task(
541 &tasks_dir,
542 1,
543 "status: blocked\nblocked_on: waiting on dependency\n",
544 );
545
546 let nudges = compute_nudges(
547 tmp.path(),
548 &members,
549 &idle_states(&members),
550 &HashMap::new(),
551 )
552 .unwrap();
553
554 assert!(
555 nudges
556 .iter()
557 .any(|target| target.capability == WorkflowCapability::Planner),
558 "expected planner nudge for topology {:?}",
559 config.name
560 );
561
562 for planner in nudges
563 .iter()
564 .filter(|target| target.capability == WorkflowCapability::Planner)
565 {
566 assert!(
567 member_capabilities(&capability_map, &planner.member)
568 .contains(&WorkflowCapability::Planner),
569 "planner nudge targeted non-planner {}",
570 planner.member
571 );
572 }
573 }
574 }
575
576 #[test]
577 fn compute_nudges_uses_real_dispatch_path_for_each_shipped_topology() {
578 let templates = [
579 include_str!("templates/team_solo.yaml"),
580 include_str!("templates/team_pair.yaml"),
581 include_str!("templates/team_simple.yaml"),
582 include_str!("templates/team_squad.yaml"),
583 include_str!("templates/team_research.yaml"),
584 include_str!("templates/team_software.yaml"),
585 include_str!("templates/team_cleanroom.yaml"),
586 ];
587
588 for yaml in templates {
589 let config = load_template(yaml);
590 let members = resolve_hierarchy(&config).unwrap();
591 let capability_map = resolve_capability_map(&members);
592
593 let tmp = tempfile::tempdir().unwrap();
594 let tasks_dir = tmp.path().join("tasks");
595 fs::create_dir_all(&tasks_dir).unwrap();
596 write_task(&tasks_dir, 1, "status: todo\n");
597
598 let nudges = compute_nudges(
599 tmp.path(),
600 &members,
601 &idle_states(&members),
602 &HashMap::new(),
603 )
604 .unwrap();
605
606 assert!(
607 nudges
608 .iter()
609 .any(|target| target.capability == WorkflowCapability::Dispatcher),
610 "expected dispatcher nudge for topology {:?}",
611 config.name
612 );
613
614 for dispatcher in nudges
615 .iter()
616 .filter(|target| target.capability == WorkflowCapability::Dispatcher)
617 {
618 assert!(
619 member_capabilities(&capability_map, &dispatcher.member)
620 .contains(&WorkflowCapability::Dispatcher),
621 "dispatcher nudge targeted non-dispatcher {}",
622 dispatcher.member
623 );
624 }
625 }
626 }
627
628 #[test]
629 fn workflow_meta_transitions_end_to_end_with_real_review_flow() {
630 let mut meta = WorkflowMeta {
631 state: TaskState::Todo,
632 execution_owner: Some("eng-1-1".to_string()),
633 review_owner: Some("manager".to_string()),
634 worktree_path: Some(".batty/worktrees/eng-1-1".to_string()),
635 branch: Some("eng-1-1/task-34".to_string()),
636 commit: Some("abc1234".to_string()),
637 artifacts: vec!["target/nextest/default.xml".to_string()],
638 ..WorkflowMeta::default()
639 };
640
641 meta.transition(TaskState::InProgress).unwrap();
642 meta.next_action = Some("run tests".to_string());
643 meta.transition(TaskState::Review).unwrap();
644 meta.review = Some(ReviewState {
645 reviewer: "manager".to_string(),
646 packet_ref: Some("review/packet-34.json".to_string()),
647 disposition: MergeDisposition::MergeReady,
648 notes: Some("ready for merge".to_string()),
649 reviewed_at: None,
650 nudge_sent: false,
651 });
652
653 apply_review(&mut meta, MergeDisposition::MergeReady, "manager").unwrap();
654
655 assert_eq!(meta.state, TaskState::Done);
656 assert_eq!(meta.review_owner.as_deref(), Some("manager"));
657 assert_eq!(
658 meta.review_disposition,
659 Some(super::super::workflow::ReviewDisposition::Approved)
660 );
661 assert_eq!(
662 meta.review
663 .as_ref()
664 .and_then(|review| review.packet_ref.as_deref()),
665 Some("review/packet-34.json")
666 );
667 assert_eq!(meta.branch.as_deref(), Some("eng-1-1/task-34"));
668 assert_eq!(meta.commit.as_deref(), Some("abc1234"));
669 assert_eq!(meta.artifacts, vec!["target/nextest/default.xml"]);
670 }
671
672 #[test]
673 fn completion_packet_ingestion_uses_real_parser_and_metadata_writer() {
674 let tmp = tempfile::tempdir().unwrap();
675 let task_path = create_task(tmp.path(), 34, "");
676
677 let message = r#"Done.
678
679## Completion Packet
680
681```json
682{
683 "task_id": 34,
684 "branch": "eng-1-1/task-34",
685 "worktree_path": ".batty/worktrees/eng-1-1",
686 "commit": "def5678",
687 "changed_paths": ["src/team/validation.rs"],
688 "tests_run": true,
689 "tests_passed": true,
690 "artifacts": ["target/nextest/default.xml"],
691 "outcome": "ready_for_review"
692}
693```"#;
694
695 let task_id = ingest_completion_message(tmp.path(), message).unwrap();
696 let metadata = read_workflow_metadata(&task_path).unwrap();
697
698 assert_eq!(task_id, Some(34));
699 assert_eq!(metadata.branch.as_deref(), Some("eng-1-1/task-34"));
700 assert_eq!(
701 metadata.worktree_path.as_deref(),
702 Some(".batty/worktrees/eng-1-1")
703 );
704 assert_eq!(metadata.commit.as_deref(), Some("def5678"));
705 assert_eq!(metadata.changed_paths, vec!["src/team/validation.rs"]);
706 assert_eq!(metadata.tests_run, Some(true));
707 assert_eq!(metadata.tests_passed, Some(true));
708 assert_eq!(metadata.artifacts, vec!["target/nextest/default.xml"]);
709 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
710 assert!(metadata.review_blockers.is_empty());
711 }
712
713 #[test]
714 fn workflow_policy_enforcement_uses_real_policy_helpers() {
715 let config: TeamConfig = serde_yaml::from_str(
716 r#"
717name: policy-team
718workflow_policy:
719 wip_limit_per_engineer: 2
720 wip_limit_per_reviewer: 1
721 escalation_threshold_secs: 120
722 review_timeout_secs: 300
723roles:
724 - name: architect
725 role_type: architect
726 agent: claude
727 - name: engineer
728 role_type: engineer
729 agent: codex
730"#,
731 )
732 .unwrap();
733
734 let policy: &WorkflowPolicy = &config.workflow_policy;
735
736 assert!(check_wip_limit(policy, RoleType::Engineer, 1));
737 assert!(!check_wip_limit(policy, RoleType::Engineer, 2));
738 assert!(check_wip_limit(policy, RoleType::Manager, 0));
739 assert!(!check_wip_limit(policy, RoleType::Manager, 1));
740 assert!(!should_escalate(policy, 119));
741 assert!(should_escalate(policy, 120));
742 assert!(!is_review_stale(policy, 299));
743 assert!(is_review_stale(policy, 300));
744 }
745}