This commit is contained in:
2026-10-30 00:31:49 +03:00
parent dce595fbc0
commit e00f63e5da
17 changed files with 6479 additions and 3200 deletions
+265 -417
View File
@@ -1,196 +1,29 @@
//! Проверки UI-логики на «сырых» событиях egui (без реального окна).
//! Тесты доменной модели, Lua-интеграции и клавиатурных привязок.
//!
//! UI-часть (заголовок, перетаскивание, изменение размера) теперь принадлежит
//! gpui-kit, поэтому её отдельно не проверяем. Остаются тесты, которые
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
use std::cell::RefCell;
use std::rc::Rc;
use eframe::egui;
use gpui_kit::{Keystroke, Modifiers, TestAppContext};
use crate::api::AppApi;
use crate::api::{AppApi, WindowCommand, drain_window_commands};
use crate::app::MyApp;
use crate::editor::EditorState;
use crate::keymap::KeyMap;
use crate::editor::{Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
use crate::script::ScriptEngine;
use crate::syntax::{HighlightStyle, Language, SyntaxState};
use crate::ui::title_bar::TitleBarResponses;
const SCREEN: egui::Vec2 = egui::vec2(600.0, 400.0);
/// Один проход кадра для данного экземпляра приложения:
/// возвращает ответы заголовка и команды окна.
fn step_app(
ctx: &egui::Context,
app: &mut MyApp,
events: Vec<egui::Event>,
) -> (TitleBarResponses, Vec<egui::ViewportCommand>) {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN)),
events,
..Default::default()
};
let mut bar = None;
let mut out = ctx.run_ui(raw, |ui| {
bar = Some(app.show(ui));
});
let commands = out
.viewport_output
.get(&egui::ViewportId::ROOT)
.map(|output| output.commands.clone())
.unwrap_or_default();
out.textures_delta.clear();
(bar.expect("заголовок отрисован"), commands)
}
/// Один проход кадра на свежем приложении.
fn step(
ctx: &egui::Context,
events: Vec<egui::Event>,
) -> (TitleBarResponses, Vec<egui::ViewportCommand>) {
let mut app = MyApp::default();
step_app(ctx, &mut app, events)
}
fn button_event(pos: egui::Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
}
}
fn click_at(ctx: &egui::Context, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
let mut commands = Vec::new();
commands.extend(step(ctx, vec![egui::Event::PointerMoved(pos)]).1);
commands.extend(step(ctx, vec![button_event(pos, true)]).1);
commands.extend(step(ctx, vec![]).1);
commands.extend(step(ctx, vec![button_event(pos, false)]).1);
commands.extend(step(ctx, vec![]).1);
commands
}
fn press_at(ctx: &egui::Context, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
let mut commands = step(ctx, vec![egui::Event::PointerMoved(pos)]).1;
commands.extend(step(ctx, vec![button_event(pos, true)]).1);
commands
}
/// Событие клавиатуры, у которого логическая клавиша может отличаться от
/// физической (как при смене раскладки).
fn key_event(
physical: egui::Key,
logical: egui::Key,
pressed: bool,
modifiers: egui::Modifiers,
) -> egui::Event {
egui::Event::Key {
key: logical,
physical_key: Some(physical),
pressed,
repeat: false,
modifiers,
}
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 title_bar_buttons_are_hoverable_and_clickable() {
let ctx = egui::Context::default();
// прогреваем: egui считает раскладку по предыдущему кадру
step(&ctx, vec![]);
let (bar, _) = step(&ctx, vec![]);
let close_pos = bar.close.rect.center();
let maximize_pos = bar.maximize.rect.center();
let minimize_pos = bar.minimize.rect.center();
// Кнопки должны находиться в заголовке, а не где-то ещё
assert!(bar.drag.rect.contains(close_pos));
// Наведение даёт подсветку
step(&ctx, vec![egui::Event::PointerMoved(close_pos)]);
let (hovered, _) = step(&ctx, vec![]);
assert!(
hovered.close.hovered(),
"кнопка «закрыть» не подсвечивается"
);
let close_cmds = click_at(&ctx, close_pos);
assert!(
close_cmds.contains(&egui::ViewportCommand::Close),
"клик по «закрыть» не отправил Close: {close_cmds:?}"
);
let max_cmds = click_at(&ctx, maximize_pos);
assert!(
max_cmds.contains(&egui::ViewportCommand::Maximized(true)),
"клик по «развернуть» не отправил Maximized: {max_cmds:?}"
);
let min_cmds = click_at(&ctx, minimize_pos);
assert!(
min_cmds.contains(&egui::ViewportCommand::Minimized(true)),
"клик по «свернуть» не отправил Minimized: {min_cmds:?}"
);
}
#[test]
fn dragging_title_bar_starts_window_drag_even_over_the_text() {
for (name, x_offset) in [("по тексту", 40.0), ("по пустому месту", 300.0)]
{
let ctx = egui::Context::default();
step(&ctx, vec![]);
let (bar, _) = step(&ctx, vec![]);
let start = egui::pos2(bar.drag.rect.left() + x_offset, bar.drag.rect.center().y);
let moved = start + egui::vec2(24.0, 12.0);
step(&ctx, vec![egui::Event::PointerMoved(start)]);
let mut commands = step(&ctx, vec![button_event(start, true)]).1;
commands.extend(step(&ctx, vec![egui::Event::PointerMoved(moved)]).1);
assert!(
commands.contains(&egui::ViewportCommand::StartDrag),
"драг заголовка {name} не инициировал перемещение окна: {commands:?}"
);
}
}
#[test]
fn edges_and_corners_start_resize() {
use egui::viewport::ResizeDirection as Dir;
let cases = [
(egui::pos2(300.0, 2.0), Dir::North),
(egui::pos2(300.0, SCREEN.y - 2.0), Dir::South),
(egui::pos2(2.0, 200.0), Dir::West),
(egui::pos2(SCREEN.x - 2.0, 200.0), Dir::East),
(egui::pos2(2.0, 2.0), Dir::NorthWest),
(egui::pos2(SCREEN.x - 2.0, 2.0), Dir::NorthEast),
(egui::pos2(SCREEN.x - 2.0, SCREEN.y - 2.0), Dir::SouthEast),
(egui::pos2(2.0, SCREEN.y - 2.0), Dir::SouthWest),
];
for (pos, expected) in cases {
let ctx = egui::Context::default();
step(&ctx, vec![]);
step(&ctx, vec![]);
let commands = press_at(&ctx, pos);
assert!(
commands.contains(&egui::ViewportCommand::BeginResize(expected)),
"в точке {pos:?} ожидался BeginResize({expected:?}), получено: {commands:?}"
);
assert!(
!commands.contains(&egui::ViewportCommand::StartDrag),
"в точке {pos:?} вместо ресайза началось перемещение окна"
);
}
}
#[test]
fn editor_state_keeps_at_least_one_tab() {
let mut state = EditorState::default();
fn workspace_keeps_at_least_one_tab() {
let mut state = Workspace::default();
assert_eq!(state.documents().len(), 2);
assert_eq!(state.active().name, "main.rs");
@@ -213,58 +46,51 @@ fn editor_state_keeps_at_least_one_tab() {
#[test]
fn closing_tab_before_active_keeps_the_same_document_active() {
let mut state = EditorState::default();
let mut state = Workspace::default();
state.open_new();
let name = state.active().name.clone();
let id = state.active().id;
state.close(0);
assert_eq!(state.active().name, name);
assert_eq!(state.active().id, id);
assert_eq!(state.active_index(), 1);
}
#[test]
fn editing_marks_document_dirty_and_saving_clears_it() {
let mut state = EditorState::default();
let mut state = Workspace::default();
assert!(!state.active().is_dirty());
state.active_mut().content.push_str("\n// правка\n");
state.set_active_text("// правка\n");
assert!(state.active().is_dirty());
state.active_mut().mark_saved();
state.mark_active_saved();
assert!(!state.active().is_dirty());
}
#[test]
fn typing_into_editor_updates_active_document() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
fn revision_changes_only_for_external_edits() {
let mut state = Workspace::default();
let revision = state.revision();
// Прогреваем раскладку и кликаем в область редактора ниже панели вкладок.
step_app(&ctx, &mut app, vec![]);
let editor_pos = egui::pos2(SCREEN.x / 2.0, SCREEN.y - 80.0);
step_app(&ctx, &mut app, vec![egui::Event::PointerMoved(editor_pos)]);
step_app(&ctx, &mut app, vec![button_event(editor_pos, true)]);
step_app(&ctx, &mut app, vec![button_event(editor_pos, false)]);
// Набор текста в виджете ревизию не двигает: редактор уже показывает текст.
state.replace_active_content("typed".to_owned());
assert_eq!(state.revision(), revision);
step_app(&ctx, &mut app, vec![egui::Event::Text("ZZZ".to_owned())]);
// Внешняя замена (Lua) — двигает.
state.set_active_text("from lua");
assert_ne!(state.revision(), revision);
let editor = app.editor();
let doc = editor.active();
assert!(
doc.content.contains("ZZZ"),
"введённый текст не попал в документ: {:?}",
doc.content
);
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
// Переключение вкладки — тоже.
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 ctx = egui::Context::default();
let mut app = MyApp::default();
step_app(&ctx, &mut app, vec![]);
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let api = AppApi::global();
let tabs = api.tabs();
assert_eq!(
tabs.iter()
@@ -279,52 +105,44 @@ fn api_lists_tabs_and_closes_them_by_number() {
assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1);
assert_eq!(app.editor().documents().len(), 1);
assert_eq!(workspace.borrow().documents().len(), 1);
}
#[test]
fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта.
let mut state = EditorState::default();
let workspace = Rc::new(RefCell::new(Workspace::default()));
let path = std::env::temp_dir().join(format!(
"egui-rust-test-save-{}-{}.rs",
"gpui-editor-test-save-{}-{}.rs",
std::process::id(),
state.active().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!(state.active().is_dirty());
assert!(workspace.borrow().active().is_dirty());
let editor = Rc::new(RefCell::new(state));
let api = AppApi::install(
egui::Context::default(),
editor.clone(),
Rc::new(RefCell::new(KeyMap::default())),
);
let (api, _keymap) = install_api(workspace.clone());
api.save().expect("сохранение должно удаться");
assert_eq!(
std::fs::read_to_string(&path).expect("файл должен существовать"),
"fn main() {}\n"
);
assert!(!editor.borrow().active().is_dirty());
assert!(!workspace.borrow().active().is_dirty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn api_switches_tabs_forward_and_back_in_a_cycle() {
let editor = Rc::new(RefCell::new(EditorState::default()));
let api = AppApi::install(
egui::Context::default(),
editor.clone(),
Rc::new(RefCell::new(KeyMap::default())),
);
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
assert_eq!(api.active_tab_index(), 0);
@@ -337,18 +155,39 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() {
// С первой вкладки предыдущая — последняя.
assert_eq!(api.previous_tab(), 1);
assert_eq!(api.previous_tab(), 0);
assert_eq!(editor.borrow().active_index(), 0);
assert_eq!(workspace.borrow().active_index(), 0);
}
#[test]
fn lua_script_can_include_files_and_control_the_app() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
// Первый кадр публикует AppApi, который использует сценарий.
step_app(&ctx, &mut app, vec![]);
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());
}
/// Временный каталог с уникальным именем для Lua-сценариев.
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"egui-rust-test-lua-{}-{}",
"gpui-editor-test-{prefix}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -356,7 +195,15 @@ fn lua_script_can_include_files_and_control_the_app() {
.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(
@@ -373,22 +220,22 @@ fn lua_script_can_include_files_and_control_the_app() {
)
.expect("main.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&main).expect("сценарий должен выполниться");
let editor = app.editor();
let state = workspace.borrow();
assert_eq!(
editor.documents()[0].content,
state.documents()[0].content,
"from lua",
"include должен выполнить подключённый файл в общем окружении"
);
assert_eq!(
editor.active_index(),
state.active_index(),
1,
"app:next_tab() не переключил вкладку"
);
assert_eq!(
editor.documents()[1].language,
state.documents()[1].language,
Some(Language::Python),
"app:set_language() не включил подсветку из Lua"
);
@@ -397,22 +244,11 @@ fn lua_script_can_include_files_and_control_the_app() {
}
#[test]
fn lua_key_bindings_use_physical_keys_and_modifiers() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
// Первый кадр публикует AppApi с картой клавиш приложения.
step_app(&ctx, &mut app, vec![]);
let dir = std::env::temp_dir().join(format!(
"egui-rust-test-keys-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("системные часы")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
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,
@@ -426,100 +262,80 @@ fn lua_key_bindings_use_physical_keys_and_modifiers() {
)
.expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("привязки должны назначиться");
let text = |app: &MyApp| app.editor().active().content.clone();
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 callback in keymap.borrow().callbacks_for(keystroke, pressed) {
callback.call::<()>(()).expect("Lua-обработчик");
}
};
// Логическая клавиша Z, физическая A: срабатывает именно физическая.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::A,
egui::Key::Z,
true,
egui::Modifiers::default(),
)],
);
assert_eq!(text(&app), "a pressed");
// Нажатие A.
fire(&stroke("a", Modifiers::default()), true);
assert_eq!(text(), "a pressed");
// Shift+A не должна срабатывать как обычная A.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::A,
egui::Key::A,
true,
egui::Modifiers {
fire(
&stroke(
"a",
Modifiers {
shift: true,
..Default::default()
},
)],
),
true,
);
assert_eq!(text(&app), "a pressed");
assert_eq!(text(), "a pressed");
// Отпускание цифры 1, логическая клавиша при этом другая.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::Num1,
egui::Key::Num2,
false,
egui::Modifiers::default(),
)],
);
assert_eq!(text(&app), "1 released");
// Отпускание цифры 1.
fire(&stroke("1", Modifiers::default()), false);
assert_eq!(text(), "1 released");
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::C,
egui::Key::C,
true,
egui::Modifiers {
fire(
&stroke(
"c",
Modifiers {
shift: true,
..Default::default()
},
)],
),
true,
);
assert_eq!(text(&app), "shift c");
assert_eq!(text(), "shift c");
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::Z,
egui::Key::Z,
true,
egui::Modifiers {
ctrl: true,
fire(
&stroke(
"z",
Modifiers {
control: true,
..Default::default()
},
)],
),
true,
);
assert_eq!(text(&app), "ctrl z");
assert_eq!(text(), "ctrl z");
// Отпускание R при зажатом Alt.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::R,
egui::Key::R,
false,
egui::Modifiers {
fire(
&stroke(
"r",
Modifiers {
alt: true,
..Default::default()
},
)],
),
false,
);
assert_eq!(text(&app), "alt r released");
assert_eq!(text(), "alt r released");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -528,119 +344,151 @@ fn lua_key_bindings_use_physical_keys_and_modifiers() {
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(&name.to_uppercase()), Some(expected));
assert_eq!(Language::from_name(name), Some(expected), "язык {name:?}");
}
// Распространённые короткие имена.
assert_eq!(Language::from_name("rs"), Some(Language::Rust));
assert_eq!(Language::from_name("golang"), Some(Language::Go));
assert_eq!(
Language::from_name("javascript"),
Some(Language::JavaScript)
);
assert_eq!(Language::from_name("py"), Some(Language::Python));
assert_eq!(Language::from_name("unknown"), None);
assert_eq!(Language::from_name(""), None);
assert_eq!(Language::from_name("cobol"), None);
}
#[test]
fn tree_sitter_highlights_all_supported_languages() {
let samples = [
(
Language::Rust,
"fn main() {\n let x = 42; // комментарий\n println!(\"hi\");\n}\n",
HighlightStyle::Keyword,
),
(
Language::Go,
"package main\n\nfunc main() {\n\tx := 42 // комментарий\n\t_ = x\n}\n",
HighlightStyle::Keyword,
),
(
Language::JavaScript,
"function main() {\n const x = 42; // comment\n console.log(\"hi\");\n}\n",
HighlightStyle::Keyword,
),
(
Language::Json,
"{\"name\": \"value\", \"count\": 42, \"ok\": true}\n",
HighlightStyle::Property,
),
(
Language::Python,
"def main():\n x = 42 # comment\n print(\"hi\")\n",
HighlightStyle::Keyword,
),
];
for (language, source, expected) in samples {
let mut syntax = SyntaxState::default();
let styles: Vec<_> = syntax
.spans(language, source)
.iter()
.map(|span| span.style)
.collect();
assert!(!styles.is_empty(), "{language:?}: подсветка пуста");
assert!(
styles.contains(&expected),
"{language:?}: нет стиля {expected:?} в {styles:?}"
);
}
}
#[test]
fn syntax_tree_is_available_for_tasks_beyond_highlighting() {
let mut syntax = SyntaxState::default();
let tree = syntax
.parse(Language::Rust, "fn main() {}\n")
.expect("дерево должно разобраться");
let root = tree.root_node();
assert_eq!(root.kind(), "source_file");
let mut cursor = root.walk();
assert!(
root.children(&mut cursor)
.any(|node| node.kind() == "function_item"),
"в дереве должна находиться функция — его можно обходить, а не только красить"
);
// Канонические имена, которые возвращает API и Lua.
assert_eq!(Language::Rust.name(), "rust");
assert_eq!(Language::JavaScript.name(), "js");
assert_eq!(Language::Python.name(), "python");
}
#[test]
fn highlighting_is_off_by_default_and_enabled_through_api() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
step_app(&ctx, &mut app, vec![]);
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
assert!(
app.editor()
.documents()
.iter()
.all(|doc| doc.language.is_none()),
"подсветка должна быть выключена по умолчанию"
assert_eq!(api.language(0), None);
assert_eq!(workspace.borrow().documents()[0].language, None);
assert!(api.set_language(0, "rs"));
assert_eq!(api.language(0), Some("rust".to_owned()));
assert_eq!(
workspace.borrow().documents()[0].language,
Some(Language::Rust)
);
let api = AppApi::global();
assert!(api.set_language(0, "rust"));
assert_eq!(api.language(0).as_deref(), Some("rust"));
assert_eq!(app.editor().documents()[0].language, Some(Language::Rust));
// Неизвестный язык и несуществующая вкладка не меняют состояние.
// Неизвестный язык и несуществующая вкладка.
assert!(!api.set_language(0, "cobol"));
assert!(!api.set_language(99, "rust"));
assert_eq!(api.language(0).as_deref(), Some("rust"));
// Кадр с включённой подсветкой должен отрисовываться без паники.
step_app(&ctx, &mut app, vec![]);
assert!(api.clear_language(0));
assert_eq!(api.language(0), None);
assert!(!api.clear_language(99));
}
#[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.bind(plain.clone(), first);
keymap.bind(plain, second);
let stroke = Keystroke {
modifiers: Modifiers::default(),
key: "a".to_owned(),
key_char: None,
};
assert_eq!(keymap.callbacks_for(&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: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 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(), "правка должна помечать документ изменённым");
}