Files
qode/src/tests.rs
T
2026-10-31 12:18:07 +03:00

2090 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Тесты доменной модели, 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, "горизонтальная стрелка закрывает список");
}