embers-client 0.1.0

Client rendering, input handling, configuration, and scripting support for Embers.
use std::path::Path;

use embers_client::input::KeyParseError;
use embers_client::{
    Action, InputResolution, KeyToken, PresentationModel, ScriptEngine, ScriptHarness,
    TabBarContext,
    config::{ConfigOrigin, LoadedConfigSource},
};
use embers_core::Size;

use crate::support::{SESSION_ID, demo_state};

#[test]
fn loaded_config_debug_snapshot_is_stable() {
    let source = LoadedConfigSource {
        origin: ConfigOrigin::BuiltIn,
        path: Some("snapshot-config.rhai".into()),
        source: r##"
            fn split_workspace(ctx) { () }
            fn on_created(ctx) { () }
            fn format_tabs(ctx) { ui.bar([], [], []) }

            set_leader("<C-a>");
            define_mode("locked");
            define_action("workspace-split", split_workspace);
            bind("normal", "<leader>ws", "workspace-split");
            on("session_created", on_created);
            tabbar.set_formatter(format_tabs);
            theme.set_palette(#{ active: "#00ff00", inactive: "#333333" });
        "##
        .trim()
        .to_owned(),
        source_hash: 0,
    };

    let engine = ScriptEngine::load(&source).unwrap();
    let loaded = engine.loaded_config();
    let debug_output = format!("{loaded:#?}");

    assert_eq!(
        loaded.source_path.as_deref(),
        Some(Path::new("snapshot-config.rhai"))
    );
    assert_eq!(loaded.source_hash, 0);
    assert_eq!(loaded.leader, vec![KeyToken::Ctrl('a')]);
    assert!(loaded.modes.contains_key("locked"));
    assert_eq!(loaded.bindings["normal"][0].notation, "<leader>ws");
    assert_eq!(
        loaded.named_actions["workspace-split"].name,
        "split_workspace"
    );
    assert_eq!(
        loaded.event_handlers["session_created"][0].name,
        "on_created"
    );
    assert_eq!(
        loaded
            .tab_bar_formatter
            .as_ref()
            .map(|formatter| formatter.name.as_str()),
        Some("format_tabs")
    );
    assert_eq!(loaded.theme.palette["active"].green, 255);
    assert!(debug_output.contains("source_path: Some("));
    assert!(debug_output.contains("ast: \"<ast>\""));
}

#[test]
fn harness_resolves_leader_binding_to_exact_match() {
    let mut harness = ScriptHarness::load(
        r#"
            fn split_workspace(ctx) { () }
            define_action("workspace-split", split_workspace);
            set_leader("<C-a>");
            bind("normal", "<leader>ws", "workspace-split");
        "#,
    )
    .unwrap();

    assert_eq!(
        harness.resolve_notation("normal", "<C-a>w").unwrap(),
        InputResolution::PrefixMatch
    );
    assert_eq!(
        harness.resolve_notation("normal", "s").unwrap(),
        InputResolution::ExactMatch(embers_client::BindingMatch {
            mode: "normal".to_owned(),
            sequence: vec![
                KeyToken::Ctrl('a'),
                KeyToken::Char('w'),
                KeyToken::Char('s'),
            ],
            target: vec![Action::RunNamedAction {
                name: "workspace-split".to_owned(),
            }],
        })
    );
}

#[test]
fn same_sequence_can_resolve_differently_by_mode() {
    let mut harness = ScriptHarness::load(
        r#"
            fn normal_action(ctx) { () }
            fn copy_action(ctx) { () }
            define_action("normal-a", normal_action);
            define_action("copy-a", copy_action);
            bind("normal", "a", "normal-a");
            bind("copy", "a", "copy-a");
        "#,
    )
    .unwrap();

    assert_eq!(
        harness.resolve_notation("normal", "a").unwrap(),
        InputResolution::ExactMatch(embers_client::BindingMatch {
            mode: "normal".to_owned(),
            sequence: vec![KeyToken::Char('a')],
            target: vec![Action::RunNamedAction {
                name: "normal-a".to_owned(),
            }],
        })
    );
    assert_eq!(
        harness.resolve_notation("copy", "a").unwrap(),
        InputResolution::ExactMatch(embers_client::BindingMatch {
            mode: "copy".to_owned(),
            sequence: vec![KeyToken::Char('a')],
            target: vec![Action::RunNamedAction {
                name: "copy-a".to_owned(),
            }],
        })
    );
}

#[test]
fn define_mode_rejects_unknown_options() {
    let source = LoadedConfigSource {
        origin: ConfigOrigin::BuiltIn,
        path: Some("bad-mode-options.rhai".into()),
        source: r#"
            fn enter(ctx) { () }
            define_mode("locked", #{
                on_enter: enter,
                on_entter: enter
            });
        "#
        .trim()
        .to_owned(),
        source_hash: 0,
    };

    let error = match ScriptEngine::load(&source) {
        Ok(_) => panic!("unknown mode options should fail"),
        Err(error) => error,
    };

    assert!(
        error
            .to_string()
            .contains("unknown mode option(s): on_entter")
    );
}

