//! Тесты доменной модели, 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>) -> (AppApi, Rc>) { 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![(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![("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!["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("", "", function() app.editor:set_active_text("shift c") end) app:map_key("", "", function() app.editor:set_active_text("ctrl z") end) app:map_key("", "", 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("", "", "next_tab") app:map_completion_key("", "completion_next") local ok, err = pcall(function() app:map_key("", "", "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(\"\", \"\", nil)\napp:map_completion_key(\"\", 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("").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::() .unwrap(); let second = lua .load("return function() end") .eval::() .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("", "", 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("", "", function() app.editor:set_gutter_icon(0, "circle") end) app.editor:map_key("", "", 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("", "", 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("-", "", function() app.editor:set_active_text("default") end) app:map_key("i", "", function() app.editor:set_active_text("insert") end) app:map_key("", "", 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", "", function() app.editor:set_active_text("editor n") end) app.editor:map_key("i", "", 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", "", 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>, 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::() .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("", "", 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::(); 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, "горизонтальная стрелка закрывает список"); }