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        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}