#![cfg(test)]
use super::editor::CodeEditor;
use super::types::{CodeEditorConfig, TextPosition, ViewportSnapshot, DEFAULT_FONT_SIZE};
use crate::core::{Point, Rect};
use crate::widget::Widget;
fn editor() -> CodeEditor {
CodeEditor::new(Rect::new(0, 0, 800, 600))
}
fn plain(lines: usize) -> String {
let mut text = String::new();
for i in 0..lines {
text.push_str(&format!("line {i}\n"));
}
text
}
#[test]
fn rows_equal_lines_without_wrap_or_fold() {
let mut editor = editor();
editor.set_text(plain(50));
assert!(
editor.visual_row_prefix_is_empty(),
"the fold-free, wrap-free case must not pay for a table"
);
for line in [0usize, 1, 17, 49] {
assert_eq!(editor.line_to_visual_row(line), line);
assert_eq!(editor.visual_row_to_line(line), line);
}
}
#[test]
fn the_row_and_line_transforms_are_inverses_under_wrapping() {
let mut editor =
CodeEditor::with_config(Rect::new(0, 0, 160, 300), CodeEditorConfig::new().word_wrap(true))
.expect("valid config");
let mut text = String::new();
for i in 0..40 {
let width = (i % 4) * 20;
text.push_str(&"x".repeat(width));
text.push('\n');
}
editor.set_text(text);
let rows = editor.visual_row_count();
assert!(rows >= editor.line_count(), "wrapping can only add rows");
for line in 0..editor.line_count() {
let row = editor.line_to_visual_row(line);
assert_eq!(
editor.visual_row_to_line(row),
line,
"line {line} at row {row} must round-trip"
);
}
}
#[test]
fn the_row_transforms_respect_folding() {
let mut editor = editor();
editor.set_text(plain(30));
editor.fold(10, 20);
assert_eq!(editor.folded_line_count(), 10);
let row_before = editor.line_to_visual_row(9);
let row_start = editor.line_to_visual_row(10);
let row_after = editor.line_to_visual_row(21);
assert_eq!(row_start, row_before + 1, "the fold start follows line 9 directly");
assert_eq!(row_after, row_start + 1, "line 21 follows the fold's single visible row");
assert_eq!(editor.visual_row_to_line(row_start), 10, "the row maps to the fold start");
assert_eq!(editor.visual_row_to_line(row_after), 21);
for hidden in 11..=20 {
assert_ne!(editor.visual_row_to_line(row_start), hidden);
}
}
#[test]
fn the_prefix_table_is_rebuilt_when_the_document_changes() {
let mut editor =
CodeEditor::with_config(Rect::new(0, 0, 160, 300), CodeEditorConfig::new().word_wrap(true))
.expect("valid config");
editor.set_text(plain(20));
assert_eq!(
editor.visual_row_prefix_len(),
editor.line_count() + 1,
"one entry per line plus the total sentinel"
);
editor.set_text(plain(5));
assert_eq!(
editor.visual_row_prefix_len(),
editor.line_count() + 1,
"the table followed the shrink"
);
editor.fold(0, 3);
assert_eq!(
editor.visual_row_prefix_len(),
editor.line_count() + 1,
"a fold rebuilds but does not resize it"
);
}
#[test]
fn a_hit_test_agrees_with_the_row_transform() {
let mut editor = editor();
editor.set_text(plain(30));
editor.fold(8, 14);
let map = editor.coordinate_map();
for row in 0..(editor.line_count() - editor.folded_line_count()).min(8) {
let y = map.row_y(row);
let line = editor.visual_row_to_line(row);
let position = editor
.position_at_point(Point::new(map.column_x(0), y))
.expect("the point is inside the text area");
assert_eq!(
position.line, line,
"row {row} drawn as line {line} must hit-test to the same line"
);
}
}
#[test]
fn a_point_outside_the_text_area_is_rejected() {
let mut editor = editor();
editor.set_text(plain(10));
let map = editor.coordinate_map();
assert!(editor.position_at_point(Point::new(map.column_x(0), map.row_y(0))).is_some());
let above_y = map.row_y(0) - 1 - editor.text_origin_y();
assert!(editor.position_at_point(Point::new(map.column_x(0), above_y)).is_none());
}
#[test]
fn the_last_row_maps_to_the_last_line() {
let mut editor = editor();
editor.set_text(plain(12));
let total = editor.visual_row_count();
assert!(total >= 12);
let last_line = editor.line_count() - 1;
assert_eq!(editor.visual_row_to_line(total.saturating_sub(1)), last_line);
assert_eq!(editor.visual_row_to_line(total + 100), last_line);
}
#[test]
fn the_row_transforms_are_inverses_under_wrap_and_fold() {
let mut editor =
CodeEditor::with_config(Rect::new(0, 0, 160, 300), CodeEditorConfig::new().word_wrap(true))
.expect("valid config");
let mut text = String::new();
for i in 0..30 {
text.push_str(&"y".repeat((i % 3) * 15));
text.push('\n');
}
editor.set_text(text);
editor.fold(5, 12);
for line in 0..editor.line_count() {
if editor.is_line_hidden(line) {
continue;
}
let row = editor.line_to_visual_row(line);
assert_eq!(editor.visual_row_to_line(row), line, "line {line} at row {row}");
}
}
#[test]
fn the_range_rectangle_uses_the_coordinate_map() {
let mut editor = editor();
editor.set_text("alpha\nbeta\n");
let map = editor.coordinate_map();
let rect = editor
.rect_for_range(TextPosition::new(1, 0), TextPosition::new(1, 4))
.expect("a valid range");
assert_eq!(rect.x, map.column_x(0));
assert_eq!(rect.y, map.row_y(editor.line_to_visual_row(1)));
assert!(rect.width > 0 && rect.height > 0);
}
#[test]
fn the_map_reflects_the_current_geometry() {
let mut editor = editor();
editor.set_text(plain(5));
let before = editor.coordinate_map();
editor.set_geometry(Rect::new(0, 40, 800, 600));
let after = editor.coordinate_map();
assert_eq!(after.row_y(0) - before.row_y(0), 40, "the map followed the widget origin");
}
#[test]
fn the_map_column_step_is_the_cell_width() {
let editor = editor();
let map = editor.coordinate_map();
let a = map.column_x(0);
let b = map.column_x(1);
assert!(b > a, "a column advance must move the origin");
let _ = DEFAULT_FONT_SIZE;
}
#[test]
fn a_viewport_snapshot_links_two_editors() {
let mut left = CodeEditor::new(Rect::new(0, 0, 400, 120));
left.set_text(plain(200));
left.set_geometry(Rect::new(0, 0, 400, 120));
left.refresh_visible_rows();
left.scroll_by(20);
assert_eq!(left.scroll_line(), 20);
let mut right = CodeEditor::new(Rect::new(0, 0, 400, 120));
right.set_text(plain(200));
right.set_geometry(Rect::new(0, 0, 400, 120));
right.refresh_visible_rows();
right.apply_viewport(left.viewport_snapshot());
assert_eq!(right.first_visual_row(), left.first_visual_row());
assert_eq!(right.scroll_column(), left.scroll_column());
assert_eq!(right.caret(), left.caret());
}
#[test]
fn applying_a_snapshot_does_not_edit_or_dirty_the_buffer() {
let mut editor = editor();
editor.set_text(plain(50));
editor.mark_saved();
editor.clear_history();
assert!(!editor.is_modified());
assert!(!editor.can_undo(), "the history starts empty for this assertion");
editor.apply_viewport(ViewportSnapshot {
first_visual_row: 5,
scroll_column: 3,
caret: TextPosition::new(10, 2),
});
assert!(!editor.is_modified(), "linking panes is not an edit");
assert_eq!(editor.caret(), TextPosition::new(10, 2));
assert!(!editor.can_undo(), "no history entry may be recorded");
}
#[test]
fn a_snapshot_is_clamped_into_a_shorter_document() {
let mut editor = editor();
editor.set_text(plain(3));
editor.apply_viewport(ViewportSnapshot {
first_visual_row: 9_999,
scroll_column: 400,
caret: TextPosition::new(9_999, 9_999),
});
let last = editor.line_count() - 1;
assert_eq!(editor.caret().line, last, "the caret clamps to the last line");
assert!(editor.first_visual_row() < editor.visual_row_count().max(1) + 1);
}
#[test]
fn the_row_height_follows_the_font_size_unless_overridden() {
let derived = CodeEditorConfig::new().font_size(20.0).line_height_ratio(1.5);
assert_eq!(derived.resolved_line_advance(), 30.0);
let overridden = derived.line_advance(24.0);
assert_eq!(
overridden.resolved_line_advance(),
24.0,
"an explicit advance is the caller's decision and wins"
);
assert!(overridden.validate().is_ok(), "24 >= 20 keeps the invariant");
}
#[test]
fn the_selection_character_count_matches_the_extracted_text() {
let mut editor = editor();
editor.set_text("alpha\nbeta\ngamma");
editor.set_cursor(0, 2, false);
editor.set_cursor(2, 3, true);
let counted = editor.selection_char_count();
let extracted = editor.selected_text().expect("non-collapsed selection");
assert_eq!(counted, extracted.chars().count());
}
#[test]
fn a_painted_row_reports_measured_character_positions() {
let mut editor = editor();
editor.set_text("fn main() {}");
let _ = crate::widget::svg::render_to_svg(&mut editor);
let positions: Vec<f32> = (0..=12).filter_map(|index| editor.cached_x(0, index)).collect();
assert_eq!(positions.len(), 13, "every boundary of the painted row has an x");
assert_eq!(positions[0], 0.0, "the row starts at x = 0");
assert!(
positions.windows(2).all(|pair| pair[1] > pair[0]),
"boundaries advance left to right: {positions:?}"
);
}
#[test]
fn a_double_click_selection_survives_the_synthetic_motion() {
use crate::event::{Event, EventHandler};
let mut editor = editor();
editor.set_text("fn main() {}");
let point = Point::new(editor.text_origin_x() + 40, editor.text_origin_y() + 4);
editor.handle_event(&Event::MousePress { pos: point, button: 1, modifiers: 0 });
editor.handle_event(&Event::MouseRelease { pos: point, button: 1 });
editor.handle_event(&Event::MouseDoubleClick { pos: point, button: 1 });
let selected = editor.selected_text();
assert_eq!(selected.as_deref(), Some("main"), "the double click selects the word");
editor.handle_event(&Event::MouseMove { pos: point });
assert_eq!(
editor.selected_text(),
selected,
"a motion that did not move must not collapse the word selection"
);
}
#[test]
fn the_pointer_shape_follows_the_region_under_it() {
use crate::platform::CursorIcon;
let editor = editor();
let rect = editor.base().geometry();
let text = Point::new(editor.text_origin_x() + 20, editor.text_origin_y() + 5);
assert_eq!(editor.cursor_icon_at(text), CursorIcon::Text, "the buffer is text");
let minimap = Point::new(
rect.x + rect.width as i32 - editor.minimap_width() / 2,
editor.text_origin_y() + 5,
);
assert_eq!(
editor.cursor_icon_at(minimap),
CursorIcon::Default,
"the minimap is a navigation strip, not text"
);
let gutter = Point::new(rect.x + 2, editor.text_origin_y() + 5);
assert_eq!(
editor.cursor_icon_at(gutter),
CursorIcon::Default,
"line numbers are not editable text"
);
}
#[test]
fn scrolling_to_a_fraction_moves_the_viewport_proportionally() {
let mut editor = editor();
editor.set_text(plain(400));
editor.base_mut().set_geometry(Rect::new(0, 0, 800, 200));
editor.refresh_visible_rows();
editor.scroll_to_fraction(0.0);
assert_eq!(editor.scroll_line(), 0, "the top of the strip is the top of the file");
editor.scroll_to_fraction(0.5);
let middle = editor.scroll_line();
assert!((100..=300).contains(&middle), "half way is about line 200, got {middle}");
editor.scroll_to_fraction(1.0);
let last = editor.scroll_line();
assert!(
last + editor.visible_rows() >= editor.line_count() - 1,
"the bottom of the strip must show the end of the file: last={last}"
);
}
fn centre(rect: Rect) -> Point {
Point::new(rect.x + rect.width as i32 / 2, rect.y + rect.height as i32 / 2)
}
#[test]
fn a_tab_click_activates_and_the_close_box_closes() {
let mut editor = editor();
editor.open_buffer("a.rs", "fn a() {}");
editor.open_buffer("b.rs", "fn b() {}");
let _ = crate::widget::svg::render_to_svg(&mut editor);
let slots = editor.tab_hits.borrow().clone();
assert_eq!(
slots.len(),
editor.buffers().len(),
"one slot per open buffer (the editor starts with an untitled one)"
);
assert_eq!(slots.len(), 3);
for pair in slots.windows(2) {
assert!(
pair[1].rect.x >= pair[0].rect.x + pair[0].rect.width as i32,
"tabs must not overlap: {:?} then {:?}",
pair[0].rect,
pair[1].rect
);
}
assert_eq!(editor.active_buffer(), 2);
editor.pointer_press(centre(slots[0].rect));
assert_eq!(editor.active_buffer(), 0, "a tab click activates its buffer");
let slots = editor.tab_hits.borrow().clone();
assert!(slots[0].close.width > 0, "with several buffers there is something to close");
editor.pointer_press(centre(slots[0].close));
assert_eq!(editor.buffers().len(), 2, "the close box closes the tab");
}
#[test]
fn the_ime_composition_is_shown_until_it_is_committed() {
use crate::event::{Event, EventHandler};
let mut editor = editor();
editor.set_text("");
editor.handle_event(&Event::ime_preedit("にほん", 3));
assert_eq!(editor.preedit_text(), Some("にほん"), "the composition is visible");
assert_eq!(editor.text(), "", "but it is not in the document yet");
editor.handle_event(&Event::ime_commit("日本"));
assert_eq!(editor.preedit_text(), None, "the commit replaces the composition");
assert_eq!(editor.text(), "日本");
}
#[test]
fn a_matched_bracket_is_marked_with_a_filled_rounded_box() {
let mut editor = editor();
editor.set_text("fn main() {\n}");
editor.set_cursor(0, 11, false);
let svg = crate::widget::svg::render_to_svg(&mut editor);
assert!(
svg.contains("rx=\"2\" ry=\"2\""),
"the bracket marker must be a rounded fill, not an outline"
);
}
#[test]
fn a_bracket_inside_a_string_does_not_match() {
let mut editor = editor();
editor.set_text("let s = \"{\";\n}");
editor.set_cursor(0, 9, false);
assert_eq!(editor.matching_brackets(), None, "a brace in a string literal is text");
editor.set_text("fn main() {\n}");
editor.set_cursor(0, 10, false);
let pair = editor.matching_brackets().expect("the block's braces still match");
assert_eq!(pair.0, TextPosition::new(0, 10));
assert_eq!(pair.1, TextPosition::new(1, 0));
}
#[test]
fn the_bracket_pair_cache_follows_the_caret_and_the_revision() {
let mut editor = editor();
editor.set_text("fn main() {\n}");
editor.set_cursor(0, 10, false);
let first = editor.matching_brackets().expect("the block's braces match");
assert_eq!(editor.matching_brackets(), Some(first), "a still caret keeps its pair");
editor.set_cursor(0, 0, false);
assert_eq!(editor.matching_brackets(), None, "the caret left the bracket");
editor.set_cursor(0, 10, false);
assert_eq!(editor.matching_brackets(), Some(first), "returning to the brace re-matches");
editor.set_text("fn main() {\n let x = 1;\n}");
editor.set_cursor(0, 10, false);
let after = editor.matching_brackets().expect("the block still closes");
assert_eq!(
after.1,
TextPosition::new(2, 0),
"the cached pair must not survive an edit that moved the closing brace"
);
editor.set_language(super::syntax::LanguageId::Rust);
editor.set_text("{ // }");
editor.set_cursor(0, 0, false);
assert_eq!(
editor.matching_brackets(),
None,
"in Rust the closing brace is inside a line comment"
);
editor.set_language(super::syntax::LanguageId::Python);
assert_eq!(
editor.matching_brackets(),
Some((TextPosition::new(0, 0), TextPosition::new(0, 5))),
"in Python `//` is not a comment, so the cached `None` must have been dropped"
);
}
#[test]
fn relative_line_numbers_count_from_the_caret() {
let mut editor = CodeEditor::with_config(
Rect::new(0, 0, 800, 600),
CodeEditorConfig::new().relative_line_numbers(true),
)
.expect("valid config");
editor.set_text(plain(10));
editor.set_cursor(5, 0, false);
assert_eq!(editor.line_number_label(5), "6", "the caret line stays absolute");
assert_eq!(editor.line_number_label(4), "1");
assert_eq!(editor.line_number_label(7), "2");
assert_eq!(editor.line_number_label(0), "5");
let absolute = CodeEditor::new(Rect::new(0, 0, 800, 600));
let mut absolute = absolute;
absolute.set_text(plain(10));
absolute.set_cursor(5, 0, false);
assert_eq!(absolute.line_number_label(0), "1", "absolute mode is the default");
}
fn wrapping_editor() -> CodeEditor {
CodeEditor::with_config(Rect::new(0, 0, 200, 300), CodeEditorConfig::new().word_wrap(true))
.expect("valid config")
}
#[test]
fn a_wrapped_rows_caret_and_hit_test_agree() {
let mut editor = wrapping_editor();
let columns = editor.text_columns().max(4);
editor.set_text("x".repeat(columns * 3));
editor.base_mut().set_geometry(Rect::new(0, 0, 200, 300));
editor.refresh_visible_rows();
editor.set_cursor(0, columns + 2, false);
assert_eq!(editor.caret_visual_row(), 1, "the second segment is the second row");
assert_eq!(editor.line_to_visual_row(0), 0, "the line itself still starts at row 0");
let map = editor.coordinate_map();
let point = Point::new(editor.geometry().x + editor.text_origin_x() + 1, map.row_y(1));
let hit = editor.position_at_point(point).expect("the point is inside the text area");
assert_eq!(hit.line, 0);
assert!(
hit.column >= columns,
"a click on the second row belongs to the second segment, got column {}",
hit.column
);
}
#[test]
fn a_continuation_row_is_indented_by_the_lines_indent() {
let mut editor = wrapping_editor();
editor.base_mut().set_geometry(Rect::new(0, 0, 200, 300));
editor.refresh_visible_rows();
let columns = editor.text_columns().max(8);
editor.set_text(format!(" {}", "y".repeat(columns * 2)));
editor.refresh_visible_rows();
let left = editor.geometry().x + editor.text_origin_x();
let cell = editor.cell_width();
let first_of_second = TextPosition::new(0, columns);
let second_char = TextPosition::new(0, columns + 1);
let first_rect = editor.rect_for_range(first_of_second, second_char).expect("valid range");
assert_eq!(
first_rect.x,
left + (4.0 * cell).round() as i32,
"the continuation row is indented by the line's four spaces"
);
assert_eq!(
first_rect.y,
editor.coordinate_map().row_y(1),
"the range sits on the second visual row"
);
assert_eq!(first_rect.width, cell.round() as u32, "one character is one cell");
}
#[test]
fn a_press_on_the_gutter_selects_the_line() {
let mut editor = editor();
editor.set_text("alpha\nbeta\ngamma\n");
editor.refresh_visible_rows();
let y = editor.coordinate_map().row_y(1) + 2;
let x = editor.geometry().x + 2;
editor.pointer_press(Point::new(x, y));
assert_eq!(
editor.selected_text().as_deref(),
Some("beta\n"),
"the gutter selects the line, including its newline"
);
}
#[test]
fn control_click_adds_a_caret() {
use crate::event::{Event, EventHandler};
let mut editor = editor();
editor.set_text("alpha\nbeta\ngamma");
editor.refresh_visible_rows();
let left = editor.text_origin_x() + 2;
let row = editor.line_height().round() as i32;
editor.pointer_press(Point::new(left, editor.text_origin_y() + 2));
assert_eq!(editor.cursors().len(), 1);
editor.handle_event(&Event::MousePress {
pos: Point::new(left, editor.text_origin_y() + 2 + row),
button: 1,
modifiers: 2,
});
assert_eq!(editor.cursors().len(), 2, "Ctrl + click adds a caret");
assert_eq!(editor.caret().line, 1);
}
#[test]
fn the_minimap_has_one_bucket_per_line_until_it_runs_out_of_rows() {
let mut short = editor();
short.base_mut().set_geometry(Rect::new(0, 0, 800, 400));
short.set_text(plain(14));
short.prepare_minimap();
let rows = short.minimap_rows();
assert_eq!(
short.minimap_buckets.len(),
short.line_count().min(rows),
"a short document must not allocate a bucket per strip pixel"
);
let first = short.minimap_buckets.len();
short.prepare_minimap();
assert_eq!(short.minimap_buckets.len(), first, "a no-op frame must not resize it");
let mut long = editor();
long.base_mut().set_geometry(Rect::new(0, 0, 800, 400));
let mut text = String::new();
for line in 0..5_000 {
if line > 0 {
text.push('\n');
}
text.push_str(&format!("line {line}"));
}
long.set_text(text);
long.prepare_minimap();
let rows = long.minimap_rows();
assert!(
long.minimap_buckets.len() <= rows,
"the summary never claims more rows than the strip has"
);
let filled = long.minimap_buckets.iter().filter(|bucket| !bucket.blank).count();
assert_eq!(
filled,
long.minimap_buckets.len(),
"no bucket may be left blank: the partition has no tail"
);
assert!(long.minimap_buckets.len() > rows - 10, "the strip is filled, not truncated");
}
#[test]
fn a_cached_row_layout_is_reused_until_the_text_changes() {
let mut editor = editor();
editor.set_text("WWWW");
let _ = crate::widget::svg::render_to_svg(&mut editor);
let wide = editor.cached_x(0, 4).expect("the painted row has a boundary");
let _ = crate::widget::svg::render_to_svg(&mut editor);
assert_eq!(editor.cached_x(0, 4), Some(wide), "unchanged inputs reuse the measurement");
editor.set_text("iiii");
let _ = crate::widget::svg::render_to_svg(&mut editor);
let narrow = editor.cached_x(0, 4).expect("the repainted row has a boundary");
assert!(
narrow < wide,
"a stale layout would keep the wide x against narrow text: {narrow} vs {wide}"
);
}
#[test]
fn a_font_size_change_invalidates_the_cached_row_layout() {
let mut editor = editor();
editor.set_text("WWWW");
let _ = crate::widget::svg::render_to_svg(&mut editor);
let small = editor.cached_x(0, 4).expect("the painted row has a boundary");
editor.config.font_size *= 2.0;
let _ = crate::widget::svg::render_to_svg(&mut editor);
let large = editor.cached_x(0, 4).expect("the repainted row has a boundary");
assert!(large > small, "the doubled font must re-measure: {large} vs {small}");
}