This commit is contained in:
2026-10-29 22:40:40 +03:00
commit dce595fbc0
18 changed files with 7983 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
//! Проверки UI-логики на «сырых» событиях egui (без реального окна).
use std::cell::RefCell;
use std::rc::Rc;
use eframe::egui;
use crate::api::AppApi;
use crate::app::MyApp;
use crate::editor::EditorState;
use crate::keymap::KeyMap;
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,
}
}
#[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();
assert_eq!(state.documents().len(), 2);
assert_eq!(state.active().name, "main.rs");
// Открытие и закрытие новых вкладок.
state.open_new();
assert_eq!(state.documents().len(), 3);
assert_eq!(state.active_index(), 2);
state.close(2);
assert_eq!(state.active_index(), 1);
// Редактор не должен оставаться пустым.
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 = EditorState::default();
state.open_new();
let name = state.active().name.clone();
state.close(0);
assert_eq!(state.active().name, name);
assert_eq!(state.active_index(), 1);
}
#[test]
fn editing_marks_document_dirty_and_saving_clears_it() {
let mut state = EditorState::default();
assert!(!state.active().is_dirty());
state.active_mut().content.push_str("\n// правка\n");
assert!(state.active().is_dirty());
state.active_mut().mark_saved();
assert!(!state.active().is_dirty());
}
#[test]
fn typing_into_editor_updates_active_document() {
let ctx = egui::Context::default();
let mut app = MyApp::default();
// Прогреваем раскладку и кликаем в область редактора ниже панели вкладок.
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)]);
step_app(&ctx, &mut app, vec![egui::Event::Text("ZZZ".to_owned())]);
let editor = app.editor();
let doc = editor.active();
assert!(
doc.content.contains("ZZZ"),
"введённый текст не попал в документ: {:?}",
doc.content
);
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
}
#[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 api = AppApi::global();
let tabs = api.tabs();
assert_eq!(
tabs.iter()
.map(|tab| (tab.index, tab.title.as_str()))
.collect::<Vec<_>>(),
vec![(0, "main.rs"), (1, "notes.md")]
);
// Несуществующий номер ничего не закрывает.
assert!(!api.close_tab(99));
assert_eq!(api.tabs().len(), 2);
assert!(api.close_tab(1));
assert_eq!(api.tabs().len(), 1);
assert_eq!(app.editor().documents().len(), 1);
}
#[test]
fn api_save_writes_active_tab_to_file_named_after_it() {
// Тест пишет во временный файл, а не в каталог проекта.
let mut state = EditorState::default();
let path = std::env::temp_dir().join(format!(
"egui-rust-test-save-{}-{}.rs",
std::process::id(),
state.active().id
));
let _ = std::fs::remove_file(&path);
{
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());
let editor = Rc::new(RefCell::new(state));
let api = AppApi::install(
egui::Context::default(),
editor.clone(),
Rc::new(RefCell::new(KeyMap::default())),
);
api.save().expect("сохранение должно удаться");
assert_eq!(
std::fs::read_to_string(&path).expect("файл должен существовать"),
"fn main() {}\n"
);
assert!(!editor.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())),
);
assert_eq!(api.active_tab_index(), 0);
assert_eq!(api.next_tab(), 1);
assert_eq!(api.active_tab_index(), 1);
// С последней вкладки следующая — первая.
assert_eq!(api.next_tab(), 0);
// С первой вкладки предыдущая — последняя.
assert_eq!(api.previous_tab(), 1);
assert_eq!(api.previous_tab(), 0);
assert_eq!(editor.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![]);
let dir = std::env::temp_dir().join(format!(
"egui-rust-test-lua-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("системные часы")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
let helper = dir.join("helper.lua");
let main = dir.join("main.lua");
std::fs::write(
&helper,
"function lua_config() app:set_active_text(\"from lua\") end\n",
)
.expect("helper.lua");
std::fs::write(
&main,
format!(
"include({:?})\nlua_config()\napp:next_tab()\napp:set_language(1, \"python\")\n",
helper.to_string_lossy()
),
)
.expect("main.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine.run_file(&main).expect("сценарий должен выполниться");
let editor = app.editor();
assert_eq!(
editor.documents()[0].content,
"from lua",
"include должен выполнить подключённый файл в общем окружении"
);
assert_eq!(
editor.active_index(),
1,
"app:next_tab() не переключил вкладку"
);
assert_eq!(
editor.documents()[1].language,
Some(Language::Python),
"app:set_language() не включил подсветку из Lua"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[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("временный каталог сценариев");
let script = dir.join("keys.lua");
std::fs::write(
&script,
r#"
app:map_key("a", function() app:set_active_text("a pressed") end)
app:map_key("!1", function() app:set_active_text("1 released") end)
app:map_key("<S-c>", function() app:set_active_text("shift c") end)
app:map_key("<C-z>", function() app:set_active_text("ctrl z") end)
app:map_key("<A-!r>", function() app:set_active_text("alt r released") end)
"#,
)
.expect("keys.lua");
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
engine
.run_file(&script)
.expect("привязки должны назначиться");
let text = |app: &MyApp| app.editor().active().content.clone();
// Логическая клавиша 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");
// Shift+A не должна срабатывать как обычная A.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::A,
egui::Key::A,
true,
egui::Modifiers {
shift: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "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");
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::C,
egui::Key::C,
true,
egui::Modifiers {
shift: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "shift c");
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::Z,
egui::Key::Z,
true,
egui::Modifiers {
ctrl: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "ctrl z");
// Отпускание R при зажатом Alt.
step_app(
&ctx,
&mut app,
vec![key_event(
egui::Key::R,
egui::Key::R,
false,
egui::Modifiers {
alt: true,
..Default::default()
},
)],
);
assert_eq!(text(&app), "alt r released");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn language_names_cover_all_supported_grammars() {
for (name, expected) in [
("rust", Language::Rust),
("go", Language::Go),
("js", Language::JavaScript),
("json", Language::Json),
("python", 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("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("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"),
"в дереве должна находиться функция — его можно обходить, а не только красить"
);
}
#[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![]);
assert!(
app.editor()
.documents()
.iter()
.all(|doc| doc.language.is_none()),
"подсветка должна быть выключена по умолчанию"
);
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));
}