diff --git a/Cargo.lock b/Cargo.lock index 44e92ef..13189ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,7 +757,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -950,7 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2162,6 +2162,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -2171,6 +2180,17 @@ dependencies = [ "cfb", ] +[[package]] +name = "integration-tests" +version = "0.1.0" +dependencies = [ + "fresnel-core", + "helix-core", + "helix-view", + "indoc", + "tokio", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3387,7 +3407,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4285,7 +4305,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4313,7 +4333,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5164,7 +5184,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3679e64..8f9bd2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/fresnel-core", "app/src-tauri"] +members = ["crates/fresnel-core", "crates/integration-tests", "app/src-tauri"] [workspace.package] version = "0.1.0" diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml new file mode 100644 index 0000000..f4c0039 --- /dev/null +++ b/crates/integration-tests/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "integration-tests" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +doctest = false + +[dependencies] + +[dev-dependencies] +fresnel-core = { workspace = true } +helix-core = { workspace = true } +helix-view = { workspace = true } +tokio = { version = "1", features = ["rt", "macros"] } +indoc = "2" diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs new file mode 100644 index 0000000..5afd74a --- /dev/null +++ b/crates/integration-tests/src/lib.rs @@ -0,0 +1,4 @@ +//! Cross-crate integration tests live under `tests/integration/*` and share +//! a single test binary through `tests/integration.rs`. Empty here on +//! purpose — this crate exists to satisfy Cargo's "one integration binary +//! per workspace" rule (see the `rust` skill). diff --git a/crates/integration-tests/tests/integration.rs b/crates/integration-tests/tests/integration.rs new file mode 100644 index 0000000..e983296 --- /dev/null +++ b/crates/integration-tests/tests/integration.rs @@ -0,0 +1,9 @@ +//! Single integration-test binary for the fresnel workspace. Per the +//! `rust` skill: one binary at the workspace level so cargo builds fewer +//! test executables. Sub-suites live in `integration/.rs`. + +mod integration { + pub mod helpers; + + pub mod movement; +} diff --git a/crates/integration-tests/tests/integration/helpers.rs b/crates/integration-tests/tests/integration/helpers.rs new file mode 100644 index 0000000..b551fbe --- /dev/null +++ b/crates/integration-tests/tests/integration/helpers.rs @@ -0,0 +1,55 @@ +//! Fresnel-native port of helix-term's integration-test DSL +//! (`helix-term/tests/test/helpers.rs`). +//! +//! Why not vendor helix-term's harness directly: it drives +//! `helix_term::application::Application`, which requires a terminal +//! backend and the `integration` feature flag that reshapes helix-event's +//! internals for tests. Neither is a fit for fresnel — we want to test +//! the editor session as it actually runs in the app, not helix-term's +//! application shell. +//! +//! What we reuse: the underlying DSL primitives +//! (`helix_core::test::{print,plain}` and `helix_view::input::parse_macro`), +//! which are already public. That lets us copy Helix's test cases +//! verbatim — including the `#[|selection]#` markers and ``/`` +//! macro strings — and run them against `EditorSession`. When a lifted +//! test fails, we know we've diverged from Helix's own behavior, not +//! from some approximation of it. + +use fresnel_core::EditorSession; +use helix_core::test; +use helix_view::input::parse_macro; + +/// Run a `(input, keys, expected)` test case against a fresh +/// `EditorSession`. Inputs and expected outputs use Helix's own +/// selection-annotated syntax (`#[|hello]#`) — see +/// [`helix_core::test::print`] for the full grammar. `keys` uses the +/// macro syntax from `helix_view::input::parse_macro` +/// (`"ihello world"` etc.). +/// +/// Panics on any parse failure or mismatch, so tests can call this +/// directly without unwrapping. +pub fn expect(input: &str, keys: &str, expected: &str) { + // Parse the annotated input into (plain-text, initial-selection). + let (in_text, in_selection) = test::print(input); + let (out_text, out_selection) = test::print(expected); + let key_events = + parse_macro(keys).unwrap_or_else(|err| panic!("parse_macro({keys:?}) failed: {err:#}")); + + let mut session = EditorSession::with_scratch(""); + session.set_state(&in_text, in_selection); + + for event in key_events { + session.handle_key(event); + } + + let actual_text = session.current_text(); + let actual_selection = session.current_selection(); + + if actual_text != out_text || actual_selection != out_selection { + let actual_annotated = test::plain(actual_text.as_str(), &actual_selection); + panic!( + "\n input: {input:?}\n keys: {keys:?}\n expected: {expected:?}\n actual: {actual_annotated:?}\n", + ); + } +} diff --git a/crates/integration-tests/tests/integration/movement.rs b/crates/integration-tests/tests/integration/movement.rs new file mode 100644 index 0000000..253866d --- /dev/null +++ b/crates/integration-tests/tests/integration/movement.rs @@ -0,0 +1,148 @@ +//! Movement + insert-mode tests using Helix's own selection-annotated +//! DSL. Cases marked `LIFTED` are copied verbatim from +//! `helix-term/tests/test/movement.rs` at the pinned Helix revision; +//! anything else is fresnel-authored using the same DSL. +//! +//! When a lifted test fails on fresnel but passes upstream, we've +//! diverged from real Helix behavior — the test is the oracle. + +use super::helpers::expect; +use indoc::indoc; + +// ----- Insert mode --------------------------------------------------------- + +/// LIFTED from `helix-term/tests/test/movement.rs::insert_mode_cursor_position`. +/// After `i` (enter insert mode), the primary selection collapses to a +/// point *before* the character it was over. Notice the head/anchor swap +/// in the `#[|\n]#` annotation. +#[tokio::test(flavor = "current_thread")] +async fn insert_mode_cursor_position_collapses_before() { + expect("#[\n|]#", "i", "#[|\n]#"); + expect("#[\n|]#", "i", "#[|\n]#"); + expect("#[\n|]#", "ii", "#[|\n]#"); +} + +/// LIFTED (single case) from `insert_to_normal_mode_cursor_position`. +#[tokio::test(flavor = "current_thread")] +async fn append_extends_selection_to_the_right() { + // `a` should place the cursor after the character, extending forward. + expect("#[f|]#oo\n", "a", "#[fo|]#o\n"); +} + +// ----- Basic normal-mode motion -------------------------------------------- + +/// `l` advances the primary head by one grapheme. +#[tokio::test(flavor = "current_thread")] +async fn l_moves_right_one_grapheme() { + expect("#[h|]#ello\n", "l", "h#[e|]#llo\n"); +} + +/// `h` retreats by one. +#[tokio::test(flavor = "current_thread")] +async fn h_moves_left_one_grapheme() { + expect("h#[e|]#llo\n", "h", "#[h|]#ello\n"); +} + +/// Counted motion: `5l` advances five graphemes. +#[tokio::test(flavor = "current_thread")] +async fn counted_l_advances_by_count() { + expect("#[a|]#bcdefgh\n", "5l", "abcde#[f|]#gh\n"); +} + +/// `gg` on a mid-buffer selection jumps to the start of the document. +/// After the jump, the primary selection is the first grapheme of the +/// buffer with forward direction (`#[l|]#`) — this is exactly how Helix's +/// own `goto_file_start` places the cursor at char 0 and then +/// `Selection::ensure_invariants` expands the collapsed range to width-1. +#[tokio::test(flavor = "current_thread")] +async fn gg_jumps_to_document_start() { + expect( + indoc! {"\ + line one + line #[t|]#wo + line three + "}, + "gg", + indoc! {"\ + #[l|]#ine one + line two + line three + "}, + ); +} + +/// `ge` in Helix goes to the *start of the last non-blank line*, not the +/// last position of the buffer. This test captures that intended behavior +/// verbatim: the resulting selection is the first grapheme of the last +/// line, forward direction. +#[tokio::test(flavor = "current_thread")] +async fn ge_jumps_to_last_line_start() { + expect( + indoc! {"\ + #[l|]#ine one + line two + line three + "}, + "ge", + indoc! {"\ + line one + line two + #[l|]#ine three + "}, + ); +} + +/// `ge` under the default Helix keymap is `goto_last_line`, not +/// `goto_file_end`. This test documents the distinction: after `ge` the +/// primary selection covers the first grapheme of the last non-blank +/// line, *not* the final position of the buffer. If you want the actual +/// end-of-buffer position, that's a keymap-level customization — bind +/// something to `goto_file_end`. +/// +/// Kept alongside `ge_jumps_to_last_line_start` deliberately: if a +/// future keymap change causes `ge` to land at the buffer end, both of +/// these fail together and a reader sees why. +#[tokio::test(flavor = "current_thread")] +async fn ge_does_not_jump_to_last_position_of_buffer() { + // Sanity: buffer has 29 chars ("line one\nline two\nline three\n"). + // If ge went to the last position we'd expect cursor at 28 (the + // trailing '\n'). Instead it lands at char 18 (start of "line + // three"). + expect( + indoc! {"\ + #[l|]#ine one + line two + line three + "}, + "ge", + // Cursor on 'l' of "line three", forward-facing, width-1. + indoc! {"\ + line one + line two + #[l|]#ine three + "}, + ); +} + +// ----- Insert-mode text entry --------------------------------------------- + +/// `i` then typed characters should insert them literally before the +/// original cursor position, and remain in insert mode. +#[tokio::test(flavor = "current_thread")] +async fn insert_i_then_letters_inserts_before_cursor() { + // Cursor on `w` in `world` — `iXYZ` should insert `XYZ` before it. + // In insert mode the primary range collapses to a point at `head` + // (the position between chars), so after typing 3 chars head = 9. + expect( + "hello #[w|]#orld\n", + "iXYZ", + "hello XYZ#[|w]#orld\n", + ); +} + +/// `i` — enter insert mode, immediately leave it. Cursor should +/// return to its original block position, not shift. +#[tokio::test(flavor = "current_thread")] +async fn insert_i_then_esc_restores_normal_block_cursor() { + expect("hello #[w|]#orld\n", "i", "hello #[|w]#orld\n"); +}