use std::sync::{Arc, Mutex};
use serde_json::json;
use crate::mcp::tools::AppRegistry;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn innovation_tools_registers_fifteen_tools() {
let tools = super::innovation_tools();
assert_eq!(
tools.len(),
15,
"expected 15 innovation tools, got {}",
tools.len()
);
}
#[test]
fn innovation_tool_names_are_unique() {
let tools = super::innovation_tools();
let names: std::collections::HashSet<&str> = tools.iter().map(|t| t.name).collect();
assert_eq!(
names.len(),
tools.len(),
"duplicate tool names in innovation set"
);
}
#[test]
fn all_innovation_tools_have_non_empty_descriptions() {
for tool in super::innovation_tools() {
assert!(
!tool.description.is_empty(),
"empty description on {}",
tool.name
);
}
}
#[test]
fn all_innovation_tools_have_annotation_fields() {
for tool in super::innovation_tools() {
let _ = tool.annotations.read_only;
let _ = tool.annotations.destructive;
let _ = tool.annotations.idempotent;
let _ = tool.annotations.open_world;
}
}
#[test]
fn innovation_tool_names_match_expected_set() {
let tools = super::innovation_tools();
let names: std::collections::HashSet<&str> = tools.iter().map(|t| t.name).collect();
for expected in &[
"ax_query",
"ax_app_profile",
"ax_test_run",
"ax_track_workflow",
"ax_workflow_create",
"ax_workflow_step",
"ax_workflow_status",
"ax_record",
"ax_analyze",
"ax_run_script",
"ax_clipboard",
"ax_session_info",
"ax_undo",
"ax_visual_diff",
"ax_a11y_audit",
] {
assert!(names.contains(*expected), "missing tool: {expected}");
}
}
#[test]
fn call_tool_innovation_unknown_name_returns_none() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result =
super::call_tool_innovation("ax_nonexistent_innovation", &json!({}), ®istry, &mut out);
assert!(result.is_none());
}
#[test]
fn call_tool_innovation_empty_name_returns_none() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation("", &json!({}), ®istry, &mut out);
assert!(result.is_none());
}
#[test]
fn call_tool_innovation_recognises_all_stateless_names() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
for name in &[
"ax_query",
"ax_app_profile",
"ax_test_run",
"ax_track_workflow",
"ax_record",
"ax_analyze",
"ax_run_script",
"ax_clipboard",
"ax_session_info",
"ax_undo",
"ax_visual_diff",
"ax_a11y_audit",
] {
let args = if *name == "ax_run_script" {
json!({"script": "return 42"})
} else if *name == "ax_clipboard" {
json!({"action": "read"})
} else if *name == "ax_session_info" {
json!({})
} else {
json!({"app": "Ghost"})
};
let result = super::call_tool_innovation(name, &args, ®istry, &mut out);
assert!(
result.is_some(),
"call_tool_innovation returned None for '{name}'"
);
}
}
#[test]
fn call_tool_innovation_returns_none_for_workflow_tool_names() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
for name in &[
"ax_workflow_create",
"ax_workflow_step",
"ax_workflow_status",
] {
let result = super::call_tool_innovation(name, &json!({"name": "wf"}), ®istry, &mut out);
assert!(
result.is_none(),
"call_tool_innovation should return None for stateful '{name}'"
);
}
}
#[test]
fn ax_app_profile_missing_app_returns_error() {
let result = super::handle_ax_app_profile(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_app_profile_known_app_returns_found_true() {
let result = super::handle_ax_app_profile(&json!({"app": "vscode"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["found"], true);
assert!(v["name"].is_string());
assert!(v["capabilities"].is_array());
assert!(v["selectors"].is_object());
assert!(v["shortcuts"].is_object());
}
#[test]
fn ax_app_profile_case_insensitive_lookup() {
let result = super::handle_ax_app_profile(&json!({"app": "SLACK"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["found"], true, "expected found=true for 'SLACK'");
}
#[test]
fn ax_app_profile_unknown_app_returns_found_false() {
let result = super::handle_ax_app_profile(&json!({"app": "NonExistentApp99"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["found"], false);
}
#[test]
fn ax_app_profile_vscode_has_cdp_port() {
let result = super::handle_ax_app_profile(&json!({"app": "VS Code"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["cdp_port"], 9222);
}
#[test]
fn ax_app_profile_vscode_contains_command_palette_shortcut() {
let result = super::handle_ax_app_profile(&json!({"app": "vscode"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["shortcuts"]["command_palette"], "Meta+Shift+P");
}
#[test]
fn ax_test_run_missing_app_returns_error() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(&json!({"test_name": "t"}), &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_test_run_missing_test_name_returns_error() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(&json!({"app": "TextEdit"}), &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_test_run_empty_case_passes_with_no_steps() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(
&json!({"app": "__ghost__", "test_name": "empty_test"}),
&mut out,
);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["passed"], true);
assert_eq!(v["test_name"], "empty_test");
assert_eq!(v["steps_completed"], 0);
}
#[test]
fn ax_test_run_emits_progress_notifications() {
let mut out = Vec::<u8>::new();
let _ = super::handle_ax_test_run(
&json!({"app": "__ghost__", "test_name": "prog_test",
"steps": [{"type": "wait_for_element", "query": "X", "timeout_ms": 1}]}),
&mut out,
);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("notifications/progress"),
"expected progress notification in output"
);
}
#[test]
fn ax_test_run_with_wait_step_times_out_for_ghost_app() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(
&json!({
"app": "__ghost__",
"test_name": "wait_timeout",
"steps": [
{ "type": "wait_for_element", "query": "Button", "timeout_ms": 1 }
]
}),
&mut out,
);
assert!(!result.is_error, "handler itself must not error");
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["passed"], false);
assert!(!v["failures"].as_array().unwrap().is_empty());
}
#[test]
fn parse_test_steps_returns_empty_for_null() {
let steps = super::parse_test_steps(&json!(null));
assert!(steps.is_empty());
}
#[test]
fn parse_test_steps_skips_unknown_step_types() {
let steps = super::parse_test_steps(&json!([
{ "type": "wait_for_element", "query": "OK", "timeout_ms": 100 },
{ "type": "unsupported_future_step" }
]));
assert_eq!(steps.len(), 1);
}
#[test]
fn parse_test_assertions_returns_empty_for_null() {
let assertions = super::parse_test_assertions(&json!(null));
assert!(assertions.is_empty());
}
#[test]
fn ax_track_workflow_missing_app_returns_error() {
let result = super::handle_ax_track_workflow(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_track_workflow_record_returns_recorded_true() {
let result = super::handle_ax_track_workflow(&json!({"app": "TestAppA", "action": "record"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "record");
assert_eq!(v["recorded"], true);
}
#[test]
fn ax_track_workflow_stats_returns_stats_object() {
let result = super::handle_ax_track_workflow(&json!({"app": "AnyApp", "action": "stats"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "stats");
assert!(v["stats"].is_object());
assert!(v["stats"]["total_transitions"].is_number());
assert!(v["stats"]["distinct_apps"].is_number());
}
#[test]
fn ax_track_workflow_detect_returns_workflows_array() {
let result = super::handle_ax_track_workflow(
&json!({"app": "AnyApp", "action": "detect", "min_frequency": 999}),
);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "detect");
assert!(v["workflows"].is_array());
}
#[test]
fn ax_track_workflow_unknown_action_returns_error() {
let result = super::handle_ax_track_workflow(&json!({"app": "App", "action": "teleport"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("teleport"));
}
#[test]
fn ax_track_workflow_default_action_is_record() {
let result = super::handle_ax_track_workflow(&json!({"app": "DefaultActionApp"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "record");
}
#[test]
fn parse_transition_trigger_maps_all_variants() {
use crate::cross_app::TransitionTrigger;
assert_eq!(
super::parse_transition_trigger("user_switch"),
TransitionTrigger::UserSwitch
);
assert_eq!(
super::parse_transition_trigger("automation"),
TransitionTrigger::Automation
);
assert_eq!(
super::parse_transition_trigger("notification"),
TransitionTrigger::Notification
);
assert_eq!(
super::parse_transition_trigger("unknown"),
TransitionTrigger::Unknown
);
assert_eq!(
super::parse_transition_trigger("bogus"),
TransitionTrigger::Unknown
);
}
#[test]
fn ax_query_missing_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_query(&json!({"query": "find the button"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_query_missing_query_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_query(&json!({"app": "Safari"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_query_unconnected_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_query(
&json!({"app": "NotConnected", "query": "find the button"}),
®istry,
);
assert!(result.is_error);
assert!(result.content[0].text.contains("not connected"));
}
fn make_workflows(
) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, super::WorkflowState>>> {
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()))
}
#[test]
fn ax_workflow_create_missing_name_returns_error() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(&json!({}), &wf);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_workflow_create_with_no_steps_returns_zero_count() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(&json!({"name": "empty-wf"}), &wf);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["created"], true);
assert_eq!(v["name"], "empty-wf");
assert_eq!(v["step_count"], 0);
}
#[test]
fn ax_workflow_create_rejects_unknown_fields() {
let wf = make_workflows();
let result =
super::handle_ax_workflow_create(&json!({"name": "wf", "steps": [], "extra": true}), &wf);
assert!(result.is_error);
assert_eq!(result.content[0].text, "unknown field: extra");
}
#[test]
fn ax_workflow_create_rejects_null_steps() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(&json!({"name": "wf", "steps": null}), &wf);
assert!(result.is_error);
assert_eq!(result.content[0].text, "Field 'steps' must be an array");
}
#[test]
fn ax_workflow_create_stores_parsed_steps() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(
&json!({
"name": "two-step-wf",
"steps": [
{ "id": "s1", "action": "click", "target": "OK" },
{ "id": "s2", "action": "checkpoint" }
]
}),
&wf,
);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["step_count"], 2);
let guard = wf.lock().unwrap();
assert!(guard.contains_key("two-step-wf"));
assert_eq!(guard["two-step-wf"].steps.len(), 2);
}
#[test]
fn ax_workflow_create_rejects_duplicate_workflow_names() {
let wf = make_workflows();
super::handle_ax_workflow_create(
&json!({
"name": "duplicate-wf",
"steps": [{ "id": "s1", "action": "checkpoint" }]
}),
&wf,
);
let result = super::handle_ax_workflow_create(
&json!({
"name": "duplicate-wf",
"steps": [{ "id": "s2", "action": "checkpoint" }]
}),
&wf,
);
assert!(result.is_error);
assert_eq!(
result.content[0].text,
"Workflow 'duplicate-wf' already exists — choose a unique name"
);
let guard = wf.lock().unwrap();
assert_eq!(guard["duplicate-wf"].steps.len(), 1);
assert_eq!(guard["duplicate-wf"].steps[0].id, "s1");
}
#[test]
fn ax_workflow_step_missing_name_returns_error() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
let result = super::handle_ax_workflow_step(&json!({}), &wf, &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_workflow_step_rejects_unknown_fields() {
let wf = make_workflows();
super::handle_ax_workflow_create(
&json!({"name": "step-wf", "steps": [{ "id": "s1", "action": "checkpoint" }]}),
&wf,
);
let mut out = Vec::<u8>::new();
let result =
super::handle_ax_workflow_step(&json!({"name": "step-wf", "extra": true}), &wf, &mut out);
assert!(result.is_error);
assert_eq!(result.content[0].text, "unknown field: extra");
}
#[test]
fn ax_workflow_step_unknown_workflow_returns_error() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
let result = super::handle_ax_workflow_step(&json!({"name": "ghost"}), &wf, &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("ghost"));
}
#[test]
fn ax_workflow_step_advances_through_all_steps() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_create(
&json!({
"name": "seq-wf",
"steps": [
{ "id": "step-1", "action": "click", "target": "File" },
{ "id": "step-2", "action": "checkpoint" }
]
}),
&wf,
);
let r1 = super::handle_ax_workflow_step(&json!({"name": "seq-wf"}), &wf, &mut out);
let r2 = super::handle_ax_workflow_step(&json!({"name": "seq-wf"}), &wf, &mut out);
let v1: serde_json::Value = serde_json::from_str(&r1.content[0].text).unwrap();
let v2: serde_json::Value = serde_json::from_str(&r2.content[0].text).unwrap();
assert_eq!(v1["completed"], false);
assert_eq!(v1["step_id"], "step-1");
assert_eq!(v1["message"], "Recorded workflow step 'step-1'");
assert_eq!(v2["completed"], true);
assert_eq!(v2["step_id"], "step-2");
assert_eq!(v2["message"], "Recorded workflow step 'step-2'");
}
#[test]
fn ax_workflow_step_emits_progress_notification() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_create(
&json!({"name": "prog-wf", "steps": [{"id": "s1", "action": "checkpoint"}]}),
&wf,
);
let _ = super::handle_ax_workflow_step(&json!({"name": "prog-wf"}), &wf, &mut out);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("notifications/progress"),
"expected progress notification"
);
}
#[test]
fn ax_workflow_step_on_completed_workflow_returns_completed_true() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_create(
&json!({"name": "done-wf", "steps": [{"id": "s1", "action": "checkpoint"}]}),
&wf,
);
super::handle_ax_workflow_step(&json!({"name": "done-wf"}), &wf, &mut out);
let result = super::handle_ax_workflow_step(&json!({"name": "done-wf"}), &wf, &mut out);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["completed"], true);
}
#[test]
fn ax_workflow_status_missing_name_returns_error() {
let wf = make_workflows();
let result = super::handle_ax_workflow_status(&json!({}), &wf);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_workflow_status_rejects_unknown_fields() {
let wf = make_workflows();
super::handle_ax_workflow_create(
&json!({"name": "status-wf", "steps": [{ "id": "s1", "action": "checkpoint" }]}),
&wf,
);
let result =
super::handle_ax_workflow_status(&json!({"name": "status-wf", "extra": true}), &wf);
assert!(result.is_error);
assert_eq!(result.content[0].text, "unknown field: extra");
}
#[test]
fn ax_workflow_status_unknown_workflow_returns_error() {
let wf = make_workflows();
let result = super::handle_ax_workflow_status(&json!({"name": "ghost"}), &wf);
assert!(result.is_error);
}
#[test]
fn ax_workflow_status_reflects_step_progress() {
let wf = make_workflows();
super::handle_ax_workflow_create(
&json!({
"name": "progress-wf",
"steps": [
{ "id": "s1", "action": "click", "target": "A" },
{ "id": "s2", "action": "click", "target": "B" },
{ "id": "s3", "action": "checkpoint" }
]
}),
&wf,
);
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_step(&json!({"name": "progress-wf"}), &wf, &mut out);
let result = super::handle_ax_workflow_status(&json!({"name": "progress-wf"}), &wf);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["name"], "progress-wf");
assert_eq!(v["current_step"], 1);
assert_eq!(v["total_steps"], 3);
assert_eq!(v["completed"], false);
assert_eq!(v["results_count"], 1);
}
#[test]
fn parse_workflow_steps_rejects_null_steps() {
let result = super::parse_workflow_steps(&json!(null));
assert_eq!(result.unwrap_err(), "Field 'steps' must be an array");
}
#[test]
fn parse_workflow_steps_rejects_unknown_actions() {
let result = super::parse_workflow_steps(&json!([
{ "id": "s1", "action": "teleport" }
]));
assert_eq!(
result.unwrap_err(),
"Workflow step 's1' has unknown action: teleport"
);
}
#[test]
fn parse_workflow_steps_all_action_variants_parse_correctly() {
let steps = super::parse_workflow_steps(&json!([
{ "id": "c", "action": "click", "target": "Btn" },
{ "id": "t", "action": "type", "target": "Field", "text": "hello" },
{ "id": "w", "action": "wait", "target": "Spinner" },
{ "id": "a", "action": "assert", "target": "Result" },
{ "id": "cp", "action": "checkpoint" }
]))
.unwrap();
assert_eq!(steps.len(), 5);
}
#[test]
fn parse_workflow_steps_rejects_type_without_text() {
let result = super::parse_workflow_steps(&json!([
{ "id": "type-1", "action": "type", "target": "Field" }
]));
assert_eq!(
result.unwrap_err(),
"Workflow step 'type-1' missing string field: text"
);
}
#[test]
fn parse_workflow_steps_rejects_unknown_step_fields() {
let result = super::parse_workflow_steps(&json!([
{ "id": "s1", "action": "checkpoint", "extra": true }
]));
assert_eq!(result.unwrap_err(), "unknown field: extra");
}
#[test]
fn parse_workflow_steps_rejects_invalid_retry_config() {
let result = super::parse_workflow_steps(&json!([
{ "id": "s1", "action": "checkpoint", "max_retries": null }
]));
assert_eq!(
result.unwrap_err(),
"Workflow field 'max_retries' must be a non-negative integer"
);
}
#[test]
fn call_workflow_tool_unknown_name_returns_none() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
let result = super::call_workflow_tool("ax_nonexistent", &json!({}), &wf, &mut out);
assert!(result.is_none());
}
#[test]
fn call_workflow_tool_recognises_all_three_names() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
for name in &[
"ax_workflow_create",
"ax_workflow_step",
"ax_workflow_status",
] {
let result = super::call_workflow_tool(name, &json!({}), &wf, &mut out);
assert!(
result.is_some(),
"call_workflow_tool returned None for '{name}'"
);
}
}
#[test]
fn ax_record_missing_app_returns_error() {
let result = super::handle_ax_record(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("app"));
}
#[test]
fn ax_record_start_action_sets_recording_true() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "start"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "start");
assert_eq!(v["recording"], true);
assert_eq!(v["event_count"], 0);
}
#[test]
fn ax_record_status_returns_state() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "status"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "status");
assert!(v["recording"].is_boolean());
assert!(v["event_count"].is_number());
}
#[test]
fn ax_record_record_missing_action_type_returns_error() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "record"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("action_type"));
}
#[test]
fn ax_record_stop_returns_events_array() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "stop"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "stop");
assert_eq!(v["recording"], false);
assert!(v["events"].is_array());
}
#[test]
fn ax_record_unknown_action_returns_error() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "teleport"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("teleport"));
}
#[test]
fn ax_record_defaults_action_to_record_when_omitted() {
let result = super::handle_ax_record(&json!({"app": "Safari"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("action_type"));
}
type NodeTuple<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>);
fn make_scene(nodes: &[NodeTuple<'_>]) -> crate::intent::SceneGraph {
let mut g = crate::intent::SceneGraph::empty();
for (role, title, label, identifier) in nodes {
let node = crate::intent::SceneNode {
id: crate::intent::NodeId(g.len()),
parent: None,
children: vec![],
role: Some(role.to_string()),
title: title.map(str::to_string),
label: label.map(str::to_string),
value: None,
description: None,
identifier: identifier.map(str::to_string),
bounds: None,
enabled: true,
depth: 0,
};
g.push(node);
}
g
}
#[test]
fn detect_patterns_login_form_detected_when_password_text_button_present() {
let scene = make_scene(&[
("AXSecureTextField", Some("Password"), None, None),
("AXTextField", Some("Username"), None, None),
("AXButton", Some("Sign In"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "login_form"),
"login_form not detected"
);
}
#[test]
fn detect_patterns_login_form_requires_password_field() {
let scene = make_scene(&[
("AXTextField", Some("Email"), None, None),
("AXButton", Some("Submit"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
!patterns.iter().any(|p| p.pattern == "login_form"),
"login_form should not be detected without a password field"
);
}
#[test]
fn detect_patterns_search_interface_from_search_field_role() {
let scene = make_scene(&[("AXSearchField", Some("Search"), None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "search_interface"));
}
#[test]
fn detect_patterns_search_interface_from_label() {
let scene = make_scene(&[("AXTextField", None, Some("Search Items"), None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "search_interface"));
}
#[test]
fn detect_patterns_navigation_from_tab_group() {
let scene = make_scene(&[("AXTabGroup", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "navigation"));
}
#[test]
fn detect_patterns_navigation_from_toolbar() {
let scene = make_scene(&[("AXToolbar", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "navigation"));
}
#[test]
fn detect_patterns_table_view_from_ax_table() {
let scene = make_scene(&[("AXTable", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "table_view"));
}
#[test]
fn detect_patterns_table_view_from_ax_grid() {
let scene = make_scene(&[("AXGrid", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "table_view"));
}
#[test]
fn detect_patterns_modal_dialog_from_sheet() {
let scene = make_scene(&[("AXSheet", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "modal_dialog"));
}
#[test]
fn detect_patterns_modal_dialog_from_ax_dialog() {
let scene = make_scene(&[("AXDialog", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "modal_dialog"));
}
#[test]
fn detect_patterns_file_save_dialog_from_sheet_save_cancel() {
let scene = make_scene(&[
("AXSheet", None, None, None),
("AXButton", Some("Save"), None, None),
("AXButton", Some("Cancel"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "file_save_dialog"));
}
#[test]
fn detect_patterns_file_open_dialog_from_sheet_open_cancel() {
let scene = make_scene(&[
("AXSheet", None, None, None),
("AXButton", Some("Open"), None, None),
("AXButton", Some("Cancel"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "file_open_dialog"));
}
#[test]
fn detect_patterns_confirmation_dialog_from_alert_ok_cancel() {
let scene = make_scene(&[
("AXAlert", None, None, None),
("AXButton", Some("OK"), None, None),
("AXButton", Some("Cancel"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "confirmation_dialog"));
}
#[test]
fn detect_patterns_error_alert_from_alert_single_button() {
let scene = make_scene(&[
("AXAlert", None, None, None),
("AXButton", Some("OK"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "error_alert"));
}
#[test]
fn detect_patterns_progress_indicator_detected() {
let scene = make_scene(&[("AXProgressIndicator", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "progress_indicator"));
}
#[test]
fn detect_patterns_form_from_multiple_text_fields_and_button() {
let scene = make_scene(&[
("AXTextField", Some("First Name"), None, None),
("AXTextField", Some("Last Name"), None, None),
("AXButton", Some("Submit"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "form"));
}
#[test]
fn detect_patterns_empty_scene_returns_no_patterns() {
let scene = crate::intent::SceneGraph::empty();
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.is_empty());
}
#[test]
fn detect_patterns_all_have_confidence_in_range() {
let scene = make_scene(&[
("AXTable", None, None, None),
("AXSearchField", None, None, None),
("AXTabGroup", None, None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
for p in &patterns {
assert!(
(0.0..=1.0).contains(&p.confidence),
"pattern '{}' has out-of-range confidence {}",
p.pattern,
p.confidence
);
}
}
#[test]
fn infer_state_idle_for_empty_scene() {
let scene = crate::intent::SceneGraph::empty();
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Idle);
}
#[test]
fn infer_state_modal_when_sheet_present() {
let scene = make_scene(&[("AXSheet", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Modal);
}
#[test]
fn infer_state_loading_when_progress_indicator_present() {
let scene = make_scene(&[("AXProgressIndicator", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Loading);
}
#[test]
fn infer_state_error_when_alert_present_without_modal() {
let scene = make_scene(&[("AXAlert", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Error);
}
#[test]
fn infer_state_error_from_error_text_label() {
let scene = make_scene(&[("AXStaticText", Some("Error: file not found"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Error);
}
#[test]
fn infer_state_auth_required_when_password_field_present_without_modal() {
let scene = make_scene(&[("AXSecureTextField", Some("Password"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::AuthRequired);
}
#[test]
fn infer_state_modal_overrides_loading() {
let scene = make_scene(&[
("AXSheet", None, None, None),
("AXProgressIndicator", None, None, None),
]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Modal);
}
#[test]
fn infer_state_loading_overrides_error() {
let scene = make_scene(&[
("AXProgressIndicator", None, None, None),
("AXAlert", None, None, None),
]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Loading);
}
#[test]
fn app_state_as_str_covers_all_variants() {
use super::AppState;
for (state, expected) in &[
(AppState::Idle, "idle"),
(AppState::Loading, "loading"),
(AppState::Error, "error"),
(AppState::Modal, "modal"),
(AppState::AuthRequired, "auth_required"),
] {
assert_eq!(state.as_str(), *expected);
}
}
#[test]
fn suggest_actions_idle_empty_patterns_returns_empty() {
let suggestions = super::suggest_actions(&[], super::AppState::Idle);
assert!(suggestions.is_empty());
}
#[test]
fn suggest_actions_modal_state_suggests_dismiss() {
let suggestions = super::suggest_actions(&[], super::AppState::Modal);
assert!(suggestions.iter().any(|s| s.tool == "ax_click"));
}
#[test]
fn suggest_actions_loading_state_suggests_wait() {
let suggestions = super::suggest_actions(&[], super::AppState::Loading);
assert!(suggestions.iter().any(|s| s.tool == "ax_wait_idle"));
}
#[test]
fn suggest_actions_error_state_suggests_get_value() {
let suggestions = super::suggest_actions(&[], super::AppState::Error);
assert!(suggestions.iter().any(|s| s.tool == "ax_get_value"));
}
#[test]
fn suggest_actions_auth_required_suggests_type() {
let suggestions = super::suggest_actions(&[], super::AppState::AuthRequired);
assert!(suggestions.iter().any(|s| s.tool == "ax_type"));
}
#[test]
fn suggest_actions_login_form_includes_submit() {
let login = super::UiPattern {
pattern: "login_form",
confidence: 0.9,
};
let suggestions = super::suggest_actions(&[login], super::AppState::Idle);
assert!(
suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query.to_lowercase().contains("sign")),
"expected ax_click with 'Sign In' query"
);
}
#[test]
fn suggest_actions_search_interface_suggests_type() {
let search = super::UiPattern {
pattern: "search_interface",
confidence: 0.85,
};
let suggestions = super::suggest_actions(&[search], super::AppState::Idle);
assert!(suggestions.iter().any(|s| s.tool == "ax_type"));
}
#[test]
fn suggest_actions_file_save_dialog_suggests_save_click() {
let save_dlg = super::UiPattern {
pattern: "file_save_dialog",
confidence: 0.88,
};
let suggestions = super::suggest_actions(&[save_dlg], super::AppState::Idle);
assert!(suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "Save"));
}
#[test]
fn suggest_actions_confirmation_dialog_includes_cancel() {
let conf = super::UiPattern {
pattern: "confirmation_dialog",
confidence: 0.87,
};
let suggestions = super::suggest_actions(&[conf], super::AppState::Idle);
assert!(suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "Cancel"));
}
#[test]
fn suggest_actions_table_view_suggests_get_value() {
let table = super::UiPattern {
pattern: "table_view",
confidence: 0.88,
};
let suggestions = super::suggest_actions(&[table], super::AppState::Idle);
assert!(suggestions.iter().any(|s| s.tool == "ax_get_value"));
}
#[test]
fn suggest_actions_all_suggestions_have_non_empty_action_and_tool() {
let patterns: Vec<super::UiPattern> = [
"login_form",
"search_interface",
"file_save_dialog",
"file_open_dialog",
"confirmation_dialog",
"error_alert",
"table_view",
"text_editor",
"form",
]
.iter()
.map(|p| super::UiPattern {
pattern: p,
confidence: 0.8,
})
.collect();
let suggestions = super::suggest_actions(&patterns, super::AppState::Idle);
for s in &suggestions {
assert!(!s.action.is_empty(), "suggestion has empty action");
assert!(!s.tool.is_empty(), "suggestion has empty tool");
}
}
#[test]
fn ax_analyze_missing_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_analyze(&json!({}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_analyze_unconnected_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_analyze(&json!({"app": "NotConnected"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("not connected"));
}
#[test]
fn ax_analyze_dispatch_returns_some_for_valid_call() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result =
super::call_tool_innovation("ax_analyze", &json!({"app": "X"}), ®istry, &mut out);
assert!(
result.is_some(),
"ax_analyze should be handled by call_tool_innovation"
);
}
#[test]
fn ax_analyze_workflow_tools_still_return_none_from_stateless_dispatch() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation(
"ax_workflow_create",
&json!({"name": "wf"}),
®istry,
&mut out,
);
assert!(result.is_none());
}
#[test]
fn pattern_to_json_produces_expected_keys() {
let p = super::UiPattern {
pattern: "login_form",
confidence: 0.9,
};
let v = super::pattern_to_json(&p);
assert_eq!(v["pattern"], "login_form");
assert!((v["confidence"].as_f64().unwrap() - 0.9).abs() < f64::EPSILON);
}
#[test]
fn suggestion_to_json_produces_expected_keys() {
let s = super::Suggestion {
action: "Click Save",
tool: "ax_click",
query: "Save",
};
let v = super::suggestion_to_json(&s);
assert_eq!(v["action"], "Click Save");
assert_eq!(v["tool"], "ax_click");
assert_eq!(v["query"], "Save");
}
#[test]
fn ax_run_script_missing_script_returns_error() {
let result = super::handle_ax_run_script(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("script"));
}
#[test]
fn ax_run_script_default_language_does_not_report_missing_field() {
let result = super::handle_ax_run_script(&json!({"script": "return \"hello\""}));
assert!(
!result.content[0].text.contains("Missing required field"),
"handler should not report missing field when language is omitted"
);
}
#[test]
fn ax_run_script_executes_trivial_applescript() {
let result = super::handle_ax_run_script(&json!({
"script": "return 42",
"language": "applescript"
}));
assert!(!result.is_error, "osascript must be available on macOS");
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["success"], true);
assert!(v["output"].is_string());
}
#[test]
fn ax_run_script_executes_trivial_jxa() {
let result = super::handle_ax_run_script(&json!({
"script": "\"hello from jxa\"",
"language": "jxa"
}));
assert!(!result.is_error, "osascript JXA must be available on macOS");
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["success"], true);
}
#[test]
fn ax_run_script_syntax_error_returns_error_not_panic() {
let result = super::handle_ax_run_script(&json!({
"script": "this is not valid applescript @@@@",
"language": "applescript"
}));
assert!(result.is_error);
assert!(
result.content[0].text.contains("Script failed"),
"expected 'Script failed' in: {}",
result.content[0].text
);
}
#[test]
fn ax_run_script_descriptor_has_destructive_annotation() {
let tools = super::innovation_tools();
let tool = tools.iter().find(|t| t.name == "ax_run_script").unwrap();
assert!(
tool.annotations.destructive,
"ax_run_script must be destructive"
);
assert!(
!tool.annotations.read_only,
"ax_run_script must not be read_only"
);
}
#[test]
fn ax_run_script_dispatch_recognises_name() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation(
"ax_run_script",
&json!({"script": "return 1"}),
®istry,
&mut out,
);
assert!(
result.is_some(),
"call_tool_innovation must handle 'ax_run_script'"
);
}
#[test]
fn ax_clipboard_missing_action_returns_error() {
let result = super::handle_ax_clipboard(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_clipboard_unknown_action_returns_error() {
let result = super::handle_ax_clipboard(&json!({"action": "flush"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Unknown clipboard action"));
}
#[test]
fn ax_clipboard_write_without_text_returns_error() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe { std::env::remove_var("AXTERMINATOR_SECURITY_MODE") };
let result = super::handle_ax_clipboard(&json!({"action": "write"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("text"));
}
#[test]
fn ax_clipboard_write_blocked_in_sandboxed_mode() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("AXTERMINATOR_SECURITY_MODE", "sandboxed") };
let result = super::handle_ax_clipboard(&json!({"action": "write", "text": "hello"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("sandboxed"));
unsafe { std::env::remove_var("AXTERMINATOR_SECURITY_MODE") };
}
#[test]
fn ax_clipboard_descriptor_has_destructive_annotation() {
let tool = super::tool_ax_clipboard();
assert!(tool.annotations.destructive);
assert!(!tool.annotations.read_only);
}
#[test]
fn ax_session_info_returns_all_required_fields() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_session_info(&json!({}), ®istry);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert!(v["connected_apps"].is_array());
assert!(v["tool_count"].is_number());
assert!(v["security_mode"].is_string());
assert!(v["version"].is_string());
}
#[test]
fn ax_session_info_security_mode_reflects_env() {
let _lock = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("AXTERMINATOR_SECURITY_MODE", "sandboxed") };
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_session_info(&json!({}), ®istry);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["security_mode"], "sandboxed");
unsafe { std::env::remove_var("AXTERMINATOR_SECURITY_MODE") };
}
#[test]
fn ax_session_info_version_is_non_empty() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_session_info(&json!({}), ®istry);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert!(!v["version"].as_str().unwrap_or("").is_empty());
}
#[test]
fn ax_session_info_connected_apps_is_empty_with_fresh_registry() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_session_info(&json!({}), ®istry);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["connected_apps"].as_array().unwrap().len(), 0);
}
#[test]
fn ax_session_info_descriptor_is_read_only() {
let tool = super::tool_ax_session_info();
assert!(tool.annotations.read_only);
assert!(!tool.annotations.destructive);
}
#[test]
fn ax_undo_missing_app_returns_error() {
let result = super::handle_ax_undo(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_undo_returns_correct_undone_count() {
let result = super::handle_ax_undo(&json!({"app": "Ghost", "count": 1}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["undone"], 1);
assert_eq!(v["app"], "Ghost");
assert_eq!(v["ok"], true);
}
#[test]
fn ax_undo_clamps_count_above_maximum() {
let result = super::handle_ax_undo(&json!({"app": "Finder", "count": 999}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["undone"], 50);
}
#[test]
fn ax_undo_defaults_to_one_when_count_absent() {
let result = super::handle_ax_undo(&json!({"app": "Notes"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["undone"], 1);
}
#[test]
fn ax_undo_descriptor_has_destructive_annotation() {
let tool = super::tool_ax_undo();
assert!(tool.annotations.destructive);
assert!(!tool.annotations.read_only);
}
#[test]
fn decode_baseline_b64_round_trips_hello() {
let encoded = "SGVsbG8=";
let result = super::decode_baseline_b64(encoded).unwrap();
assert_eq!(result, b"Hello");
}
#[test]
fn decode_baseline_b64_empty_string_returns_empty_vec() {
let result = super::decode_baseline_b64("").unwrap();
assert!(result.is_empty());
}
#[test]
fn decode_baseline_b64_rejects_invalid_character() {
let result = super::decode_baseline_b64("SGVs!G8=");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid base64"));
}
#[test]
fn decode_baseline_b64_handles_three_byte_input_without_padding() {
let result = super::decode_baseline_b64("TWFu").unwrap();
assert_eq!(result, b"Man");
}
#[test]
fn compute_diff_identical_slices_returns_zero() {
let data = b"identical_data";
let diff = super::compute_diff(data, data);
assert_eq!(diff, 0.0);
}
#[test]
fn compute_diff_both_empty_returns_zero() {
let diff = super::compute_diff(&[], &[]);
assert_eq!(diff, 0.0);
}
#[test]
fn compute_diff_completely_different_same_length_returns_one() {
let a = [0u8; 4];
let b = [255u8; 4];
let diff = super::compute_diff(&a, &b);
assert_eq!(diff, 1.0);
}
#[test]
fn compute_diff_size_mismatch_is_penalised() {
let baseline = [1u8, 2, 3, 4];
let current = [1u8, 2];
let diff = super::compute_diff(&baseline, ¤t);
assert!(diff > 0.0 && diff <= 1.0, "diff {diff} not in (0, 1]");
}
#[test]
fn compute_diff_result_always_in_unit_interval() {
let a = b"hello world this is a test";
let b = b"Hello World";
let diff = super::compute_diff(a, b);
assert!((0.0..=1.0).contains(&diff));
}
#[test]
fn ax_visual_diff_missing_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_visual_diff(&json!({"baseline": "SGVsbG8="}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_visual_diff_missing_baseline_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_visual_diff(&json!({"app": "Safari"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_visual_diff_invalid_base64_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_visual_diff(
&json!({"app": "Safari", "baseline": "not!valid@b64"}),
®istry,
);
assert!(result.is_error);
assert!(result.content[0].text.contains("baseline decode failed"));
}
#[test]
fn ax_visual_diff_unconnected_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_visual_diff(
&json!({"app": "GhostApp", "baseline": "SGVsbG8="}),
®istry,
);
assert!(result.is_error);
assert!(result.content[0].text.contains("not connected"));
}
#[test]
fn ax_visual_diff_dispatch_returns_some_for_valid_call() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation(
"ax_visual_diff",
&json!({"app": "X", "baseline": "SGVsbG8="}),
®istry,
&mut out,
);
assert!(result.is_some());
}
fn make_a11y_scene(nodes: &[NodeTuple<'_>]) -> crate::intent::SceneGraph {
let mut g = crate::intent::SceneGraph::empty();
for (role, title, label, description) in nodes {
let node = crate::intent::SceneNode {
id: crate::intent::NodeId(g.len()),
parent: None,
children: vec![],
role: Some(role.to_string()),
title: title.map(str::to_string),
label: label.map(str::to_string),
value: None,
description: description.map(str::to_string),
identifier: None,
bounds: None,
enabled: true,
depth: 0,
};
g.push(node);
}
g
}
#[test]
fn audit_accessibility_empty_scene_returns_no_issues() {
let scene = crate::intent::SceneGraph::empty();
let issues = super::audit_accessibility(&scene);
assert!(issues.is_empty());
}
#[test]
fn audit_accessibility_labeled_button_raises_no_missing_label() {
let scene = make_a11y_scene(&[("AXButton", Some("OK"), None, None)]);
let issues = super::audit_accessibility(&scene);
assert!(!issues.iter().any(|v| v["issue"] == "missing_label"));
}
#[test]
fn audit_accessibility_unlabeled_button_is_critical_1_3_1() {
let scene = make_a11y_scene(&[("AXButton", None, None, None)]);
let issues = super::audit_accessibility(&scene);
let crit: Vec<_> = issues
.iter()
.filter(|v| v["issue"] == "missing_label" && v["severity"] == "critical")
.collect();
assert_eq!(crit.len(), 1);
assert_eq!(crit[0]["wcag"], "1.3.1");
}
#[test]
fn audit_accessibility_all_interactive_roles_flagged_when_unlabeled() {
let roles = super::INTERACTIVE_ROLES;
let nodes: Vec<NodeTuple<'_>> = roles.iter().map(|r| (*r, None, None, None)).collect();
let scene = make_a11y_scene(&nodes);
let issues = super::audit_accessibility(&scene);
let missing_count = issues
.iter()
.filter(|v| v["issue"] == "missing_label")
.count();
assert_eq!(missing_count, roles.len());
}
#[test]
fn audit_accessibility_unknown_role_is_warning_4_1_2() {
let scene = make_a11y_scene(&[("AXUnknown", None, None, None)]);
let issues = super::audit_accessibility(&scene);
let warn: Vec<_> = issues
.iter()
.filter(|v| v["issue"] == "unknown_role" && v["severity"] == "warning")
.collect();
assert_eq!(warn.len(), 1);
assert_eq!(warn[0]["wcag"], "4.1.2");
}
#[test]
fn audit_accessibility_empty_role_string_triggers_unknown_role() {
let mut scene = crate::intent::SceneGraph::empty();
let node = crate::intent::SceneNode {
id: crate::intent::NodeId(0),
parent: None,
children: vec![],
role: Some(String::new()),
title: None,
label: None,
value: None,
description: None,
identifier: None,
bounds: None,
enabled: true,
depth: 0,
};
scene.push(node);
let issues = super::audit_accessibility(&scene);
assert!(issues.iter().any(|v| v["issue"] == "unknown_role"));
}
#[test]
fn audit_accessibility_unlabeled_image_is_critical_1_1_1() {
let scene = make_a11y_scene(&[("AXImage", None, None, None)]);
let issues = super::audit_accessibility(&scene);
let img: Vec<_> = issues
.iter()
.filter(|v| v["issue"] == "unlabeled_image" && v["severity"] == "critical")
.collect();
assert_eq!(img.len(), 1);
assert_eq!(img[0]["wcag"], "1.1.1");
}
#[test]
fn audit_accessibility_labeled_image_passes() {
let scene = make_a11y_scene(&[("AXImage", None, None, Some("Company logo"))]);
let issues = super::audit_accessibility(&scene);
assert!(!issues.iter().any(|v| v["issue"] == "unlabeled_image"));
}
#[test]
fn audit_accessibility_non_interactive_unlabeled_node_clean() {
let scene = make_a11y_scene(&[("AXStaticText", None, None, None)]);
let issues = super::audit_accessibility(&scene);
assert!(!issues.iter().any(|v| v["issue"] == "missing_label"));
}
#[test]
fn count_by_severity_aggregates_correctly() {
let issues = vec![
json!({"severity": "critical", "issue": "missing_label", "wcag": "1.3.1"}),
json!({"severity": "critical", "issue": "unlabeled_image", "wcag": "1.1.1"}),
json!({"severity": "warning", "issue": "unknown_role", "wcag": "4.1.2"}),
];
assert_eq!(super::count_by_severity(&issues, "critical"), 2);
assert_eq!(super::count_by_severity(&issues, "warning"), 1);
assert_eq!(super::count_by_severity(&issues, "info"), 0);
}
#[test]
fn ax_a11y_audit_missing_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_a11y_audit(&json!({}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_a11y_audit_unconnected_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_a11y_audit(&json!({"app": "GhostApp"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("not connected"));
}
#[test]
fn ax_a11y_audit_dispatch_returns_some_for_valid_call() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result =
super::call_tool_innovation("ax_a11y_audit", &json!({"app": "X"}), ®istry, &mut out);
assert!(result.is_some());
}
#[test]
fn ax_visual_diff_descriptor_is_read_only() {
let tool = super::tool_ax_visual_diff();
assert!(tool.annotations.read_only);
assert!(!tool.annotations.destructive);
}
#[test]
fn ax_visual_diff_descriptor_requires_app_and_baseline() {
let tool = super::tool_ax_visual_diff();
let required = tool.input_schema["required"].as_array().unwrap();
let fields: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(fields.contains(&"app"));
assert!(fields.contains(&"baseline"));
}
#[test]
fn ax_a11y_audit_descriptor_is_read_only() {
let tool = super::tool_ax_a11y_audit();
assert!(tool.annotations.read_only);
assert!(!tool.annotations.destructive);
}
#[test]
fn ax_a11y_audit_descriptor_requires_only_app() {
let tool = super::tool_ax_a11y_audit();
let required = tool.input_schema["required"].as_array().unwrap();
let fields: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
assert!(fields.contains(&"app"));
assert!(!fields.contains(&"scope"));
}
#[test]
fn detect_patterns_table_view_from_ax_outline() {
let scene = make_scene(&[("AXOutline", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "table_view"),
"AXOutline should trigger table_view"
);
}
#[test]
fn detect_patterns_progress_indicator_from_ax_busy_indicator() {
let scene = make_scene(&[("AXBusyIndicator", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "progress_indicator"),
"AXBusyIndicator should trigger progress_indicator"
);
}
#[test]
fn detect_patterns_settings_page_from_groups_and_checkboxes() {
let scene = make_scene(&[
("AXGroup", None, None, None),
("AXGroup", None, None, None),
("AXGroup", None, None, None),
("AXCheckBox", Some("Enable feature"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "settings_page"),
"settings_page not detected with 3 groups + checkbox"
);
}
#[test]
fn detect_patterns_settings_page_from_groups_and_popups() {
let scene = make_scene(&[
("AXGroup", None, None, None),
("AXGroup", None, None, None),
("AXGroup", None, None, None),
("AXPopUpButton", Some("Color scheme"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "settings_page"),
"settings_page not detected with 3 groups + popup button"
);
}
#[test]
fn detect_patterns_settings_page_suppressed_by_modal() {
let scene = make_scene(&[
("AXGroup", None, None, None),
("AXGroup", None, None, None),
("AXGroup", None, None, None),
("AXCheckBox", Some("Option"), None, None),
("AXSheet", None, None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
!patterns.iter().any(|p| p.pattern == "settings_page"),
"settings_page should be suppressed when a modal is present"
);
}
#[test]
fn detect_patterns_text_editor_from_text_area_with_toolbar() {
let scene = make_scene(&[
("AXTextArea", None, None, None),
("AXToolbar", None, None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "text_editor"),
"text_editor not detected with AXTextArea + AXToolbar"
);
}
#[test]
fn detect_patterns_text_editor_from_text_area_with_many_nodes() {
let mut nodes: Vec<NodeTuple<'_>> = vec![("AXTextArea", None, None, None)];
for _ in 0..11 {
nodes.push(("AXStaticText", None, None, None));
}
let scene = make_scene(&nodes);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "text_editor"),
"text_editor not detected with AXTextArea + >10 nodes"
);
}
#[test]
fn detect_patterns_browser_main_from_address_field_and_tab_group() {
let mut g = crate::intent::SceneGraph::empty();
let addr_node = crate::intent::SceneNode {
id: crate::intent::NodeId(0),
parent: None,
children: vec![],
role: Some("AXTextField".into()),
title: None,
label: None,
value: None,
description: None,
identifier: Some("address-bar".into()),
bounds: None,
enabled: true,
depth: 0,
};
let tab_node = crate::intent::SceneNode {
id: crate::intent::NodeId(1),
parent: None,
children: vec![],
role: Some("AXTabGroup".into()),
title: None,
label: None,
value: None,
description: None,
identifier: None,
bounds: None,
enabled: true,
depth: 0,
};
g.push(addr_node);
g.push(tab_node);
let patterns = super::detect_ui_patterns(&g);
assert!(
patterns.iter().any(|p| p.pattern == "browser_main"),
"browser_main not detected with address-bar field + tab group"
);
}
#[test]
fn detect_patterns_browser_main_requires_tab_group() {
let mut g = crate::intent::SceneGraph::empty();
let node = crate::intent::SceneNode {
id: crate::intent::NodeId(0),
parent: None,
children: vec![],
role: Some("AXTextField".into()),
title: None,
label: None,
value: None,
description: None,
identifier: Some("url-field".into()),
bounds: None,
enabled: true,
depth: 0,
};
g.push(node);
let patterns = super::detect_ui_patterns(&g);
assert!(
!patterns.iter().any(|p| p.pattern == "browser_main"),
"browser_main should require AXTabGroup"
);
}
#[test]
fn detect_patterns_form_requires_at_least_two_text_fields() {
let scene = make_scene(&[
("AXTextField", Some("Email"), None, None),
("AXButton", Some("Submit"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
!patterns.iter().any(|p| p.pattern == "form"),
"form should require at least 2 text fields"
);
}
#[test]
fn infer_state_modal_from_ax_dialog() {
let scene = make_scene(&[("AXDialog", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Modal);
}
#[test]
fn infer_state_loading_from_ax_busy_indicator() {
let scene = make_scene(&[("AXBusyIndicator", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Loading);
}
#[test]
fn infer_state_loading_from_label_text() {
let scene = make_scene(&[("AXStaticText", Some("Loading…"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Loading);
}
#[test]
fn infer_state_error_from_failed_label() {
let scene = make_scene(&[("AXStaticText", Some("Connection failed"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Error);
}
#[test]
fn infer_state_error_from_invalid_label() {
let scene = make_scene(&[("AXStaticText", Some("Invalid password"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Error);
}
#[test]
fn suggest_actions_file_open_dialog_suggests_open_click() {
let open_dlg = super::UiPattern {
pattern: "file_open_dialog",
confidence: 0.88,
};
let suggestions = super::suggest_actions(&[open_dlg], super::AppState::Idle);
assert!(
suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "Open"),
"expected ax_click with 'Open' query for file_open_dialog"
);
}
#[test]
fn suggest_actions_error_alert_suggests_dismiss_ok() {
let alert = super::UiPattern {
pattern: "error_alert",
confidence: 0.80,
};
let suggestions = super::suggest_actions(&[alert], super::AppState::Idle);
assert!(
suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "OK"),
"expected ax_click 'OK' for error_alert dismissal"
);
}
#[test]
fn suggest_actions_text_editor_suggests_type() {
let editor = super::UiPattern {
pattern: "text_editor",
confidence: 0.78,
};
let suggestions = super::suggest_actions(&[editor], super::AppState::Idle);
assert!(
suggestions.iter().any(|s| s.tool == "ax_type"),
"expected ax_type suggestion for text_editor"
);
}
#[test]
fn suggest_actions_form_suggests_submit_click() {
let form = super::UiPattern {
pattern: "form",
confidence: 0.72,
};
let suggestions = super::suggest_actions(&[form], super::AppState::Idle);
assert!(
suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "Submit"),
"expected ax_click 'Submit' for form pattern"
);
}
#[test]
fn compute_diff_one_byte_different_in_large_array_is_small_fraction() {
let baseline: Vec<u8> = (0u8..=255).cycle().take(1000).collect();
let mut current = baseline.clone();
current[500] ^= 0xFF;
let diff = super::compute_diff(&baseline, ¤t);
assert!(
(diff - 0.001).abs() < f64::EPSILON * 10.0,
"expected ~0.001 for 1 byte diff in 1000-byte array, got {diff}"
);
}
#[test]
fn decode_baseline_b64_two_char_chunk_produces_one_byte() {
let result = super::decode_baseline_b64("YQ==").unwrap();
assert_eq!(result, b"a");
}
#[test]
fn decode_baseline_b64_three_char_chunk_produces_two_bytes() {
let result = super::decode_baseline_b64("YWI=").unwrap();
assert_eq!(result, b"ab");
}
#[test]
fn parse_test_assertions_parses_element_exists() {
let assertions = super::parse_test_assertions(&json!([
{ "type": "element_exists", "query": "Submit" }
]));
assert_eq!(assertions.len(), 1);
}
#[test]
fn parse_test_assertions_parses_element_has_text() {
let assertions = super::parse_test_assertions(&json!([
{ "type": "element_has_text", "query": "Title", "expected": "Hello" }
]));
assert_eq!(assertions.len(), 1);
}
#[test]
fn parse_test_assertions_parses_element_not_exists() {
let assertions = super::parse_test_assertions(&json!([
{ "type": "element_not_exists", "query": "Error" }
]));
assert_eq!(assertions.len(), 1);
}
#[test]
fn parse_test_assertions_parses_screen_contains() {
let assertions = super::parse_test_assertions(&json!([
{ "type": "screen_contains", "needle": "Welcome" }
]));
assert_eq!(assertions.len(), 1);
}
#[test]
fn parse_test_assertions_skips_unknown_type() {
let assertions = super::parse_test_assertions(&json!([
{ "type": "element_exists", "query": "OK" },
{ "type": "unsupported_future_assertion" }
]));
assert_eq!(assertions.len(), 1);
}
#[test]
fn ax_record_click_action_type_increments_event_count() {
super::handle_ax_record(&json!({"app": "Safari", "action": "start"}));
let result = super::handle_ax_record(&json!({
"app": "Safari",
"action": "record",
"action_type": "click",
"query": "Submit"
}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["recorded_action_type"], "click");
assert!(v["event_count"].as_u64().unwrap() >= 1);
}
#[test]
fn ax_record_type_action_type_records_text() {
super::handle_ax_record(&json!({"app": "Safari", "action": "start"}));
let result = super::handle_ax_record(&json!({
"app": "Safari",
"action": "record",
"action_type": "type",
"query": "Username",
"text": "alice@example.com"
}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["recorded_action_type"], "type");
}
#[test]
fn ax_record_unknown_action_type_returns_error() {
let result = super::handle_ax_record(&json!({
"app": "Safari",
"action": "record",
"action_type": "teleport"
}));
assert!(result.is_error);
assert!(result.content[0].text.contains("teleport"));
}
#[test]
fn workflow_tracking_data_returns_valid_json_structure() {
let data = super::workflow_tracking_data();
assert!(data["workflows_detected"].is_number());
assert!(data["workflows"].is_array());
assert!(data["stats"].is_object());
assert!(data["stats"]["total_transitions"].is_number());
assert!(data["stats"]["distinct_apps"].is_number());
}
#[test]
fn detect_patterns_confirmation_dialog_from_alert_yes_no_buttons() {
let scene = make_scene(&[
("AXAlert", None, None, None),
("AXButton", Some("Yes"), None, None),
("AXButton", Some("No"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "confirmation_dialog"),
"confirmation_dialog should be detected with Yes/No buttons"
);
}
#[test]
fn detect_patterns_login_form_confidence_is_0_90() {
let scene = make_scene(&[
("AXSecureTextField", Some("Password"), None, None),
("AXTextField", Some("Username"), None, None),
("AXButton", Some("Sign In"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
let login = patterns.iter().find(|p| p.pattern == "login_form").unwrap();
assert!(
(login.confidence - 0.90).abs() < f64::EPSILON,
"expected confidence 0.90, got {}",
login.confidence
);
}