499 lines
19 KiB
Rust
499 lines
19 KiB
Rust
use super::SlashCommandEntryState;
|
|
use crate::report_if_error;
|
|
use crate::search::slash_command_menu::static_commands::commands;
|
|
use crate::settings::AISettings;
|
|
use crate::terminal::input::tests::{add_window_with_bootstrapped_terminal, initialize_app};
|
|
use settings::Setting as _;
|
|
use warpui::{App, SingletonEntity as _};
|
|
|
|
#[test]
|
|
fn test_parse_slash_command_handles_argument_rules() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(
|
|
&mut app, None, /* history_file_commands */
|
|
None,
|
|
)
|
|
.await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
let slash_command_data_source =
|
|
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
|
|
|
slash_command_data_source.read(&app, |data_source, _| {
|
|
let no_argument_command_name = data_source
|
|
.active_commands()
|
|
.find_map(|(_, command)| command.argument.is_none().then_some(command.name))
|
|
.expect("expected at least one slash command without an argument");
|
|
|
|
let optional_argument_command_name = data_source
|
|
.active_commands()
|
|
.find_map(|(_, command)| {
|
|
(command.argument.is_some()
|
|
&& data_source.parse_slash_command(command.name).is_some())
|
|
.then_some(command.name)
|
|
})
|
|
.expect("expected at least one slash command that accepts optional arguments");
|
|
|
|
let with_extra_text = format!("{no_argument_command_name} trailing");
|
|
assert!(
|
|
data_source.parse_slash_command(&with_extra_text).is_none(),
|
|
"commands without arguments should reject non-whitespace suffixes"
|
|
);
|
|
|
|
let with_whitespace = format!("{no_argument_command_name} ");
|
|
let detected_with_whitespace = data_source
|
|
.parse_slash_command(&with_whitespace)
|
|
.expect("command with whitespace-only suffix should still be parsed");
|
|
assert_eq!(
|
|
detected_with_whitespace.command.name,
|
|
no_argument_command_name
|
|
);
|
|
|
|
let detected_without_argument = data_source
|
|
.parse_slash_command(optional_argument_command_name)
|
|
.expect("optional-argument command should parse without an argument");
|
|
assert_eq!(detected_without_argument.argument, None);
|
|
|
|
let with_argument = format!("{optional_argument_command_name} prompt");
|
|
let detected_with_argument = data_source
|
|
.parse_slash_command(&with_argument)
|
|
.expect("optional-argument command should parse with an argument");
|
|
assert_eq!(detected_with_argument.argument.as_deref(), Some("prompt"));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_rename_tab_slash_command_arguments() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(
|
|
&mut app, None, /* history_file_commands */
|
|
None,
|
|
)
|
|
.await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
let slash_command_data_source =
|
|
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
|
|
|
slash_command_data_source.read(&app, |data_source, _| {
|
|
assert!(
|
|
data_source
|
|
.parse_slash_command(commands::RENAME_TAB.name)
|
|
.is_none(),
|
|
"expected /rename-tab without an argument not to parse"
|
|
);
|
|
|
|
let detected_with_argument = data_source
|
|
.parse_slash_command("/rename-tab Backend")
|
|
.expect("expected /rename-tab to parse with an argument");
|
|
assert_eq!(detected_with_argument.argument.as_deref(), Some("Backend"));
|
|
|
|
let detected_with_multi_word_argument = data_source
|
|
.parse_slash_command("/rename-tab Backend API")
|
|
.expect("expected /rename-tab to preserve the rest of the line as one argument");
|
|
assert_eq!(
|
|
detected_with_multi_word_argument.argument.as_deref(),
|
|
Some("Backend API")
|
|
);
|
|
|
|
let detected_with_empty_argument = data_source
|
|
.parse_slash_command("/rename-tab ")
|
|
.expect("expected /rename-tab to parse once the required argument is started");
|
|
assert_eq!(detected_with_empty_argument.argument.as_deref(), Some(""));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_ai_commands_remain_active_when_ai_is_disabled() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(
|
|
&mut app, None, /* history_file_commands */
|
|
None,
|
|
)
|
|
.await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
let slash_command_data_source =
|
|
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
|
|
|
// Disable AI globally.
|
|
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
|
report_if_error!(settings.is_any_ai_enabled.set_value(false, ctx));
|
|
});
|
|
|
|
slash_command_data_source.read(&app, |data_source, _| {
|
|
let active_command_names: Vec<&str> = data_source
|
|
.active_commands()
|
|
.map(|(_, command)| command.name)
|
|
.collect();
|
|
|
|
// Commands that don't require AI should still be active.
|
|
// `/rename-tab` is a good canary because it has no session-context requirements
|
|
// other than ALWAYS.
|
|
assert!(
|
|
active_command_names.contains(&commands::RENAME_TAB.name),
|
|
"/rename-tab should remain active when AI is off, got: {active_command_names:?}"
|
|
);
|
|
|
|
// Commands that require AI should be filtered out.
|
|
assert!(
|
|
!active_command_names.contains(&commands::AGENT.name),
|
|
"/agent should NOT be active when AI is off, got: {active_command_names:?}"
|
|
);
|
|
assert!(
|
|
!active_command_names.contains(&commands::PLAN.name),
|
|
"/plan should NOT be active when AI is off, got: {active_command_names:?}"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_disabled_until_empty_buffer_ignores_non_slash_edits() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(
|
|
&mut app, None, /* history_file_commands */
|
|
None,
|
|
)
|
|
.await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.update(&mut app, |input, ctx| {
|
|
input.set_input_mode_natural_language_detection(ctx);
|
|
input.user_insert("echo hello", ctx);
|
|
});
|
|
input.update(&mut app, |input, ctx| {
|
|
input.slash_command_model.update(ctx, |model, ctx| {
|
|
model.disable(ctx);
|
|
});
|
|
});
|
|
|
|
input.read(&app, |input, ctx| {
|
|
assert!(matches!(
|
|
input.slash_command_model.as_ref(ctx).state(),
|
|
SlashCommandEntryState::DisabledUntilEmptyBuffer
|
|
));
|
|
});
|
|
|
|
input.update(&mut app, |input, ctx| {
|
|
input.user_insert(" world", ctx);
|
|
});
|
|
|
|
input.read(&app, |input, ctx| {
|
|
assert!(matches!(
|
|
input.slash_command_model.as_ref(ctx).state(),
|
|
SlashCommandEntryState::DisabledUntilEmptyBuffer
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_disabled_until_empty_buffer_reevaluates_when_slash_is_added_to_start() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(
|
|
&mut app, None, /* history_file_commands */
|
|
None,
|
|
)
|
|
.await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
let slash_command_data_source =
|
|
input.read(&app, |input, _| input.slash_command_data_source.clone());
|
|
let detected_command_name = slash_command_data_source.read(&app, |data_source, _| {
|
|
data_source
|
|
.active_commands()
|
|
.find_map(|(_, command)| {
|
|
data_source
|
|
.parse_slash_command(command.name)
|
|
.is_some()
|
|
.then(|| command.name.to_owned())
|
|
})
|
|
.expect("expected at least one active slash command")
|
|
});
|
|
|
|
input.update(&mut app, |input, ctx| {
|
|
input.set_input_mode_natural_language_detection(ctx);
|
|
input.user_insert("echo hello", ctx);
|
|
});
|
|
input.update(&mut app, |input, ctx| {
|
|
input.slash_command_model.update(ctx, |model, ctx| {
|
|
model.disable(ctx);
|
|
});
|
|
});
|
|
|
|
input.update(&mut app, |input, ctx| {
|
|
input.user_replace_editor_text(&detected_command_name, ctx);
|
|
});
|
|
|
|
input.read(&app, |input, ctx| {
|
|
assert!(
|
|
matches!(
|
|
input.slash_command_model.as_ref(ctx).state(),
|
|
SlashCommandEntryState::SlashCommand(_)
|
|
),
|
|
"adding '/' at the start of the buffer should re-enable slash command parsing"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_second_slash_in_command_token_sets_state_to_none() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(
|
|
&mut app, None, /* history_file_commands */
|
|
None,
|
|
)
|
|
.await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.update(&mut app, |input, ctx| {
|
|
input.set_input_mode_natural_language_detection(ctx);
|
|
input.user_insert("/foo/bar", ctx);
|
|
});
|
|
|
|
input.read(&app, |input, ctx| {
|
|
assert!(matches!(
|
|
input.slash_command_model.as_ref(ctx).state(),
|
|
SlashCommandEntryState::None
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_command_returns_none_for_plain_text() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.read(&app, |input, ctx| {
|
|
let result = input
|
|
.slash_command_model
|
|
.as_ref(ctx)
|
|
.detect_command("fix the tests", ctx);
|
|
assert!(
|
|
matches!(result, SlashCommandEntryState::None),
|
|
"plain text should not be detected as a command"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_command_finds_known_slash_command() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.read(&app, |input, ctx| {
|
|
let slash_command_data_source = &input.slash_command_data_source;
|
|
let command_name = slash_command_data_source
|
|
.as_ref(ctx)
|
|
.active_commands()
|
|
.find_map(|(_, command)| {
|
|
slash_command_data_source
|
|
.as_ref(ctx)
|
|
.parse_slash_command(command.name)
|
|
.is_some()
|
|
.then(|| command.name.to_owned())
|
|
})
|
|
.expect("expected at least one active slash command");
|
|
|
|
let result = input
|
|
.slash_command_model
|
|
.as_ref(ctx)
|
|
.detect_command(&command_name, ctx);
|
|
assert!(
|
|
matches!(result, SlashCommandEntryState::SlashCommand(_)),
|
|
"known command should be detected: {command_name}"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_command_returns_none_for_unknown_slash() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.read(&app, |input, ctx| {
|
|
let result = input
|
|
.slash_command_model
|
|
.as_ref(ctx)
|
|
.detect_command("/nonexistent-command-xyz", ctx);
|
|
assert!(
|
|
matches!(result, SlashCommandEntryState::None),
|
|
"unknown /command should return None"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_command_matches_buffer_driven_detection() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
let command_name = input.read(&app, |input, ctx| {
|
|
let ds = &input.slash_command_data_source;
|
|
ds.as_ref(ctx)
|
|
.active_commands()
|
|
.find_map(|(_, command)| {
|
|
ds.as_ref(ctx)
|
|
.parse_slash_command(command.name)
|
|
.is_some()
|
|
.then(|| command.name.to_owned())
|
|
})
|
|
.expect("expected at least one active slash command")
|
|
});
|
|
|
|
// Type the command into the buffer so the buffer-driven model processes it.
|
|
input.update(&mut app, |input, ctx| {
|
|
input.set_input_mode_natural_language_detection(ctx);
|
|
input.user_insert(&command_name, ctx);
|
|
});
|
|
|
|
// Compare the buffer-driven state with the stateless detect_command result.
|
|
input.read(&app, |input, ctx| {
|
|
let buffer_state = input.slash_command_model.as_ref(ctx).state().clone();
|
|
let detect_state = input
|
|
.slash_command_model
|
|
.as_ref(ctx)
|
|
.detect_command(&command_name, ctx);
|
|
|
|
let buffer_is_slash = matches!(buffer_state, SlashCommandEntryState::SlashCommand(_));
|
|
let detect_is_slash = matches!(detect_state, SlashCommandEntryState::SlashCommand(_));
|
|
assert_eq!(
|
|
buffer_is_slash, detect_is_slash,
|
|
"detect_command and buffer-driven detection should agree for '{command_name}'"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_command_parses_queue_with_argument() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.read(&app, |input, ctx| {
|
|
let result = input
|
|
.slash_command_model
|
|
.as_ref(ctx)
|
|
.detect_command("/queue fix the tests", ctx);
|
|
|
|
if let SlashCommandEntryState::SlashCommand(detected) = result {
|
|
assert_eq!(detected.command.name, "/queue");
|
|
assert_eq!(detected.argument.as_deref(), Some("fix the tests"));
|
|
} else {
|
|
// /queue may not be registered if the feature flag is off in tests.
|
|
// That's fine — this test validates argument extraction when it is.
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_command_returns_queue_with_no_argument() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.read(&app, |input, ctx| {
|
|
let result = input
|
|
.slash_command_model
|
|
.as_ref(ctx)
|
|
.detect_command("/queue", ctx);
|
|
|
|
// /queue requires an argument, so bare "/queue" should not be detected.
|
|
// (StaticCommand with required argument rejects input without a space-delimited arg.)
|
|
assert!(
|
|
!matches!(result, SlashCommandEntryState::SlashCommand(_)),
|
|
"/queue with no argument should not be detected as a complete slash command"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_submit_queued_prompt_routes_plain_text_to_conversation() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
// Set up AI input mode so the input can interact with the AI controller.
|
|
input.update(&mut app, |input, ctx| {
|
|
input.set_input_mode_natural_language_detection(ctx);
|
|
});
|
|
|
|
// submit_queued_prompt with plain text should not panic or crash.
|
|
// It routes through detect_command (returning None) and falls through
|
|
// to send_user_query_in_new_conversation.
|
|
input.update(&mut app, |input, ctx| {
|
|
input.submit_queued_prompt("fix the tests".to_string(), ctx);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_submit_queued_prompt_detects_slash_command() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await;
|
|
let input = terminal.read(&app, |terminal, _| terminal.input().clone());
|
|
|
|
input.update(&mut app, |input, ctx| {
|
|
input.set_input_mode_natural_language_detection(ctx);
|
|
});
|
|
|
|
// Verify that submit_queued_prompt correctly detects slash commands.
|
|
// Find a command that exists and has an optional argument.
|
|
let command_with_arg = input.read(&app, |input, ctx| {
|
|
let ds = &input.slash_command_data_source;
|
|
ds.as_ref(ctx).active_commands().find_map(|(_, command)| {
|
|
(command.argument.as_ref().is_some_and(|a| a.is_optional)
|
|
&& ds.as_ref(ctx).parse_slash_command(command.name).is_some())
|
|
.then(|| command.name.to_owned())
|
|
})
|
|
});
|
|
|
|
if let Some(command_text) = command_with_arg {
|
|
// submit_queued_prompt should detect the slash command and route through
|
|
// execute_slash_command. This should not panic.
|
|
input.update(&mut app, |input, ctx| {
|
|
input.submit_queued_prompt(command_text, ctx);
|
|
});
|
|
}
|
|
});
|
|
}
|