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