2090 lines
70 KiB
Rust
2090 lines
70 KiB
Rust
//! Тесты доменной модели, Lua-интеграции и клавиатурных привязок.
|
||
//!
|
||
//! UI-часть (заголовок, перетаскивание, изменение размера) теперь принадлежит
|
||
//! gpui-kit, поэтому её отдельно не проверяем. Остаются тесты, которые
|
||
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
|
||
|
||
use std::cell::RefCell;
|
||
use std::path::Path;
|
||
use std::rc::Rc;
|
||
|
||
use gpui_kit::{Keystroke, Modifiers, TestAppContext, point, px};
|
||
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
|
||
|
||
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||
use crate::app::MyApp;
|
||
use crate::editor::{FocusAfterSync, Language, Workspace};
|
||
use crate::keymap::{KeyBinding, KeyContext, KeyHandler, KeyMap, Modifiers as KeyModifiers};
|
||
use crate::panel::PanelBlock;
|
||
use crate::script::ScriptEngine;
|
||
use crate::toolbar::ToolbarKind;
|
||
use crate::tree::TreeNode;
|
||
|
||
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
||
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
||
let api = AppApi::install(workspace, keymap.clone());
|
||
(api, keymap)
|
||
}
|
||
|
||
#[test]
|
||
fn lua_lsp_api_lists_no_servers_and_validates_config() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
let dir = temp_script_dir("lsp-api");
|
||
let script = dir.join("lsp.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
assert(#app.editor:lsp_servers() == 0)
|
||
assert(app.editor:stop_lsp("missing") == false)
|
||
assert(app.editor:restart_lsp("missing") == false)
|
||
|
||
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
|
||
local ok, err = pcall(function() app.editor:run_lsp({ name = "broken" }) end)
|
||
assert(not ok)
|
||
assert(tostring(err):find("cmd", 1, true), tostring(err))
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&script).expect("скрипт LSP API");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn workspace_keeps_at_least_one_tab() {
|
||
let mut state = Workspace::default();
|
||
assert_eq!(state.documents().len(), 1);
|
||
assert_eq!(state.active().name, "untitled-1.txt");
|
||
|
||
// Открытие и закрытие новых вкладок.
|
||
state.open_new();
|
||
assert_eq!(state.documents().len(), 2);
|
||
assert_eq!(state.active_index(), 1);
|
||
|
||
state.close(1);
|
||
assert_eq!(state.active_index(), 0);
|
||
|
||
// Редактор не должен оставаться пустым.
|
||
while state.documents().len() > 1 {
|
||
state.close(0);
|
||
}
|
||
state.close(0);
|
||
assert_eq!(state.documents().len(), 1);
|
||
assert_eq!(state.active_index(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn closing_tab_before_active_keeps_the_same_document_active() {
|
||
let mut state = Workspace::default();
|
||
state.open_new();
|
||
let id = state.active().id;
|
||
|
||
state.close(0);
|
||
assert_eq!(state.active().id, id);
|
||
assert_eq!(state.active_index(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn editing_marks_document_dirty_and_saving_clears_it() {
|
||
let mut state = Workspace::default();
|
||
assert!(!state.active().is_dirty());
|
||
|
||
state.set_active_text("// правка\n");
|
||
assert!(state.active().is_dirty());
|
||
|
||
state.mark_active_saved();
|
||
assert!(!state.active().is_dirty());
|
||
}
|
||
|
||
#[test]
|
||
fn revision_changes_only_for_external_edits() {
|
||
let mut state = Workspace::default();
|
||
let revision = state.revision();
|
||
|
||
// Набор текста в виджете ревизию не двигает: редактор уже показывает текст.
|
||
state.replace_active_content("typed".to_owned());
|
||
assert_eq!(state.revision(), revision);
|
||
|
||
// Внешняя замена (Lua) — двигает.
|
||
state.set_active_text("from lua");
|
||
assert_ne!(state.revision(), revision);
|
||
|
||
// Переключение вкладки — тоже.
|
||
state.open_new();
|
||
state.set_active(0);
|
||
let before_switch = state.revision();
|
||
state.set_active(1);
|
||
assert_ne!(state.revision(), before_switch);
|
||
}
|
||
|
||
#[test]
|
||
fn api_lists_tabs_and_closes_them_by_number() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let tabs = api.editor().tabs();
|
||
assert_eq!(
|
||
tabs.iter()
|
||
.map(|tab| (tab.index, tab.title.as_str()))
|
||
.collect::<Vec<_>>(),
|
||
vec![(0, "untitled-1.txt")]
|
||
);
|
||
|
||
// Несуществующий номер ничего не закрывает.
|
||
assert!(!api.editor().close_tab(99));
|
||
assert_eq!(api.editor().tabs().len(), 1);
|
||
|
||
api.editor().open_tab();
|
||
assert_eq!(api.editor().tabs().len(), 2);
|
||
assert!(api.editor().close_tab(1));
|
||
assert_eq!(api.editor().tabs().len(), 1);
|
||
assert_eq!(workspace.borrow().documents().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn api_sets_and_clears_tab_icons() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
// По умолчанию у всех вкладок иконка file.
|
||
assert_eq!(api.editor().tabs()[0].icon, "file");
|
||
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file"));
|
||
|
||
assert!(api.editor().set_tab_icon(0, "file-code"));
|
||
assert_eq!(api.editor().tabs()[0].icon, "file-code");
|
||
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file-code"));
|
||
|
||
// Имя нормализуется, а вкладка возвращает канонический вариант.
|
||
assert!(api.editor().set_tab_icon(0, "icons/book-open.svg"));
|
||
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open"));
|
||
|
||
// Неизвестная иконка и несуществующая вкладка ничего не меняют.
|
||
assert!(!api.editor().set_tab_icon(0, "definitely-not-an-icon"));
|
||
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("book-open"));
|
||
assert!(!api.editor().set_tab_icon(99, "file"));
|
||
|
||
assert!(api.editor().clear_tab_icon(0));
|
||
assert_eq!(api.editor().tab_icon(0).as_deref(), Some("file"));
|
||
assert!(!api.editor().clear_tab_icon(99));
|
||
assert_eq!(api.editor().tab_icon(99), None);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_script_can_set_tab_icons() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("tab-icons");
|
||
let script = dir.join("icons.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
assert(app.editor:tab_icon(0) == "file")
|
||
assert(app.editor:set_tab_icon(0, "book-open"))
|
||
assert(app.editor:tab_icon(0) == "book-open")
|
||
app.editor:open_tab()
|
||
assert(app.editor:tabs()[2].icon == "file")
|
||
assert(not app.editor:set_tab_icon(0, "no-such-icon"))
|
||
assert(app.editor:clear_tab_icon(0))
|
||
assert(app.editor:tab_icon(0) == "file")
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&script).expect("скрипт иконок");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn api_sets_and_clears_gutter_icons_per_document() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
assert_eq!(api.editor().gutter_icon(0), None);
|
||
assert!(api.editor().set_gutter_icon(2, "circle", None));
|
||
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("circle"));
|
||
assert_eq!(api.editor().gutter_color(2), None);
|
||
|
||
// Неизвестная иконка и некорректный цвет ничего не меняют.
|
||
assert!(
|
||
!api.editor()
|
||
.set_gutter_icon(3, "definitely-not-an-icon", None)
|
||
);
|
||
assert!(
|
||
!api.editor()
|
||
.set_gutter_icon(3, "circle", Some("not-a-color"))
|
||
);
|
||
assert_eq!(api.editor().gutter_icon(3), None);
|
||
|
||
assert!(api.editor().set_gutter_icon(2, "play", Some("#ff0000")));
|
||
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play"));
|
||
assert_eq!(api.editor().gutter_color(2).as_deref(), Some("#ff0000"));
|
||
|
||
// Иконки принадлежат вкладке, а не приложению.
|
||
api.editor().open_tab();
|
||
assert_eq!(api.editor().gutter_icon(2), None);
|
||
assert!(api.editor().set_gutter_icon(0, "circle", None));
|
||
api.editor().set_active_tab(0);
|
||
assert_eq!(api.editor().gutter_icon(2).as_deref(), Some("play"));
|
||
|
||
assert!(api.editor().clear_gutter_icon(2));
|
||
assert!(!api.editor().clear_gutter_icon(2));
|
||
assert_eq!(api.editor().gutter_icon(2), None);
|
||
|
||
api.editor().clear_gutter();
|
||
assert_eq!(api.editor().gutter_icon(0), None);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_script_can_set_gutter_icons() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
let dir = temp_script_dir("gutter-icons");
|
||
let script = dir.join("gutter.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r##"
|
||
assert(app.editor:gutter_icon(0) == nil)
|
||
assert(app.editor:set_gutter_icon(4, "circle"))
|
||
assert(app.editor:gutter_icon(4) == "circle")
|
||
assert(app.editor:gutter_color(4) == nil)
|
||
|
||
assert(app.editor:set_gutter_icon(4, "play", "#00ff00"))
|
||
assert(app.editor:gutter_icon(4) == "play")
|
||
assert(app.editor:gutter_color(4) == "#00ff00")
|
||
|
||
assert(not app.editor:set_gutter_icon(1, "no-such-icon"))
|
||
assert(not app.editor:set_gutter_icon(1, "circle", "oops"))
|
||
assert(app.editor:gutter_icon(1) == nil)
|
||
|
||
assert(app.editor:clear_gutter_icon(4))
|
||
assert(not app.editor:clear_gutter_icon(4))
|
||
assert(app.editor:gutter_icon(4) == nil)
|
||
|
||
app.editor:set_gutter_icon(0, "circle")
|
||
app.editor:clear_gutter()
|
||
assert(app.editor:gutter_icon(0) == nil)
|
||
"##,
|
||
)
|
||
.unwrap();
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&script).expect("скрипт gutter");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn api_save_writes_active_tab_to_file_named_after_it() {
|
||
// Тест пишет во временный файл, а не в каталог проекта.
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let path = std::env::temp_dir().join(format!(
|
||
"qode-save-{}-{}.rs",
|
||
std::process::id(),
|
||
workspace.borrow().active().id
|
||
));
|
||
let _ = std::fs::remove_file(&path);
|
||
|
||
{
|
||
let mut state = workspace.borrow_mut();
|
||
let doc = state.active_mut();
|
||
doc.name = path.to_string_lossy().into_owned();
|
||
doc.content = "fn main() {}\n".to_owned();
|
||
}
|
||
assert!(workspace.borrow().active().is_dirty());
|
||
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
api.editor().save().expect("сохранение должно удаться");
|
||
|
||
assert_eq!(
|
||
std::fs::read_to_string(&path).expect("файл должен существовать"),
|
||
"fn main() {}\n"
|
||
);
|
||
assert!(!workspace.borrow().active().is_dirty());
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn api_switches_tabs_forward_and_back_in_a_cycle() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
api.editor().open_tab();
|
||
api.editor().set_active_tab(0);
|
||
assert_eq!(api.editor().active_tab_index(), 0);
|
||
|
||
assert_eq!(api.editor().next_tab(), 1);
|
||
assert_eq!(api.editor().active_tab_index(), 1);
|
||
|
||
// С последней вкладки следующая — первая.
|
||
assert_eq!(api.editor().next_tab(), 0);
|
||
|
||
// С первой вкладки предыдущая — последняя.
|
||
assert_eq!(api.editor().previous_tab(), 1);
|
||
assert_eq!(api.editor().previous_tab(), 0);
|
||
assert_eq!(workspace.borrow().active_index(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn window_commands_are_queued_for_the_view() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
// Очередь могла остаться от другого теста на этом потоке — очищаем.
|
||
let _ = drain_window_commands();
|
||
|
||
api.minimize();
|
||
api.toggle_maximize();
|
||
api.set_maximized(false);
|
||
api.close();
|
||
|
||
assert_eq!(
|
||
drain_window_commands(),
|
||
vec![
|
||
WindowCommand::Minimize,
|
||
WindowCommand::ToggleMaximize,
|
||
WindowCommand::SetMaximized(false),
|
||
WindowCommand::Close,
|
||
]
|
||
);
|
||
// Повторное чтение пустой очереди ничего не возвращает.
|
||
assert!(drain_window_commands().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn panel_api_opens_closes_and_toggles_the_same_panel_state() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
let right = api.right_panel_handle();
|
||
let bottom = api.bottom_panel_handle();
|
||
let left = api.left_panel_handle();
|
||
|
||
assert!(!left.borrow().is_open());
|
||
assert!(!right.borrow().is_open());
|
||
assert!(!bottom.borrow().is_open());
|
||
|
||
api.left_panel().open();
|
||
assert!(left.borrow().is_open());
|
||
assert!(api.left_panel().is_open());
|
||
api.left_panel().close();
|
||
assert!(!left.borrow().is_open());
|
||
|
||
api.right_panel().open();
|
||
assert!(right.borrow().is_open());
|
||
assert!(api.right_panel().is_open());
|
||
|
||
// toggle возвращает новое состояние и не трогает вторую панель.
|
||
assert!(!api.right_panel().toggle());
|
||
assert!(!right.borrow().is_open());
|
||
assert!(api.right_panel().toggle());
|
||
assert!(right.borrow().is_open());
|
||
|
||
api.right_panel().close();
|
||
assert!(!right.borrow().is_open());
|
||
|
||
api.bottom_panel().open();
|
||
assert!(bottom.borrow().is_open());
|
||
assert!(!right.borrow().is_open());
|
||
}
|
||
|
||
#[test]
|
||
fn lua_script_can_control_panels() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
let right = api.right_panel_handle();
|
||
let bottom = api.bottom_panel_handle();
|
||
let left = api.left_panel_handle();
|
||
|
||
let dir = temp_script_dir("panels");
|
||
let script = dir.join("panels.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
app.left_panel:open()
|
||
assert(app.left_panel:is_open())
|
||
app.left_panel:close()
|
||
assert(not app.left_panel:is_open())
|
||
assert(app.left_panel:toggle() == true)
|
||
|
||
app.right_panel:open()
|
||
assert(app.right_panel:is_open())
|
||
assert(not app.bottom_panel:is_open())
|
||
|
||
app.bottom_panel:open()
|
||
app.right_panel:toggle()
|
||
assert(not app.right_panel:is_open())
|
||
|
||
assert(app.bottom_panel:toggle() == false)
|
||
app.bottom_panel:close()
|
||
assert(not app.bottom_panel:is_open())
|
||
|
||
assert(app.right_panel:toggle() == true)
|
||
"#,
|
||
)
|
||
.expect("panels.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine
|
||
.run_file(&script)
|
||
.expect("панели должны управляться из Lua");
|
||
|
||
assert!(left.borrow().is_open());
|
||
assert!(right.borrow().is_open());
|
||
assert!(!bottom.borrow().is_open());
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_script_configures_toolbar_and_hides_items_by_id() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
let toolbar = api.toolbar_handle();
|
||
|
||
let dir = temp_script_dir("toolbar");
|
||
let script = dir.join("toolbar.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r##"
|
||
app.toolbar:add_label("Готово", nil, "#808080", "status", "left")
|
||
app.toolbar:add_button("Запустить", "play", nil, "run", "right", function() end)
|
||
app.toolbar:add_label("UTF-8", nil, nil, "encoding", "right")
|
||
|
||
assert(app.toolbar:hide("run") == true)
|
||
assert(app.toolbar:show("run") == true)
|
||
assert(app.toolbar:hide("missing") == false)
|
||
assert(app.toolbar:show("missing") == false)
|
||
|
||
-- Неизвестная сторона и иконка — ошибка, а не молчаливый пропуск.
|
||
assert(not pcall(function()
|
||
app.toolbar:add_label("x", nil, nil, "bad", "middle")
|
||
end))
|
||
assert(not pcall(function()
|
||
app.toolbar:add_label("x", "not-an-icon", nil, "bad-icon", "left")
|
||
end))
|
||
assert(not pcall(function()
|
||
app.toolbar:add_label("x", nil, "not-a-color", "bad-color", "left")
|
||
end))
|
||
"##,
|
||
)
|
||
.expect("toolbar.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine
|
||
.run_file(&script)
|
||
.expect("верхняя панель должна настраиваться из Lua");
|
||
|
||
let toolbar = toolbar.borrow();
|
||
assert_eq!(toolbar.left().len(), 1);
|
||
assert_eq!(toolbar.left()[0].id, "status");
|
||
assert_eq!(toolbar.left()[0].text, "Готово");
|
||
assert!(toolbar.left()[0].background.is_some());
|
||
assert!(toolbar.left()[0].visible);
|
||
|
||
// Первый добавленный элемент правой области рисуется у правого края.
|
||
assert_eq!(toolbar.right().len(), 2);
|
||
assert_eq!(toolbar.right()[0].id, "run");
|
||
assert!(matches!(
|
||
toolbar.right()[0].kind,
|
||
ToolbarKind::Button { .. }
|
||
));
|
||
assert!(toolbar.right()[0].visible);
|
||
assert_eq!(toolbar.right()[1].id, "encoding");
|
||
assert!(matches!(toolbar.right()[1].kind, ToolbarKind::Label));
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn toolbar_replaces_items_with_the_same_id() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
let toolbar_api = api.toolbar();
|
||
let toolbar = api.toolbar_handle();
|
||
|
||
toolbar_api
|
||
.add_label("Старый".into(), None, None, "item".into(), "left".into())
|
||
.expect("лейбл");
|
||
toolbar_api
|
||
.add_label(
|
||
"Новый".into(),
|
||
None,
|
||
Some("#112233".into()),
|
||
"item".into(),
|
||
"right".into(),
|
||
)
|
||
.expect("замена");
|
||
|
||
let toolbar = toolbar.borrow();
|
||
assert!(toolbar.left().is_empty());
|
||
assert_eq!(toolbar.right().len(), 1);
|
||
assert_eq!(toolbar.right()[0].text, "Новый");
|
||
|
||
drop(toolbar);
|
||
toolbar_api.clear();
|
||
assert!(!toolbar_api.hide("item".to_string()));
|
||
assert!(api.toolbar_handle().borrow().right().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn lua_tree_block_appends_sorts_and_removes_nodes() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
let dir = temp_script_dir("tree-block");
|
||
let script = dir.join("tree.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
local tree = app.left_panel:add_tree()
|
||
tree:append({
|
||
{ id = "b", label = "banana", expandable = false },
|
||
{ id = "src", label = "src", icon = "folder", expandable = true, expanded = true },
|
||
{ id = "a", label = "Apple", expandable = false },
|
||
}, "")
|
||
tree:append({
|
||
{ id = "src/main.rs", label = "main.rs", icon = "file-code" },
|
||
}, "src")
|
||
|
||
local toggled, clicked = 0, 0
|
||
tree:on_toggle(function(expanded, id, loaded) toggled = toggled + 1 end)
|
||
tree:on_click(function(id) clicked = clicked + 1 end)
|
||
assert(toggled == 0 and clicked == 0)
|
||
assert(tree:remove("missing") == false)
|
||
"#,
|
||
)
|
||
.expect("tree.lua");
|
||
|
||
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
|
||
engine
|
||
.run_file(&script)
|
||
.expect("дерево должно наполняться из Lua");
|
||
drop(engine);
|
||
|
||
let panel = api.left_panel_handle();
|
||
let panel = panel.borrow();
|
||
let PanelBlock::Tree { tree, title } = &panel.blocks()[0];
|
||
assert_eq!(title, "Дерево");
|
||
let rows = tree.borrow().rows();
|
||
assert_eq!(
|
||
rows.iter()
|
||
.map(|row| (row.label.as_str(), row.depth))
|
||
.collect::<Vec<_>>(),
|
||
vec![("src", 0), ("main.rs", 1), ("Apple", 0), ("banana", 0)]
|
||
);
|
||
|
||
assert!(tree.borrow_mut().remove("src"));
|
||
assert_eq!(
|
||
tree.borrow()
|
||
.rows()
|
||
.iter()
|
||
.map(|row| row.label.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec!["Apple", "banana"]
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_bottom_panel_tree_has_a_tab_title_and_clears() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
let bottom = api.bottom_panel_handle();
|
||
|
||
let dir = temp_script_dir("bottom-tree");
|
||
let script = dir.join("bottom.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
diag_tree = app.bottom_panel:add_tree("Диагностика")
|
||
diag_tree:append({
|
||
{ id = "a", label = "a", expandable = true, expanded = true },
|
||
}, "")
|
||
diag_tree:append({
|
||
{ id = "a:1", label = "ошибка" },
|
||
}, "a")
|
||
"#,
|
||
)
|
||
.expect("bottom.lua");
|
||
let clear = dir.join("clear.lua");
|
||
std::fs::write(&clear, "diag_tree:clear()\n").expect("clear.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&script).expect("блок нижней панели");
|
||
|
||
{
|
||
let panel = bottom.borrow();
|
||
assert_eq!(panel.blocks().len(), 1);
|
||
assert_eq!(panel.active(), 0);
|
||
assert_eq!(panel.blocks()[0].title(), "Диагностика");
|
||
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
|
||
assert_eq!(tree.borrow().rows().len(), 2);
|
||
}
|
||
|
||
engine.run_file(&clear).expect("tree:clear");
|
||
let panel = bottom.borrow();
|
||
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
|
||
assert!(tree.borrow().rows().is_empty());
|
||
drop(panel);
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_diagnostics_handler_receives_every_server_and_file() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("diagnostics-handler");
|
||
let script = dir.join("diagnostics.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
app.editor:on_lsp_diagnostics(0, function(servers)
|
||
assert(#servers == 1, #servers)
|
||
local entry = servers[1]
|
||
assert(entry.server == "test")
|
||
assert(entry.path ~= nil)
|
||
assert(#entry.diagnostics == 2, #entry.diagnostics)
|
||
|
||
local first = entry.diagnostics[1]
|
||
assert(first.line == 2)
|
||
assert(first.character == 3)
|
||
assert(first.end_line == 2)
|
||
assert(first.end_character == 5)
|
||
assert(first.severity == 1)
|
||
assert(first.severity_name == "error")
|
||
assert(first.message == "плохо")
|
||
|
||
assert(entry.diagnostics[2].severity_name == "warning")
|
||
app.editor:open_tab()
|
||
end)
|
||
"#,
|
||
)
|
||
.expect("diagnostics.lua");
|
||
|
||
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
|
||
engine.run_file(&script).expect("регистрация обработчика");
|
||
assert_eq!(
|
||
api.editor().diagnostics_delay(),
|
||
Some(std::time::Duration::ZERO)
|
||
);
|
||
|
||
let uri = crate::lsp::path_to_uri(Path::new("/tmp/qode-diagnostics.go"));
|
||
let diagnostics = vec![
|
||
Diagnostic {
|
||
range: Range::new(Position::new(2, 3), Position::new(2, 5)),
|
||
severity: Some(DiagnosticSeverity::ERROR),
|
||
message: "плохо".to_owned(),
|
||
..Default::default()
|
||
},
|
||
Diagnostic {
|
||
range: Range::new(Position::new(4, 0), Position::new(4, 1)),
|
||
severity: Some(DiagnosticSeverity::WARNING),
|
||
message: "осторожно".to_owned(),
|
||
..Default::default()
|
||
},
|
||
];
|
||
api.lsp_handle()
|
||
.set_diagnostics_for_test(uri.as_str(), diagnostics);
|
||
|
||
let report = api.lsp_handle().diagnostics_snapshot();
|
||
assert_eq!(report.len(), 1);
|
||
assert_eq!(report[0].server, "test");
|
||
assert_eq!(report[0].path.as_deref(), Some("/tmp/qode-diagnostics.go"));
|
||
assert_eq!(report[0].diagnostics.len(), 2);
|
||
|
||
assert!(api.editor().emit_diagnostics(report));
|
||
assert_eq!(
|
||
workspace.borrow().documents().len(),
|
||
2,
|
||
"обработчик должен был открыть новую вкладку"
|
||
);
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_app_open_input_queues_options() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
let requests = api.prompt_request_handle();
|
||
|
||
let dir = temp_script_dir("open-input");
|
||
let script = dir.join("input.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
app:open_input({ title = "Имя", placeholder = "main.rs", callback = function(text) end })
|
||
"#,
|
||
)
|
||
.expect("input.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&script).expect("app:open_input с опциями");
|
||
|
||
let request = requests.borrow_mut().take().expect("запрос на поле ввода");
|
||
assert_eq!(request.title.as_deref(), Some("Имя"));
|
||
assert_eq!(request.placeholder.as_deref(), Some("main.rs"));
|
||
assert!(request.callback.is_some());
|
||
|
||
// Без аргументов окно состоит из одной строки ввода, колбэка нет.
|
||
std::fs::write(&script, "app:open_input()\n").expect("input.lua");
|
||
engine.run_file(&script).expect("app:open_input()");
|
||
let request = requests.borrow_mut().take().expect("запрос без опций");
|
||
assert!(request.title.is_none());
|
||
assert!(request.placeholder.is_none());
|
||
assert!(request.callback.is_none());
|
||
|
||
// Некорректные типы опций — ошибка Lua, а не молчаливый пропуск.
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
assert(not pcall(function() app:open_input({ title = 1 }) end))
|
||
assert(not pcall(function() app:open_input({ placeholder = 1 }) end))
|
||
assert(not pcall(function() app:open_input({ callback = "x" }) end))
|
||
"#,
|
||
)
|
||
.expect("input.lua");
|
||
engine.run_file(&script).expect("проверка типов опций");
|
||
assert!(requests.borrow().is_none());
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_list_dir_returns_sorted_entries_with_paths() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace);
|
||
|
||
let dir = temp_script_dir("list-dir");
|
||
std::fs::create_dir(dir.join("sub")).expect("подкаталог");
|
||
std::fs::write(dir.join("b.txt"), "b").expect("b.txt");
|
||
std::fs::write(dir.join("A.txt"), "a").expect("A.txt");
|
||
let path = dir.to_string_lossy().into_owned();
|
||
let quoted = format!("{path:?}");
|
||
|
||
let scripts = temp_script_dir("list-dir-script");
|
||
let script = scripts.join("list.lua");
|
||
std::fs::write(
|
||
&script,
|
||
format!(
|
||
r#"
|
||
local entries = app:list_dir({quoted})
|
||
assert(#entries == 3, #entries)
|
||
assert(entries[1].name == "A.txt")
|
||
assert(entries[2].name == "b.txt")
|
||
assert(entries[3].name == "sub")
|
||
assert(entries[1].is_dir == false)
|
||
assert(entries[3].is_dir == true)
|
||
assert(entries[1].id == {quoted} .. "/A.txt")
|
||
"#
|
||
),
|
||
)
|
||
.expect("list.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&script).expect("app:list_dir");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let _ = std::fs::remove_dir_all(&scripts);
|
||
}
|
||
|
||
/// Временный каталог с уникальным именем для Lua-сценариев.
|
||
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"qode-{prefix}-{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.expect("системные часы")
|
||
.as_nanos()
|
||
));
|
||
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
|
||
dir
|
||
}
|
||
|
||
#[test]
|
||
fn lua_script_can_include_files_and_control_the_app() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("lua");
|
||
let helper = dir.join("helper.lua");
|
||
let main = dir.join("main.lua");
|
||
std::fs::write(
|
||
&helper,
|
||
"function lua_config() app.editor:set_active_text(\"from lua\") end\n",
|
||
)
|
||
.expect("helper.lua");
|
||
std::fs::write(
|
||
&main,
|
||
format!(
|
||
"include({:?})\nlua_config()\napp.editor:open_tab()\napp.editor:set_language(1, \"python\")\n",
|
||
helper.to_string_lossy()
|
||
),
|
||
)
|
||
.expect("main.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine.run_file(&main).expect("сценарий должен выполниться");
|
||
|
||
let state = workspace.borrow();
|
||
assert_eq!(
|
||
state.documents()[0].content,
|
||
"from lua",
|
||
"include должен выполнить подключённый файл в общем окружении"
|
||
);
|
||
assert_eq!(
|
||
state.active_index(),
|
||
1,
|
||
"app.editor:open_tab() не переключил вкладку"
|
||
);
|
||
assert_eq!(
|
||
state.documents()[1].language,
|
||
Some(Language::Python),
|
||
"app.editor:set_language() не включил подсветку из Lua"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn api_opens_file_from_disk_and_detects_language() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("open-file");
|
||
let path = dir.join("main.go");
|
||
std::fs::write(&path, "package main\n").unwrap();
|
||
|
||
let index = api.editor().open(&path).expect("файл открыт");
|
||
{
|
||
let state = workspace.borrow();
|
||
assert_eq!(index, state.active_index());
|
||
assert_eq!(state.active().name, path.to_string_lossy());
|
||
assert_eq!(state.active().content, "package main\n");
|
||
assert_eq!(state.active().language, Some(Language::Go));
|
||
assert!(!state.active().is_dirty());
|
||
}
|
||
|
||
// Повторное открытие чистого файла переиспользует вкладку.
|
||
let tabs = workspace.borrow().documents().len();
|
||
assert_eq!(api.editor().open(&path).unwrap(), index);
|
||
assert_eq!(workspace.borrow().documents().len(), tabs);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_open_controls_focus_and_cursor() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("lua-open-focus");
|
||
let path = dir.join("main.go");
|
||
std::fs::write(&path, "package main\n\nfunc main() {}\n").unwrap();
|
||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||
|
||
// Без второго аргумента фокус остаётся прежним.
|
||
let plain = dir.join("plain.lua");
|
||
std::fs::write(
|
||
&plain,
|
||
format!("app.editor:open({:?})\n", path.to_string_lossy()),
|
||
)
|
||
.unwrap();
|
||
engine
|
||
.run_file(&plain)
|
||
.expect("app.editor:open без позиции");
|
||
assert_eq!(
|
||
workspace.borrow_mut().take_focus_after_sync(),
|
||
Some(FocusAfterSync::Keep)
|
||
);
|
||
|
||
// Пустая таблица — фокус в редактор, курсор на месте.
|
||
let focus = dir.join("focus.lua");
|
||
std::fs::write(
|
||
&focus,
|
||
format!("app.editor:open({:?}, {{}})\n", path.to_string_lossy()),
|
||
)
|
||
.unwrap();
|
||
engine
|
||
.run_file(&focus)
|
||
.expect("app.editor:open с пустой таблицей");
|
||
assert_eq!(
|
||
workspace.borrow_mut().take_focus_after_sync(),
|
||
Some(FocusAfterSync::Editor(None))
|
||
);
|
||
|
||
// Строка 2, столбец 5 — байтовое смещение 19.
|
||
let at = dir.join("at.lua");
|
||
std::fs::write(
|
||
&at,
|
||
format!("app.editor:open({:?}, {{2, 5}})\n", path.to_string_lossy()),
|
||
)
|
||
.unwrap();
|
||
engine.run_file(&at).expect("app.editor:open с позицией");
|
||
assert_eq!(
|
||
workspace.borrow_mut().take_focus_after_sync(),
|
||
Some(FocusAfterSync::Editor(Some(19)))
|
||
);
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn api_open_missing_file_creates_an_empty_buffer() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("open-missing");
|
||
let path = dir.join("new.go");
|
||
|
||
let index = api.editor().open(&path).expect("новый буфер");
|
||
let state = workspace.borrow();
|
||
assert_eq!(index, state.active_index());
|
||
assert_eq!(state.active().name, path.to_string_lossy());
|
||
assert!(state.active().content.is_empty());
|
||
assert_eq!(state.active().language, Some(Language::Go));
|
||
assert!(!state.active().is_dirty());
|
||
drop(state);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_app_open_opens_a_file_and_returns_its_tab() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("lua-open");
|
||
let path = dir.join("main.go");
|
||
std::fs::write(&path, "package main\n").unwrap();
|
||
let script = dir.join("open.lua");
|
||
std::fs::write(
|
||
&script,
|
||
format!(
|
||
"local index = app.editor:open({:?})\nassert(index == app.editor:active_tab())\n",
|
||
path.to_string_lossy()
|
||
),
|
||
)
|
||
.unwrap();
|
||
|
||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||
engine.run_file(&script).expect("app.editor:open из Lua");
|
||
|
||
let state = workspace.borrow();
|
||
assert_eq!(state.active().name, path.to_string_lossy());
|
||
assert_eq!(state.active().content, "package main\n");
|
||
assert_eq!(state.active().language, Some(Language::Go));
|
||
drop(state);
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_engine_loads_init_from_a_custom_config_dir() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("config-dir");
|
||
std::fs::write(
|
||
dir.join("helper.lua"),
|
||
"function config_value() app.editor:set_active_text(\"custom config\") end\n",
|
||
)
|
||
.expect("helper.lua");
|
||
std::fs::write(
|
||
dir.join("init.lua"),
|
||
"include(\"helper.lua\")\nconfig_value()\n",
|
||
)
|
||
.expect("init.lua");
|
||
|
||
// --config меняет именно каталог поиска init.lua/include, а не cwd.
|
||
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
|
||
engine.run_startup().expect("init.lua из своего каталога");
|
||
|
||
assert_eq!(workspace.borrow().active().content, "custom config");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_key_bindings_match_press_release_and_modifiers() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("keys");
|
||
let script = dir.join("keys.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
app:map_key("", "a", function() app.editor:set_active_text("a pressed") end)
|
||
app:map_key("", "!1", function() app.editor:set_active_text("1 released") end)
|
||
app:map_key("", "<S-c>", function() app.editor:set_active_text("shift c") end)
|
||
app:map_key("", "<C-z>", function() app.editor:set_active_text("ctrl z") end)
|
||
app:map_key("", "<A-!r>", function() app.editor:set_active_text("alt r released") end)
|
||
"#,
|
||
)
|
||
.expect("keys.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine
|
||
.run_file(&script)
|
||
.expect("привязки должны назначиться");
|
||
|
||
let text = || workspace.borrow().active().content.clone();
|
||
let stroke = |key: &str, modifiers: gpui_kit::Modifiers| Keystroke {
|
||
modifiers,
|
||
key: key.to_owned(),
|
||
key_char: None,
|
||
};
|
||
let fire = |keystroke: &Keystroke, pressed: bool| {
|
||
for handler in keymap
|
||
.borrow()
|
||
.handlers_for(KeyContext::Global, keystroke, pressed)
|
||
{
|
||
if let KeyHandler::Lua(callback) = handler {
|
||
callback.call::<()>(()).expect("Lua-обработчик");
|
||
}
|
||
}
|
||
};
|
||
|
||
// Нажатие A.
|
||
fire(&stroke("a", Modifiers::default()), true);
|
||
assert_eq!(text(), "a pressed");
|
||
|
||
// Shift+A не должна срабатывать как обычная A.
|
||
fire(
|
||
&stroke(
|
||
"a",
|
||
Modifiers {
|
||
shift: true,
|
||
..Default::default()
|
||
},
|
||
),
|
||
true,
|
||
);
|
||
assert_eq!(text(), "a pressed");
|
||
|
||
// Отпускание цифры 1.
|
||
fire(&stroke("1", Modifiers::default()), false);
|
||
assert_eq!(text(), "1 released");
|
||
|
||
fire(
|
||
&stroke(
|
||
"c",
|
||
Modifiers {
|
||
shift: true,
|
||
..Default::default()
|
||
},
|
||
),
|
||
true,
|
||
);
|
||
assert_eq!(text(), "shift c");
|
||
|
||
fire(
|
||
&stroke(
|
||
"z",
|
||
Modifiers {
|
||
control: true,
|
||
..Default::default()
|
||
},
|
||
),
|
||
true,
|
||
);
|
||
assert_eq!(text(), "ctrl z");
|
||
|
||
// Отпускание R при зажатом Alt.
|
||
fire(
|
||
&stroke(
|
||
"r",
|
||
Modifiers {
|
||
alt: true,
|
||
..Default::default()
|
||
},
|
||
),
|
||
false,
|
||
);
|
||
assert_eq!(text(), "alt r released");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_keymap_accepts_command_names_and_nil() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, keymap) = install_api(workspace);
|
||
|
||
let dir = temp_script_dir("key-commands");
|
||
let script = dir.join("keys.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
app:map_key("", "<C-n>", "next_tab")
|
||
app:map_completion_key("<C-n>", "completion_next")
|
||
|
||
local ok, err = pcall(function()
|
||
app:map_key("", "<C-x>", "no_such_command")
|
||
end)
|
||
assert(not ok)
|
||
assert(tostring(err):find("доступны", 1, true), tostring(err))
|
||
"#,
|
||
)
|
||
.expect("keys.lua");
|
||
|
||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||
engine
|
||
.run_file(&script)
|
||
.expect("команды должны назначиться");
|
||
|
||
let stroke = Keystroke {
|
||
modifiers: Modifiers {
|
||
control: true,
|
||
..Default::default()
|
||
},
|
||
key: "n".to_owned(),
|
||
key_char: None,
|
||
};
|
||
assert!(matches!(
|
||
keymap
|
||
.borrow()
|
||
.handlers_for(KeyContext::Global, &stroke, true)
|
||
.as_slice(),
|
||
[KeyHandler::App(crate::keymap::AppCommand::NextTab)]
|
||
));
|
||
assert!(matches!(
|
||
keymap
|
||
.borrow()
|
||
.handlers_for(KeyContext::Completion, &stroke, true)
|
||
.as_slice(),
|
||
[KeyHandler::Completion(
|
||
crate::keymap::CompletionCommand::Next
|
||
)]
|
||
));
|
||
|
||
// nil удаляет привязку, не оставляя заглушку.
|
||
std::fs::write(
|
||
&script,
|
||
"app:map_key(\"\", \"<C-n>\", nil)\napp:map_completion_key(\"<C-n>\", nil)\n",
|
||
)
|
||
.expect("keys.lua");
|
||
engine.run_file(&script).expect("привязки должны удалиться");
|
||
assert!(
|
||
keymap
|
||
.borrow()
|
||
.handlers_for(KeyContext::Global, &stroke, true)
|
||
.is_empty()
|
||
);
|
||
assert!(
|
||
keymap
|
||
.borrow()
|
||
.handlers_for(KeyContext::Completion, &stroke, true)
|
||
.is_empty()
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn language_names_cover_all_supported_grammars() {
|
||
for (name, expected) in [
|
||
("rust", Language::Rust),
|
||
("rs", Language::Rust),
|
||
("Rust", Language::Rust),
|
||
("go", Language::Go),
|
||
("golang", Language::Go),
|
||
("js", Language::JavaScript),
|
||
("javascript", Language::JavaScript),
|
||
("json", Language::Json),
|
||
("python", Language::Python),
|
||
("py", Language::Python),
|
||
] {
|
||
assert_eq!(Language::from_name(name), Some(expected), "язык {name:?}");
|
||
}
|
||
|
||
assert_eq!(Language::from_name("unknown"), None);
|
||
assert_eq!(Language::from_name(""), None);
|
||
|
||
// Канонические имена, которые возвращает API и Lua.
|
||
assert_eq!(Language::Rust.name(), "rust");
|
||
assert_eq!(Language::JavaScript.name(), "js");
|
||
assert_eq!(Language::Python.name(), "python");
|
||
|
||
// Каждый язык перечитывается по имени и по полному имени для
|
||
// tree-sitter/LSP.
|
||
for language in [
|
||
Language::Rust,
|
||
Language::Go,
|
||
Language::JavaScript,
|
||
Language::Json,
|
||
Language::Python,
|
||
] {
|
||
assert_eq!(Language::from_name(language.name()), Some(language));
|
||
assert_eq!(Language::from_name(language.syntax_name()), Some(language));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn highlighting_is_off_by_default_and_enabled_through_api() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, _keymap) = install_api(workspace.clone());
|
||
|
||
assert_eq!(api.editor().language(0), None);
|
||
assert_eq!(workspace.borrow().documents()[0].language, None);
|
||
|
||
assert!(api.editor().set_language(0, "rs"));
|
||
assert_eq!(api.editor().language(0), Some("rust".to_owned()));
|
||
assert_eq!(
|
||
workspace.borrow().documents()[0].language,
|
||
Some(Language::Rust)
|
||
);
|
||
|
||
// Неизвестный язык и несуществующая вкладка.
|
||
assert!(!api.editor().set_language(0, "cobol"));
|
||
assert!(!api.editor().set_language(99, "rust"));
|
||
|
||
assert!(api.editor().clear_language(0));
|
||
assert_eq!(api.editor().language(0), None);
|
||
}
|
||
|
||
#[test]
|
||
fn keymap_bindings_are_parsed_and_replaced() {
|
||
let plain = KeyBinding::parse("a").expect("a");
|
||
let ctrl = KeyBinding::parse("<C-s>").expect("ctrl-s");
|
||
assert_eq!(plain.modifiers, KeyModifiers::default());
|
||
assert!(ctrl.modifiers.ctrl);
|
||
assert!(!ctrl.on_release);
|
||
|
||
// Повторное назначение той же клавиши не увеличивает число привязок:
|
||
// проверить это можно только через KeyMap, поэтому создаём пару функций.
|
||
let lua = mlua::Lua::new();
|
||
let mut keymap = KeyMap::default();
|
||
let first = lua
|
||
.load("return function() end")
|
||
.eval::<mlua::Function>()
|
||
.unwrap();
|
||
let second = lua
|
||
.load("return function() end")
|
||
.eval::<mlua::Function>()
|
||
.unwrap();
|
||
keymap.set(
|
||
KeyContext::Global,
|
||
"",
|
||
plain.clone(),
|
||
KeyHandler::Lua(first),
|
||
);
|
||
keymap.set(KeyContext::Global, "", plain, KeyHandler::Lua(second));
|
||
|
||
let stroke = Keystroke {
|
||
modifiers: Modifiers::default(),
|
||
key: "a".to_owned(),
|
||
key_char: None,
|
||
};
|
||
assert_eq!(
|
||
keymap.handlers_for(KeyContext::Global, &stroke, true).len(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let mut workspace = None;
|
||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||
let app = MyApp::new(window, cx);
|
||
workspace = Some(app.workspace_handle());
|
||
app
|
||
});
|
||
let workspace = workspace.expect("вид создан");
|
||
|
||
// Регистрируем привязку через Lua — так же, как это делает init.lua.
|
||
let dir = temp_script_dir("ui-keys");
|
||
let script = dir.join("keys.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("alt r pressed") end)"#,
|
||
)
|
||
.expect("keys.lua");
|
||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||
engine.run_file(&script).expect("привязка");
|
||
|
||
visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle()
|
||
.update(cx, |state, cx| state.focus(window, cx));
|
||
});
|
||
});
|
||
|
||
// Событие клавиатуры идёт через фокус редактора вверх до корня вида,
|
||
// где его видит KeyMap.
|
||
visual.simulate_keystrokes("alt-r");
|
||
visual.run_until_parked();
|
||
|
||
assert_eq!(workspace.borrow().active().content, "alt r pressed");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn editor_and_block_keymaps_depend_on_focus(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let dir = temp_script_dir("focus-keys");
|
||
let script = dir.join("keys.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
app:map_key("", "<A-g>", function() app.editor:set_gutter_icon(0, "circle") end)
|
||
app.editor:map_key("", "<A-r>", function() app.editor:set_gutter_icon(0, "play") end)
|
||
|
||
local tree = app.left_panel:add_tree()
|
||
tree:append({ { id = "a", label = "a" } }, "")
|
||
tree:map_key("", "<A-t>", function() app.editor:set_gutter_icon(0, "file-code") end)
|
||
app.left_panel:open()
|
||
"#,
|
||
)
|
||
.expect("keys.lua");
|
||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||
engine.run_file(&script).expect("привязки");
|
||
|
||
let tree = {
|
||
let panel = AppApi::global().left_panel_handle();
|
||
let panel = panel.borrow();
|
||
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
|
||
tree.clone()
|
||
};
|
||
let icon = || AppApi::global().editor().gutter_icon(0);
|
||
|
||
// Фокус в редакторе: работают глобальные и редакторские привязки.
|
||
visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle()
|
||
.update(cx, |state, cx| state.focus(window, cx));
|
||
});
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
visual.simulate_keystrokes("alt-r");
|
||
visual.run_until_parked();
|
||
assert_eq!(icon().as_deref(), Some("play"));
|
||
|
||
// Привязка блока при фокусе в редакторе не срабатывает.
|
||
visual.simulate_keystrokes("alt-t");
|
||
visual.run_until_parked();
|
||
assert_eq!(icon().as_deref(), Some("play"));
|
||
|
||
visual.simulate_keystrokes("alt-g");
|
||
visual.run_until_parked();
|
||
assert_eq!(icon().as_deref(), Some("circle"));
|
||
|
||
// Фокус на дереве: работают глобальные и привязки блока, а не редактора.
|
||
tree.borrow_mut().focus("a");
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
visual.simulate_keystrokes("alt-t");
|
||
visual.run_until_parked();
|
||
assert_eq!(icon().as_deref(), Some("file-code"));
|
||
|
||
visual.simulate_keystrokes("alt-r");
|
||
visual.run_until_parked();
|
||
assert_eq!(icon().as_deref(), Some("file-code"));
|
||
|
||
visual.simulate_keystrokes("alt-g");
|
||
visual.run_until_parked();
|
||
assert_eq!(icon().as_deref(), Some("circle"));
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn lua_key_modes_are_per_object_and_dynamic() {
|
||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||
let (api, keymap) = install_api(workspace.clone());
|
||
|
||
let dir = temp_script_dir("key-modes");
|
||
let script = dir.join("modes.lua");
|
||
std::fs::write(
|
||
&script,
|
||
r#"
|
||
assert(app:mode() == "-")
|
||
app:set_mode("i")
|
||
assert(app:mode() == "i")
|
||
app:map_key("-", "<A-a>", function() app.editor:set_active_text("default") end)
|
||
app:map_key("i", "<A-b>", function() app.editor:set_active_text("insert") end)
|
||
app:map_key("", "<A-c>", function() app.editor:set_active_text("all") end)
|
||
|
||
assert(app.editor:mode() == "-")
|
||
app.editor:set_mode("n")
|
||
assert(app.editor:mode() == "n")
|
||
app.editor:map_key("n", "<A-d>", function() app.editor:set_active_text("editor n") end)
|
||
app.editor:map_key("i", "<A-e>", function() app.editor:set_active_text("editor i") end)
|
||
|
||
local tree = app.left_panel:add_tree()
|
||
assert(tree:mode() == "-")
|
||
tree:set_mode("v")
|
||
assert(tree:mode() == "v")
|
||
tree:map_key("v", "<A-f>", function() app.editor:set_active_text("tree v") end)
|
||
"#,
|
||
)
|
||
.expect("modes.lua");
|
||
let engine = ScriptEngine::new(api.clone()).expect("движок Lua");
|
||
engine.run_file(&script).expect("режимы");
|
||
|
||
let tree_keymap = {
|
||
let panel = api.left_panel_handle();
|
||
let panel = panel.borrow();
|
||
let PanelBlock::Tree { tree, .. } = &panel.blocks()[0];
|
||
tree.borrow().keymap_handle()
|
||
};
|
||
let editor_keymap = api.editor_keymap_handle();
|
||
|
||
let alt = Modifiers {
|
||
alt: true,
|
||
..Default::default()
|
||
};
|
||
let fire = |keymap: &Rc<RefCell<KeyMap>>, key: &str| {
|
||
let stroke = Keystroke {
|
||
modifiers: alt,
|
||
key: key.to_owned(),
|
||
key_char: None,
|
||
};
|
||
for handler in keymap
|
||
.borrow()
|
||
.handlers_for(KeyContext::Global, &stroke, true)
|
||
{
|
||
if let KeyHandler::Lua(callback) = handler {
|
||
callback.call::<()>(()).expect("Lua-обработчик");
|
||
}
|
||
}
|
||
};
|
||
let text = || workspace.borrow().active().content.clone();
|
||
|
||
// Глобальная карта в режиме i: "-" не действует, "i" и "" — действуют.
|
||
fire(&keymap, "a");
|
||
assert_eq!(text(), "");
|
||
fire(&keymap, "b");
|
||
assert_eq!(text(), "insert");
|
||
fire(&keymap, "c");
|
||
assert_eq!(text(), "all");
|
||
|
||
// Смена режима не пересоздаёт привязки.
|
||
keymap.borrow_mut().set_mode("n").unwrap();
|
||
fire(&keymap, "b");
|
||
assert_eq!(text(), "all");
|
||
// Привязка "-" действует только в режиме по умолчанию.
|
||
fire(&keymap, "a");
|
||
assert_eq!(text(), "all");
|
||
keymap.borrow_mut().set_mode("-").unwrap();
|
||
fire(&keymap, "a");
|
||
assert_eq!(text(), "default");
|
||
|
||
// У редактора и блока свои режимы и карты.
|
||
fire(&editor_keymap, "d");
|
||
assert_eq!(text(), "editor n");
|
||
fire(&editor_keymap, "e");
|
||
assert_eq!(text(), "editor n");
|
||
|
||
fire(&tree_keymap, "f");
|
||
assert_eq!(text(), "tree v");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let api = AppApi::global();
|
||
api.left_panel().open();
|
||
api.right_panel().open();
|
||
api.bottom_panel().open();
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
// Состояние осталось у панелей, которые видит вид.
|
||
assert!(api.left_panel_handle().borrow().is_open());
|
||
assert!(api.right_panel_handle().borrow().is_open());
|
||
assert!(api.bottom_panel_handle().borrow().is_open());
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn toolbar_items_render_from_api(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
// Функция создаётся в отдельном Lua-состоянии: для проверки отрисовки
|
||
// достаточно, чтобы обработчик существовал.
|
||
let lua = mlua::Lua::new();
|
||
let callback = lua
|
||
.load("return function() end")
|
||
.eval::<mlua::Function>()
|
||
.unwrap();
|
||
|
||
let toolbar = AppApi::global().toolbar();
|
||
toolbar
|
||
.add_label(
|
||
"Слева".into(),
|
||
Some("file".into()),
|
||
Some("#808080".into()),
|
||
"label".into(),
|
||
"left".into(),
|
||
)
|
||
.expect("лейбл");
|
||
toolbar
|
||
.add_button(
|
||
"Кнопка".into(),
|
||
Some("play".into()),
|
||
None,
|
||
"button".into(),
|
||
"right".into(),
|
||
callback,
|
||
)
|
||
.expect("кнопка");
|
||
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
let toolbar = AppApi::global().toolbar_handle();
|
||
assert_eq!(toolbar.borrow().left().len(), 1);
|
||
assert_eq!(toolbar.borrow().right().len(), 1);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn gutter_icons_render_from_api(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let api = AppApi::global();
|
||
assert!(api.editor().set_gutter_icon(0, "circle", Some("#ff0000")));
|
||
assert!(api.editor().set_gutter_icon(5, "play", None));
|
||
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
assert_eq!(
|
||
AppApi::global().editor().gutter_icon(0).as_deref(),
|
||
Some("circle")
|
||
);
|
||
assert_eq!(
|
||
AppApi::global().editor().gutter_color(0).as_deref(),
|
||
Some("#ff0000")
|
||
);
|
||
assert_eq!(
|
||
AppApi::global().editor().gutter_icon(5).as_deref(),
|
||
Some("play")
|
||
);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn tree_block_renders_with_nested_nodes(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let api = AppApi::global();
|
||
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
|
||
tree.borrow_mut()
|
||
.append(
|
||
"",
|
||
vec![TreeNode {
|
||
id: "src".to_owned(),
|
||
label: "src".to_owned(),
|
||
icon: Some("folder".to_owned()),
|
||
expandable: true,
|
||
expanded: true,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
tree.borrow_mut()
|
||
.append(
|
||
"src",
|
||
vec![TreeNode {
|
||
id: "src/main.rs".to_owned(),
|
||
label: "main.rs".to_owned(),
|
||
icon: Some("file-code".to_owned()),
|
||
expandable: false,
|
||
expanded: false,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
api.left_panel().open();
|
||
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
assert!(api.left_panel_handle().borrow().is_open());
|
||
assert_eq!(tree.borrow().rows().len(), 2);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn bottom_panel_renders_blocks_as_tabs(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let api = AppApi::global();
|
||
let panel = api.bottom_panel_handle();
|
||
panel
|
||
.borrow_mut()
|
||
.add_tree(Some("Диагностика".to_owned()))
|
||
.borrow_mut()
|
||
.append(
|
||
"",
|
||
vec![TreeNode {
|
||
id: "a".to_owned(),
|
||
label: "a".to_owned(),
|
||
icon: Some("file".to_owned()),
|
||
expandable: false,
|
||
expanded: false,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
panel.borrow_mut().add_tree(Some("Тесты".to_owned()));
|
||
|
||
assert_eq!(panel.borrow().active(), 1);
|
||
assert_eq!(panel.borrow().blocks()[0].title(), "Диагностика");
|
||
assert_eq!(panel.borrow().blocks()[1].title(), "Тесты");
|
||
assert!(panel.borrow_mut().set_active(0));
|
||
assert!(!panel.borrow_mut().set_active(2));
|
||
|
||
api.bottom_panel().open();
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
assert!(api.bottom_panel_handle().borrow().is_open());
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn focused_tree_moves_selection_with_arrow_keys(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let api = AppApi::global();
|
||
let tree = api.left_panel_handle().borrow_mut().add_tree(None);
|
||
tree.borrow_mut()
|
||
.append(
|
||
"",
|
||
vec![
|
||
TreeNode {
|
||
id: "src".to_owned(),
|
||
label: "src".to_owned(),
|
||
icon: Some("folder".to_owned()),
|
||
expandable: true,
|
||
expanded: false,
|
||
},
|
||
TreeNode {
|
||
id: "readme".to_owned(),
|
||
label: "README.md".to_owned(),
|
||
icon: Some("file".to_owned()),
|
||
expandable: false,
|
||
expanded: false,
|
||
},
|
||
],
|
||
)
|
||
.unwrap();
|
||
api.left_panel().open();
|
||
tree.borrow_mut().focus("src");
|
||
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
assert_eq!(tree.borrow().selected(), Some("src"));
|
||
|
||
// Панель закрыли и открыли снова: фокус должен вернуться на выбранную
|
||
// строку, иначе стрелка уйдёт в редактор.
|
||
api.left_panel().close();
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
api.left_panel().open();
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
visual.simulate_keystrokes("down");
|
||
assert_eq!(tree.borrow().selected(), Some("readme"));
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn lsp_diagnostics_are_underlined_in_editor(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let uri = visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle().update(cx, |state, cx| {
|
||
state.set_value("abc\n", window, cx);
|
||
});
|
||
let name = this.workspace_handle().borrow().active().name.clone();
|
||
crate::lsp::document_uri(&name).expect("у документа есть URI")
|
||
})
|
||
});
|
||
|
||
let diagnostic = |range: lsp_types::Range, severity| lsp_types::Diagnostic {
|
||
range,
|
||
severity: Some(severity),
|
||
message: "сообщение".to_owned(),
|
||
..Default::default()
|
||
};
|
||
let diagnostics = vec![
|
||
diagnostic(
|
||
lsp_types::Range::new(
|
||
lsp_types::Position::new(0, 0),
|
||
lsp_types::Position::new(0, 2),
|
||
),
|
||
lsp_types::DiagnosticSeverity::ERROR,
|
||
),
|
||
diagnostic(
|
||
lsp_types::Range::new(
|
||
lsp_types::Position::new(0, 2),
|
||
lsp_types::Position::new(0, 3),
|
||
),
|
||
lsp_types::DiagnosticSeverity::WARNING,
|
||
),
|
||
];
|
||
AppApi::global()
|
||
.lsp_handle()
|
||
.set_diagnostics_for_test(uri.as_str(), diagnostics);
|
||
|
||
let ranges = visual.update(|_window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.refresh_diagnostics(cx);
|
||
this.diagnostic_ranges(cx)
|
||
})
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
assert_eq!(
|
||
ranges,
|
||
vec![0..2, 2..3],
|
||
"ошибка и предупреждение должны стать подчёркнутыми диапазонами"
|
||
);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let mut workspace = None;
|
||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||
let app = MyApp::new(window, cx);
|
||
workspace = Some(app.workspace_handle());
|
||
app
|
||
});
|
||
let workspace = workspace.expect("вид создан");
|
||
|
||
// Фокус нужен до первого ввода: simulate_input отправляет текст в
|
||
// сфокусированный ввод.
|
||
visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle()
|
||
.update(cx, |state, cx| state.focus(window, cx));
|
||
});
|
||
});
|
||
visual.simulate_input("ZZZ");
|
||
|
||
let state = workspace.borrow();
|
||
let doc = state.active();
|
||
assert!(
|
||
doc.content.contains("ZZZ"),
|
||
"введённый текст не попал в документ: {:?}",
|
||
doc.content
|
||
);
|
||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn floating_input_blocks_keys_and_submits_on_enter(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let mut workspace = None;
|
||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||
let app = MyApp::new(window, cx);
|
||
workspace = Some(app.workspace_handle());
|
||
app
|
||
});
|
||
let workspace = workspace.expect("вид создан");
|
||
|
||
let dir = temp_script_dir("floating-input");
|
||
let keys = dir.join("keys.lua");
|
||
let open = dir.join("open.lua");
|
||
std::fs::write(
|
||
&keys,
|
||
r#"app:map_key("", "<A-r>", function() app.editor:set_active_text("blocked") end)"#,
|
||
)
|
||
.expect("keys.lua");
|
||
std::fs::write(
|
||
&open,
|
||
r#"
|
||
app:open_input({
|
||
title = "Имя",
|
||
placeholder = "main.rs",
|
||
callback = function(text) app.editor:set_active_text(text) end,
|
||
})
|
||
"#,
|
||
)
|
||
.expect("open.lua");
|
||
|
||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||
engine.run_file(&keys).expect("привязка");
|
||
engine.run_file(&open).expect("app:open_input");
|
||
|
||
let notify = |visual: &mut gpui_kit::VisualTestContext| {
|
||
visual.update(|_window, cx| {
|
||
app.update(cx, |_, cx| cx.notify());
|
||
});
|
||
visual.run_until_parked();
|
||
};
|
||
let prompt_open = |visual: &mut gpui_kit::VisualTestContext| {
|
||
visual.update(|_window, cx| app.read(cx).prompt_is_open())
|
||
};
|
||
|
||
notify(visual);
|
||
assert!(prompt_open(visual), "поле ввода должно открыться");
|
||
|
||
// Пока окно открыто, привязки приложения не срабатывают.
|
||
visual.simulate_keystrokes("alt-r");
|
||
visual.run_until_parked();
|
||
assert_eq!(workspace.borrow().active().content, "");
|
||
|
||
// Esc закрывает окно без колбэка.
|
||
visual.simulate_keystrokes("escape");
|
||
visual.run_until_parked();
|
||
assert!(!prompt_open(visual), "Esc должен закрыть поле ввода");
|
||
assert_eq!(workspace.borrow().active().content, "");
|
||
|
||
// Клик вне окна тоже закрывает его без колбэка.
|
||
engine.run_file(&open).expect("app:open_input повторно");
|
||
notify(visual);
|
||
assert!(prompt_open(visual));
|
||
visual.simulate_click(point(px(2.), px(2.)), Modifiers::default());
|
||
visual.run_until_parked();
|
||
assert!(
|
||
!prompt_open(visual),
|
||
"клик вне окна должен закрыть поле ввода"
|
||
);
|
||
assert_eq!(workspace.borrow().active().content, "");
|
||
|
||
// Enter подтверждает ввод и вызывает колбэк уже после закрытия окна.
|
||
engine.run_file(&open).expect("app:open_input в третий раз");
|
||
notify(visual);
|
||
assert!(prompt_open(visual));
|
||
|
||
visual.simulate_input("main.go");
|
||
visual.simulate_keystrokes("enter");
|
||
visual.run_until_parked();
|
||
|
||
assert!(!prompt_open(visual));
|
||
assert_eq!(workspace.borrow().active().content, "main.go");
|
||
|
||
drop(engine);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let before = visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle()
|
||
.update(cx, |state, cx| state.focus(window, cx));
|
||
this.open_test_completion(cx);
|
||
this.editor_handle().read(cx).cursor()
|
||
})
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
visual.simulate_keystrokes("down");
|
||
visual.run_until_parked();
|
||
|
||
let (cursor, selected, open) = visual.update(|_window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
(
|
||
this.editor_handle().read(cx).cursor(),
|
||
this.completion_selected(),
|
||
this.completion_is_open(),
|
||
)
|
||
})
|
||
});
|
||
|
||
assert_eq!(selected, 1, "стрелка вниз должна выбрать второй элемент");
|
||
assert_eq!(cursor, before, "пока список открыт, курсор не двигается");
|
||
assert!(open, "список должен остаться открытым");
|
||
|
||
// На последнем элементе выделение стоит на месте, но клавиша всё равно
|
||
// принадлежит списку и не должна двигать курсор.
|
||
visual.simulate_keystrokes("down");
|
||
visual.run_until_parked();
|
||
visual.simulate_keystrokes("up");
|
||
visual.run_until_parked();
|
||
|
||
let (cursor, selected, open) = visual.update(|_window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
(
|
||
this.editor_handle().read(cx).cursor(),
|
||
this.completion_selected(),
|
||
this.completion_is_open(),
|
||
)
|
||
})
|
||
});
|
||
assert_eq!(selected, 0, "стрелка вверх должна вернуть первый элемент");
|
||
assert_eq!(cursor, before);
|
||
assert!(open);
|
||
|
||
visual.simulate_keystrokes("escape");
|
||
visual.run_until_parked();
|
||
|
||
let (cursor, open) = visual.update(|_window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
(
|
||
this.editor_handle().read(cx).cursor(),
|
||
this.completion_is_open(),
|
||
)
|
||
})
|
||
});
|
||
assert!(!open, "Esc должен закрыть список");
|
||
assert_eq!(cursor, before);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn long_completion_list_scrolls_instead_of_shrinking_rows(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle()
|
||
.update(cx, |state, cx| state.focus(window, cx));
|
||
this.open_test_completion_with_count(60, cx);
|
||
});
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
let scroll =
|
||
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_scroll_handle()));
|
||
|
||
assert!(
|
||
scroll.max_offset().y > px(0.),
|
||
"длинный список должен прокручиваться, а не сжимать строки"
|
||
);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn completion_popup_above_the_caret_renders(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
let text = (0..200)
|
||
.map(|index| format!("let value_{index} = {index};\n"))
|
||
.collect::<String>();
|
||
this.editor_handle().update(cx, |state, cx| {
|
||
state.set_value(text.clone(), window, cx);
|
||
let end = text.len();
|
||
state.set_selected_range(end..end, cx);
|
||
state.focus(window, cx);
|
||
});
|
||
this.open_test_completion(cx);
|
||
});
|
||
});
|
||
|
||
// Первый кадр обновляет прокрутку редактора, второй — положение поповера,
|
||
// который должен оказаться над каретой.
|
||
visual.run_until_parked();
|
||
visual.run_until_parked();
|
||
|
||
assert!(
|
||
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_is_open())),
|
||
"поповер над каретой должен отрисоваться"
|
||
);
|
||
}
|
||
|
||
#[gpui_kit::test]
|
||
fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
|
||
cx.update(gpui_kit::init);
|
||
|
||
let (app, visual) = cx.add_window_view(MyApp::new);
|
||
|
||
let before = visual.update(|window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
this.editor_handle().update(cx, |state, cx| {
|
||
state.set_value("abc", window, cx);
|
||
state.set_selected_range(0..0, cx);
|
||
state.focus(window, cx);
|
||
});
|
||
this.open_test_completion(cx);
|
||
this.editor_handle().read(cx).cursor()
|
||
})
|
||
});
|
||
visual.run_until_parked();
|
||
|
||
visual.simulate_keystrokes("right");
|
||
visual.run_until_parked();
|
||
|
||
let (after, open) = visual.update(|_window, cx| {
|
||
app.update(cx, |this, cx| {
|
||
(
|
||
this.editor_handle().read(cx).cursor(),
|
||
this.completion_is_open(),
|
||
)
|
||
})
|
||
});
|
||
|
||
assert!(
|
||
after > before,
|
||
"горизонтальная стрелка двигает курсор как обычно"
|
||
);
|
||
assert!(!open, "горизонтальная стрелка закрывает список");
|
||
}
|