Skip to main content

native/guppy_nif/src/menu_tests.rs

use super::{
    GuppyDockMenuAction, GuppyMenuAction, MenuActionSpec, MenuItemSpec, MenuOsAction, MenuSpec,
};
use crate::bridge_text_input;
use eetf::{Atom, Binary, List, Map, Term};
use gpui::{Action, MenuItem, OsAction};

fn atom(name: &str) -> Term {
    Term::Atom(Atom::from(name))
}

fn binary(value: &str) -> Term {
    Term::Binary(Binary {
        bytes: value.as_bytes().to_vec(),
    })
}

fn list(elements: Vec<Term>) -> Term {
    Term::List(List { elements })
}

fn map(entries: Vec<(&str, Term)>) -> Term {
    Term::Map(Map {
        map: entries
            .into_iter()
            .map(|(key, value)| (atom(key), value))
            .collect(),
    })
}

fn encode(term: Term) -> Vec<u8> {
    let mut bytes = Vec::new();
    term.encode(&mut bytes).unwrap();
    bytes
}

fn sample_menus() -> Vec<MenuSpec> {
    MenuSpec::decode_etf(&encode(list(vec![map(vec![
        ("label", binary("File")),
        (
            "items",
            list(vec![
                map(vec![
                    ("id", binary("new_file")),
                    ("label", binary("New File")),
                    ("callback", binary("new_file")),
                    ("shortcut", binary("cmd-n")),
                ]),
                atom("separator"),
                map(vec![
                    ("label", binary("Edit")),
                    (
                        "items",
                        list(vec![map(vec![
                            ("id", binary("copy")),
                            ("label", binary("Copy")),
                            ("os_action", atom("copy")),
                        ])]),
                    ),
                ]),
                map(vec![
                    ("label", binary("Services")),
                    ("system_menu", atom("services")),
                ]),
            ]),
        ),
    ])])))
    .unwrap()
}

#[test]
fn decodes_menu_specs() {
    let menus = sample_menus();

    assert_eq!(menus[0].label, "File");
    match &menus[0].items[0] {
        MenuItemSpec::Action(action) => {
            assert_eq!(action.callback.as_deref(), Some("new_file"));
            assert_eq!(action.shortcut.as_deref(), Some("cmd-n"));
        }
        other => panic!("expected action, got {other:?}"),
    }
    assert!(matches!(menus[0].items[1], MenuItemSpec::Separator));
    match &menus[0].items[2] {
        MenuItemSpec::Submenu(menu) => match &menu.items[0] {
            MenuItemSpec::Action(action) => {
                assert_eq!(action.callback, None);
                assert_eq!(action.os_action, Some(MenuOsAction::Copy));
            }
            other => panic!("expected action, got {other:?}"),
        },
        other => panic!("expected submenu, got {other:?}"),
    }
    assert!(matches!(menus[0].items[3], MenuItemSpec::SystemMenu(_)));
}

#[test]
fn rejects_invalid_shortcuts() {
    let err = MenuSpec::decode_etf(&encode(list(vec![map(vec![
        ("label", binary("File")),
        (
            "items",
            list(vec![map(vec![
                ("id", binary("bad")),
                ("label", binary("Bad")),
                ("callback", binary("bad")),
                ("shortcut", binary("cmd-n-extra")),
            ])]),
        ),
    ])])))
    .unwrap_err();

    assert!(err.contains("invalid") || err.contains("keystroke"));
}

#[test]
fn rejects_action_items_without_callback_or_os_action() {
    let err = MenuSpec::decode_etf(&encode(list(vec![map(vec![
        ("label", binary("File")),
        (
            "items",
            list(vec![map(vec![
                ("id", binary("bad")),
                ("label", binary("Bad")),
            ])]),
        ),
    ])])))
    .unwrap_err();

    assert!(err.contains("callback or os_action"));
}

#[test]
fn menu_action_identity_compares_payloads() {
    let first = GuppyMenuAction {
        id: "new_file".into(),
        callback: "new_file".into(),
    };
    let same = GuppyMenuAction {
        id: "new_file".into(),
        callback: "new_file".into(),
    };
    let different = GuppyMenuAction {
        id: "open_file".into(),
        callback: "open_file".into(),
    };

    assert!(first.partial_eq(&same));
    assert!(!first.partial_eq(&different));
}

