Skip to main content

batty_cli/team/
validation.rs

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}