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 orchestrator_enabled_uses_real_workflow_modes() {
342 let legacy: TeamConfig = serde_yaml::from_str(
343 r#"
344name: legacy
345workflow_mode: legacy
346orchestrator_pane: true
347roles:
348 - name: architect
349 role_type: architect
350 agent: claude
351"#,
352 )
353 .unwrap();
354 let hybrid: TeamConfig = serde_yaml::from_str(
355 r#"
356name: hybrid
357workflow_mode: hybrid
358orchestrator_pane: true
359roles:
360 - name: architect
361 role_type: architect
362 agent: claude
363"#,
364 )
365 .unwrap();
366 let workflow_first: TeamConfig = serde_yaml::from_str(
367 r#"
368name: wf
369workflow_mode: workflow_first
370orchestrator_pane: true
371roles:
372 - name: architect
373 role_type: architect
374 agent: claude
375"#,
376 )
377 .unwrap();
378 let workflow_first_hidden: TeamConfig = serde_yaml::from_str(
379 r#"
380name: wf-hidden
381workflow_mode: workflow_first
382orchestrator_pane: false
383roles:
384 - name: architect
385 role_type: architect
386 agent: claude
387"#,
388 )
389 .unwrap();
390
391 assert!(!legacy.orchestrator_enabled());
392 assert!(hybrid.orchestrator_enabled());
393 assert!(workflow_first.orchestrator_enabled());
394 assert!(!workflow_first_hidden.orchestrator_enabled());
395 }
396
397 #[test]
398 fn resolve_board_and_runnable_tasks_use_real_workflow_resolver() {
399 let tmp = tempfile::tempdir().unwrap();
400 let tasks_dir = tmp.path().join("tasks");
401 fs::create_dir_all(&tasks_dir).unwrap();
402
403 write_task(&tasks_dir, 1, "status: done\n");
404 write_task(
405 &tasks_dir,
406 2,
407 "status: todo\nexecution_owner: eng-1-1\nclaimed_by: eng-1-1\n",
408 );
409 write_task(&tasks_dir, 3, "status: review\nreview_owner: manager\n");
410 write_task(&tasks_dir, 4, "status: todo\nblocked_on: waiting for api\n");
411 write_task(&tasks_dir, 5, "status: todo\ndepends_on: [6]\n");
412 write_task(&tasks_dir, 6, "status: backlog\n");
413
414 let members = resolve_hierarchy(
415 &serde_yaml::from_str::<TeamConfig>(
416 r#"
417name: team
418roles:
419 - name: manager
420 role_type: manager
421 agent: claude
422 - name: engineer
423 role_type: engineer
424 agent: codex
425"#,
426 )
427 .unwrap(),
428 )
429 .unwrap();
430
431 let resolutions = resolve_board(tmp.path(), &members).unwrap();
432 let runnable = runnable_tasks(&resolutions);
433
434 assert_eq!(
435 runnable.iter().map(|task| task.task_id).collect::<Vec<_>>(),
436 vec![2, 6]
437 );
438 assert_eq!(
439 resolutions
440 .iter()
441 .find(|task| task.task_id == 2)
442 .map(|task| task.status),
443 Some(ResolutionStatus::Runnable)
444 );
445 assert_eq!(
446 resolutions
447 .iter()
448 .find(|task| task.task_id == 3)
449 .map(|task| task.status),
450 Some(ResolutionStatus::NeedsReview)
451 );
452 assert_eq!(
453 resolutions
454 .iter()
455 .find(|task| task.task_id == 4)
456 .and_then(|task| task.blocking_reason.as_deref()),
457 Some("waiting for api")
458 );
459 assert_eq!(
460 resolutions
461 .iter()
462 .find(|task| task.task_id == 5)
463 .and_then(|task| task.blocking_reason.as_deref()),
464 Some("unmet dependency #6")
465 );
466 }
467
468 #[test]
469 fn compute_nudges_uses_real_planner_path_for_each_shipped_topology() {
470 let templates = [
471 include_str!("templates/team_solo.yaml"),
472 include_str!("templates/team_pair.yaml"),
473 include_str!("templates/team_simple.yaml"),
474 include_str!("templates/team_squad.yaml"),
475 include_str!("templates/team_research.yaml"),
476 include_str!("templates/team_software.yaml"),
477 ];
478
479 for yaml in templates {
480 let config = load_template(yaml);
481 let members = resolve_hierarchy(&config).unwrap();
482 let capability_map = resolve_capability_map(&members);
483
484 let tmp = tempfile::tempdir().unwrap();
485 let tasks_dir = tmp.path().join("tasks");
486 fs::create_dir_all(&tasks_dir).unwrap();
487 write_task(
488 &tasks_dir,
489 1,
490 "status: blocked\nblocked_on: waiting on dependency\n",
491 );
492
493 let nudges = compute_nudges(
494 tmp.path(),
495 &members,
496 &idle_states(&members),
497 &HashMap::new(),
498 )
499 .unwrap();
500
501 assert!(
502 nudges
503 .iter()
504 .any(|target| target.capability == WorkflowCapability::Planner),
505 "expected planner nudge for topology {:?}",
506 config.name
507 );
508
509 for planner in nudges
510 .iter()
511 .filter(|target| target.capability == WorkflowCapability::Planner)
512 {
513 assert!(
514 member_capabilities(&capability_map, &planner.member)
515 .contains(&WorkflowCapability::Planner),
516 "planner nudge targeted non-planner {}",
517 planner.member
518 );
519 }
520 }
521 }
522
523 #[test]
524 fn compute_nudges_uses_real_dispatch_path_for_each_shipped_topology() {
525 let templates = [
526 include_str!("templates/team_solo.yaml"),
527 include_str!("templates/team_pair.yaml"),
528 include_str!("templates/team_simple.yaml"),
529 include_str!("templates/team_squad.yaml"),
530 include_str!("templates/team_research.yaml"),
531 include_str!("templates/team_software.yaml"),
532 ];
533
534 for yaml in templates {
535 let config = load_template(yaml);
536 let members = resolve_hierarchy(&config).unwrap();
537 let capability_map = resolve_capability_map(&members);
538
539 let tmp = tempfile::tempdir().unwrap();
540 let tasks_dir = tmp.path().join("tasks");
541 fs::create_dir_all(&tasks_dir).unwrap();
542 write_task(&tasks_dir, 1, "status: todo\n");
543
544 let nudges = compute_nudges(
545 tmp.path(),
546 &members,
547 &idle_states(&members),
548 &HashMap::new(),
549 )
550 .unwrap();
551
552 assert!(
553 nudges
554 .iter()
555 .any(|target| target.capability == WorkflowCapability::Dispatcher),
556 "expected dispatcher nudge for topology {:?}",
557 config.name
558 );
559
560 for dispatcher in nudges
561 .iter()
562 .filter(|target| target.capability == WorkflowCapability::Dispatcher)
563 {
564 assert!(
565 member_capabilities(&capability_map, &dispatcher.member)
566 .contains(&WorkflowCapability::Dispatcher),
567 "dispatcher nudge targeted non-dispatcher {}",
568 dispatcher.member
569 );
570 }
571 }
572 }
573
574 #[test]
575 fn workflow_meta_transitions_end_to_end_with_real_review_flow() {
576 let mut meta = WorkflowMeta {
577 state: TaskState::Todo,
578 execution_owner: Some("eng-1-1".to_string()),
579 review_owner: Some("manager".to_string()),
580 worktree_path: Some(".batty/worktrees/eng-1-1".to_string()),
581 branch: Some("eng-1-1/task-34".to_string()),
582 commit: Some("abc1234".to_string()),
583 artifacts: vec!["target/nextest/default.xml".to_string()],
584 ..WorkflowMeta::default()
585 };
586
587 meta.transition(TaskState::InProgress).unwrap();
588 meta.next_action = Some("run tests".to_string());
589 meta.transition(TaskState::Review).unwrap();
590 meta.review = Some(ReviewState {
591 reviewer: "manager".to_string(),
592 packet_ref: Some("review/packet-34.json".to_string()),
593 disposition: MergeDisposition::MergeReady,
594 notes: Some("ready for merge".to_string()),
595 reviewed_at: None,
596 nudge_sent: false,
597 });
598
599 apply_review(&mut meta, MergeDisposition::MergeReady, "manager").unwrap();
600
601 assert_eq!(meta.state, TaskState::Done);
602 assert_eq!(meta.review_owner.as_deref(), Some("manager"));
603 assert_eq!(
604 meta.review_disposition,
605 Some(super::super::workflow::ReviewDisposition::Approved)
606 );
607 assert_eq!(
608 meta.review
609 .as_ref()
610 .and_then(|review| review.packet_ref.as_deref()),
611 Some("review/packet-34.json")
612 );
613 assert_eq!(meta.branch.as_deref(), Some("eng-1-1/task-34"));
614 assert_eq!(meta.commit.as_deref(), Some("abc1234"));
615 assert_eq!(meta.artifacts, vec!["target/nextest/default.xml"]);
616 }
617
618 #[test]
619 fn completion_packet_ingestion_uses_real_parser_and_metadata_writer() {
620 let tmp = tempfile::tempdir().unwrap();
621 let task_path = create_task(tmp.path(), 34, "");
622
623 let message = r#"Done.
624
625## Completion Packet
626
627```json
628{
629 "task_id": 34,
630 "branch": "eng-1-1/task-34",
631 "worktree_path": ".batty/worktrees/eng-1-1",
632 "commit": "def5678",
633 "changed_paths": ["src/team/validation.rs"],
634 "tests_run": true,
635 "tests_passed": true,
636 "artifacts": ["target/nextest/default.xml"],
637 "outcome": "ready_for_review"
638}
639```"#;
640
641 let task_id = ingest_completion_message(tmp.path(), message).unwrap();
642 let metadata = read_workflow_metadata(&task_path).unwrap();
643
644 assert_eq!(task_id, Some(34));
645 assert_eq!(metadata.branch.as_deref(), Some("eng-1-1/task-34"));
646 assert_eq!(
647 metadata.worktree_path.as_deref(),
648 Some(".batty/worktrees/eng-1-1")
649 );
650 assert_eq!(metadata.commit.as_deref(), Some("def5678"));
651 assert_eq!(metadata.changed_paths, vec!["src/team/validation.rs"]);
652 assert_eq!(metadata.tests_run, Some(true));
653 assert_eq!(metadata.tests_passed, Some(true));
654 assert_eq!(metadata.artifacts, vec!["target/nextest/default.xml"]);
655 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
656 assert!(metadata.review_blockers.is_empty());
657 }
658
659 #[test]
660 fn workflow_policy_enforcement_uses_real_policy_helpers() {
661 let config: TeamConfig = serde_yaml::from_str(
662 r#"
663name: policy-team
664workflow_policy:
665 wip_limit_per_engineer: 2
666 wip_limit_per_reviewer: 1
667 escalation_threshold_secs: 120
668 review_timeout_secs: 300
669roles:
670 - name: architect
671 role_type: architect
672 agent: claude
673 - name: engineer
674 role_type: engineer
675 agent: codex
676"#,
677 )
678 .unwrap();
679
680 let policy: &WorkflowPolicy = &config.workflow_policy;
681
682 assert!(check_wip_limit(policy, RoleType::Engineer, 1));
683 assert!(!check_wip_limit(policy, RoleType::Engineer, 2));
684 assert!(check_wip_limit(policy, RoleType::Manager, 0));
685 assert!(!check_wip_limit(policy, RoleType::Manager, 1));
686 assert!(!should_escalate(policy, 119));
687 assert!(should_escalate(policy, 120));
688 assert!(!is_review_stale(policy, 299));
689 assert!(is_review_stale(policy, 300));
690 }
691}