#[test]
fn maps_custom_and_os_actions_to_gpui_menu_items() {
    let gpui_menus = super::to_gpui_menus(sample_menus());
    assert_eq!(gpui_menus[0].name.as_ref(), "File");

    match &gpui_menus[0].items[0] {
        MenuItem::Action {
            name,
            action,
            os_action,
        } => {
            assert_eq!(name.as_ref(), "New File");
            assert!(os_action.is_none());
            let action = action.as_any().downcast_ref::<GuppyMenuAction>().unwrap();
            assert_eq!(action.id, "new_file");
            assert_eq!(action.callback, "new_file");
        }
        other => panic!("expected action, got {}", menu_item_name(other)),
    }

    match &gpui_menus[0].items[2] {
        MenuItem::Submenu(menu) => match &menu.items[0] {
            MenuItem::Action {
                name,
                action,
                os_action,
            } => {
                assert_eq!(name.as_ref(), "Copy");
                assert!(matches!(os_action, Some(action) if *action == OsAction::Copy));
                assert!(action.as_any().is::<bridge_text_input::Copy>());
            }
            other => panic!("expected submenu action, got {}", menu_item_name(other)),
        },
        other => panic!("expected submenu, got {}", menu_item_name(other)),
    }

    match &gpui_menus[0].items[3] {
        MenuItem::SystemMenu(menu) => assert_eq!(menu.name.as_ref(), "Services"),
        other => panic!("expected system menu, got {}", menu_item_name(other)),
    }
}

#[test]
fn maps_internal_custom_action_without_callback_to_disabled_action() {
    let gpui_menus = super::to_gpui_menus(vec![MenuSpec {
        label: "File".into(),
        items: vec![MenuItemSpec::Action(MenuActionSpec {
            id: "invalid".into(),
            label: "Invalid".into(),
            callback: None,
            shortcut: None,
            enabled: true,
            os_action: None,
        })],
    }]);

    match &gpui_menus[0].items[0] {
        MenuItem::Action {
            name,
            action,
            os_action,
        } => {
            assert_eq!(name.as_ref(), "Invalid");
            assert!(os_action.is_none());
            assert!(action.as_any().is::<super::GuppyDisabledMenuAction>());
        }
        other => panic!("expected action, got {}", menu_item_name(other)),
    }
}

#[test]
fn decodes_and_maps_dock_menu_items_to_dock_actions() {
    let items = MenuItemSpec::decode_list_etf(&encode(list(vec![map(vec![
        ("id", binary("new_window")),
        ("label", binary("New Window")),
        ("callback", binary("new_window")),
    ])])))
    .unwrap();

    let gpui_items = super::to_gpui_dock_menu_items(items);

    match &gpui_items[0] {
        MenuItem::Action {
            name,
            action,
            os_action,
        } => {
            assert_eq!(name.as_ref(), "New Window");
            assert!(os_action.is_none());
            let action = action
                .as_any()
                .downcast_ref::<GuppyDockMenuAction>()
                .unwrap();
            assert_eq!(action.id, "new_window");
            assert_eq!(action.callback, "new_window");
        }
        other => panic!("expected action, got {}", menu_item_name(other)),
    }
}

#[test]
fn key_bindings_are_derived_from_current_menu_specs() {
    let menus = sample_menus();
    let bindings = super::key_bindings(&menus);
    assert_eq!(bindings.len(), 1);
    assert!(bindings[0].action().as_any().is::<GuppyMenuAction>());

    let replacement = vec![MenuSpec {
        label: "Edit".into(),
        items: vec![MenuItemSpec::Action(super::MenuActionSpec {
            id: "select_all".into(),
            label: "Select All".into(),
            callback: None,
            shortcut: Some("cmd-a".into()),
            enabled: true,
            os_action: Some(MenuOsAction::SelectAll),
        })],
    }];
    let replacement_bindings = super::key_bindings(&replacement);
    assert_eq!(replacement_bindings.len(), 1);
    assert!(
        replacement_bindings[0]
            .action()
            .as_any()
            .is::<bridge_text_input::SelectAll>()
    );

    assert!(super::key_bindings(&[]).is_empty());
}

#[test]
fn send_menu_action_records_callback_payload() {
    let before = crate::native_event_send_snapshot_for_test();
    let _ = crate::send_menu_action_event("new_file", "new_file");
    let after = crate::native_event_send_snapshot_for_test();

    assert!(after.0 > before.0);
    let event = crate::take_menu_event_snapshot_for_test().unwrap();
    assert_eq!(event.action_id, "new_file");
    assert_eq!(event.callback_id, "new_file");

    let _ = crate::send_dock_menu_action_event("new_window", "new_window");
    let event = crate::take_menu_event_snapshot_for_test().unwrap();
    assert_eq!(event.action_id, "new_window");
    assert_eq!(event.callback_id, "new_window");
}

fn menu_item_name(item: &MenuItem) -> &'static str {
    match item {
        MenuItem::Separator => "separator",
        MenuItem::Submenu(_) => "submenu",
        MenuItem::SystemMenu(_) => "system_menu",
        MenuItem::Action { .. } => "action",
    }
}