Files
Marcos Paulo 67bb5fe06f
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Cmd/Ctrl+Enter toggles a task in the Source view
Ticks or unticks a box, adds a box to a list item, makes a plain or empty line a task.
Round 5 tasks 36-37 added; the Visual block editor's version (37) is open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:22:41 -03:00

158 lines
6.8 KiB
Rust

//! Headless UI tests: two frames at 900x700 with egui's default fonts,
//! driving the editor through `RawInput` events.
use notescraft_editor::colors::EditorColors;
use notescraft_editor::completion;
use notescraft_editor::editor::{EditorOptions, EditorState, set_byte_cursor_range, show_editor, show_modal_editor, widget_id};
use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, Vec2};
fn input(events: Vec<Event>) -> RawInput {
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 700.0))), events, ..RawInput::default() }
}
/// Runs one frame; the editor gets focus first so it sees the events.
fn frame(ctx: &egui::Context, opts: &EditorOptions, state: &mut EditorState, events: Vec<Event>) {
let colors = EditorColors::fallback_dark();
let out = ctx.run_ui(input(events), |ui| {
ui.memory_mut(|mem| mem.request_focus(ui.id().with(opts.id_salt)));
let _ = show_editor(ui, state, &colors, &|t| !t.contains("missing"), opts);
});
out.drop_without_applying_deltas();
}
#[test]
fn editor_renders_and_applies_typed_text() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let mut state = EditorState { text: String::new(), cursor: None, version: 0, ..Default::default() };
frame(&ctx, &opts, &mut state, vec![]); // first frame: just render
assert_eq!(state.text, "");
frame(&ctx, &opts, &mut state, vec![Event::Text(String::from("hello"))]);
assert_eq!(state.text, "hello", "TextEdit inserts the Text event");
assert_eq!(state.version, 1, "one bump for the one modifying frame");
assert_eq!(state.cursor, Some(5));
// Enter without list marker: ordinary newline kept by TextEdit.
frame(&ctx, &opts, &mut state, vec![key(Key::Enter)]);
assert_eq!(state.text, "hello\n");
// Cmd/Ctrl+Enter makes the caret line a task: no newline, no continuation.
let mods = Modifiers { command: true, ..Modifiers::default() };
let cmd = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: mods };
frame(&ctx, &opts, &mut state, vec![cmd]);
assert_eq!(state.text, "hello\n- [ ] ", "Cmd+Enter inserts no newline; it turns the empty line into a task");
}
fn key(key: Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() }
}
#[test]
fn enter_continues_list_through_raw_input() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let mut state = EditorState { text: String::from("- item"), cursor: Some(6), version: 1, ..Default::default() };
frame(&ctx, &opts, &mut state, vec![]); // establish focus
frame(&ctx, &opts, &mut state, vec![key(Key::Enter)]);
assert_eq!(state.text, "- item\n- ", "list continuation consumed the Enter");
}
#[test]
fn enter_on_numbered_list_increments() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let mut state = EditorState { text: String::from("2. one"), cursor: Some(6), version: 1, ..Default::default() };
frame(&ctx, &opts, &mut state, vec![]);
frame(&ctx, &opts, &mut state, vec![key(Key::Enter)]);
assert_eq!(state.text, "2. one\n3. ");
}
#[test]
fn auto_close_pair_and_skip_over() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let mut state = EditorState { text: String::new(), cursor: None, version: 0, ..Default::default() };
frame(&ctx, &opts, &mut state, vec![Event::Text(String::from("["))]);
assert_eq!(state.text, "[]", "auto-closed");
frame(&ctx, &opts, &mut state, vec![Event::Text(String::from("]"))]);
assert_eq!(state.text, "[]", "typed closer skips over, no double bracket");
}
#[test]
fn tab_inserts_four_spaces() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let mut state = EditorState { text: String::new(), cursor: None, version: 0, ..Default::default() };
frame(&ctx, &opts, &mut state, vec![key(Key::Tab)]);
assert_eq!(state.text, " ", "Tab became four spaces");
}
#[test]
fn popup_renders_without_selection_click() {
let ctx = egui::Context::default();
let items = vec![String::from("alpha"), String::from("Beta")];
let mut chosen = None;
let colors = EditorColors::fallback_dark();
let out = ctx.run_ui(input(vec![]), |ui| {
let opts = EditorOptions::default();
let mut state = EditorState::default();
ui.memory_mut(|mem| mem.request_focus(ui.id().with(opts.id_salt)));
let resp = show_editor(ui, &mut state, &colors, &|t| !t.contains("missing"), &opts);
let _ = resp;
let anchor = Rect::from_min_size(Pos2::ZERO, Vec2::new(80.0, 20.0));
chosen = completion::show_popup(ui, anchor, &items, 0);
});
out.drop_without_applying_deltas();
assert_eq!(chosen, None);
}
#[test]
fn two_frames_stay_stable() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let mut state = EditorState { text: String::from("# Title\n\n- a\n- b\n"), cursor: None, version: 7, ..Default::default() };
frame(&ctx, &opts, &mut state, vec![]);
let v1 = state.version;
frame(&ctx, &opts, &mut state, vec![]);
assert_eq!(state.version, v1, "no phantom edits on idle frames");
}
#[test]
fn modal_editor_paints_block_at_unicode_and_line_end_cursors() {
let ctx = egui::Context::default();
let opts = EditorOptions::default();
let colors = EditorColors::fallback_dark();
for (text, cursor) in [("é🦀\n", 2), ("é🦀\n", 6), ("", 0)] {
let mut state = EditorState { text: String::from(text), ..Default::default() };
let out = ctx.run_ui(input(vec![]), |ui| {
let id = widget_id(ui, &opts);
ui.memory_mut(|mem| mem.request_focus(id));
set_byte_cursor_range(ui.ctx(), id, text, (cursor, cursor)).expect("valid cursor");
let style_before = ui.visuals().text_cursor.clone();
let response = show_modal_editor(ui, &mut state, &colors, &|_| true, &opts, true);
assert_eq!(ui.visuals().text_cursor, style_before);
assert!(response.caret_rect.is_some());
});
let block_color = colors.accent.gamma_multiply(0.4);
assert!(
out.shapes.iter().any(
|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == block_color && rect.rect.width() > 0.0 && rect.rect.height() > 0.0)
),
"missing block for {text:?} at {cursor}: {:?}",
out.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) => Some(rect),
_ => None,
})
.collect::<Vec<_>>()
);
assert_eq!(state.cursor, Some(cursor));
assert_eq!(state.version, 0);
out.drop_without_applying_deltas();
}
}