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
430 assert!(!legacy.orchestrator_enabled());
431 assert!(hybrid.orchestrator_enabled());
432 assert!(workflow_first.orchestrator_enabled());
433 assert!(!workflow_first_hidden.orchestrator_enabled());
434 }
435
436 #[test]
437 fn resolve_board_and_runnable_tasks_use_real_workflow_resolver() {
438 let tmp = tempfile::tempdir().unwrap();
439 let tasks_dir = tmp.path().join("tasks");
440 fs::create_dir_all(&tasks_dir).unwrap();
441
442 write_task(&tasks_dir, 1, "status: done\n");
443 write_task(
444 &tasks_dir,
445 2,
446 "status: todo\nexecution_owner: eng-1-1\nclaimed_by: eng-1-1\n",
447 );
448 write_task(&tasks_dir, 3, "status: review\nreview_owner: manager\n");
449 write_task(&tasks_dir, 4, "status: todo\nblocked_on: waiting for api\n");
450 write_task(&tasks_dir, 5, "status: todo\ndepends_on: [6]\n");
451 write_task(&tasks_dir, 6, "status: backlog\n");
452
453 let members = resolve_hierarchy(
454 &serde_yaml::from_str::<TeamConfig>(
455 r#"
456name: team
457roles:
458 - name: manager
459 role_type: manager
460 agent: claude
461 - name: engineer
462 role_type: engineer
463 agent: codex
464"#,
465 )
466 .unwrap(),
467 )
468 .unwrap();
469
470 let resolutions = resolve_board(tmp.path(), &members).unwrap();
471 let runnable = runnable_tasks(&resolutions);
472
473 assert_eq!(
474 runnable.iter().map(|task| task.task_id).collect::<Vec<_>>(),
475 vec![2, 6]
476 );
477 assert_eq!(
478 resolutions
479 .iter()
480 .find(|task| task.task_id == 2)
481 .map(|task| task.status),
482 Some(ResolutionStatus::Runnable)
483 );
484 assert_eq!(
485 resolutions
486 .iter()
487 .find(|task| task.task_id == 3)
488 .map(|task| task.status),
489 Some(ResolutionStatus::NeedsReview)
490 );
491 assert_eq!(
492 resolutions
493 .iter()
494 .find(|task| task.task_id == 4)
495 .and_then(|task| task.blocking_reason.as_deref()),
496 Some("waiting for api")
497 );
498 assert_eq!(
499 resolutions
500 .iter()
501 .find(|task| task.task_id == 5)
502 .and_then(|task| task.blocking_reason.as_deref()),
503 Some("unmet dependency #6")
504 );
505 }
506
507 #[test]
508 fn compute_nudges_uses_real_planner_path_for_each_shipped_topology() {
509 let templates = [
510 include_str!("templates/team_solo.yaml"),
511 include_str!("templates/team_pair.yaml"),
512 include_str!("templates/team_simple.yaml"),
513 include_str!("templates/team_squad.yaml"),
514 include_str!("templates/team_research.yaml"),
515 include_str!("templates/team_software.yaml"),
516 include_str!("templates/team_cleanroom.yaml"),
517 ];
518
519 for yaml in templates {
520 let config = load_template(yaml);
521 let members = resolve_hierarchy(&config).unwrap();
522 let capability_map = resolve_capability_map(&members);
523
524 let tmp = tempfile::tempdir().unwrap();
525 let tasks_dir = tmp.path().join("tasks");
526 fs::create_dir_all(&tasks_dir).unwrap();
527 write_task(
528 &tasks_dir,
529 1,
530 "status: blocked\nblocked_on: waiting on dependency\n",
531 );
532
533 let nudges = compute_nudges(
534 tmp.path(),
535 &members,
536 &idle_states(&members),
537 &HashMap::new(),
538 )
539 .unwrap();
540
541 assert!(
542 nudges
543 .iter()
544 .any(|target| target.capability == WorkflowCapability::Planner),
545 "expected planner nudge for topology {:?}",
546 config.name
547 );
548
549 for planner in nudges
550 .iter()
551 .filter(|target| target.capability == WorkflowCapability::Planner)
552 {
553 assert!(
554 member_capabilities(&capability_map, &planner.member)
555 .contains(&WorkflowCapability::Planner),
556 "planner nudge targeted non-planner {}",
557 planner.member
558 );
559 }
560 }
561 }
562
563 #[test]
564 fn compute_nudges_uses_real_dispatch_path_for_each_shipped_topology() {
565 let templates = [
566 include_str!("templates/team_solo.yaml"),
567 include_str!("templates/team_pair.yaml"),
568 include_str!("templates/team_simple.yaml"),
569 include_str!("templates/team_squad.yaml"),
570 include_str!("templates/team_research.yaml"),
571 include_str!("templates/team_software.yaml"),
572 include_str!("templates/team_cleanroom.yaml"),
573 ];
574
575 for yaml in templates {
576 let config = load_template(yaml);
577 let members = resolve_hierarchy(&config).unwrap();
578 let capability_map = resolve_capability_map(&members);
579
580 let tmp = tempfile::tempdir().unwrap();
581 let tasks_dir = tmp.path().join("tasks");
582 fs::create_dir_all(&tasks_dir).unwrap();
583 write_task(&tasks_dir, 1, "status: todo\n");
584
585 let nudges = compute_nudges(
586 tmp.path(),
587 &members,
588 &idle_states(&members),
589 &HashMap::new(),
590 )
591 .unwrap();
592
593 assert!(
594 nudges
595 .iter()
596 .any(|target| target.capability == WorkflowCapability::Dispatcher),
597 "expected dispatcher nudge for topology {:?}",
598 config.name
599 );
600
601 for dispatcher in nudges
602 .iter()
603 .filter(|target| target.capability == WorkflowCapability::Dispatcher)
604 {
605 assert!(
606 member_capabilities(&capability_map, &dispatcher.member)
607 .contains(&WorkflowCapability::Dispatcher),
608 "dispatcher nudge targeted non-dispatcher {}",
609 dispatcher.member
610 );
611 }
612 }
613 }
614
615 #[test]
616 fn workflow_meta_transitions_end_to_end_with_real_review_flow() {
617 let mut meta = WorkflowMeta {
618 state: TaskState::Todo,
619 execution_owner: Some("eng-1-1".to_string()),
620 review_owner: Some("manager".to_string()),
621 worktree_path: Some(".batty/worktrees/eng-1-1".to_string()),
622 branch: Some("eng-1-1/task-34".to_string()),
623 commit: Some("abc1234".to_string()),
624 artifacts: vec!["target/nextest/default.xml".to_string()],
625 ..WorkflowMeta::default()
626 };
627
628 meta.transition(TaskState::InProgress).unwrap();
629 meta.next_action = Some("run tests".to_string());
630 meta.transition(TaskState::Review).unwrap();
631 meta.review = Some(ReviewState {
632 reviewer: "manager".to_string(),
633 packet_ref: Some("review/packet-34.json".to_string()),
634 disposition: MergeDisposition::MergeReady,
635 notes: Some("ready for merge".to_string()),
636 reviewed_at: None,
637 nudge_sent: false,
638 });
639
640 apply_review(&mut meta, MergeDisposition::MergeReady, "manager").unwrap();
641
642 assert_eq!(meta.state, TaskState::Done);
643 assert_eq!(meta.review_owner.as_deref(), Some("manager"));
644 assert_eq!(
645 meta.review_disposition,
646 Some(super::super::workflow::ReviewDisposition::Approved)
647 );
648 assert_eq!(
649 meta.review
650 .as_ref()
651 .and_then(|review| review.packet_ref.as_deref()),
652 Some("review/packet-34.json")
653 );
654 assert_eq!(meta.branch.as_deref(), Some("eng-1-1/task-34"));
655 assert_eq!(meta.commit.as_deref(), Some("abc1234"));
656 assert_eq!(meta.artifacts, vec!["target/nextest/default.xml"]);
657 }
658
659 #[test]
660 fn completion_packet_ingestion_uses_real_parser_and_metadata_writer() {
661 let tmp = tempfile::tempdir().unwrap();
662 let task_path = create_task(tmp.path(), 34, "");
663
664 let message = r#"Done.
665
666## Completion Packet
667
668```json
669{
670 "task_id": 34,
671 "branch": "eng-1-1/task-34",
672 "worktree_path": ".batty/worktrees/eng-1-1",
673 "commit": "def5678",
674 "changed_paths": ["src/team/validation.rs"],
675 "tests_run": true,
676 "tests_passed": true,
677 "artifacts": ["target/nextest/default.xml"],
678 "outcome": "ready_for_review"
679}
680```"#;
681
682 let task_id = ingest_completion_message(tmp.path(), message).unwrap();
683 let metadata = read_workflow_metadata(&task_path).unwrap();
684
685 assert_eq!(task_id, Some(34));
686 assert_eq!(metadata.branch.as_deref(), Some("eng-1-1/task-34"));
687 assert_eq!(
688 metadata.worktree_path.as_deref(),
689 Some(".batty/worktrees/eng-1-1")
690 );
691 assert_eq!(metadata.commit.as_deref(), Some("def5678"));
692 assert_eq!(metadata.changed_paths, vec!["src/team/validation.rs"]);
693 assert_eq!(metadata.tests_run, Some(true));
694 assert_eq!(metadata.tests_passed, Some(true));
695 assert_eq!(metadata.artifacts, vec!["target/nextest/default.xml"]);
696 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
697 assert!(metadata.review_blockers.is_empty());
698 }
699
700 #[test]
701 fn workflow_policy_enforcement_uses_real_policy_helpers() {
702 let config: TeamConfig = serde_yaml::from_str(
703 r#"
704name: policy-team
705workflow_policy:
706 wip_limit_per_engineer: 2
707 wip_limit_per_reviewer: 1
708 escalation_threshold_secs: 120
709 review_timeout_secs: 300
710roles:
711 - name: architect
712 role_type: architect
713 agent: claude
714 - name: engineer
715 role_type: engineer
716 agent: codex
717"#,
718 )
719 .unwrap();
720
721 let policy: &WorkflowPolicy = &config.workflow_policy;
722
723 assert!(check_wip_limit(policy, RoleType::Engineer, 1));
724 assert!(!check_wip_limit(policy, RoleType::Engineer, 2));
725 assert!(check_wip_limit(policy, RoleType::Manager, 0));
726 assert!(!check_wip_limit(policy, RoleType::Manager, 1));
727 assert!(!should_escalate(policy, 119));
728 assert!(should_escalate(policy, 120));
729 assert!(!is_review_stale(policy, 299));
730 assert!(is_review_stale(policy, 300));
731 }
732}