#[test]
fn formatter_functions_build_bar_specs_from_runtime_context() {
    let source = LoadedConfigSource {
        origin: ConfigOrigin::BuiltIn,
        path: Some("formatters.rhai".into()),
        source: r##"
            fn format_tabs(ctx) {
                let tabs = ctx.tabs();
                let active = tabs[ctx.active_index()];
                if ctx.is_root() {
                    ui.bar([
                        ui.segment("ROOT ", #{
                            fg: theme.color("active"),
                            bg: theme.color("inactive")
                        }),
                        ui.segment(active.title())
                    ], [], [])
                } else {
                    ui.bar([
                        ui.segment("NESTED "),
                        ui.segment(active.title(), #{ fg: theme.color("active") })
                    ], [], [])
                }
            }

            tabbar.set_formatter(format_tabs);
            theme.set_palette(#{ active: "#00ff00", inactive: "#102030" });
        "##
        .trim()
        .to_owned(),
        source_hash: 0,
    };
    let engine = ScriptEngine::load(&source).unwrap();
    let state = demo_state();
    let presentation = PresentationModel::project(
        &state,
        SESSION_ID,
        Size {
            width: 80,
            height: 20,
        },
    )
    .unwrap();

    let root = engine
        .format_tab_bar(TabBarContext::from_frame(
            presentation.root_tabs.as_ref().unwrap(),
            "normal",
            80,
        ))
        .unwrap()
        .unwrap();
    let nested = engine
        .format_tab_bar(TabBarContext::from_frame(
            presentation.focused_tabs().unwrap(),
            "normal",
            80,
        ))
        .unwrap()
        .unwrap();

    assert_eq!(root.left.len(), 2);
    assert!(root.center.is_empty());
    assert!(root.right.is_empty());
    assert_eq!(root.left[0].text, "ROOT ");
    assert_eq!(root.left[1].text, "workspace");
    assert_eq!(root.left[0].style.fg.unwrap().green, 255);
    assert_eq!(root.left[0].style.bg.unwrap().blue, 48);

    assert_eq!(nested.left.len(), 2);
    assert!(nested.center.is_empty());
    assert!(nested.right.is_empty());
    assert_eq!(nested.left[0].text, "NESTED ");
    assert_eq!(nested.left[1].text, "logs-long-title");
    assert_eq!(nested.left[1].style.fg.unwrap().green, 255);
}

#[test]
fn harness_rejects_empty_notation_without_panicking() {
    let mut harness = ScriptHarness::load("").unwrap();

    assert_eq!(
        harness.resolve_notation("normal", "").unwrap_err(),
        KeyParseError::EmptySequence
    );
}