diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..9e70b77 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,64 @@ +name: Linux Build + +# Validation build for the Linux artifacts. Runs on demand and on PRs that +# touch the build, so the cross-platform compile and packaging are exercised +# without cutting a release. Artifacts are uploaded to the run for download. +on: + workflow_dispatch: {} + pull_request: + paths: + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - "assets/prompt.desktop" + - "scripts/linux.sh" + - ".github/workflows/linux.yml" + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + - arch: aarch64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + curl \ + file \ + desktop-file-utils \ + libasound2-dev \ + libfontconfig-dev \ + libssl-dev \ + libvulkan1 \ + libwayland-dev \ + libx11-xcb-dev \ + libxkbcommon-x11-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-deb + run: cargo install cargo-deb --locked + + - name: Build + package + run: scripts/linux.sh ${{ matrix.arch }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: prompt-linux-${{ matrix.arch }} + path: dist/linux/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f8d791..ae3d8e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,56 @@ jobs: env: GH_TOKEN: ${{ github.token }} + build-linux: + needs: [check-version, create-release] + if: needs.check-version.outputs.changed == 'true' + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + - arch: aarch64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + ref: "v${{ needs.check-version.outputs.version }}" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + curl \ + file \ + desktop-file-utils \ + libasound2-dev \ + libfontconfig-dev \ + libssl-dev \ + libvulkan1 \ + libwayland-dev \ + libx11-xcb-dev \ + libxkbcommon-x11-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-deb + run: cargo install cargo-deb --locked + + - name: Build + package + run: scripts/linux.sh ${{ matrix.arch }} + + - name: Upload to release + run: gh release upload "v${{ needs.check-version.outputs.version }}" dist/linux/*.tar.gz dist/linux/*.deb dist/linux/*.AppImage --clobber + env: + GH_TOKEN: ${{ github.token }} + update-homebrew: runs-on: ubuntu-latest needs: [check-version, build] diff --git a/Cargo.lock b/Cargo.lock index 43b116a..e829923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,7 +193,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app" -version = "0.2.2" +version = "0.3.0" dependencies = [ "assist", "config", @@ -300,7 +300,7 @@ dependencies = [ [[package]] name = "assist" -version = "0.2.2" +version = "0.3.0" dependencies = [ "candle-core", ] @@ -1175,7 +1175,7 @@ dependencies = [ [[package]] name = "config" -version = "0.2.2" +version = "0.3.0" [[package]] name = "console_error_panic_hook" @@ -3260,7 +3260,7 @@ dependencies = [ [[package]] name = "input" -version = "0.2.2" +version = "0.3.0" [[package]] name = "instant" @@ -3678,7 +3678,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" [[package]] name = "macros" -version = "0.2.2" +version = "0.3.0" [[package]] name = "malloc_buf" @@ -3701,7 +3701,7 @@ dependencies = [ [[package]] name = "mcp" -version = "0.2.2" +version = "0.3.0" dependencies = [ "serde_json", ] @@ -4655,7 +4655,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plugin" -version = "0.2.2" +version = "0.3.0" [[package]] name = "png" @@ -4846,7 +4846,7 @@ dependencies = [ [[package]] name = "pty" -version = "0.2.2" +version = "0.3.0" dependencies = [ "rustix 0.38.44", ] @@ -6160,7 +6160,7 @@ dependencies = [ [[package]] name = "terminal" -version = "0.2.2" +version = "0.3.0" dependencies = [ "pty", "vt", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "theme" -version = "0.2.2" +version = "0.3.0" [[package]] name = "thiserror" @@ -6801,7 +6801,7 @@ dependencies = [ [[package]] name = "vt" -version = "0.2.2" +version = "0.3.0" dependencies = [ "bitflags 2.13.0", "unicode-width", @@ -7794,7 +7794,7 @@ dependencies = [ [[package]] name = "workspace" -version = "0.2.2" +version = "0.3.0" [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index cad505c..9cf78c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd block = { path = "thirdparty/block" } [workspace.package] -version = "0.2.2" +version = "0.3.0" edition = "2021" license = "Apache-2.0" diff --git a/assets/icon512.png b/assets/icon512.png new file mode 100644 index 0000000..f73a986 Binary files /dev/null and b/assets/icon512.png differ diff --git a/assets/prompt.desktop b/assets/prompt.desktop new file mode 100644 index 0000000..ed4616a --- /dev/null +++ b/assets/prompt.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Prompt +GenericName=Terminal Emulator +Comment=A fast, modern terminal that gets out of your way +Exec=prompt +Icon=prompt +Terminal=false +Categories=System;TerminalEmulator;Utility; +Keywords=terminal;shell;command;console; +StartupWMClass=prompt diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 359f2e0..dbff858 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,13 +1,34 @@ [package] +autotests = false name = "app" version.workspace = true edition.workspace = true license.workspace = true +description = "A fast, modern terminal that gets out of your way" [[bin]] name = "prompt" path = "src/main.rs" +# Linux .deb packaging (see scripts/linux.sh). The `prompt` binary is added +# automatically; here we add the desktop entry and icon. Paths are relative to +# this crate directory. +[package.metadata.deb] +name = "prompt" +maintainer = "Wess Cope " +license-file = ["../../LICENSE", "0"] +section = "utils" +priority = "optional" +extended-description = "Prompt is a GPU-accelerated terminal emulator with tabs, recursive splits, live-reload config, and built-in themes." +# An explicit assets list disables cargo-deb's automatic binary inclusion, so +# the binary is listed here too. cargo-deb remaps the `target/release/` prefix +# to the actual --target/profile path. +assets = [ + ["target/release/prompt", "usr/bin/prompt", "755"], + ["../../assets/prompt.desktop", "usr/share/applications/prompt.desktop", "644"], + ["../../assets/icon512.png", "usr/share/pixmaps/prompt.png", "644"], +] + [dependencies] assist = { path = "../assist" } config = { path = "../config" } diff --git a/crates/app/src/boxdraw.rs b/crates/app/src/boxdraw.rs index 7313c45..b7d278b 100644 --- a/crates/app/src/boxdraw.rs +++ b/crates/app/src/boxdraw.rs @@ -104,94 +104,5 @@ fn blocks(ch: char, w: f32, h: f32) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn horizontal_line_is_a_centred_bar() { - let g = rects('\u{2500}', 8.0, 16.0).unwrap(); - let t = thickness(16.0); - assert_eq!(g.rects, vec![(0.0, ((16.0 - t) / 2.0).round(), 8.0, t)]); - assert_eq!(g.alpha, 1.0); - } - - #[test] - fn cross_has_both_bars() { - let g = rects('\u{253C}', 8.0, 16.0).unwrap(); - assert_eq!(g.rects.len(), 2); // h_full + v_full - } - - #[test] - fn corner_joins_two_arms() { - // ┌ spans right and down from centre. - let g = rects('\u{250C}', 10.0, 16.0).unwrap(); - assert_eq!(g.rects.len(), 2); - } - - #[test] - fn full_block_fills_cell() { - let g = rects('\u{2588}', 8.0, 16.0).unwrap(); - assert_eq!(g.rects, vec![(0.0, 0.0, 8.0, 16.0)]); - } - - #[test] - fn halves_cover_their_side() { - assert_eq!( - rects('\u{2580}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 0.0, 8.0, 8.0)] - ); - assert_eq!( - rects('\u{2584}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 8.0, 8.0, 8.0)] - ); - assert_eq!( - rects('\u{258C}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 0.0, 4.0, 16.0)] - ); - assert_eq!( - rects('\u{2590}', 8.0, 16.0).unwrap().rects, - vec![(4.0, 0.0, 4.0, 16.0)] - ); - } - - #[test] - fn shades_use_alpha() { - assert_eq!(rects('\u{2591}', 8.0, 16.0).unwrap().alpha, 0.25); - assert_eq!(rects('\u{2592}', 8.0, 16.0).unwrap().alpha, 0.5); - assert_eq!(rects('\u{2593}', 8.0, 16.0).unwrap().alpha, 0.75); - } - - #[test] - fn lower_eighths_grow_from_bottom() { - // ▁ = 1/8 tall at the bottom. - assert_eq!( - rects('\u{2581}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 14.0, 8.0, 2.0)] - ); - // ▇ = 7/8 tall. - assert_eq!( - rects('\u{2587}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 2.0, 8.0, 14.0)] - ); - } - - #[test] - fn left_eighths_grow_from_left() { - // ▏ = 1/8 wide on the left. - assert_eq!( - rects('\u{258F}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 0.0, 1.0, 16.0)] - ); - // ▉ = 7/8 wide. - assert_eq!( - rects('\u{2589}', 8.0, 16.0).unwrap().rects, - vec![(0.0, 0.0, 7.0, 16.0)] - ); - } - - #[test] - fn ordinary_characters_are_not_handled() { - assert!(rects('a', 8.0, 16.0).is_none()); - assert!(rects('═', 8.0, 16.0).is_none()); // heavy/double not yet covered - } -} +#[path = "../tests/boxdraw.rs"] +mod tests; diff --git a/crates/app/src/bridge.rs b/crates/app/src/bridge.rs index 811417d..5ed6e18 100644 --- a/crates/app/src/bridge.rs +++ b/crates/app/src/bridge.rs @@ -26,32 +26,5 @@ pub fn forward(events: Receiver) -> UnboundedReceiver { } #[cfg(test)] -mod tests { - use super::*; - use futures::StreamExt; - - #[test] - fn forwards_in_order_and_closes() { - let (tx, rx) = std::sync::mpsc::channel(); - tx.send(Event::Wakeup).unwrap(); - tx.send(Event::TitleChanged("t".to_string())).unwrap(); - tx.send(Event::Exit(Some(0))).unwrap(); - drop(tx); - let mut stream = forward(rx); - let collected = futures::executor::block_on(async { - let mut seen = Vec::new(); - while let Some(event) = stream.next().await { - seen.push(event); - } - seen - }); - assert_eq!( - collected, - vec![ - Event::Wakeup, - Event::TitleChanged("t".to_string()), - Event::Exit(Some(0)), - ] - ); - } -} +#[path = "../tests/bridge.rs"] +mod tests; diff --git a/crates/app/src/colors.rs b/crates/app/src/colors.rs index 1f7ad55..fedb857 100644 --- a/crates/app/src/colors.rs +++ b/crates/app/src/colors.rs @@ -107,142 +107,5 @@ pub fn hsla(c: Rgb) -> gpui::Hsla { } #[cfg(test)] -mod tests { - use super::*; - - fn colors() -> Colors { - from_config(&config::Options::default()) - } - - fn no_override(_: u8) -> Option<(u8, u8, u8)> { - None - } - - #[test] - fn default_maps_to_given_default() { - let c = colors(); - let fg = cell_rgb(vt::Color::Default, c.fg, false, &c.palette, no_override); - assert_eq!(fg, c.fg); - let bg = cell_rgb(vt::Color::Default, c.bg, false, &c.palette, no_override); - assert_eq!(bg, c.bg); - } - - #[test] - fn ansi_indices_come_from_scheme() { - let c = colors(); - let scheme = theme::default_scheme(); - for i in 0..16u8 { - let got = cell_rgb(vt::Color::Indexed(i), c.fg, false, &c.palette, no_override); - assert_eq!(got, scheme.ansi[i as usize], "index {i}"); - } - } - - #[test] - fn cube_and_grayscale_resolve() { - let c = colors(); - // 16 + 36*5 + 6*0 + 0 = 196 -> pure red in the xterm cube. - let red = cell_rgb( - vt::Color::Indexed(196), - c.fg, - false, - &c.palette, - no_override, - ); - assert_eq!(red, Rgb::new(255, 0, 0)); - // Grayscale ramp: 232 -> #080808, 255 -> #eeeeee. - let lo = cell_rgb( - vt::Color::Indexed(232), - c.fg, - false, - &c.palette, - no_override, - ); - assert_eq!(lo, Rgb::new(8, 8, 8)); - let hi = cell_rgb( - vt::Color::Indexed(255), - c.fg, - false, - &c.palette, - no_override, - ); - assert_eq!(hi, Rgb::new(0xee, 0xee, 0xee)); - } - - #[test] - fn bold_brightens_only_low_ansi() { - let c = colors(); - let scheme = theme::default_scheme(); - let bright = cell_rgb(vt::Color::Indexed(1), c.fg, true, &c.palette, no_override); - assert_eq!(bright, scheme.ansi[9]); - // Already-bright and extended indices are untouched. - let same = cell_rgb(vt::Color::Indexed(9), c.fg, true, &c.palette, no_override); - assert_eq!(same, scheme.ansi[9]); - let cube = cell_rgb(vt::Color::Indexed(196), c.fg, true, &c.palette, no_override); - assert_eq!(cube, Rgb::new(255, 0, 0)); - } - - #[test] - fn osc4_override_wins_after_brightening() { - let c = colors(); - let ovr = |i: u8| (i == 9).then_some((1u8, 2u8, 3u8)); - let got = cell_rgb(vt::Color::Indexed(1), c.fg, true, &c.palette, ovr); - assert_eq!(got, Rgb::new(1, 2, 3)); - // Index 1 itself is not overridden, so unbrightened stays themed. - let plain = cell_rgb(vt::Color::Indexed(1), c.fg, false, &c.palette, ovr); - assert_eq!(plain, theme::default_scheme().ansi[1]); - } - - #[test] - fn truecolor_passes_through() { - let c = colors(); - let got = cell_rgb( - vt::Color::Rgb(12, 34, 56), - c.fg, - true, - &c.palette, - no_override, - ); - assert_eq!(got, Rgb::new(12, 34, 56)); - } - - #[test] - fn config_overrides_apply() { - let mut opts = config::Options::default(); - opts.foreground = Some("#102030".to_string()); - opts.background = Some("#abcdef".to_string()); - opts.palette = vec![(1, "#ff0000".to_string()), (200, "#00ff00".to_string())]; - let c = from_config(&opts); - assert_eq!(c.fg, Rgb::new(0x10, 0x20, 0x30)); - assert_eq!(c.bg, Rgb::new(0xab, 0xcd, 0xef)); - assert_eq!(c.palette.get(1), Rgb::new(255, 0, 0)); - assert_eq!(c.palette.get(200), Rgb::new(0, 255, 0)); - } - - #[test] - fn selection_colors_come_from_scheme() { - let c = colors(); - let scheme = theme::default_scheme(); - assert_eq!(c.selection_bg, scheme.selection_background); - assert_eq!(c.selection_fg, scheme.selection_foreground); - } - - #[test] - fn bad_config_colors_fall_back() { - let mut opts = config::Options::default(); - opts.foreground = Some("nonsense".to_string()); - opts.palette = vec![(1, "alsobad".to_string())]; - let c = from_config(&opts); - let scheme = theme::default_scheme(); - assert_eq!(c.fg, scheme.foreground); - assert_eq!(c.palette.get(1), scheme.ansi[1]); - } - - #[test] - fn rgba_conversion_is_opaque_unit_range() { - let v = rgba(Rgb::new(255, 0, 128)); - assert_eq!(v.r, 1.0); - assert_eq!(v.g, 0.0); - assert!((v.b - 128.0 / 255.0).abs() < 1e-6); - assert_eq!(v.a, 1.0); - } -} +#[path = "../tests/colors.rs"] +mod tests; diff --git a/crates/app/src/element.rs b/crates/app/src/element.rs index 0c6a9c2..99fe9bd 100644 --- a/crates/app/src/element.rs +++ b/crates/app/src/element.rs @@ -696,188 +696,5 @@ impl IntoElement for TerminalElement { } #[cfg(test)] -mod tests { - use super::*; - - fn test_colors() -> Colors { - colors::from_config(&config::Options::default()) - } - - #[test] - fn snapshot_batches_plain_text_into_one_span() { - let mut term = vt::Terminal::new(20, 4, 0); - term.feed(b"hello world"); - let snap = snapshot(&mut term, &test_colors(), None); - // Spaces between words break spans (they are skipped). - assert_eq!(snap.spans.len(), 2); - assert_eq!(snap.spans[0].text, "hello"); - assert_eq!(snap.spans[0].col, 0); - assert_eq!(snap.spans[1].text, "world"); - assert_eq!(snap.spans[1].col, 6); - assert!(snap.bg_runs.is_empty()); - } - - #[test] - fn snapshot_splits_spans_on_style_change() { - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"ab\x1b[1mcd\x1b[0mef"); - let snap = snapshot(&mut term, &test_colors(), None); - let texts: Vec<&str> = snap.spans.iter().map(|s| s.text.as_str()).collect(); - assert_eq!(texts, vec!["ab", "cd", "ef"]); - assert!(snap.spans[1].flags.contains(CellFlags::BOLD)); - } - - #[test] - fn snapshot_merges_background_runs() { - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"\x1b[41mabc\x1b[0m"); - let snap = snapshot(&mut term, &test_colors(), None); - assert_eq!(snap.bg_runs.len(), 1); - let run = &snap.bg_runs[0]; - assert_eq!((run.row, run.col, run.len), (0, 0, 3)); - assert_eq!(run.color, theme::default_scheme().ansi[1]); - } - - #[test] - fn snapshot_inverse_swaps_colors() { - let colors = test_colors(); - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"\x1b[7mx\x1b[0m"); - let snap = snapshot(&mut term, &colors, None); - assert_eq!(snap.spans[0].fg, colors.bg); - assert_eq!(snap.bg_runs[0].color, colors.fg); - } - - #[test] - fn snapshot_skips_invisible_but_keeps_background() { - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"\x1b[8;41mhid\x1b[0m"); - let snap = snapshot(&mut term, &test_colors(), None); - assert!(snap.spans.is_empty()); - assert_eq!(snap.bg_runs.len(), 1); - } - - #[test] - fn snapshot_wide_char_is_its_own_span() { - let mut term = vt::Terminal::new(20, 2, 0); - term.feed("a世b".as_bytes()); - let snap = snapshot(&mut term, &test_colors(), None); - let texts: Vec<&str> = snap.spans.iter().map(|s| s.text.as_str()).collect(); - assert_eq!(texts, vec!["a", "世", "b"]); - assert_eq!(snap.spans[1].width, 2); - // The cell after the wide char sits two columns later. - assert_eq!(snap.spans[2].col, 3); - } - - #[test] - fn snapshot_cursor_follows_visibility() { - let colors = test_colors(); - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"hi"); - let snap = snapshot(&mut term, &colors, None); - let cursor = snap.cursor.expect("visible by default"); - assert_eq!((cursor.row, cursor.col), (0, 2)); - assert_eq!(cursor.color, colors.cursor); - term.feed(b"\x1b[?25l"); - let snap = snapshot(&mut term, &colors, None); - assert!(snap.cursor.is_none()); - } - - #[test] - fn snapshot_cursor_honors_osc12_color() { - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"\x1b]12;rgb:ff/00/00\x07"); - let snap = snapshot(&mut term, &test_colors(), None); - assert_eq!(snap.cursor.expect("cursor").color, Rgb::new(255, 0, 0)); - } - - #[test] - fn cursor_shape_mapping() { - use config::CursorStyle as C; - use vt::CursorStyle as V; - // Power-on default defers to config. - assert_eq!(cursor_shape(V::BlinkingBlock, C::Bar), C::Bar); - assert_eq!(cursor_shape(V::BlinkingBlock, C::Block), C::Block); - // Explicit DECSCUSR wins. - assert_eq!(cursor_shape(V::SteadyBlock, C::Bar), C::Block); - assert_eq!(cursor_shape(V::BlinkingBar, C::Block), C::Bar); - assert_eq!(cursor_shape(V::SteadyUnderline, C::Block), C::Underline); - } - - #[test] - fn snapshot_underlined_space_is_kept() { - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"\x1b[4m \x1b[0m"); - let snap = snapshot(&mut term, &test_colors(), None); - assert_eq!(snap.spans.len(), 1); - assert!(snap.spans[0].flags.contains(CellFlags::UNDERLINE)); - } - - #[test] - fn snapshot_selection_overrides_colors() { - // Default fg must differ from selection fg for the span split. - let mut opts = config::Options::default(); - opts.foreground = Some("#abb2bf".to_string()); - let colors = colors::from_config(&opts); - assert_ne!(colors.fg, colors.selection_fg); - let mut term = vt::Terminal::new(20, 2, 0); - term.feed(b"hello"); - term.start_selection(vt::SelectionMode::Cell, vt::Point::new(0, 1)); - term.update_selection(vt::Point::new(0, 3)); - let snap = snapshot(&mut term, &colors, None); - // "h" + "ell" (selected) + "o". - let texts: Vec<&str> = snap.spans.iter().map(|s| s.text.as_str()).collect(); - assert_eq!(texts, vec!["h", "ell", "o"]); - assert_eq!(snap.spans[0].fg, colors.fg); - assert_eq!(snap.spans[1].fg, colors.selection_fg); - assert_eq!(snap.spans[2].fg, colors.fg); - // Selected cells paint the selection background. - assert_eq!(snap.bg_runs.len(), 1); - let run = &snap.bg_runs[0]; - assert_eq!((run.row, run.col, run.len), (0, 1, 3)); - assert_eq!(run.color, colors.selection_bg); - } - - #[test] - fn snapshot_selection_honors_display_offset() { - let colors = test_colors(); - let mut term = vt::Terminal::new(10, 2, 10); - term.feed(b"old\r\na\r\nb\r\nc"); // pushes rows into scrollback - // Select the scrollback line holding "old" (line -2). - term.start_selection(vt::SelectionMode::Cell, vt::Point::new(-2, 0)); - term.update_selection(vt::Point::new(-2, 2)); - // At the live bottom the selected row is off screen: no override. - let snap = snapshot(&mut term, &colors, None); - assert!(snap.bg_runs.iter().all(|r| r.color != colors.selection_bg)); - // Scrolled back so the row is visible, the override applies. - term.scroll_display(2); - let snap = snapshot(&mut term, &colors, None); - assert!(snap.bg_runs.iter().any(|r| r.color == colors.selection_bg)); - assert_eq!(snap.offset, 2); - assert_eq!(snap.scrollback, 2); - } - - #[test] - fn scroll_indicator_only_when_scrolled_back() { - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(400.0))); - assert!(scroll_indicator(&bounds, 24, 0, 100).is_none()); - assert!(scroll_indicator(&bounds, 24, 0, 0).is_none()); - let thumb = scroll_indicator(&bounds, 24, 50, 100).expect("visible"); - // Hugs the right edge at the configured width. - assert_eq!(thumb.origin.x, px(100.0 - INDICATOR_WIDTH)); - assert_eq!(thumb.size.width, px(INDICATOR_WIDTH)); - assert!(f32::from(thumb.size.height) >= INDICATOR_MIN); - } - - #[test] - fn scroll_indicator_tracks_position() { - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(400.0))); - // Fully scrolled back: thumb at the top. - let top = scroll_indicator(&bounds, 24, 100, 100).expect("top"); - assert_eq!(top.origin.y, px(0.0)); - // Barely scrolled: thumb near the bottom, still inside bounds. - let low = scroll_indicator(&bounds, 24, 1, 100).expect("low"); - assert!(low.origin.y > top.origin.y); - assert!(f32::from(low.origin.y) + f32::from(low.size.height) <= 400.0 + 1e-3); - } -} +#[path = "../tests/element.rs"] +mod tests; diff --git a/crates/app/src/font.rs b/crates/app/src/font.rs index 0377b42..6c59ab9 100644 --- a/crates/app/src/font.rs +++ b/crates/app/src/font.rs @@ -65,47 +65,5 @@ fn valid_tag(tag: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - fn feats(entries: &[&str]) -> Vec<(String, u32)> { - let owned: Vec = entries.iter().map(|s| s.to_string()).collect(); - features(&owned).0.as_ref().clone() - } - - #[test] - fn parses_sign_and_value_forms() { - assert_eq!( - feats(&["liga", "+ss01", "-calt", "cv01=2"]), - vec![ - ("liga".into(), 1), - ("ss01".into(), 1), - ("calt".into(), 0), - ("cv01".into(), 2), - ] - ); - } - - #[test] - fn skips_invalid_entries() { - // Too-long tag, empty, and non-numeric value are dropped. - assert_eq!(feats(&["toolongtag", "", "x=abc"]), Vec::new()); - } - - #[test] - fn build_uses_primary_and_fallbacks() { - let mut opts = config::Options::default(); - opts.font_family = vec!["JetBrains Mono".into(), "Menlo".into()]; - let font = build(&opts); - assert_eq!(font.family.as_ref(), "JetBrains Mono"); - let fb = font.fallbacks.expect("fallbacks"); - assert_eq!(fb.fallback_list(), ["Menlo"]); - } - - #[test] - fn build_defaults_to_menlo_without_config() { - let font = build(&config::Options::default()); - assert_eq!(font.family.as_ref(), "Menlo"); - assert!(font.fallbacks.is_none()); - } -} +#[path = "../tests/font.rs"] +mod tests; diff --git a/crates/app/src/keys.rs b/crates/app/src/keys.rs index 660fc7e..1c250f8 100644 --- a/crates/app/src/keys.rs +++ b/crates/app/src/keys.rs @@ -41,93 +41,5 @@ fn gpui_key(key: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn mods(ctrl: bool, shift: bool, alt: bool, cmd: bool) -> config::Mods { - config::Mods { - ctrl, - shift, - alt, - cmd, - } - } - - #[test] - fn plain_and_modified_keys() { - assert_eq!( - keystroke(mods(false, false, false, true), "t").unwrap(), - "secondary-t" - ); - assert_eq!( - keystroke(mods(false, true, false, true), "d").unwrap(), - "shift-secondary-d" - ); - assert_eq!( - keystroke(mods(true, false, true, false), "x").unwrap(), - "ctrl-alt-x" - ); - assert_eq!(keystroke(config::Mods::default(), "a").unwrap(), "a"); - } - - #[test] - fn modifier_order_is_canonical() { - // ctrl, alt, shift, cmd(secondary) regardless of how many are set. - assert_eq!( - keystroke(mods(true, true, true, true), "k").unwrap(), - "ctrl-alt-shift-secondary-k" - ); - } - - #[test] - fn paged_keys_are_renamed() { - assert_eq!( - keystroke(mods(false, true, false, false), "page_up").unwrap(), - "shift-pageup" - ); - assert_eq!( - keystroke(mods(false, true, false, false), "page_down").unwrap(), - "shift-pagedown" - ); - } - - #[test] - fn punctuation_keys_pass_through() { - // The minus key with cmd renders as `secondary--`, which gpui parses - // as the platform modifier + the `-` key. - assert_eq!( - keystroke(mods(false, false, false, true), "-").unwrap(), - "secondary--" - ); - assert_eq!( - keystroke(mods(false, false, false, true), "+").unwrap(), - "secondary-+" - ); - assert_eq!( - keystroke(mods(false, false, false, true), "=").unwrap(), - "secondary-=" - ); - assert_eq!( - keystroke(mods(false, false, false, true), ",").unwrap(), - "secondary-," - ); - } - - #[test] - fn named_keys_pass_through() { - for k in ["enter", "tab", "escape", "space", "up", "home", "f5"] { - assert_eq!(keystroke(config::Mods::default(), k).unwrap(), k); - } - } - - #[test] - fn every_emitted_default_binding_parses_in_gpui() { - // The whole default set must produce gpui-parseable keystrokes, - // so binding them at startup never panics. - for kb in config::default_keybinds() { - let ks = keystroke(kb.mods, &kb.key) - .unwrap_or_else(|| panic!("no keystroke for {:?}+{}", kb.mods, kb.key)); - gpui::Keystroke::parse(&ks).unwrap_or_else(|e| panic!("gpui rejected {ks:?}: {e:?}")); - } - } -} +#[path = "../tests/keys.rs"] +mod tests; diff --git a/crates/app/src/linux.rs b/crates/app/src/linux.rs index a0ffcd8..44c6284 100644 --- a/crates/app/src/linux.rs +++ b/crates/app/src/linux.rs @@ -29,7 +29,7 @@ pub fn is_wayland() -> bool { /// normal window on X11 (decorated by [`make_overlay`]). pub fn window_kind() -> WindowKind { if is_wayland() { - use gpui::{Anchor, KeyboardInteractivity, Layer, LayerShellOptions}; + use gpui::layer_shell::{Anchor, KeyboardInteractivity, Layer, LayerShellOptions}; WindowKind::LayerShell(LayerShellOptions { namespace: "prompt-quick".to_string(), layer: Layer::Overlay, @@ -76,7 +76,7 @@ fn x11_overlay(window: &Window) -> Result<(), Box> { let above = atom(b"_NET_WM_STATE_ABOVE")?; let sticky = atom(b"_NET_WM_STATE_STICKY")?; conn.change_property32( - PropMode::Replace, + PropMode::REPLACE, win, wm_state, AtomEnum::ATOM, @@ -84,7 +84,7 @@ fn x11_overlay(window: &Window) -> Result<(), Box> { )?; let desktop = atom(b"_NET_WM_DESKTOP")?; conn.change_property32( - PropMode::Replace, + PropMode::REPLACE, win, desktop, AtomEnum::CARDINAL, @@ -93,7 +93,7 @@ fn x11_overlay(window: &Window) -> Result<(), Box> { // Borderless: Motif hints with the decorations flag set and no decorations. let motif = atom(b"_MOTIF_WM_HINTS")?; - conn.change_property32(PropMode::Replace, win, motif, motif, &[2, 0, 0, 0, 0])?; + conn.change_property32(PropMode::REPLACE, win, motif, motif, &[2, 0, 0, 0, 0])?; conn.flush()?; Ok(()) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 705559d..680be1b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -117,6 +117,9 @@ pub fn open_window( appears_transparent: true, traffic_light_position: Some(point(px(9.0), px(9.0))), }), + // Matches the `.desktop` StartupWMClass so Linux associates the window + // with our icon in the taskbar/dock. + app_id: Some("prompt".into()), ..Default::default() }; #[cfg(target_os = "linux")] diff --git a/crates/app/src/metrics.rs b/crates/app/src/metrics.rs index ac6b098..ce802fe 100644 --- a/crates/app/src/metrics.rs +++ b/crates/app/src/metrics.rs @@ -85,103 +85,5 @@ pub fn measure( } #[cfg(test)] -mod tests { - use super::*; - - const CELL: CellSize = CellSize { - width: 8.0, - height: 17.0, - }; - const PAD: Padding = Padding { x: 2.0, y: 2.0 }; - - #[test] - fn grid_size_floors_partial_cells() { - // 644 - 4 = 640 / 8 = 80 cols; 412.8 - 4 = 408.8 / 17 = 24.04 -> 24. - assert_eq!(grid_size(644.0, 412.8, PAD, CELL), (80, 24)); - // One pixel short of a column. - assert_eq!(grid_size(643.0, 412.8, PAD, CELL), (79, 24)); - } - - #[test] - fn grid_size_clamps_minimums() { - assert_eq!(grid_size(0.0, 0.0, PAD, CELL), (2, 1)); - assert_eq!(grid_size(-50.0, 5.0, PAD, CELL), (2, 1)); - assert_eq!(grid_size(10.0, 18.0, Padding::default(), CELL), (2, 1)); - } - - #[test] - fn pixel_size_includes_padding_on_both_sides() { - assert_eq!(pixel_size(80, 24, PAD, CELL), (644.0, 412.0)); - assert_eq!(pixel_size(80, 24, Padding::default(), CELL), (640.0, 408.0)); - } - - #[test] - fn round_trips_exact_grids() { - let (w, h) = pixel_size(120, 40, PAD, CELL); - assert_eq!(grid_size(w, h, PAD, CELL), (120, 40)); - } - - #[test] - fn line_height_factor_is_sane() { - assert!(LINE_HEIGHT > 1.0 && LINE_HEIGHT < 2.0); - } - - #[test] - fn cell_at_accounts_for_origin_and_padding() { - // Window origin (100, 50), pad 2: cell (0,0) spans x 102..110. - assert_eq!( - cell_at((102.0, 52.0), (100.0, 50.0), PAD, CELL, 80, 24), - (0, 0) - ); - assert_eq!( - cell_at((109.9, 68.9), (100.0, 50.0), PAD, CELL, 80, 24), - (0, 0) - ); - // One pixel into the next cell each way. - assert_eq!( - cell_at((110.0, 69.0), (100.0, 50.0), PAD, CELL, 80, 24), - (1, 1) - ); - // Mid-grid. - assert_eq!( - cell_at( - (102.0 + 8.0 * 10.0, 52.0 + 17.0 * 3.0), - (100.0, 50.0), - PAD, - CELL, - 80, - 24 - ), - (3, 10) - ); - } - - #[test] - fn cell_at_clamps_to_grid() { - // Inside the padding band, above/left of cell 0. - assert_eq!(cell_at((0.0, 0.0), (0.0, 0.0), PAD, CELL, 80, 24), (0, 0)); - // Way past the bottom-right corner. - assert_eq!( - cell_at((9999.0, 9999.0), (0.0, 0.0), PAD, CELL, 80, 24), - (23, 79) - ); - // Negative positions (drag left/above the window). - assert_eq!( - cell_at((-50.0, -50.0), (0.0, 0.0), PAD, CELL, 80, 24), - (0, 0) - ); - // Degenerate grid never underflows. - assert_eq!(cell_at((5.0, 5.0), (0.0, 0.0), PAD, CELL, 0, 0), (0, 0)); - } - - #[test] - fn selection_point_maps_display_offset() { - // Live view: viewport row == content line. - assert_eq!(selection_point(0, 3, 0), vt::Point::new(0, 3)); - assert_eq!(selection_point(5, 0, 0), vt::Point::new(5, 0)); - // Scrolled back 4 lines: top viewport row shows scrollback line -4. - assert_eq!(selection_point(0, 1, 4), vt::Point::new(-4, 1)); - assert_eq!(selection_point(4, 1, 4), vt::Point::new(0, 1)); - assert_eq!(selection_point(6, 9, 4), vt::Point::new(2, 9)); - } -} +#[path = "../tests/metrics.rs"] +mod tests; diff --git a/crates/app/src/mouse.rs b/crates/app/src/mouse.rs index b39c195..9d67983 100644 --- a/crates/app/src/mouse.rs +++ b/crates/app/src/mouse.rs @@ -88,82 +88,5 @@ pub fn wheel_lines(acc: &mut f32, delta: f32) -> i32 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reporting_requires_mode_and_no_shift() { - assert!(!reports(MouseMode::None, false)); - assert!(reports(MouseMode::Click, false)); - assert!(reports(MouseMode::Drag, false)); - assert!(reports(MouseMode::Motion, false)); - // Shift always bypasses reporting. - assert!(!reports(MouseMode::Motion, true)); - assert!(!reports(MouseMode::Click, true)); - } - - #[test] - fn motion_reporting_per_mode() { - let held = Some(MouseButton::Left); - assert!(!reports_motion(MouseMode::None, held)); - assert!(!reports_motion(MouseMode::Click, held)); - assert!(reports_motion(MouseMode::Drag, held)); - assert!(!reports_motion(MouseMode::Drag, None)); - assert!(reports_motion(MouseMode::Motion, held)); - assert!(reports_motion(MouseMode::Motion, None)); - } - - #[test] - fn click_counts_map_to_modes() { - assert_eq!(click_mode(0), SelectionMode::Cell); // defensive - assert_eq!(click_mode(1), SelectionMode::Cell); - assert_eq!(click_mode(2), SelectionMode::Word); - assert_eq!(click_mode(3), SelectionMode::Line); - // Rapid clicking cycles. - assert_eq!(click_mode(4), SelectionMode::Cell); - assert_eq!(click_mode(5), SelectionMode::Word); - assert_eq!(click_mode(6), SelectionMode::Line); - } - - #[test] - fn wheel_routing_precedence() { - use WheelRoute::*; - // Reporting wins over everything when active and shift is up. - assert_eq!(route_wheel(MouseMode::Click, false, true, true), Report); - assert_eq!(route_wheel(MouseMode::Motion, false, false, false), Report); - // Shift bypasses reporting; alt scroll applies on the alt screen. - assert_eq!(route_wheel(MouseMode::Motion, true, true, true), Display); - assert_eq!(route_wheel(MouseMode::None, false, true, true), Arrows); - // Alt scroll needs both the alt screen and the mode. - assert_eq!(route_wheel(MouseMode::None, false, true, false), Display); - assert_eq!(route_wheel(MouseMode::None, false, false, true), Display); - assert_eq!(route_wheel(MouseMode::None, false, false, false), Display); - } - - #[test] - fn wheel_lines_accumulates_fractions() { - let mut acc = 0.0; - assert_eq!(wheel_lines(&mut acc, 0.4), 0); - assert_eq!(wheel_lines(&mut acc, 0.4), 0); - assert_eq!(wheel_lines(&mut acc, 0.4), 1); // 1.2 -> 1, carry 0.2 - assert!((acc - 0.2).abs() < 1e-6); - assert_eq!(wheel_lines(&mut acc, 2.0), 2); - } - - #[test] - fn wheel_lines_whole_deltas_pass_through() { - let mut acc = 0.0; - assert_eq!(wheel_lines(&mut acc, 3.0), 3); - assert_eq!(wheel_lines(&mut acc, -2.0), -2); - assert_eq!(acc, 0.0); - } - - #[test] - fn wheel_lines_direction_change_drops_fraction() { - let mut acc = 0.0; - assert_eq!(wheel_lines(&mut acc, 0.9), 0); - // Reversing direction must not fight the stale +0.9. - assert_eq!(wheel_lines(&mut acc, -1.0), -1); - assert_eq!(acc, 0.0); - } -} +#[path = "../tests/mouse.rs"] +mod tests; diff --git a/crates/app/src/session.rs b/crates/app/src/session.rs index b2760fc..d0ac4d5 100644 --- a/crates/app/src/session.rs +++ b/crates/app/src/session.rs @@ -90,91 +90,5 @@ fn percentdecode(s: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn options_default_is_login_shell() { - let opts = config::Options::default(); - let session = options(&opts, 100, 30, None); - assert_eq!((session.cols, session.rows), (100, 30)); - assert_eq!(session.scrollback_limit, 10_000); - assert!(session.spawn.login); - // With no inherit and no configured directory, defaults to home. - assert_eq!(session.spawn.cwd, home()); - } - - #[test] - fn options_honors_command_and_cwd() { - let mut opts = config::Options::default(); - opts.shell = Some("/bin/bash -i".to_string()); - opts.working_directory = Some("/tmp".to_string()); - opts.scrollback_limit = 42; - let session = options(&opts, 80, 24, None); - assert_eq!(session.spawn.argv, vec!["/bin/bash", "-i"]); - assert!(!session.spawn.login); - assert_eq!(session.spawn.cwd, Some(PathBuf::from("/tmp"))); - assert_eq!(session.scrollback_limit, 42); - } - - #[test] - fn options_empty_command_falls_back_to_shell() { - let mut opts = config::Options::default(); - opts.shell = Some(" ".to_string()); - let session = options(&opts, 80, 24, None); - assert!(session.spawn.login); - assert!(!session.spawn.argv.is_empty()); - } - - #[test] - fn options_inherited_cwd_beats_config() { - let mut opts = config::Options::default(); - opts.working_directory = Some("/tmp".to_string()); - let session = options(&opts, 80, 24, Some(PathBuf::from("/work"))); - assert_eq!(session.spawn.cwd, Some(PathBuf::from("/work"))); - } - - #[test] - fn shellname_takes_basename_of_first_word() { - assert_eq!(shellname(Some("/bin/bash -i")), "bash"); - assert_eq!(shellname(Some("zsh")), "zsh"); - assert_eq!(shellname(Some("/usr/local/bin/fish --login")), "fish"); - } - - #[test] - fn shellname_defaults_to_user_shell() { - assert!(!shellname(None).is_empty()); - assert!(!shellname(Some(" ")).is_empty()); - } - - #[test] - fn cwdpath_parses_file_urls() { - assert_eq!( - cwdpath("file://host/Users/me"), - Some(PathBuf::from("/Users/me")) - ); - assert_eq!(cwdpath("file:///tmp"), Some(PathBuf::from("/tmp"))); - assert_eq!( - cwdpath("file://host/a%20dir/b"), - Some(PathBuf::from("/a dir/b")) - ); - } - - #[test] - fn cwdpath_accepts_plain_paths_rejects_junk() { - assert_eq!(cwdpath("/var/log"), Some(PathBuf::from("/var/log"))); - assert_eq!(cwdpath("relative/path"), None); - assert_eq!(cwdpath(""), None); - assert_eq!(cwdpath("file://hostonly"), None); - } - - #[test] - fn percentdecode_handles_malformed_escapes() { - assert_eq!(percentdecode("/a%2fb"), "/a/b"); - assert_eq!(percentdecode("/x%zz"), "/x%zz"); - assert_eq!(percentdecode("/trail%2"), "/trail%2"); - assert_eq!(percentdecode("/plain"), "/plain"); - // Multi-byte characters after % must not split a codepoint. - assert_eq!(percentdecode("/x%éy"), "/x%éy"); - } -} +#[path = "../tests/session.rs"] +mod tests; diff --git a/crates/app/src/splits.rs b/crates/app/src/splits.rs index e068682..85bb458 100644 --- a/crates/app/src/splits.rs +++ b/crates/app/src/splits.rs @@ -347,97 +347,5 @@ impl IntoElement for SplitsElement { } #[cfg(test)] -mod tests { - use super::*; - use workspace::PaneIds; - - fn ids(n: usize) -> Vec { - let mut alloc = PaneIds::new(); - (0..n).map(|_| alloc.next()).collect() - } - - #[test] - fn splitrect_root_is_full_rect() { - let p = ids(2); - let mut tree = PaneTree::new(p[0]); - let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let rect = Rect::new(10.0, 20.0, 100.0, 50.0); - assert_eq!(splitrect(&tree, rect, 2.0, s), Some(rect)); - } - - #[test] - fn splitrect_nested_matches_compute_layout() { - // a | (b over c): the inner split spans exactly b + divider + c. - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - let rect = Rect::new(0.0, 0.0, 104.0, 104.0); - let layout = compute_layout(&tree, rect, 4.0); - - assert_eq!(splitrect(&tree, rect, 4.0, outer), Some(rect)); - let irect = splitrect(&tree, rect, 4.0, inner).expect("inner rect"); - let b = layout.pane_rect(p[1]).unwrap(); - let c = layout.pane_rect(p[2]).unwrap(); - assert_eq!((irect.x, irect.y), (b.x, b.y)); - assert_eq!(irect.w, b.w); - assert_eq!(irect.h, b.h + 4.0 + c.h); - // The inner divider sits inside the inner split's rect. - let (_, drect, _) = layout.dividers[1]; - assert!(drect.y >= irect.y && drect.y + drect.h <= irect.y + irect.h); - } - - #[test] - fn splitrect_honors_nondefault_ratio() { - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - tree.set_ratio(outer, 0.25); - let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - let rect = Rect::new(0.0, 0.0, 102.0, 60.0); - // avail = 100, first = 25, divider 2: inner starts at x = 27. - let irect = splitrect(&tree, rect, 2.0, inner).expect("inner"); - assert_eq!(irect, Rect::new(27.0, 0.0, 75.0, 60.0)); - } - - #[test] - fn splitrect_missing_split_is_none() { - // Mint an id the queried trees never allocated: a second split in - // a throwaway tree (per-tree counters make it id 2). - let p = ids(3); - let mut donor = PaneTree::new(p[0]); - donor.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let foreign = donor.split(p[1], Axis::Vertical, p[2], false).unwrap(); - - let rect = Rect::new(0.0, 0.0, 10.0, 10.0); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - assert_eq!(splitrect(&tree, rect, 1.0, foreign), None); - let single = PaneTree::new(p[0]); - assert_eq!(splitrect(&single, rect, 1.0, foreign), None); - } - - #[test] - fn dragratio_tracks_pointer_horizontally() { - let rect = Rect::new(0.0, 0.0, 102.0, 50.0); - // Pointer at the divider center for ratio 0.5: x = 50 + 1. - let r = dragratio(rect, Axis::Horizontal, 2.0, (51.0, 0.0)); - assert!((r - 0.5).abs() < 1e-6); - let r = dragratio(rect, Axis::Horizontal, 2.0, (26.0, 999.0)); - assert!((r - 0.25).abs() < 1e-6); - } - - #[test] - fn dragratio_vertical_uses_y_and_offset() { - let rect = Rect::new(0.0, 100.0, 80.0, 102.0); - let r = dragratio(rect, Axis::Vertical, 2.0, (0.0, 151.0)); - assert!((r - 0.5).abs() < 1e-6); - } - - #[test] - fn dragratio_clamps_to_bounds() { - let rect = Rect::new(0.0, 0.0, 102.0, 50.0); - assert_eq!(dragratio(rect, Axis::Horizontal, 2.0, (-50.0, 0.0)), 0.1); - assert_eq!(dragratio(rect, Axis::Horizontal, 2.0, (500.0, 0.0)), 0.9); - } -} +#[path = "../tests/splits.rs"] +mod tests; diff --git a/crates/app/src/tabbar.rs b/crates/app/src/tabbar.rs index d650e14..63a9e63 100644 --- a/crates/app/src/tabbar.rs +++ b/crates/app/src/tabbar.rs @@ -105,26 +105,5 @@ pub fn tabs( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn blend_endpoints_and_midpoint() { - let a = Rgb::new(0, 0, 0); - let b = Rgb::new(255, 255, 255); - assert_eq!(blend(a, b, 0.0), a); - assert_eq!(blend(a, b, 1.0), b); - assert_eq!(blend(a, b, 0.5), Rgb::new(128, 128, 128)); - // Out-of-range t clamps. - assert_eq!(blend(a, b, -1.0), a); - assert_eq!(blend(a, b, 2.0), b); - } - - #[test] - fn blend_mixes_channels_independently() { - let a = Rgb::new(10, 200, 0); - let b = Rgb::new(20, 100, 255); - let m = blend(a, b, 0.1); - assert_eq!(m, Rgb::new(11, 190, 26)); - } -} +#[path = "../tests/tabbar.rs"] +mod tests; diff --git a/crates/app/src/textedit.rs b/crates/app/src/textedit.rs index 0814c08..bf571f7 100644 --- a/crates/app/src/textedit.rs +++ b/crates/app/src/textedit.rs @@ -77,56 +77,5 @@ impl TextEdit { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_places_cursor_at_end() { - let e = TextEdit::new("abc"); - assert_eq!(e.split(), ("abc".into(), "".into())); - } - - #[test] - fn insert_at_cursor() { - let mut e = TextEdit::new("ac"); - e.left(); - e.insert("b"); - assert_eq!(e.text(), "abc"); - assert_eq!(e.split(), ("ab".into(), "c".into())); - } - - #[test] - fn backspace_and_delete() { - let mut e = TextEdit::new("abc"); - assert!(e.backspace()); // removes 'c' - assert_eq!(e.text(), "ab"); - e.home(); - assert!(e.delete()); // removes 'a' - assert_eq!(e.text(), "b"); - // Boundaries are no-ops. - e.home(); - assert!(!e.backspace()); - e.end(); - assert!(!e.delete()); - } - - #[test] - fn cursor_movement_clamps() { - let mut e = TextEdit::new("ab"); - e.home(); - e.left(); - assert_eq!(e.split(), ("".into(), "ab".into())); - e.end(); - e.right(); - assert_eq!(e.split(), ("ab".into(), "".into())); - } - - #[test] - fn handles_unicode() { - let mut e = TextEdit::new("café"); - assert!(e.backspace()); // drops 'é' as one unit - assert_eq!(e.text(), "caf"); - e.insert("é"); - assert_eq!(e.text(), "café"); - } -} +#[path = "../tests/textedit.rs"] +mod tests; diff --git a/crates/app/src/view.rs b/crates/app/src/view.rs index e6fc2db..9d0d810 100644 --- a/crates/app/src/view.rs +++ b/crates/app/src/view.rs @@ -1061,89 +1061,5 @@ impl Render for TerminalView { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn label_prefers_nonblank_title() { - assert_eq!(label(Some("vim"), "zsh"), "vim"); - assert_eq!(label(Some(""), "zsh"), "zsh"); - assert_eq!(label(Some(" "), "zsh"), "zsh"); - assert_eq!(label(None, "zsh"), "zsh"); - } - - const OPT: input::Mods = input::Mods { - shift: false, - alt: true, - ctrl: false, - cmd: false, - }; - - fn policy( - p: config::OptionAsAlt, - held: bool, - key: &str, - key_char: Option<&str>, - ) -> (input::Mods, Option) { - let (mods, text) = option_policy(p, true, held, key, key_char, OPT); - (mods, text.map(str::to_string)) - } - - #[test] - fn option_off_drops_alt_and_keeps_composed_glyph() { - // Default policy: Option composes. Alt is cleared (so arrows stay - // plain) and the glyph macOS produced is emitted verbatim. - let (mods, text) = policy(config::OptionAsAlt::False, true, "b", Some("\u{222b}")); - assert!(!mods.alt); - assert_eq!(text.as_deref(), Some("\u{222b}")); - // Arrow: no text, alt cleared -> encode_key emits the plain `ESC[D`. - let (mods, text) = policy(config::OptionAsAlt::False, true, "left", None); - assert!(!mods.alt); - assert_eq!(text, None); - assert_eq!( - input::encode_key("left", text.as_deref(), mods, STATE), - Some(b"\x1b[D".to_vec()) - ); - } - - #[test] - fn option_as_alt_meta_prefixes_base_key() { - // Option = Alt/Meta: a letter ESC-prefixes its *base* key, not the - // composed glyph, so Option+b -> `ESC b`. - let (mods, text) = policy(config::OptionAsAlt::True, true, "b", Some("\u{222b}")); - assert!(mods.alt); - assert_eq!(text.as_deref(), Some("b")); - assert_eq!( - input::encode_key("b", text.as_deref(), mods, STATE), - Some(b"\x1bb".to_vec()) - ); - // Arrow keeps alt -> `ESC[1;3D`. - let (mods, text) = policy(config::OptionAsAlt::Left, true, "left", None); - assert_eq!( - input::encode_key("left", text.as_deref(), mods, STATE), - Some(b"\x1b[1;3D".to_vec()) - ); - } - - #[test] - fn non_macos_and_no_option_pass_through() { - // Off the macOS path, text passes through untouched even with the - // compose policy. - let (mods, text) = - option_policy(config::OptionAsAlt::False, false, true, "b", Some("b"), OPT); - assert!(mods.alt); - assert_eq!(text, Some("b")); - // Option not actually held: untouched regardless of policy. - let (mods, text) = - option_policy(config::OptionAsAlt::False, true, false, "b", Some("b"), OPT); - assert!(mods.alt); - assert_eq!(text, Some("b")); - } - - const STATE: input::TermState = input::TermState { - cursor_keys_app: false, - keypad_app: false, - bracketed_paste: false, - kitty_flags: 0, - }; -} +#[path = "../tests/view.rs"] +mod tests; diff --git a/crates/app/tests/boxdraw.rs b/crates/app/tests/boxdraw.rs new file mode 100644 index 0000000..ce0d60a --- /dev/null +++ b/crates/app/tests/boxdraw.rs @@ -0,0 +1,89 @@ +use super::*; + +#[test] +fn horizontal_line_is_a_centred_bar() { + let g = rects('\u{2500}', 8.0, 16.0).unwrap(); + let t = thickness(16.0); + assert_eq!(g.rects, vec![(0.0, ((16.0 - t) / 2.0).round(), 8.0, t)]); + assert_eq!(g.alpha, 1.0); +} + +#[test] +fn cross_has_both_bars() { + let g = rects('\u{253C}', 8.0, 16.0).unwrap(); + assert_eq!(g.rects.len(), 2); // h_full + v_full +} + +#[test] +fn corner_joins_two_arms() { + // ┌ spans right and down from centre. + let g = rects('\u{250C}', 10.0, 16.0).unwrap(); + assert_eq!(g.rects.len(), 2); +} + +#[test] +fn full_block_fills_cell() { + let g = rects('\u{2588}', 8.0, 16.0).unwrap(); + assert_eq!(g.rects, vec![(0.0, 0.0, 8.0, 16.0)]); +} + +#[test] +fn halves_cover_their_side() { + assert_eq!( + rects('\u{2580}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 0.0, 8.0, 8.0)] + ); + assert_eq!( + rects('\u{2584}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 8.0, 8.0, 8.0)] + ); + assert_eq!( + rects('\u{258C}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 0.0, 4.0, 16.0)] + ); + assert_eq!( + rects('\u{2590}', 8.0, 16.0).unwrap().rects, + vec![(4.0, 0.0, 4.0, 16.0)] + ); +} + +#[test] +fn shades_use_alpha() { + assert_eq!(rects('\u{2591}', 8.0, 16.0).unwrap().alpha, 0.25); + assert_eq!(rects('\u{2592}', 8.0, 16.0).unwrap().alpha, 0.5); + assert_eq!(rects('\u{2593}', 8.0, 16.0).unwrap().alpha, 0.75); +} + +#[test] +fn lower_eighths_grow_from_bottom() { + // ▁ = 1/8 tall at the bottom. + assert_eq!( + rects('\u{2581}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 14.0, 8.0, 2.0)] + ); + // ▇ = 7/8 tall. + assert_eq!( + rects('\u{2587}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 2.0, 8.0, 14.0)] + ); +} + +#[test] +fn left_eighths_grow_from_left() { + // ▏ = 1/8 wide on the left. + assert_eq!( + rects('\u{258F}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 0.0, 1.0, 16.0)] + ); + // ▉ = 7/8 wide. + assert_eq!( + rects('\u{2589}', 8.0, 16.0).unwrap().rects, + vec![(0.0, 0.0, 7.0, 16.0)] + ); +} + +#[test] +fn ordinary_characters_are_not_handled() { + assert!(rects('a', 8.0, 16.0).is_none()); + assert!(rects('═', 8.0, 16.0).is_none()); // heavy/double not yet covered +} diff --git a/crates/app/tests/bridge.rs b/crates/app/tests/bridge.rs new file mode 100644 index 0000000..455406f --- /dev/null +++ b/crates/app/tests/bridge.rs @@ -0,0 +1,27 @@ +use super::*; +use futures::StreamExt; + +#[test] +fn forwards_in_order_and_closes() { + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(Event::Wakeup).unwrap(); + tx.send(Event::TitleChanged("t".to_string())).unwrap(); + tx.send(Event::Exit(Some(0))).unwrap(); + drop(tx); + let mut stream = forward(rx); + let collected = futures::executor::block_on(async { + let mut seen = Vec::new(); + while let Some(event) = stream.next().await { + seen.push(event); + } + seen + }); + assert_eq!( + collected, + vec![ + Event::Wakeup, + Event::TitleChanged("t".to_string()), + Event::Exit(Some(0)), + ] + ); +} diff --git a/crates/app/tests/colors.rs b/crates/app/tests/colors.rs new file mode 100644 index 0000000..ef18e70 --- /dev/null +++ b/crates/app/tests/colors.rs @@ -0,0 +1,137 @@ +use super::*; + +fn colors() -> Colors { + from_config(&config::Options::default()) +} + +fn no_override(_: u8) -> Option<(u8, u8, u8)> { + None +} + +#[test] +fn default_maps_to_given_default() { + let c = colors(); + let fg = cell_rgb(vt::Color::Default, c.fg, false, &c.palette, no_override); + assert_eq!(fg, c.fg); + let bg = cell_rgb(vt::Color::Default, c.bg, false, &c.palette, no_override); + assert_eq!(bg, c.bg); +} + +#[test] +fn ansi_indices_come_from_scheme() { + let c = colors(); + let scheme = theme::default_scheme(); + for i in 0..16u8 { + let got = cell_rgb(vt::Color::Indexed(i), c.fg, false, &c.palette, no_override); + assert_eq!(got, scheme.ansi[i as usize], "index {i}"); + } +} + +#[test] +fn cube_and_grayscale_resolve() { + let c = colors(); + // 16 + 36*5 + 6*0 + 0 = 196 -> pure red in the xterm cube. + let red = cell_rgb( + vt::Color::Indexed(196), + c.fg, + false, + &c.palette, + no_override, + ); + assert_eq!(red, Rgb::new(255, 0, 0)); + // Grayscale ramp: 232 -> #080808, 255 -> #eeeeee. + let lo = cell_rgb( + vt::Color::Indexed(232), + c.fg, + false, + &c.palette, + no_override, + ); + assert_eq!(lo, Rgb::new(8, 8, 8)); + let hi = cell_rgb( + vt::Color::Indexed(255), + c.fg, + false, + &c.palette, + no_override, + ); + assert_eq!(hi, Rgb::new(0xee, 0xee, 0xee)); +} + +#[test] +fn bold_brightens_only_low_ansi() { + let c = colors(); + let scheme = theme::default_scheme(); + let bright = cell_rgb(vt::Color::Indexed(1), c.fg, true, &c.palette, no_override); + assert_eq!(bright, scheme.ansi[9]); + // Already-bright and extended indices are untouched. + let same = cell_rgb(vt::Color::Indexed(9), c.fg, true, &c.palette, no_override); + assert_eq!(same, scheme.ansi[9]); + let cube = cell_rgb(vt::Color::Indexed(196), c.fg, true, &c.palette, no_override); + assert_eq!(cube, Rgb::new(255, 0, 0)); +} + +#[test] +fn osc4_override_wins_after_brightening() { + let c = colors(); + let ovr = |i: u8| (i == 9).then_some((1u8, 2u8, 3u8)); + let got = cell_rgb(vt::Color::Indexed(1), c.fg, true, &c.palette, ovr); + assert_eq!(got, Rgb::new(1, 2, 3)); + // Index 1 itself is not overridden, so unbrightened stays themed. + let plain = cell_rgb(vt::Color::Indexed(1), c.fg, false, &c.palette, ovr); + assert_eq!(plain, theme::default_scheme().ansi[1]); +} + +#[test] +fn truecolor_passes_through() { + let c = colors(); + let got = cell_rgb( + vt::Color::Rgb(12, 34, 56), + c.fg, + true, + &c.palette, + no_override, + ); + assert_eq!(got, Rgb::new(12, 34, 56)); +} + +#[test] +fn config_overrides_apply() { + let mut opts = config::Options::default(); + opts.foreground = Some("#102030".to_string()); + opts.background = Some("#abcdef".to_string()); + opts.palette = vec![(1, "#ff0000".to_string()), (200, "#00ff00".to_string())]; + let c = from_config(&opts); + assert_eq!(c.fg, Rgb::new(0x10, 0x20, 0x30)); + assert_eq!(c.bg, Rgb::new(0xab, 0xcd, 0xef)); + assert_eq!(c.palette.get(1), Rgb::new(255, 0, 0)); + assert_eq!(c.palette.get(200), Rgb::new(0, 255, 0)); +} + +#[test] +fn selection_colors_come_from_scheme() { + let c = colors(); + let scheme = theme::default_scheme(); + assert_eq!(c.selection_bg, scheme.selection_background); + assert_eq!(c.selection_fg, scheme.selection_foreground); +} + +#[test] +fn bad_config_colors_fall_back() { + let mut opts = config::Options::default(); + opts.foreground = Some("nonsense".to_string()); + opts.palette = vec![(1, "alsobad".to_string())]; + let c = from_config(&opts); + let scheme = theme::default_scheme(); + assert_eq!(c.fg, scheme.foreground); + assert_eq!(c.palette.get(1), scheme.ansi[1]); +} + +#[test] +fn rgba_conversion_is_opaque_unit_range() { + let v = rgba(Rgb::new(255, 0, 128)); + assert_eq!(v.r, 1.0); + assert_eq!(v.g, 0.0); + assert!((v.b - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(v.a, 1.0); +} diff --git a/crates/app/tests/element.rs b/crates/app/tests/element.rs new file mode 100644 index 0000000..8eaf341 --- /dev/null +++ b/crates/app/tests/element.rs @@ -0,0 +1,183 @@ +use super::*; + +fn test_colors() -> Colors { + colors::from_config(&config::Options::default()) +} + +#[test] +fn snapshot_batches_plain_text_into_one_span() { + let mut term = vt::Terminal::new(20, 4, 0); + term.feed(b"hello world"); + let snap = snapshot(&mut term, &test_colors(), None); + // Spaces between words break spans (they are skipped). + assert_eq!(snap.spans.len(), 2); + assert_eq!(snap.spans[0].text, "hello"); + assert_eq!(snap.spans[0].col, 0); + assert_eq!(snap.spans[1].text, "world"); + assert_eq!(snap.spans[1].col, 6); + assert!(snap.bg_runs.is_empty()); +} + +#[test] +fn snapshot_splits_spans_on_style_change() { + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"ab\x1b[1mcd\x1b[0mef"); + let snap = snapshot(&mut term, &test_colors(), None); + let texts: Vec<&str> = snap.spans.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(texts, vec!["ab", "cd", "ef"]); + assert!(snap.spans[1].flags.contains(CellFlags::BOLD)); +} + +#[test] +fn snapshot_merges_background_runs() { + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"\x1b[41mabc\x1b[0m"); + let snap = snapshot(&mut term, &test_colors(), None); + assert_eq!(snap.bg_runs.len(), 1); + let run = &snap.bg_runs[0]; + assert_eq!((run.row, run.col, run.len), (0, 0, 3)); + assert_eq!(run.color, theme::default_scheme().ansi[1]); +} + +#[test] +fn snapshot_inverse_swaps_colors() { + let colors = test_colors(); + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"\x1b[7mx\x1b[0m"); + let snap = snapshot(&mut term, &colors, None); + assert_eq!(snap.spans[0].fg, colors.bg); + assert_eq!(snap.bg_runs[0].color, colors.fg); +} + +#[test] +fn snapshot_skips_invisible_but_keeps_background() { + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"\x1b[8;41mhid\x1b[0m"); + let snap = snapshot(&mut term, &test_colors(), None); + assert!(snap.spans.is_empty()); + assert_eq!(snap.bg_runs.len(), 1); +} + +#[test] +fn snapshot_wide_char_is_its_own_span() { + let mut term = vt::Terminal::new(20, 2, 0); + term.feed("a世b".as_bytes()); + let snap = snapshot(&mut term, &test_colors(), None); + let texts: Vec<&str> = snap.spans.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(texts, vec!["a", "世", "b"]); + assert_eq!(snap.spans[1].width, 2); + // The cell after the wide char sits two columns later. + assert_eq!(snap.spans[2].col, 3); +} + +#[test] +fn snapshot_cursor_follows_visibility() { + let colors = test_colors(); + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"hi"); + let snap = snapshot(&mut term, &colors, None); + let cursor = snap.cursor.expect("visible by default"); + assert_eq!((cursor.row, cursor.col), (0, 2)); + assert_eq!(cursor.color, colors.cursor); + term.feed(b"\x1b[?25l"); + let snap = snapshot(&mut term, &colors, None); + assert!(snap.cursor.is_none()); +} + +#[test] +fn snapshot_cursor_honors_osc12_color() { + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"\x1b]12;rgb:ff/00/00\x07"); + let snap = snapshot(&mut term, &test_colors(), None); + assert_eq!(snap.cursor.expect("cursor").color, Rgb::new(255, 0, 0)); +} + +#[test] +fn cursor_shape_mapping() { + use config::CursorStyle as C; + use vt::CursorStyle as V; + // Power-on default defers to config. + assert_eq!(cursor_shape(V::BlinkingBlock, C::Bar), C::Bar); + assert_eq!(cursor_shape(V::BlinkingBlock, C::Block), C::Block); + // Explicit DECSCUSR wins. + assert_eq!(cursor_shape(V::SteadyBlock, C::Bar), C::Block); + assert_eq!(cursor_shape(V::BlinkingBar, C::Block), C::Bar); + assert_eq!(cursor_shape(V::SteadyUnderline, C::Block), C::Underline); +} + +#[test] +fn snapshot_underlined_space_is_kept() { + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"\x1b[4m \x1b[0m"); + let snap = snapshot(&mut term, &test_colors(), None); + assert_eq!(snap.spans.len(), 1); + assert!(snap.spans[0].flags.contains(CellFlags::UNDERLINE)); +} + +#[test] +fn snapshot_selection_overrides_colors() { + // Default fg must differ from selection fg for the span split. + let mut opts = config::Options::default(); + opts.foreground = Some("#abb2bf".to_string()); + let colors = colors::from_config(&opts); + assert_ne!(colors.fg, colors.selection_fg); + let mut term = vt::Terminal::new(20, 2, 0); + term.feed(b"hello"); + term.start_selection(vt::SelectionMode::Cell, vt::Point::new(0, 1)); + term.update_selection(vt::Point::new(0, 3)); + let snap = snapshot(&mut term, &colors, None); + // "h" + "ell" (selected) + "o". + let texts: Vec<&str> = snap.spans.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(texts, vec!["h", "ell", "o"]); + assert_eq!(snap.spans[0].fg, colors.fg); + assert_eq!(snap.spans[1].fg, colors.selection_fg); + assert_eq!(snap.spans[2].fg, colors.fg); + // Selected cells paint the selection background. + assert_eq!(snap.bg_runs.len(), 1); + let run = &snap.bg_runs[0]; + assert_eq!((run.row, run.col, run.len), (0, 1, 3)); + assert_eq!(run.color, colors.selection_bg); +} + +#[test] +fn snapshot_selection_honors_display_offset() { + let colors = test_colors(); + let mut term = vt::Terminal::new(10, 2, 10); + term.feed(b"old\r\na\r\nb\r\nc"); // pushes rows into scrollback + // Select the scrollback line holding "old" (line -2). + term.start_selection(vt::SelectionMode::Cell, vt::Point::new(-2, 0)); + term.update_selection(vt::Point::new(-2, 2)); + // At the live bottom the selected row is off screen: no override. + let snap = snapshot(&mut term, &colors, None); + assert!(snap.bg_runs.iter().all(|r| r.color != colors.selection_bg)); + // Scrolled back so the row is visible, the override applies. + term.scroll_display(2); + let snap = snapshot(&mut term, &colors, None); + assert!(snap.bg_runs.iter().any(|r| r.color == colors.selection_bg)); + assert_eq!(snap.offset, 2); + assert_eq!(snap.scrollback, 2); +} + +#[test] +fn scroll_indicator_only_when_scrolled_back() { + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(400.0))); + assert!(scroll_indicator(&bounds, 24, 0, 100).is_none()); + assert!(scroll_indicator(&bounds, 24, 0, 0).is_none()); + let thumb = scroll_indicator(&bounds, 24, 50, 100).expect("visible"); + // Hugs the right edge at the configured width. + assert_eq!(thumb.origin.x, px(100.0 - INDICATOR_WIDTH)); + assert_eq!(thumb.size.width, px(INDICATOR_WIDTH)); + assert!(f32::from(thumb.size.height) >= INDICATOR_MIN); +} + +#[test] +fn scroll_indicator_tracks_position() { + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(400.0))); + // Fully scrolled back: thumb at the top. + let top = scroll_indicator(&bounds, 24, 100, 100).expect("top"); + assert_eq!(top.origin.y, px(0.0)); + // Barely scrolled: thumb near the bottom, still inside bounds. + let low = scroll_indicator(&bounds, 24, 1, 100).expect("low"); + assert!(low.origin.y > top.origin.y); + assert!(f32::from(low.origin.y) + f32::from(low.size.height) <= 400.0 + 1e-3); +} diff --git a/crates/app/tests/font.rs b/crates/app/tests/font.rs new file mode 100644 index 0000000..aa07d28 --- /dev/null +++ b/crates/app/tests/font.rs @@ -0,0 +1,42 @@ +use super::*; + +fn feats(entries: &[&str]) -> Vec<(String, u32)> { + let owned: Vec = entries.iter().map(|s| s.to_string()).collect(); + features(&owned).0.as_ref().clone() +} + +#[test] +fn parses_sign_and_value_forms() { + assert_eq!( + feats(&["liga", "+ss01", "-calt", "cv01=2"]), + vec![ + ("liga".into(), 1), + ("ss01".into(), 1), + ("calt".into(), 0), + ("cv01".into(), 2), + ] + ); +} + +#[test] +fn skips_invalid_entries() { + // Too-long tag, empty, and non-numeric value are dropped. + assert_eq!(feats(&["toolongtag", "", "x=abc"]), Vec::new()); +} + +#[test] +fn build_uses_primary_and_fallbacks() { + let mut opts = config::Options::default(); + opts.font_family = vec!["JetBrains Mono".into(), "Menlo".into()]; + let font = build(&opts); + assert_eq!(font.family.as_ref(), "JetBrains Mono"); + let fb = font.fallbacks.expect("fallbacks"); + assert_eq!(fb.fallback_list(), ["Menlo"]); +} + +#[test] +fn build_defaults_to_menlo_without_config() { + let font = build(&config::Options::default()); + assert_eq!(font.family.as_ref(), "Menlo"); + assert!(font.fallbacks.is_none()); +} diff --git a/crates/app/tests/keys.rs b/crates/app/tests/keys.rs new file mode 100644 index 0000000..c0afeb6 --- /dev/null +++ b/crates/app/tests/keys.rs @@ -0,0 +1,88 @@ +use super::*; + +fn mods(ctrl: bool, shift: bool, alt: bool, cmd: bool) -> config::Mods { + config::Mods { + ctrl, + shift, + alt, + cmd, + } +} + +#[test] +fn plain_and_modified_keys() { + assert_eq!( + keystroke(mods(false, false, false, true), "t").unwrap(), + "secondary-t" + ); + assert_eq!( + keystroke(mods(false, true, false, true), "d").unwrap(), + "shift-secondary-d" + ); + assert_eq!( + keystroke(mods(true, false, true, false), "x").unwrap(), + "ctrl-alt-x" + ); + assert_eq!(keystroke(config::Mods::default(), "a").unwrap(), "a"); +} + +#[test] +fn modifier_order_is_canonical() { + // ctrl, alt, shift, cmd(secondary) regardless of how many are set. + assert_eq!( + keystroke(mods(true, true, true, true), "k").unwrap(), + "ctrl-alt-shift-secondary-k" + ); +} + +#[test] +fn paged_keys_are_renamed() { + assert_eq!( + keystroke(mods(false, true, false, false), "page_up").unwrap(), + "shift-pageup" + ); + assert_eq!( + keystroke(mods(false, true, false, false), "page_down").unwrap(), + "shift-pagedown" + ); +} + +#[test] +fn punctuation_keys_pass_through() { + // The minus key with cmd renders as `secondary--`, which gpui parses + // as the platform modifier + the `-` key. + assert_eq!( + keystroke(mods(false, false, false, true), "-").unwrap(), + "secondary--" + ); + assert_eq!( + keystroke(mods(false, false, false, true), "+").unwrap(), + "secondary-+" + ); + assert_eq!( + keystroke(mods(false, false, false, true), "=").unwrap(), + "secondary-=" + ); + assert_eq!( + keystroke(mods(false, false, false, true), ",").unwrap(), + "secondary-," + ); +} + +#[test] +fn named_keys_pass_through() { + for k in ["enter", "tab", "escape", "space", "up", "home", "f5"] { + assert_eq!(keystroke(config::Mods::default(), k).unwrap(), k); + } +} + +#[test] +fn every_emitted_default_binding_parses_in_gpui() { + // The whole default set must produce gpui-parseable keystrokes, + // so binding them at startup never panics. + for kb in config::default_keybinds() { + let ks = keystroke(kb.mods, &kb.key) + .unwrap_or_else(|| panic!("no keystroke for {:?}+{}", kb.mods, kb.key)); + gpui::Keystroke::parse(&ks).unwrap_or_else(|e| panic!("gpui rejected {ks:?}: {e:?}")); + } +} diff --git a/crates/app/tests/metrics.rs b/crates/app/tests/metrics.rs new file mode 100644 index 0000000..6f509ef --- /dev/null +++ b/crates/app/tests/metrics.rs @@ -0,0 +1,98 @@ +use super::*; + +const CELL: CellSize = CellSize { + width: 8.0, + height: 17.0, +}; +const PAD: Padding = Padding { x: 2.0, y: 2.0 }; + +#[test] +fn grid_size_floors_partial_cells() { + // 644 - 4 = 640 / 8 = 80 cols; 412.8 - 4 = 408.8 / 17 = 24.04 -> 24. + assert_eq!(grid_size(644.0, 412.8, PAD, CELL), (80, 24)); + // One pixel short of a column. + assert_eq!(grid_size(643.0, 412.8, PAD, CELL), (79, 24)); +} + +#[test] +fn grid_size_clamps_minimums() { + assert_eq!(grid_size(0.0, 0.0, PAD, CELL), (2, 1)); + assert_eq!(grid_size(-50.0, 5.0, PAD, CELL), (2, 1)); + assert_eq!(grid_size(10.0, 18.0, Padding::default(), CELL), (2, 1)); +} + +#[test] +fn pixel_size_includes_padding_on_both_sides() { + assert_eq!(pixel_size(80, 24, PAD, CELL), (644.0, 412.0)); + assert_eq!(pixel_size(80, 24, Padding::default(), CELL), (640.0, 408.0)); +} + +#[test] +fn round_trips_exact_grids() { + let (w, h) = pixel_size(120, 40, PAD, CELL); + assert_eq!(grid_size(w, h, PAD, CELL), (120, 40)); +} + +#[test] +fn line_height_factor_is_sane() { + assert!(LINE_HEIGHT > 1.0 && LINE_HEIGHT < 2.0); +} + +#[test] +fn cell_at_accounts_for_origin_and_padding() { + // Window origin (100, 50), pad 2: cell (0,0) spans x 102..110. + assert_eq!( + cell_at((102.0, 52.0), (100.0, 50.0), PAD, CELL, 80, 24), + (0, 0) + ); + assert_eq!( + cell_at((109.9, 68.9), (100.0, 50.0), PAD, CELL, 80, 24), + (0, 0) + ); + // One pixel into the next cell each way. + assert_eq!( + cell_at((110.0, 69.0), (100.0, 50.0), PAD, CELL, 80, 24), + (1, 1) + ); + // Mid-grid. + assert_eq!( + cell_at( + (102.0 + 8.0 * 10.0, 52.0 + 17.0 * 3.0), + (100.0, 50.0), + PAD, + CELL, + 80, + 24 + ), + (3, 10) + ); +} + +#[test] +fn cell_at_clamps_to_grid() { + // Inside the padding band, above/left of cell 0. + assert_eq!(cell_at((0.0, 0.0), (0.0, 0.0), PAD, CELL, 80, 24), (0, 0)); + // Way past the bottom-right corner. + assert_eq!( + cell_at((9999.0, 9999.0), (0.0, 0.0), PAD, CELL, 80, 24), + (23, 79) + ); + // Negative positions (drag left/above the window). + assert_eq!( + cell_at((-50.0, -50.0), (0.0, 0.0), PAD, CELL, 80, 24), + (0, 0) + ); + // Degenerate grid never underflows. + assert_eq!(cell_at((5.0, 5.0), (0.0, 0.0), PAD, CELL, 0, 0), (0, 0)); +} + +#[test] +fn selection_point_maps_display_offset() { + // Live view: viewport row == content line. + assert_eq!(selection_point(0, 3, 0), vt::Point::new(0, 3)); + assert_eq!(selection_point(5, 0, 0), vt::Point::new(5, 0)); + // Scrolled back 4 lines: top viewport row shows scrollback line -4. + assert_eq!(selection_point(0, 1, 4), vt::Point::new(-4, 1)); + assert_eq!(selection_point(4, 1, 4), vt::Point::new(0, 1)); + assert_eq!(selection_point(6, 9, 4), vt::Point::new(2, 9)); +} diff --git a/crates/app/tests/mouse.rs b/crates/app/tests/mouse.rs new file mode 100644 index 0000000..1bb3352 --- /dev/null +++ b/crates/app/tests/mouse.rs @@ -0,0 +1,77 @@ +use super::*; + +#[test] +fn reporting_requires_mode_and_no_shift() { + assert!(!reports(MouseMode::None, false)); + assert!(reports(MouseMode::Click, false)); + assert!(reports(MouseMode::Drag, false)); + assert!(reports(MouseMode::Motion, false)); + // Shift always bypasses reporting. + assert!(!reports(MouseMode::Motion, true)); + assert!(!reports(MouseMode::Click, true)); +} + +#[test] +fn motion_reporting_per_mode() { + let held = Some(MouseButton::Left); + assert!(!reports_motion(MouseMode::None, held)); + assert!(!reports_motion(MouseMode::Click, held)); + assert!(reports_motion(MouseMode::Drag, held)); + assert!(!reports_motion(MouseMode::Drag, None)); + assert!(reports_motion(MouseMode::Motion, held)); + assert!(reports_motion(MouseMode::Motion, None)); +} + +#[test] +fn click_counts_map_to_modes() { + assert_eq!(click_mode(0), SelectionMode::Cell); // defensive + assert_eq!(click_mode(1), SelectionMode::Cell); + assert_eq!(click_mode(2), SelectionMode::Word); + assert_eq!(click_mode(3), SelectionMode::Line); + // Rapid clicking cycles. + assert_eq!(click_mode(4), SelectionMode::Cell); + assert_eq!(click_mode(5), SelectionMode::Word); + assert_eq!(click_mode(6), SelectionMode::Line); +} + +#[test] +fn wheel_routing_precedence() { + use WheelRoute::*; + // Reporting wins over everything when active and shift is up. + assert_eq!(route_wheel(MouseMode::Click, false, true, true), Report); + assert_eq!(route_wheel(MouseMode::Motion, false, false, false), Report); + // Shift bypasses reporting; alt scroll applies on the alt screen. + assert_eq!(route_wheel(MouseMode::Motion, true, true, true), Display); + assert_eq!(route_wheel(MouseMode::None, false, true, true), Arrows); + // Alt scroll needs both the alt screen and the mode. + assert_eq!(route_wheel(MouseMode::None, false, true, false), Display); + assert_eq!(route_wheel(MouseMode::None, false, false, true), Display); + assert_eq!(route_wheel(MouseMode::None, false, false, false), Display); +} + +#[test] +fn wheel_lines_accumulates_fractions() { + let mut acc = 0.0; + assert_eq!(wheel_lines(&mut acc, 0.4), 0); + assert_eq!(wheel_lines(&mut acc, 0.4), 0); + assert_eq!(wheel_lines(&mut acc, 0.4), 1); // 1.2 -> 1, carry 0.2 + assert!((acc - 0.2).abs() < 1e-6); + assert_eq!(wheel_lines(&mut acc, 2.0), 2); +} + +#[test] +fn wheel_lines_whole_deltas_pass_through() { + let mut acc = 0.0; + assert_eq!(wheel_lines(&mut acc, 3.0), 3); + assert_eq!(wheel_lines(&mut acc, -2.0), -2); + assert_eq!(acc, 0.0); +} + +#[test] +fn wheel_lines_direction_change_drops_fraction() { + let mut acc = 0.0; + assert_eq!(wheel_lines(&mut acc, 0.9), 0); + // Reversing direction must not fight the stale +0.9. + assert_eq!(wheel_lines(&mut acc, -1.0), -1); + assert_eq!(acc, 0.0); +} diff --git a/crates/app/tests/session.rs b/crates/app/tests/session.rs new file mode 100644 index 0000000..67cf394 --- /dev/null +++ b/crates/app/tests/session.rs @@ -0,0 +1,86 @@ +use super::*; + +#[test] +fn options_default_is_login_shell() { + let opts = config::Options::default(); + let session = options(&opts, 100, 30, None); + assert_eq!((session.cols, session.rows), (100, 30)); + assert_eq!(session.scrollback_limit, 10_000); + assert!(session.spawn.login); + // With no inherit and no configured directory, defaults to home. + assert_eq!(session.spawn.cwd, home()); +} + +#[test] +fn options_honors_command_and_cwd() { + let mut opts = config::Options::default(); + opts.shell = Some("/bin/bash -i".to_string()); + opts.working_directory = Some("/tmp".to_string()); + opts.scrollback_limit = 42; + let session = options(&opts, 80, 24, None); + assert_eq!(session.spawn.argv, vec!["/bin/bash", "-i"]); + assert!(!session.spawn.login); + assert_eq!(session.spawn.cwd, Some(PathBuf::from("/tmp"))); + assert_eq!(session.scrollback_limit, 42); +} + +#[test] +fn options_empty_command_falls_back_to_shell() { + let mut opts = config::Options::default(); + opts.shell = Some(" ".to_string()); + let session = options(&opts, 80, 24, None); + assert!(session.spawn.login); + assert!(!session.spawn.argv.is_empty()); +} + +#[test] +fn options_inherited_cwd_beats_config() { + let mut opts = config::Options::default(); + opts.working_directory = Some("/tmp".to_string()); + let session = options(&opts, 80, 24, Some(PathBuf::from("/work"))); + assert_eq!(session.spawn.cwd, Some(PathBuf::from("/work"))); +} + +#[test] +fn shellname_takes_basename_of_first_word() { + assert_eq!(shellname(Some("/bin/bash -i")), "bash"); + assert_eq!(shellname(Some("zsh")), "zsh"); + assert_eq!(shellname(Some("/usr/local/bin/fish --login")), "fish"); +} + +#[test] +fn shellname_defaults_to_user_shell() { + assert!(!shellname(None).is_empty()); + assert!(!shellname(Some(" ")).is_empty()); +} + +#[test] +fn cwdpath_parses_file_urls() { + assert_eq!( + cwdpath("file://host/Users/me"), + Some(PathBuf::from("/Users/me")) + ); + assert_eq!(cwdpath("file:///tmp"), Some(PathBuf::from("/tmp"))); + assert_eq!( + cwdpath("file://host/a%20dir/b"), + Some(PathBuf::from("/a dir/b")) + ); +} + +#[test] +fn cwdpath_accepts_plain_paths_rejects_junk() { + assert_eq!(cwdpath("/var/log"), Some(PathBuf::from("/var/log"))); + assert_eq!(cwdpath("relative/path"), None); + assert_eq!(cwdpath(""), None); + assert_eq!(cwdpath("file://hostonly"), None); +} + +#[test] +fn percentdecode_handles_malformed_escapes() { + assert_eq!(percentdecode("/a%2fb"), "/a/b"); + assert_eq!(percentdecode("/x%zz"), "/x%zz"); + assert_eq!(percentdecode("/trail%2"), "/trail%2"); + assert_eq!(percentdecode("/plain"), "/plain"); + // Multi-byte characters after % must not split a codepoint. + assert_eq!(percentdecode("/x%éy"), "/x%éy"); +} diff --git a/crates/app/tests/splits.rs b/crates/app/tests/splits.rs new file mode 100644 index 0000000..7d38f7d --- /dev/null +++ b/crates/app/tests/splits.rs @@ -0,0 +1,92 @@ +use super::*; +use workspace::PaneIds; + +fn ids(n: usize) -> Vec { + let mut alloc = PaneIds::new(); + (0..n).map(|_| alloc.next()).collect() +} + +#[test] +fn splitrect_root_is_full_rect() { + let p = ids(2); + let mut tree = PaneTree::new(p[0]); + let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let rect = Rect::new(10.0, 20.0, 100.0, 50.0); + assert_eq!(splitrect(&tree, rect, 2.0, s), Some(rect)); +} + +#[test] +fn splitrect_nested_matches_compute_layout() { + // a | (b over c): the inner split spans exactly b + divider + c. + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + let rect = Rect::new(0.0, 0.0, 104.0, 104.0); + let layout = compute_layout(&tree, rect, 4.0); + + assert_eq!(splitrect(&tree, rect, 4.0, outer), Some(rect)); + let irect = splitrect(&tree, rect, 4.0, inner).expect("inner rect"); + let b = layout.pane_rect(p[1]).unwrap(); + let c = layout.pane_rect(p[2]).unwrap(); + assert_eq!((irect.x, irect.y), (b.x, b.y)); + assert_eq!(irect.w, b.w); + assert_eq!(irect.h, b.h + 4.0 + c.h); + // The inner divider sits inside the inner split's rect. + let (_, drect, _) = layout.dividers[1]; + assert!(drect.y >= irect.y && drect.y + drect.h <= irect.y + irect.h); +} + +#[test] +fn splitrect_honors_nondefault_ratio() { + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + tree.set_ratio(outer, 0.25); + let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + let rect = Rect::new(0.0, 0.0, 102.0, 60.0); + // avail = 100, first = 25, divider 2: inner starts at x = 27. + let irect = splitrect(&tree, rect, 2.0, inner).expect("inner"); + assert_eq!(irect, Rect::new(27.0, 0.0, 75.0, 60.0)); +} + +#[test] +fn splitrect_missing_split_is_none() { + // Mint an id the queried trees never allocated: a second split in + // a throwaway tree (per-tree counters make it id 2). + let p = ids(3); + let mut donor = PaneTree::new(p[0]); + donor.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let foreign = donor.split(p[1], Axis::Vertical, p[2], false).unwrap(); + + let rect = Rect::new(0.0, 0.0, 10.0, 10.0); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + assert_eq!(splitrect(&tree, rect, 1.0, foreign), None); + let single = PaneTree::new(p[0]); + assert_eq!(splitrect(&single, rect, 1.0, foreign), None); +} + +#[test] +fn dragratio_tracks_pointer_horizontally() { + let rect = Rect::new(0.0, 0.0, 102.0, 50.0); + // Pointer at the divider center for ratio 0.5: x = 50 + 1. + let r = dragratio(rect, Axis::Horizontal, 2.0, (51.0, 0.0)); + assert!((r - 0.5).abs() < 1e-6); + let r = dragratio(rect, Axis::Horizontal, 2.0, (26.0, 999.0)); + assert!((r - 0.25).abs() < 1e-6); +} + +#[test] +fn dragratio_vertical_uses_y_and_offset() { + let rect = Rect::new(0.0, 100.0, 80.0, 102.0); + let r = dragratio(rect, Axis::Vertical, 2.0, (0.0, 151.0)); + assert!((r - 0.5).abs() < 1e-6); +} + +#[test] +fn dragratio_clamps_to_bounds() { + let rect = Rect::new(0.0, 0.0, 102.0, 50.0); + assert_eq!(dragratio(rect, Axis::Horizontal, 2.0, (-50.0, 0.0)), 0.1); + assert_eq!(dragratio(rect, Axis::Horizontal, 2.0, (500.0, 0.0)), 0.9); +} diff --git a/crates/app/tests/tabbar.rs b/crates/app/tests/tabbar.rs new file mode 100644 index 0000000..73e6fa6 --- /dev/null +++ b/crates/app/tests/tabbar.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn blend_endpoints_and_midpoint() { + let a = Rgb::new(0, 0, 0); + let b = Rgb::new(255, 255, 255); + assert_eq!(blend(a, b, 0.0), a); + assert_eq!(blend(a, b, 1.0), b); + assert_eq!(blend(a, b, 0.5), Rgb::new(128, 128, 128)); + // Out-of-range t clamps. + assert_eq!(blend(a, b, -1.0), a); + assert_eq!(blend(a, b, 2.0), b); +} + +#[test] +fn blend_mixes_channels_independently() { + let a = Rgb::new(10, 200, 0); + let b = Rgb::new(20, 100, 255); + let m = blend(a, b, 0.1); + assert_eq!(m, Rgb::new(11, 190, 26)); +} diff --git a/crates/app/tests/textedit.rs b/crates/app/tests/textedit.rs new file mode 100644 index 0000000..a7e3e2d --- /dev/null +++ b/crates/app/tests/textedit.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn new_places_cursor_at_end() { + let e = TextEdit::new("abc"); + assert_eq!(e.split(), ("abc".into(), "".into())); +} + +#[test] +fn insert_at_cursor() { + let mut e = TextEdit::new("ac"); + e.left(); + e.insert("b"); + assert_eq!(e.text(), "abc"); + assert_eq!(e.split(), ("ab".into(), "c".into())); +} + +#[test] +fn backspace_and_delete() { + let mut e = TextEdit::new("abc"); + assert!(e.backspace()); // removes 'c' + assert_eq!(e.text(), "ab"); + e.home(); + assert!(e.delete()); // removes 'a' + assert_eq!(e.text(), "b"); + // Boundaries are no-ops. + e.home(); + assert!(!e.backspace()); + e.end(); + assert!(!e.delete()); +} + +#[test] +fn cursor_movement_clamps() { + let mut e = TextEdit::new("ab"); + e.home(); + e.left(); + assert_eq!(e.split(), ("".into(), "ab".into())); + e.end(); + e.right(); + assert_eq!(e.split(), ("ab".into(), "".into())); +} + +#[test] +fn handles_unicode() { + let mut e = TextEdit::new("café"); + assert!(e.backspace()); // drops 'é' as one unit + assert_eq!(e.text(), "caf"); + e.insert("é"); + assert_eq!(e.text(), "café"); +} diff --git a/crates/app/tests/view.rs b/crates/app/tests/view.rs new file mode 100644 index 0000000..8e43920 --- /dev/null +++ b/crates/app/tests/view.rs @@ -0,0 +1,84 @@ +use super::*; + +#[test] +fn label_prefers_nonblank_title() { + assert_eq!(label(Some("vim"), "zsh"), "vim"); + assert_eq!(label(Some(""), "zsh"), "zsh"); + assert_eq!(label(Some(" "), "zsh"), "zsh"); + assert_eq!(label(None, "zsh"), "zsh"); +} + +const OPT: input::Mods = input::Mods { + shift: false, + alt: true, + ctrl: false, + cmd: false, +}; + +fn policy( + p: config::OptionAsAlt, + held: bool, + key: &str, + key_char: Option<&str>, +) -> (input::Mods, Option) { + let (mods, text) = option_policy(p, true, held, key, key_char, OPT); + (mods, text.map(str::to_string)) +} + +#[test] +fn option_off_drops_alt_and_keeps_composed_glyph() { + // Default policy: Option composes. Alt is cleared (so arrows stay + // plain) and the glyph macOS produced is emitted verbatim. + let (mods, text) = policy(config::OptionAsAlt::False, true, "b", Some("\u{222b}")); + assert!(!mods.alt); + assert_eq!(text.as_deref(), Some("\u{222b}")); + // Arrow: no text, alt cleared -> encode_key emits the plain `ESC[D`. + let (mods, text) = policy(config::OptionAsAlt::False, true, "left", None); + assert!(!mods.alt); + assert_eq!(text, None); + assert_eq!( + input::encode_key("left", text.as_deref(), mods, STATE), + Some(b"\x1b[D".to_vec()) + ); +} + +#[test] +fn option_as_alt_meta_prefixes_base_key() { + // Option = Alt/Meta: a letter ESC-prefixes its *base* key, not the + // composed glyph, so Option+b -> `ESC b`. + let (mods, text) = policy(config::OptionAsAlt::True, true, "b", Some("\u{222b}")); + assert!(mods.alt); + assert_eq!(text.as_deref(), Some("b")); + assert_eq!( + input::encode_key("b", text.as_deref(), mods, STATE), + Some(b"\x1bb".to_vec()) + ); + // Arrow keeps alt -> `ESC[1;3D`. + let (mods, text) = policy(config::OptionAsAlt::Left, true, "left", None); + assert_eq!( + input::encode_key("left", text.as_deref(), mods, STATE), + Some(b"\x1b[1;3D".to_vec()) + ); +} + +#[test] +fn non_macos_and_no_option_pass_through() { + // Off the macOS path, text passes through untouched even with the + // compose policy. + let (mods, text) = + option_policy(config::OptionAsAlt::False, false, true, "b", Some("b"), OPT); + assert!(mods.alt); + assert_eq!(text, Some("b")); + // Option not actually held: untouched regardless of policy. + let (mods, text) = + option_policy(config::OptionAsAlt::False, true, false, "b", Some("b"), OPT); + assert!(mods.alt); + assert_eq!(text, Some("b")); +} + +const STATE: input::TermState = input::TermState { + cursor_keys_app: false, + keypad_app: false, + bracketed_paste: false, + kitty_flags: 0, +}; diff --git a/crates/assist/Cargo.toml b/crates/assist/Cargo.toml index ac5b731..a95467e 100644 --- a/crates/assist/Cargo.toml +++ b/crates/assist/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "assist" version.workspace = true edition.workspace = true diff --git a/crates/assist/src/context.rs b/crates/assist/src/context.rs index 3a4909e..9a84ebe 100644 --- a/crates/assist/src/context.rs +++ b/crates/assist/src/context.rs @@ -164,54 +164,5 @@ fn cosine(a: &HashMap, b: &HashMap) -> f32 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn groups_blocks_by_prompt_lines() { - let lines = vec![ - Line { - number: 0, - text: "$ cargo test".into(), - prompt: true, - }, - Line { - number: 1, - text: "running".into(), - prompt: false, - }, - Line { - number: 2, - text: "$ bun test".into(), - prompt: true, - }, - ]; - let got = blocks(&lines); - assert_eq!(got.len(), 2); - assert_eq!((got[0].start, got[0].end), (0, 1)); - assert_eq!((got[1].start, got[1].end), (2, 2)); - } - - #[test] - fn ranks_related_failures() { - let lines = vec![ - Line { - number: 0, - text: "$ deploy".into(), - prompt: true, - }, - Line { - number: 1, - text: "permission denied for token".into(), - prompt: false, - }, - Line { - number: 2, - text: "$ ls".into(), - prompt: true, - }, - ]; - let hits = search("auth error", &lines, 3); - assert_eq!(hits[0].block.start, 0); - } -} +#[path = "../tests/context.rs"] +mod tests; diff --git a/crates/assist/src/model.rs b/crates/assist/src/model.rs index c9c8be7..d93519d 100644 --- a/crates/assist/src/model.rs +++ b/crates/assist/src/model.rs @@ -73,25 +73,5 @@ pub fn candleprobe() -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn composes_common_commands() { - assert_eq!( - compose("show listening ports"), - "lsof -iTCP -sTCP:LISTEN -n -P" - ); - assert_eq!(compose("run bun tests"), "bun test"); - } - - #[test] - fn explains_permission_errors() { - assert!(explain("permission denied").contains("permission")); - } - - #[test] - fn candle_backend_is_available() { - assert!(candleprobe()); - } -} +#[path = "../tests/model.rs"] +mod tests; diff --git a/crates/assist/src/safety.rs b/crates/assist/src/safety.rs index faf8ae7..6f858e7 100644 --- a/crates/assist/src/safety.rs +++ b/crates/assist/src/safety.rs @@ -52,16 +52,5 @@ pub fn analyze(text: &str) -> PasteRisk { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn multiline_is_medium_risk() { - assert_eq!(analyze("echo a\necho b").level, RiskLevel::Medium); - } - - #[test] - fn destructive_is_high_risk() { - assert_eq!(analyze("rm -rf /tmp/x").level, RiskLevel::High); - } -} +#[path = "../tests/safety.rs"] +mod tests; diff --git a/crates/assist/tests/context.rs b/crates/assist/tests/context.rs new file mode 100644 index 0000000..53c9184 --- /dev/null +++ b/crates/assist/tests/context.rs @@ -0,0 +1,49 @@ +use super::*; + +#[test] +fn groups_blocks_by_prompt_lines() { + let lines = vec![ + Line { + number: 0, + text: "$ cargo test".into(), + prompt: true, + }, + Line { + number: 1, + text: "running".into(), + prompt: false, + }, + Line { + number: 2, + text: "$ bun test".into(), + prompt: true, + }, + ]; + let got = blocks(&lines); + assert_eq!(got.len(), 2); + assert_eq!((got[0].start, got[0].end), (0, 1)); + assert_eq!((got[1].start, got[1].end), (2, 2)); +} + +#[test] +fn ranks_related_failures() { + let lines = vec![ + Line { + number: 0, + text: "$ deploy".into(), + prompt: true, + }, + Line { + number: 1, + text: "permission denied for token".into(), + prompt: false, + }, + Line { + number: 2, + text: "$ ls".into(), + prompt: true, + }, + ]; + let hits = search("auth error", &lines, 3); + assert_eq!(hits[0].block.start, 0); +} diff --git a/crates/assist/tests/model.rs b/crates/assist/tests/model.rs new file mode 100644 index 0000000..f86c573 --- /dev/null +++ b/crates/assist/tests/model.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn composes_common_commands() { + assert_eq!( + compose("show listening ports"), + "lsof -iTCP -sTCP:LISTEN -n -P" + ); + assert_eq!(compose("run bun tests"), "bun test"); +} + +#[test] +fn explains_permission_errors() { + assert!(explain("permission denied").contains("permission")); +} + +#[test] +fn candle_backend_is_available() { + assert!(candleprobe()); +} diff --git a/crates/assist/tests/safety.rs b/crates/assist/tests/safety.rs new file mode 100644 index 0000000..6d8cb69 --- /dev/null +++ b/crates/assist/tests/safety.rs @@ -0,0 +1,11 @@ +use super::*; + +#[test] +fn multiline_is_medium_risk() { + assert_eq!(analyze("echo a\necho b").level, RiskLevel::Medium); +} + +#[test] +fn destructive_is_high_risk() { + assert_eq!(analyze("rm -rf /tmp/x").level, RiskLevel::High); +} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 9d3c1f6..e41c01f 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "config" version.workspace = true edition.workspace = true diff --git a/crates/config/src/action.rs b/crates/config/src/action.rs index 2cf0cc2..dcccd9b 100644 --- a/crates/config/src/action.rs +++ b/crates/config/src/action.rs @@ -401,224 +401,5 @@ fn amount(name: &str, param: Option<&str>) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn simple_actions() { - let cases = [ - ("new_window", Action::NewWindow), - ("new_tab", Action::NewTab), - ("close_surface", Action::CloseSurface), - ("close_tab", Action::CloseTab), - ("close_window", Action::CloseWindow), - ("close_all_windows", Action::CloseAllWindows), - ("previous_tab", Action::PreviousTab), - ("next_tab", Action::NextTab), - ("copy_to_clipboard", Action::Copy), - ("copy", Action::Copy), - ("paste_from_clipboard", Action::Paste), - ("paste", Action::Paste), - ("reset_font_size", Action::ResetFontSize), - ("scroll_page_up", Action::ScrollPageUp), - ("scroll_page_down", Action::ScrollPageDown), - ("scroll_to_top", Action::ScrollToTop), - ("scroll_to_bottom", Action::ScrollToBottom), - ("clear_screen", Action::ClearScreen), - ("toggle_semantic_search", Action::ToggleSemanticSearch), - ("explain_output", Action::ExplainOutput), - ("compose_command", Action::ComposeCommand), - ("reload_config", Action::ReloadConfig), - ("show_help", Action::ShowHelp), - ("help", Action::ShowHelp), - ("toggle_fullscreen", Action::ToggleFullscreen), - ("zoom_split", Action::ZoomSplit), - ("toggle_split_zoom", Action::ZoomSplit), - ("equalize_splits", Action::EqualizeSplits), - ("minimize_window", Action::MinimizeWindow), - ("zoom_window", Action::ZoomWindow), - ("hide_all", Action::HideAll), - ("bring_all_to_front", Action::BringAllToFront), - ("return_to_default_size", Action::ReturnToDefaultSize), - ("use_as_default", Action::UseAsDefault), - ("change_tab_title", Action::ChangeTabTitle), - ("change_terminal_title", Action::ChangeTerminalTitle), - ("toggle_read_only", Action::ToggleReadOnly), - ("toggle_quick_terminal", Action::ToggleQuickTerminal), - ("quick_terminal", Action::ToggleQuickTerminal), - ("quit", Action::Quit), - ("unbind", Action::Unbound), - ]; - for (src, want) in cases { - assert_eq!(Action::parse(src), Ok(want), "{src}"); - } - } - - #[test] - fn name_is_case_insensitive() { - assert_eq!(Action::parse("NEW_TAB"), Ok(Action::NewTab)); - assert_eq!( - Action::parse("New_Split:Right"), - Ok(Action::NewSplit(SplitDirection::Right)) - ); - } - - #[test] - fn new_split_params() { - let cases = [ - ("new_split:right", SplitDirection::Right), - ("new_split:down", SplitDirection::Down), - ("new_split:left", SplitDirection::Left), - ("new_split:up", SplitDirection::Up), - ]; - for (src, dir) in cases { - assert_eq!(Action::parse(src), Ok(Action::NewSplit(dir)), "{src}"); - } - assert!(Action::parse("new_split:sideways").is_err()); - assert!(Action::parse("new_split").is_err()); - assert!(Action::parse("new_split:").is_err()); - } - - #[test] - fn resize_split_params() { - let cases = [ - ("resize_split:up", ResizeDir::Up), - ("resize_split:down", ResizeDir::Down), - ("resize_split:left", ResizeDir::Left), - ("resize_split:right", ResizeDir::Right), - ]; - for (src, dir) in cases { - assert_eq!(Action::parse(src), Ok(Action::ResizeSplit(dir)), "{src}"); - } - assert!(Action::parse("resize_split:bigger").is_err()); - assert!(Action::parse("resize_split").is_err()); - } - - #[test] - fn goto_split_params() { - let cases = [ - ("goto_split:previous", SplitFocus::Previous), - ("goto_split:next", SplitFocus::Next), - ("goto_split:up", SplitFocus::Up), - ("goto_split:down", SplitFocus::Down), - ("goto_split:left", SplitFocus::Left), - ("goto_split:right", SplitFocus::Right), - ]; - for (src, focus) in cases { - assert_eq!(Action::parse(src), Ok(Action::GotoSplit(focus)), "{src}"); - } - assert!(Action::parse("goto_split:over").is_err()); - assert!(Action::parse("goto_split").is_err()); - } - - #[test] - fn goto_tab_params() { - assert_eq!(Action::parse("goto_tab:3"), Ok(Action::GotoTab(3))); - assert_eq!(Action::parse("goto_tab:-1"), Ok(Action::GotoTab(-1))); - assert!(Action::parse("goto_tab:0").is_err()); - assert!(Action::parse("goto_tab:first").is_err()); - assert!(Action::parse("goto_tab").is_err()); - } - - #[test] - fn move_tab_params() { - assert_eq!(Action::parse("move_tab:1"), Ok(Action::MoveTab(1))); - assert_eq!(Action::parse("move_tab:-2"), Ok(Action::MoveTab(-2))); - assert!(Action::parse("move_tab").is_err()); - assert!(Action::parse("move_tab:x").is_err()); - } - - #[test] - fn font_size_params() { - assert_eq!( - Action::parse("increase_font_size"), - Ok(Action::IncreaseFontSize(1.0)) - ); - assert_eq!( - Action::parse("increase_font_size:2.5"), - Ok(Action::IncreaseFontSize(2.5)) - ); - assert_eq!( - Action::parse("decrease_font_size"), - Ok(Action::DecreaseFontSize(1.0)) - ); - assert_eq!( - Action::parse("decrease_font_size:0.5"), - Ok(Action::DecreaseFontSize(0.5)) - ); - assert!(Action::parse("increase_font_size:0").is_err()); - assert!(Action::parse("increase_font_size:-1").is_err()); - assert!(Action::parse("increase_font_size:big").is_err()); - } - - #[test] - fn plugin_command_param() { - assert_eq!( - Action::parse("plugin_command:tools/top"), - Ok(Action::PluginCommand("tools/top".to_string())) - ); - assert!(Action::parse("plugin_command").is_err()); - assert!(Action::parse("plugin_command:tools").is_err()); - assert!(Action::parse("plugin_command:Tools/top").is_err()); - } - - #[test] - fn macro_actions() { - assert_eq!(Action::parse("macro_record"), Ok(Action::MacroRecord)); - assert_eq!( - Action::parse("macro:deploy"), - Ok(Action::MacroReplay("deploy".to_string())) - ); - assert_eq!( - Action::parse("macro:build.all"), - Ok(Action::MacroReplay("build.all".to_string())) - ); - assert!(Action::parse("macro").is_err()); - assert!(Action::parse("macro:Deploy").is_err()); - assert!(Action::parse("macro_record:now").is_err()); - } - - #[test] - fn param_on_paramless_action_is_error() { - assert!(Action::parse("quit:now").is_err()); - assert!(Action::parse("new_tab:2").is_err()); - assert!(Action::parse("unbind:all").is_err()); - } - - #[test] - fn unknown_action_is_error() { - assert!(Action::parse("select_all").is_err()); - assert!(Action::parse("").is_err()); - } - - #[test] - fn to_config_round_trips() { - let cases = [ - Action::NewWindow, - Action::NewTab, - Action::CloseSurface, - Action::CloseAllWindows, - Action::NewSplit(SplitDirection::Down), - Action::GotoSplit(SplitFocus::Left), - Action::ResizeSplit(ResizeDir::Up), - Action::GotoTab(3), - Action::GotoTab(-1), - Action::MoveTab(-2), - Action::Copy, - Action::Paste, - Action::IncreaseFontSize(1.0), - Action::IncreaseFontSize(2.5), - Action::DecreaseFontSize(1.0), - Action::JumpToPrompt(-1), - Action::PluginCommand("tools/top".into()), - Action::MacroReplay("deploy".into()), - Action::ToggleQuickTerminal, - Action::Quit, - Action::Unbound, - ]; - for want in cases { - let s = want.to_config(); - assert_eq!(Action::parse(&s), Ok(want.clone()), "{s}"); - } - } -} +#[path = "../tests/action.rs"] +mod tests; diff --git a/crates/config/src/apply.rs b/crates/config/src/apply.rs index 5a3872e..6617d1f 100644 --- a/crates/config/src/apply.rs +++ b/crates/config/src/apply.rs @@ -300,154 +300,5 @@ fn bad(expected: &str, got: &str) -> String { } #[cfg(test)] -mod tests { - use crate::options::{ClipboardAccess, FontStyle, OptionAsAlt, Options}; - use crate::parse::parse_str; - - #[test] - fn new_options_parse() { - let src = r#" -font-style = bold-italic -font-feature = -liga -font-feature = +ss01 -adjust-cell-width = 2 -adjust-cell-height = -1px -cursor-color = #ff0000 -cursor-text = 00ff00 -selection-foreground = #FFFFFF -selection-background = #000000 -bold-is-bright = true -minimum-contrast = 3 -unfocused-split-opacity = 0.5 -split-divider-color = #444444 -mouse-scroll-multiplier = 2.5 -macos-option-as-alt = left -window-inherit-working-directory = false -quit-after-last-window-closed = false -title = my terminal -clipboard-read = deny -clipboard-write = ask -"#; - let (o, diags) = parse_str(src); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(o.font_style, FontStyle::BoldItalic); - assert_eq!( - o.font_feature, - vec!["-liga".to_string(), "+ss01".to_string()] - ); - assert_eq!(o.adjust_cell_width, 2); - assert_eq!(o.adjust_cell_height, -1); - assert_eq!(o.cursor_color.as_deref(), Some("#ff0000")); - assert_eq!(o.cursor_text.as_deref(), Some("#00ff00")); - assert_eq!(o.selection_foreground.as_deref(), Some("#ffffff")); - assert_eq!(o.selection_background.as_deref(), Some("#000000")); - assert!(o.bold_is_bright); - assert_eq!(o.minimum_contrast, 3.0); - assert_eq!(o.unfocused_split_opacity, 0.5); - assert_eq!(o.split_divider_color.as_deref(), Some("#444444")); - assert_eq!(o.mouse_scroll_multiplier, 2.5); - assert_eq!(o.macos_option_as_alt, OptionAsAlt::Left); - assert!(!o.window_inherit_working_directory); - assert!(!o.quit_after_last_window_closed); - assert_eq!(o.title.as_deref(), Some("my terminal")); - assert_eq!(o.clipboard_read, ClipboardAccess::Deny); - assert_eq!(o.clipboard_write, ClipboardAccess::Ask); - } - - #[test] - fn new_options_bad_values_diagnose() { - let cases = [ - "font-style = fancy", - "font-feature = no good", - "adjust-cell-width = wide", - "adjust-cell-height = 10%", - "cursor-color = red", - "cursor-text = #fff", - "selection-foreground = #12345", - "selection-background = blue", - "bold-is-bright = maybe", - "minimum-contrast = abc", - "unfocused-split-opacity = dim", - "split-divider-color = gray", - "mouse-scroll-multiplier = fast", - "macos-option-as-alt = middle", - "window-inherit-working-directory = sometimes", - "quit-after-last-window-closed = perhaps", - "clipboard-read = never", - "clipboard-write = always", - ]; - for case in cases { - let (o, diags) = parse_str(case); - assert_eq!(diags.len(), 1, "no diagnostic for `{case}`"); - assert_eq!(o, Options::default(), "value applied for `{case}`"); - } - } - - #[test] - fn new_options_empty_value_resets() { - let src = "font-style = bold\nfont-style =\n\ - font-feature = -liga\nfont-feature =\n\ - adjust-cell-width = 3\nadjust-cell-width =\n\ - adjust-cell-height = 3\nadjust-cell-height =\n\ - cursor-color = #ff0000\ncursor-color =\n\ - cursor-text = #ff0000\ncursor-text =\n\ - selection-foreground = #ff0000\nselection-foreground =\n\ - selection-background = #ff0000\nselection-background =\n\ - bold-is-bright = true\nbold-is-bright =\n\ - minimum-contrast = 4\nminimum-contrast =\n\ - unfocused-split-opacity = 0.5\nunfocused-split-opacity =\n\ - split-divider-color = #ff0000\nsplit-divider-color =\n\ - mouse-scroll-multiplier = 3\nmouse-scroll-multiplier =\n\ - macos-option-as-alt = left\nmacos-option-as-alt =\n\ - window-inherit-working-directory = false\nwindow-inherit-working-directory =\n\ - quit-after-last-window-closed = false\nquit-after-last-window-closed =\n\ - title = x\ntitle =\n\ - clipboard-read = deny\nclipboard-read =\n\ - clipboard-write = deny\nclipboard-write =\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(o, Options::default()); - } - - #[test] - fn ranged_values_clamp() { - let src = "minimum-contrast = 0.5\nunfocused-split-opacity = 0.01\n\ - mouse-scroll-multiplier = 0.001\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty()); - assert_eq!(o.minimum_contrast, 1.0); - assert_eq!(o.unfocused_split_opacity, 0.15); - assert_eq!(o.mouse_scroll_multiplier, 0.01); - - let src = "minimum-contrast = 100\nunfocused-split-opacity = 2\n\ - mouse-scroll-multiplier = 99999999\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty()); - assert_eq!(o.minimum_contrast, 21.0); - assert_eq!(o.unfocused_split_opacity, 1.0); - assert_eq!(o.mouse_scroll_multiplier, 10_000.0); - } - - #[test] - fn font_feature_accumulates_and_resets() { - let src = "font-feature = -liga\nfont-feature = ss01\n\ - font-feature =\nfont-feature = +calt\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty()); - assert_eq!(o.font_feature, vec!["+calt".to_string()]); - } - - #[test] - fn font_family_builds_a_fallback_chain() { - let src = "font-family = JetBrains Mono\nfont-family = Menlo\n\ - font-family = Apple Color Emoji\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(o.primary_font(), "JetBrains Mono"); - assert_eq!(o.font_fallbacks(), ["Menlo", "Apple Color Emoji"]); - // An empty value resets the chain back to the default. - let (o, _) = parse_str("font-family = X\nfont-family =\n"); - assert!(o.font_family.is_empty()); - assert_eq!(o.primary_font(), "Menlo"); - } -} +#[path = "../tests/apply.rs"] +mod tests; diff --git a/crates/config/src/edit.rs b/crates/config/src/edit.rs index a2274ef..45ea887 100644 --- a/crates/config/src/edit.rs +++ b/crates/config/src/edit.rs @@ -63,73 +63,5 @@ fn line_key(line: &str) -> Option<&str> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn appends_when_absent() { - assert_eq!(upsert("", "theme", "nord"), "theme = nord\n"); - assert_eq!( - upsert("font-size = 14\n", "theme", "nord"), - "font-size = 14\ntheme = nord\n" - ); - } - - #[test] - fn replaces_in_place_preserving_rest() { - let src = "# my config\nfont-size = 12\ntheme = dark\ncopy-on-select = true\n"; - let got = upsert(src, "theme", "nord"); - assert_eq!( - got, - "# my config\nfont-size = 12\ntheme = nord\ncopy-on-select = true\n" - ); - } - - #[test] - fn ignores_commented_keys() { - let src = "# theme = dark\nfont-size = 12\n"; - let got = upsert(src, "theme", "nord"); - assert_eq!(got, "# theme = dark\nfont-size = 12\ntheme = nord\n"); - } - - #[test] - fn only_replaces_first_occurrence() { - let src = "theme = a\ntheme = b\n"; - assert_eq!(upsert(src, "theme", "c"), "theme = c\ntheme = b\n"); - } - - #[test] - fn set_list_appends_when_absent() { - let got = set_list( - "font-size = 14\n", - "keybind", - &["cmd+t=new_tab".into(), "cmd+w=close_surface".into()], - ); - assert_eq!( - got, - "font-size = 14\nkeybind = cmd+t=new_tab\nkeybind = cmd+w=close_surface\n" - ); - } - - #[test] - fn set_list_replaces_block_in_place() { - let src = "# top\nplugin = a\nfont-size = 12\nplugin = b\nplugin = c\n"; - let got = set_list(src, "plugin", &["x".into(), "y".into()]); - assert_eq!(got, "# top\nplugin = x\nplugin = y\nfont-size = 12\n"); - } - - #[test] - fn set_list_empty_removes_every_entry() { - let src = "keybind = cmd+t=new_tab\nfont-size = 12\nkeybind = cmd+w=quit\n"; - assert_eq!(set_list(src, "keybind", &[]), "font-size = 12\n"); - } - - #[test] - fn round_trips_through_parser() { - let text = upsert(&upsert("", "theme", "nord"), "font-size", "16"); - let (opts, diags) = crate::parse_str(&text); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(opts.theme, "nord"); - assert_eq!(opts.font_size, 16.0); - } -} +#[path = "../tests/edit.rs"] +mod tests; diff --git a/crates/config/src/keybind.rs b/crates/config/src/keybind.rs index 673562c..92a153b 100644 --- a/crates/config/src/keybind.rs +++ b/crates/config/src/keybind.rs @@ -307,332 +307,5 @@ pub fn resolve(raw: &[String]) -> (Vec, Vec) { } #[cfg(test)] -mod tests { - use super::*; - - fn mods(ctrl: bool, shift: bool, alt: bool, cmd: bool) -> Mods { - Mods { - ctrl, - shift, - alt, - cmd, - } - } - - fn trigger(s: &str) -> (Mods, String) { - parse_trigger(s).unwrap_or_else(|e| panic!("{s}: {e}")) - } - - #[test] - fn plain_key() { - assert_eq!(trigger("a"), (Mods::default(), "a".to_string())); - assert_eq!(trigger("enter"), (Mods::default(), "enter".to_string())); - } - - #[test] - fn modifier_order_does_not_matter() { - let want = (mods(true, true, false, false), "a".to_string()); - assert_eq!(trigger("ctrl+shift+a"), want); - assert_eq!(trigger("shift+ctrl+a"), want); - } - - #[test] - fn modifier_aliases() { - let alt = (mods(false, false, true, false), "x".to_string()); - assert_eq!(trigger("alt+x"), alt); - assert_eq!(trigger("opt+x"), alt); - assert_eq!(trigger("option+x"), alt); - let cmd = (mods(false, false, false, true), "x".to_string()); - assert_eq!(trigger("cmd+x"), cmd); - assert_eq!(trigger("command+x"), cmd); - assert_eq!(trigger("super+x"), cmd); - assert_eq!(trigger("control+x"), trigger("ctrl+x")); - } - - #[test] - fn modifiers_are_case_insensitive() { - assert_eq!(trigger("Ctrl+Shift+A"), trigger("ctrl+shift+a")); - } - - #[test] - fn named_keys() { - for name in [ - "enter", - "tab", - "escape", - "backspace", - "delete", - "up", - "down", - "left", - "right", - "home", - "end", - "page_up", - "page_down", - "space", - "insert", - "f1", - "f12", - ] { - assert_eq!(trigger(&format!("ctrl+{name}")).1, name, "{name}"); - } - } - - #[test] - fn punctuation_key_names_map_to_chars() { - assert_eq!(trigger("cmd+plus").1, "+"); - assert_eq!(trigger("cmd+minus").1, "-"); - assert_eq!(trigger("cmd+equal").1, "="); - assert_eq!(trigger("cmd+comma").1, ","); - assert_eq!(trigger("cmd+bracket_left").1, "["); - assert_eq!(trigger("cmd+bracket_right").1, "]"); - } - - #[test] - fn trailing_plus_is_the_plus_key() { - assert_eq!( - trigger("cmd++"), - (mods(false, false, false, true), "+".to_string()) - ); - assert_eq!(trigger("+"), (Mods::default(), "+".to_string())); - } - - #[test] - fn single_chars_lowercase() { - assert_eq!(trigger("cmd+A").1, "a"); - assert_eq!(trigger("cmd+[").1, "["); - assert_eq!(trigger("cmd+9").1, "9"); - } - - #[test] - fn bad_triggers() { - assert!(parse_trigger("").is_err()); - assert!(parse_trigger("cmd+").is_err()); - assert!(parse_trigger("hyper+a").is_err()); - assert!(parse_trigger("cmd+foo").is_err()); - } - - #[test] - fn parse_keybind_full() { - let kb = parse_keybind("ctrl+shift+c=copy_to_clipboard").unwrap(); - assert_eq!(kb.mods, mods(true, true, false, false)); - assert_eq!(kb.key, "c"); - assert_eq!(kb.action, Action::Copy); - - let kb = parse_keybind("cmd+shift+d=new_split:down").unwrap(); - assert_eq!(kb.mods, mods(false, true, false, true)); - assert_eq!(kb.key, "d"); - assert_eq!(kb.action, Action::NewSplit(SplitDirection::Down)); - - let kb = parse_keybind(" cmd+9 = goto_tab:-1 ").unwrap(); - assert_eq!(kb.action, Action::GotoTab(-1)); - } - - #[test] - fn parse_keybind_errors() { - assert!(parse_keybind("no equals here").is_err()); - assert!(parse_keybind("cmd+t=do_a_flip").is_err()); - assert!(parse_keybind("cmd+huh=new_tab").is_err()); - // Binding `=` needs the `equal` name; `cmd+=` has no key. - assert!(parse_keybind("cmd+=copy").is_err()); - assert!(parse_keybind("cmd+equal=copy").is_ok()); - } - - #[test] - fn defaults_cover_the_hardcoded_set() { - let binds = default_keybinds(); - let cmd = mods(false, false, false, true); - let cmd_shift = mods(false, true, false, true); - let cmd_alt = mods(false, false, true, true); - let cmd_alt_shift = mods(false, true, true, true); - let find = |m: Mods, k: &str| { - binds - .iter() - .find(|b| b.mods == m && b.key == k) - .unwrap_or_else(|| panic!("missing {m:?}+{k}")) - .action - .clone() - }; - assert_eq!(find(cmd, "n"), Action::NewWindow); - assert_eq!(find(cmd, "t"), Action::NewTab); - assert_eq!(find(cmd, "w"), Action::CloseSurface); - assert_eq!(find(cmd_alt, "w"), Action::CloseTab); - assert_eq!(find(cmd_shift, "w"), Action::CloseWindow); - assert_eq!(find(cmd_alt_shift, "w"), Action::CloseAllWindows); - assert_eq!(find(cmd, "d"), Action::NewSplit(SplitDirection::Right)); - assert_eq!(find(cmd_shift, "d"), Action::NewSplit(SplitDirection::Down)); - assert_eq!(find(cmd_shift, "["), Action::PreviousTab); - assert_eq!(find(cmd_shift, "]"), Action::NextTab); - assert_eq!(find(cmd_alt, "up"), Action::GotoSplit(SplitFocus::Up)); - assert_eq!(find(cmd_alt, "down"), Action::GotoSplit(SplitFocus::Down)); - assert_eq!(find(cmd_alt, "left"), Action::GotoSplit(SplitFocus::Left)); - assert_eq!(find(cmd_alt, "right"), Action::GotoSplit(SplitFocus::Right)); - for n in 1..=9 { - assert_eq!(find(cmd, &n.to_string()), Action::GotoTab(n)); - } - assert_eq!(find(cmd, "c"), Action::Copy); - assert_eq!(find(cmd, "v"), Action::Paste); - assert_eq!(find(cmd, "+"), Action::IncreaseFontSize(1.0)); - assert_eq!(find(cmd, "="), Action::IncreaseFontSize(1.0)); - assert_eq!(find(cmd, "-"), Action::DecreaseFontSize(1.0)); - assert_eq!(find(cmd, "0"), Action::ResetFontSize); - assert_eq!(find(cmd, "k"), Action::ClearScreen); - assert_eq!(find(cmd, ","), Action::ToggleSettings); - assert_eq!(find(cmd_shift, ","), Action::ReloadConfig); - assert_eq!(find(cmd, "q"), Action::Quit); - // No duplicate triggers among defaults. - for (i, a) in binds.iter().enumerate() { - for b in &binds[i + 1..] { - assert!( - !(a.mods == b.mods && a.key == b.key), - "duplicate default trigger {a:?}" - ); - } - } - } - - #[test] - fn resolve_with_no_user_binds_is_defaults() { - let (binds, diags) = resolve(&[]); - assert_eq!(binds, default_keybinds()); - assert!(diags.is_empty()); - } - - #[test] - fn user_bind_overrides_default_with_same_trigger() { - let raw = vec!["cmd+t=quit".to_string()]; - let (binds, diags) = resolve(&raw); - assert!(diags.is_empty()); - assert_eq!(binds.len(), default_keybinds().len()); - let cmd_t: Vec<_> = binds - .iter() - .filter(|b| b.mods == mods(false, false, false, true) && b.key == "t") - .collect(); - assert_eq!(cmd_t.len(), 1); - assert_eq!(cmd_t[0].action, Action::Quit); - } - - #[test] - fn later_user_bind_wins_over_earlier() { - let raw = vec!["ctrl+x=new_tab".to_string(), "ctrl+x=quit".to_string()]; - let (binds, _) = resolve(&raw); - let hits: Vec<_> = binds - .iter() - .filter(|b| b.mods == mods(true, false, false, false) && b.key == "x") - .collect(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].action, Action::Quit); - } - - #[test] - fn new_trigger_is_appended() { - let raw = vec!["ctrl+shift+page_up=scroll_page_up".to_string()]; - let (binds, diags) = resolve(&raw); - assert!(diags.is_empty()); - assert_eq!(binds.len(), default_keybinds().len() + 1); - let kb = binds.last().unwrap(); - assert_eq!(kb.mods, mods(true, true, false, false)); - assert_eq!(kb.key, "page_up"); - assert_eq!(kb.action, Action::ScrollPageUp); - } - - #[test] - fn unbind_removes_a_default() { - let raw = vec!["cmd+q=unbind".to_string()]; - let (binds, diags) = resolve(&raw); - assert!(diags.is_empty()); - assert_eq!(binds.len(), default_keybinds().len() - 1); - assert!(!binds - .iter() - .any(|b| b.mods == mods(false, false, false, true) && b.key == "q")); - } - - #[test] - fn unbind_unknown_trigger_is_harmless() { - let raw = vec!["ctrl+alt+f9=unbind".to_string()]; - let (binds, diags) = resolve(&raw); - assert!(diags.is_empty()); - assert_eq!(binds, default_keybinds()); - } - - #[test] - fn invalid_entries_diagnose_and_skip() { - let raw = vec![ - "cmd+t=do_a_flip".to_string(), - "garbage".to_string(), - "ctrl+x=new_tab".to_string(), - ]; - let (binds, diags) = resolve(&raw); - assert_eq!(diags.len(), 2); - assert!(diags.iter().all(|d| d.key == "keybind")); - assert!(diags[0].message.contains("do_a_flip")); - // The bad cmd+t entry must not have removed the default. - assert!(binds - .iter() - .any(|b| b.key == "t" && b.action == Action::NewTab)); - assert_eq!(binds.len(), default_keybinds().len() + 1); - } - - #[test] - fn format_trigger_round_trips() { - let cmd_shift = mods(false, true, false, true); - assert_eq!(format_trigger(cmd_shift, "t"), "cmd+shift+t"); - assert_eq!(parse_trigger("cmd+shift+t").unwrap(), (cmd_shift, "t".into())); - // Punctuation keys spell out so the line never mis-splits. - assert_eq!(format_trigger(mods(false, false, false, true), "+"), "cmd+plus"); - assert_eq!(format_trigger(mods(false, false, false, true), "="), "cmd+equal"); - assert_eq!(format_trigger(mods(false, false, false, true), "["), "cmd+bracket_left"); - for key in ["plus", "equal", "bracket_left", "comma"] { - let (m, k) = parse_trigger(&format!("cmd+{key}")).unwrap(); - assert_eq!(format_trigger(m, &k), format!("cmd+{key}")); - } - } - - #[test] - fn diff_round_trips_through_resolve() { - // Start from defaults, change one action, drop one, add one new. - let mut desired = default_keybinds(); - for kb in &mut desired { - if kb.mods == mods(false, false, false, true) && kb.key == "t" { - kb.action = Action::Quit; // change cmd+t - } - } - desired.retain(|kb| !(kb.mods == mods(false, false, false, true) && kb.key == "q")); // drop cmd+q - desired.push(Keybind { - mods: mods(true, true, false, false), - key: "page_up".into(), - action: Action::ScrollPageUp, - }); // add new - - let lines = diff_from_defaults(&desired); - let (resolved, diags) = resolve(&lines); - assert!(diags.is_empty(), "{diags:?}"); - - let key = |b: &Keybind| (b.mods, b.key.clone()); - let mut got: Vec<_> = resolved.iter().map(|b| (key(b), b.action.clone())).collect(); - let mut want: Vec<_> = desired.iter().map(|b| (key(b), b.action.clone())).collect(); - got.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); - want.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); - assert_eq!(got, want); - } - - #[test] - fn diff_of_defaults_is_empty() { - assert!(diff_from_defaults(&default_keybinds()).is_empty()); - } - - #[test] - fn equivalent_triggers_in_different_spellings_collide() { - let raw = vec![ - "super+t=quit".to_string(), // same trigger as default cmd+t - ]; - let (binds, _) = resolve(&raw); - let hits: Vec<_> = binds - .iter() - .filter(|b| b.mods == mods(false, false, false, true) && b.key == "t") - .collect(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].action, Action::Quit); - } -} +#[path = "../tests/keybind.rs"] +mod tests; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 509e593..60b5f9f 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -59,34 +59,5 @@ pub fn load() -> (Options, Vec) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn missing_file_yields_defaults() { - let (opts, diags) = load_path(std::path::Path::new("/nonexistent/prompt/config")); - assert_eq!(opts, Options::default()); - assert!(diags.is_empty()); - } - - #[test] - fn load_path_reads_file() { - let dir = std::env::temp_dir().join(format!("promptconfigtest{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("config"); - std::fs::write(&file, "font-size = 17\nbogus = 1\n").unwrap(); - let (opts, diags) = load_path(&file); - assert_eq!(opts.font_size, 17.0); - assert_eq!(diags.len(), 1); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn default_path_shape() { - // Whatever the environment, if a path comes back it must end with - // prompt/config. - if let Some(p) = default_path() { - assert!(p.ends_with("prompt/config"), "{p:?}"); - } - } -} +#[path = "../tests/lib.rs"] +mod tests; diff --git a/crates/config/src/options.rs b/crates/config/src/options.rs index 1b614a7..824bd66 100644 --- a/crates/config/src/options.rs +++ b/crates/config/src/options.rs @@ -237,97 +237,5 @@ impl Default for Options { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults() { - let o = Options::default(); - assert!(o.font_family.is_empty()); - assert_eq!(o.primary_font(), "Menlo"); - assert_eq!(o.font_size, 13.0); - assert_eq!(o.theme, ""); - assert_eq!(o.background, None); - assert_eq!(o.foreground, None); - assert_eq!(o.cursor_style, CursorStyle::Block); - assert!(o.cursor_style_blink); - assert_eq!(o.scrollback_limit, 10_000); - assert_eq!(o.window_padding_x, 2); - assert_eq!(o.window_padding_y, 2); - assert_eq!(o.window_width, 0); - assert_eq!(o.window_height, 0); - assert_eq!(o.shell, None); - assert_eq!(o.working_directory, None); - assert!(!o.copy_on_select); - assert!(o.confirm_close_surface); - assert!(!o.mouse_hide_while_typing); - assert!(o.palette.is_empty()); - assert!(o.plugin.is_empty()); - assert!(o.keybind.is_empty()); - assert_eq!(o.font_style, FontStyle::Normal); - assert!(o.font_feature.is_empty()); - assert_eq!(o.adjust_cell_width, 0); - assert_eq!(o.adjust_cell_height, 0); - assert_eq!(o.cursor_color, None); - assert_eq!(o.cursor_text, None); - assert_eq!(o.selection_foreground, None); - assert_eq!(o.selection_background, None); - assert!(!o.bold_is_bright); - assert_eq!(o.minimum_contrast, 1.0); - assert_eq!(o.unfocused_split_opacity, 0.7); - assert_eq!(o.split_divider_color, None); - assert_eq!(o.mouse_scroll_multiplier, 1.0); - assert_eq!(o.macos_option_as_alt, OptionAsAlt::False); - assert!(o.window_inherit_working_directory); - assert!(o.quit_after_last_window_closed); - assert_eq!(o.title, None); - assert_eq!(o.clipboard_read, ClipboardAccess::Ask); - assert_eq!(o.clipboard_write, ClipboardAccess::Allow); - } - - #[test] - fn font_style_parse() { - assert_eq!(FontStyle::parse("normal"), Some(FontStyle::Normal)); - assert_eq!(FontStyle::parse("Bold"), Some(FontStyle::Bold)); - assert_eq!(FontStyle::parse("ITALIC"), Some(FontStyle::Italic)); - assert_eq!(FontStyle::parse("bold-italic"), Some(FontStyle::BoldItalic)); - assert_eq!(FontStyle::parse("bold italic"), None); - assert_eq!(FontStyle::parse(""), None); - } - - #[test] - fn option_as_alt_parse() { - assert_eq!(OptionAsAlt::parse("false"), Some(OptionAsAlt::False)); - assert_eq!(OptionAsAlt::parse("true"), Some(OptionAsAlt::True)); - assert_eq!(OptionAsAlt::parse("no"), Some(OptionAsAlt::False)); - assert_eq!(OptionAsAlt::parse("1"), Some(OptionAsAlt::True)); - assert_eq!(OptionAsAlt::parse("Left"), Some(OptionAsAlt::Left)); - assert_eq!(OptionAsAlt::parse("RIGHT"), Some(OptionAsAlt::Right)); - assert_eq!(OptionAsAlt::parse("middle"), None); - assert_eq!(OptionAsAlt::parse(""), None); - } - - #[test] - fn clipboard_access_parse() { - assert_eq!( - ClipboardAccess::parse("allow"), - Some(ClipboardAccess::Allow) - ); - assert_eq!(ClipboardAccess::parse("Ask"), Some(ClipboardAccess::Ask)); - assert_eq!(ClipboardAccess::parse("DENY"), Some(ClipboardAccess::Deny)); - assert_eq!(ClipboardAccess::parse("never"), None); - assert_eq!(ClipboardAccess::parse(""), None); - } - - #[test] - fn cursor_style_parse() { - assert_eq!(CursorStyle::parse("block"), Some(CursorStyle::Block)); - assert_eq!(CursorStyle::parse("Bar"), Some(CursorStyle::Bar)); - assert_eq!( - CursorStyle::parse("UNDERLINE"), - Some(CursorStyle::Underline) - ); - assert_eq!(CursorStyle::parse("beam"), None); - assert_eq!(CursorStyle::parse(""), None); - } -} +#[path = "../tests/options.rs"] +mod tests; diff --git a/crates/config/src/parse.rs b/crates/config/src/parse.rs index 14c9aa0..2470632 100644 --- a/crates/config/src/parse.rs +++ b/crates/config/src/parse.rs @@ -58,179 +58,5 @@ pub fn parse_str(input: &str) -> (Options, Vec) { } #[cfg(test)] -mod tests { - use super::*; - use crate::options::CursorStyle; - - #[test] - fn empty_input_is_defaults() { - let (opts, diags) = parse_str(""); - assert_eq!(opts, Options::default()); - assert!(diags.is_empty()); - } - - #[test] - fn full_sample_config() { - let src = r#" -# Prompt configuration -font-family = JetBrains Mono -font-size = 14.5 -theme = catppuccin -background = #1d1f21 -foreground = #c5c8c6 -cursor-style = bar -cursor-style-blink = false -scrollback-limit = 50000 -window-padding-x = 8 -window-padding-y = 4 -window-width = 120 -window-height = 40 -command = /bin/zsh -working-directory = /tmp -copy-on-select = true -confirm-close-surface = false -mouse-hide-while-typing = true -palette = 0=#1d1f21 -palette = 1=#cc6666 -plugin = ~/.config/prompt/plugins/tools -keybind = ctrl+shift+c=copy_to_clipboard -keybind = ctrl+shift+v=paste_from_clipboard -"#; - let (o, diags) = parse_str(src); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(o.primary_font(), "JetBrains Mono"); - assert_eq!(o.font_size, 14.5); - assert_eq!(o.theme, "catppuccin"); - assert_eq!(o.background.as_deref(), Some("#1d1f21")); - assert_eq!(o.foreground.as_deref(), Some("#c5c8c6")); - assert_eq!(o.cursor_style, CursorStyle::Bar); - assert!(!o.cursor_style_blink); - assert_eq!(o.scrollback_limit, 50_000); - assert_eq!(o.window_padding_x, 8); - assert_eq!(o.window_padding_y, 4); - assert_eq!(o.window_width, 120); - assert_eq!(o.window_height, 40); - assert_eq!(o.shell.as_deref(), Some("/bin/zsh")); - assert_eq!(o.working_directory.as_deref(), Some("/tmp")); - assert!(o.copy_on_select); - assert!(!o.confirm_close_surface); - assert!(o.mouse_hide_while_typing); - assert_eq!( - o.palette, - vec![(0, "#1d1f21".to_string()), (1, "#cc6666".to_string()),] - ); - assert_eq!(o.plugin, vec!["~/.config/prompt/plugins/tools".to_string()]); - assert_eq!( - o.keybind, - vec![ - "ctrl+shift+c=copy_to_clipboard".to_string(), - "ctrl+shift+v=paste_from_clipboard".to_string(), - ] - ); - } - - #[test] - fn comments_and_blank_lines_ignored() { - let src = "# a comment\n\n \nfont-size = 15\n# font-size = 99\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty()); - assert_eq!(o.font_size, 15.0); - } - - #[test] - fn last_wins_for_scalars() { - let (o, diags) = parse_str("font-size = 10\nfont-size = 20\n"); - assert!(diags.is_empty()); - assert_eq!(o.font_size, 20.0); - } - - #[test] - fn empty_value_resets_to_default() { - let src = "font-size = 20\nfont-size = \n\ - font-family = Foo\nfont-family =\n\ - command = /bin/fish\ncommand = \n\ - copy-on-select = true\ncopy-on-select =\n\ - palette = 0=#000000\npalette =\n\ - plugin = /tmp/plugin\nplugin =\n\ - keybind = a=b\nkeybind =\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(o, Options::default()); - } - - #[test] - fn unknown_key_diagnostic_but_continues() { - let (o, diags) = parse_str("bogus-key = 1\nfont-size = 18\n"); - assert_eq!(diags.len(), 1); - assert_eq!(diags[0].line, 1); - assert_eq!(diags[0].key, "bogus-key"); - assert!(diags[0].message.contains("unknown key")); - assert_eq!(o.font_size, 18.0); - } - - #[test] - fn bad_value_diagnostic_but_continues() { - let src = "font-size = huge\ncursor-style = wedge\n\ - copy-on-select = perhaps\npalette = 0=red\nfont-size = 11\n"; - let (o, diags) = parse_str(src); - assert_eq!(diags.len(), 4); - assert_eq!(diags[0].line, 1); - assert_eq!(diags[0].key, "font-size"); - assert_eq!(diags[1].key, "cursor-style"); - assert_eq!(diags[2].key, "copy-on-select"); - assert_eq!(diags[3].key, "palette"); - assert_eq!(o.font_size, 11.0); - assert!(o.palette.is_empty()); - assert_eq!(o.cursor_style, CursorStyle::Block); - } - - #[test] - fn line_without_equals_is_diagnostic() { - let (o, diags) = parse_str("just some words\nfont-size = 12\n"); - assert_eq!(diags.len(), 1); - assert_eq!(diags[0].line, 1); - assert_eq!(diags[0].key, ""); - assert_eq!(o.font_size, 12.0); - } - - #[test] - fn missing_key_is_diagnostic() { - let (_, diags) = parse_str("= value\n"); - assert_eq!(diags.len(), 1); - assert!(diags[0].message.contains("missing key")); - } - - #[test] - fn repeated_palette_accumulates_and_reset_clears() { - let src = "palette = 0=#000000\npalette = 1=#111111\n\ - palette =\npalette = 2=#222222\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty()); - assert_eq!(o.palette, vec![(2, "#222222".to_string())]); - } - - #[test] - fn quoted_values_are_stripped() { - let src = "font-family = \"SF Mono\"\ntheme = \"gruvbox dark\"\n\ - working-directory = \"/Users/me/My Stuff\"\n"; - let (o, diags) = parse_str(src); - assert!(diags.is_empty()); - assert_eq!(o.primary_font(), "SF Mono"); - assert_eq!(o.theme, "gruvbox dark"); - assert_eq!(o.working_directory.as_deref(), Some("/Users/me/My Stuff")); - } - - #[test] - fn values_may_contain_equals() { - let (o, diags) = parse_str("keybind = ctrl+a=select_all\n"); - assert!(diags.is_empty()); - assert_eq!(o.keybind, vec!["ctrl+a=select_all".to_string()]); - } - - #[test] - fn whitespace_around_key_and_value_is_trimmed() { - let (o, diags) = parse_str(" font-size = 16 \n"); - assert!(diags.is_empty()); - assert_eq!(o.font_size, 16.0); - } -} +#[path = "../tests/parse.rs"] +mod tests; diff --git a/crates/config/src/value.rs b/crates/config/src/value.rs index 485b189..a8d4045 100644 --- a/crates/config/src/value.rs +++ b/crates/config/src/value.rs @@ -88,98 +88,5 @@ pub fn parse_palette(s: &str) -> Option<(u8, String)> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bools() { - assert_eq!(parse_bool("true"), Some(true)); - assert_eq!(parse_bool("TRUE"), Some(true)); - assert_eq!(parse_bool("1"), Some(true)); - assert_eq!(parse_bool("yes"), Some(true)); - assert_eq!(parse_bool("false"), Some(false)); - assert_eq!(parse_bool("0"), Some(false)); - assert_eq!(parse_bool("no"), Some(false)); - assert_eq!(parse_bool("maybe"), None); - assert_eq!(parse_bool(""), None); - } - - #[test] - fn numbers() { - assert_eq!(parse_f32("13.5"), Some(13.5)); - assert_eq!(parse_f32("13"), Some(13.0)); - assert_eq!(parse_f32("abc"), None); - assert_eq!(parse_u32("42"), Some(42)); - assert_eq!(parse_u32("-1"), None); - assert_eq!(parse_usize("10000"), Some(10000)); - assert_eq!(parse_usize("x"), None); - } - - #[test] - fn f32_ranges() { - assert_eq!(parse_f32_range("1.5", 1.0, 21.0), Some(1.5)); - assert_eq!(parse_f32_range("0.5", 1.0, 21.0), Some(1.0)); - assert_eq!(parse_f32_range("100", 1.0, 21.0), Some(21.0)); - assert_eq!(parse_f32_range("abc", 1.0, 21.0), None); - assert_eq!(parse_f32_range("NaN", 1.0, 21.0), None); - assert_eq!(parse_f32_range("inf", 1.0, 21.0), None); - } - - #[test] - fn adjusts() { - assert_eq!(parse_adjust("2"), Some(2)); - assert_eq!(parse_adjust("-1"), Some(-1)); - assert_eq!(parse_adjust("+3"), Some(3)); - assert_eq!(parse_adjust("4px"), Some(4)); - assert_eq!(parse_adjust("-2px"), Some(-2)); - assert_eq!(parse_adjust("2 px"), Some(2)); - assert_eq!(parse_adjust("10%"), None); - assert_eq!(parse_adjust("abc"), None); - assert_eq!(parse_adjust(""), None); - } - - #[test] - fn colors() { - assert_eq!(parse_color("#1d1f21"), Some("#1d1f21".to_string())); - assert_eq!(parse_color("1d1f21"), Some("#1d1f21".to_string())); - assert_eq!(parse_color("#FFAA00"), Some("#ffaa00".to_string())); - assert_eq!(parse_color("#fff"), None); - assert_eq!(parse_color("red"), None); - assert_eq!(parse_color("#12345g"), None); - assert_eq!(parse_color(""), None); - } - - #[test] - fn fontfeatures() { - assert_eq!(parse_fontfeature("liga"), Some("liga".to_string())); - assert_eq!(parse_fontfeature("-liga"), Some("-liga".to_string())); - assert_eq!(parse_fontfeature("+ss01"), Some("+ss01".to_string())); - assert_eq!(parse_fontfeature("-"), None); - assert_eq!(parse_fontfeature("no spaces"), None); - assert_eq!(parse_fontfeature(""), None); - } - - #[test] - fn unquoting() { - assert_eq!(unquote("\"hello\""), "hello"); - assert_eq!(unquote("hello"), "hello"); - assert_eq!(unquote(" \"spaced value\" "), "spaced value"); - assert_eq!(unquote("\""), "\""); - assert_eq!(unquote(""), ""); - assert_eq!(unquote("\"\""), ""); - } - - #[test] - fn palette() { - assert_eq!(parse_palette("0=#1d1f21"), Some((0, "#1d1f21".to_string()))); - assert_eq!( - parse_palette(" 15 = #FFFFFF "), - Some((15, "#FFFFFF".to_string())) - ); - assert_eq!(parse_palette("256=#000000"), None); // index overflows u8 - assert_eq!(parse_palette("0=1d1f21"), None); // missing # - assert_eq!(parse_palette("0=#zzz"), None); // bad hex - assert_eq!(parse_palette("0=#12345"), None); // short hex - assert_eq!(parse_palette("#1d1f21"), None); // missing index - } -} +#[path = "../tests/value.rs"] +mod tests; diff --git a/crates/config/src/watch.rs b/crates/config/src/watch.rs index 80e315a..0125ffc 100644 --- a/crates/config/src/watch.rs +++ b/crates/config/src/watch.rs @@ -68,92 +68,5 @@ pub fn watch( } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::AtomicUsize; - use std::time::Instant; - - fn tempfile(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("promptwatchtest{}{tag}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir.join("config") - } - - #[test] - fn fires_on_mtime_change_and_stops_on_drop() { - let file = tempfile("fires"); - std::fs::write(&file, "font-size = 13\n").unwrap(); - let hits = Arc::new(AtomicUsize::new(0)); - let counter = hits.clone(); - let handle = watch(file.clone(), Duration::from_millis(10), move || { - counter.fetch_add(1, Ordering::SeqCst); - }); - - // Bump the mtime until the watcher notices (bounded by a deadline). - let f = std::fs::File::options().write(true).open(&file).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - let mut bump = 10; - while hits.load(Ordering::SeqCst) == 0 && Instant::now() < deadline { - f.set_modified(SystemTime::now() + Duration::from_secs(bump)) - .unwrap(); - bump += 10; - std::thread::sleep(Duration::from_millis(20)); - } - assert!(hits.load(Ordering::SeqCst) >= 1, "watcher never fired"); - - // After drop the thread is joined: no further callbacks. - drop(handle); - let settled = hits.load(Ordering::SeqCst); - f.set_modified(SystemTime::now() + Duration::from_secs(bump)) - .unwrap(); - std::thread::sleep(Duration::from_millis(100)); - assert_eq!(hits.load(Ordering::SeqCst), settled); - std::fs::remove_dir_all(file.parent().unwrap()).ok(); - } - - #[test] - fn unchanged_file_does_not_fire() { - let file = tempfile("quiet"); - std::fs::write(&file, "a").unwrap(); - let hits = Arc::new(AtomicUsize::new(0)); - let counter = hits.clone(); - let handle = watch(file.clone(), Duration::from_millis(10), move || { - counter.fetch_add(1, Ordering::SeqCst); - }); - std::thread::sleep(Duration::from_millis(150)); - drop(handle); - assert_eq!(hits.load(Ordering::SeqCst), 0); - std::fs::remove_dir_all(file.parent().unwrap()).ok(); - } - - #[test] - fn drop_is_prompt_with_long_interval() { - let file = tempfile("longint"); - std::fs::write(&file, "a").unwrap(); - let handle = watch(file.clone(), Duration::from_secs(60), || {}); - let start = Instant::now(); - drop(handle); - assert!(start.elapsed() < Duration::from_secs(1)); - std::fs::remove_dir_all(file.parent().unwrap()).ok(); - } - - #[test] - fn file_appearing_fires() { - let file = tempfile("appear"); - std::fs::remove_file(&file).ok(); - let hits = Arc::new(AtomicUsize::new(0)); - let counter = hits.clone(); - let handle = watch(file.clone(), Duration::from_millis(10), move || { - counter.fetch_add(1, Ordering::SeqCst); - }); - std::thread::sleep(Duration::from_millis(50)); - std::fs::write(&file, "created").unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while hits.load(Ordering::SeqCst) == 0 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(20)); - } - assert!(hits.load(Ordering::SeqCst) >= 1); - drop(handle); - std::fs::remove_dir_all(file.parent().unwrap()).ok(); - } -} +#[path = "../tests/watch.rs"] +mod tests; diff --git a/crates/config/tests/action.rs b/crates/config/tests/action.rs new file mode 100644 index 0000000..d379b93 --- /dev/null +++ b/crates/config/tests/action.rs @@ -0,0 +1,219 @@ +use super::*; + +#[test] +fn simple_actions() { + let cases = [ + ("new_window", Action::NewWindow), + ("new_tab", Action::NewTab), + ("close_surface", Action::CloseSurface), + ("close_tab", Action::CloseTab), + ("close_window", Action::CloseWindow), + ("close_all_windows", Action::CloseAllWindows), + ("previous_tab", Action::PreviousTab), + ("next_tab", Action::NextTab), + ("copy_to_clipboard", Action::Copy), + ("copy", Action::Copy), + ("paste_from_clipboard", Action::Paste), + ("paste", Action::Paste), + ("reset_font_size", Action::ResetFontSize), + ("scroll_page_up", Action::ScrollPageUp), + ("scroll_page_down", Action::ScrollPageDown), + ("scroll_to_top", Action::ScrollToTop), + ("scroll_to_bottom", Action::ScrollToBottom), + ("clear_screen", Action::ClearScreen), + ("toggle_semantic_search", Action::ToggleSemanticSearch), + ("explain_output", Action::ExplainOutput), + ("compose_command", Action::ComposeCommand), + ("reload_config", Action::ReloadConfig), + ("show_help", Action::ShowHelp), + ("help", Action::ShowHelp), + ("toggle_fullscreen", Action::ToggleFullscreen), + ("zoom_split", Action::ZoomSplit), + ("toggle_split_zoom", Action::ZoomSplit), + ("equalize_splits", Action::EqualizeSplits), + ("minimize_window", Action::MinimizeWindow), + ("zoom_window", Action::ZoomWindow), + ("hide_all", Action::HideAll), + ("bring_all_to_front", Action::BringAllToFront), + ("return_to_default_size", Action::ReturnToDefaultSize), + ("use_as_default", Action::UseAsDefault), + ("change_tab_title", Action::ChangeTabTitle), + ("change_terminal_title", Action::ChangeTerminalTitle), + ("toggle_read_only", Action::ToggleReadOnly), + ("toggle_quick_terminal", Action::ToggleQuickTerminal), + ("quick_terminal", Action::ToggleQuickTerminal), + ("quit", Action::Quit), + ("unbind", Action::Unbound), + ]; + for (src, want) in cases { + assert_eq!(Action::parse(src), Ok(want), "{src}"); + } +} + +#[test] +fn name_is_case_insensitive() { + assert_eq!(Action::parse("NEW_TAB"), Ok(Action::NewTab)); + assert_eq!( + Action::parse("New_Split:Right"), + Ok(Action::NewSplit(SplitDirection::Right)) + ); +} + +#[test] +fn new_split_params() { + let cases = [ + ("new_split:right", SplitDirection::Right), + ("new_split:down", SplitDirection::Down), + ("new_split:left", SplitDirection::Left), + ("new_split:up", SplitDirection::Up), + ]; + for (src, dir) in cases { + assert_eq!(Action::parse(src), Ok(Action::NewSplit(dir)), "{src}"); + } + assert!(Action::parse("new_split:sideways").is_err()); + assert!(Action::parse("new_split").is_err()); + assert!(Action::parse("new_split:").is_err()); +} + +#[test] +fn resize_split_params() { + let cases = [ + ("resize_split:up", ResizeDir::Up), + ("resize_split:down", ResizeDir::Down), + ("resize_split:left", ResizeDir::Left), + ("resize_split:right", ResizeDir::Right), + ]; + for (src, dir) in cases { + assert_eq!(Action::parse(src), Ok(Action::ResizeSplit(dir)), "{src}"); + } + assert!(Action::parse("resize_split:bigger").is_err()); + assert!(Action::parse("resize_split").is_err()); +} + +#[test] +fn goto_split_params() { + let cases = [ + ("goto_split:previous", SplitFocus::Previous), + ("goto_split:next", SplitFocus::Next), + ("goto_split:up", SplitFocus::Up), + ("goto_split:down", SplitFocus::Down), + ("goto_split:left", SplitFocus::Left), + ("goto_split:right", SplitFocus::Right), + ]; + for (src, focus) in cases { + assert_eq!(Action::parse(src), Ok(Action::GotoSplit(focus)), "{src}"); + } + assert!(Action::parse("goto_split:over").is_err()); + assert!(Action::parse("goto_split").is_err()); +} + +#[test] +fn goto_tab_params() { + assert_eq!(Action::parse("goto_tab:3"), Ok(Action::GotoTab(3))); + assert_eq!(Action::parse("goto_tab:-1"), Ok(Action::GotoTab(-1))); + assert!(Action::parse("goto_tab:0").is_err()); + assert!(Action::parse("goto_tab:first").is_err()); + assert!(Action::parse("goto_tab").is_err()); +} + +#[test] +fn move_tab_params() { + assert_eq!(Action::parse("move_tab:1"), Ok(Action::MoveTab(1))); + assert_eq!(Action::parse("move_tab:-2"), Ok(Action::MoveTab(-2))); + assert!(Action::parse("move_tab").is_err()); + assert!(Action::parse("move_tab:x").is_err()); +} + +#[test] +fn font_size_params() { + assert_eq!( + Action::parse("increase_font_size"), + Ok(Action::IncreaseFontSize(1.0)) + ); + assert_eq!( + Action::parse("increase_font_size:2.5"), + Ok(Action::IncreaseFontSize(2.5)) + ); + assert_eq!( + Action::parse("decrease_font_size"), + Ok(Action::DecreaseFontSize(1.0)) + ); + assert_eq!( + Action::parse("decrease_font_size:0.5"), + Ok(Action::DecreaseFontSize(0.5)) + ); + assert!(Action::parse("increase_font_size:0").is_err()); + assert!(Action::parse("increase_font_size:-1").is_err()); + assert!(Action::parse("increase_font_size:big").is_err()); +} + +#[test] +fn plugin_command_param() { + assert_eq!( + Action::parse("plugin_command:tools/top"), + Ok(Action::PluginCommand("tools/top".to_string())) + ); + assert!(Action::parse("plugin_command").is_err()); + assert!(Action::parse("plugin_command:tools").is_err()); + assert!(Action::parse("plugin_command:Tools/top").is_err()); +} + +#[test] +fn macro_actions() { + assert_eq!(Action::parse("macro_record"), Ok(Action::MacroRecord)); + assert_eq!( + Action::parse("macro:deploy"), + Ok(Action::MacroReplay("deploy".to_string())) + ); + assert_eq!( + Action::parse("macro:build.all"), + Ok(Action::MacroReplay("build.all".to_string())) + ); + assert!(Action::parse("macro").is_err()); + assert!(Action::parse("macro:Deploy").is_err()); + assert!(Action::parse("macro_record:now").is_err()); +} + +#[test] +fn param_on_paramless_action_is_error() { + assert!(Action::parse("quit:now").is_err()); + assert!(Action::parse("new_tab:2").is_err()); + assert!(Action::parse("unbind:all").is_err()); +} + +#[test] +fn unknown_action_is_error() { + assert!(Action::parse("select_all").is_err()); + assert!(Action::parse("").is_err()); +} + +#[test] +fn to_config_round_trips() { + let cases = [ + Action::NewWindow, + Action::NewTab, + Action::CloseSurface, + Action::CloseAllWindows, + Action::NewSplit(SplitDirection::Down), + Action::GotoSplit(SplitFocus::Left), + Action::ResizeSplit(ResizeDir::Up), + Action::GotoTab(3), + Action::GotoTab(-1), + Action::MoveTab(-2), + Action::Copy, + Action::Paste, + Action::IncreaseFontSize(1.0), + Action::IncreaseFontSize(2.5), + Action::DecreaseFontSize(1.0), + Action::JumpToPrompt(-1), + Action::PluginCommand("tools/top".into()), + Action::MacroReplay("deploy".into()), + Action::ToggleQuickTerminal, + Action::Quit, + Action::Unbound, + ]; + for want in cases { + let s = want.to_config(); + assert_eq!(Action::parse(&s), Ok(want.clone()), "{s}"); + } +} diff --git a/crates/config/tests/apply.rs b/crates/config/tests/apply.rs new file mode 100644 index 0000000..8facdc4 --- /dev/null +++ b/crates/config/tests/apply.rs @@ -0,0 +1,149 @@ + use crate::options::{ClipboardAccess, FontStyle, OptionAsAlt, Options}; + use crate::parse::parse_str; + + #[test] + fn new_options_parse() { + let src = r#" +font-style = bold-italic +font-feature = -liga +font-feature = +ss01 +adjust-cell-width = 2 +adjust-cell-height = -1px +cursor-color = #ff0000 +cursor-text = 00ff00 +selection-foreground = #FFFFFF +selection-background = #000000 +bold-is-bright = true +minimum-contrast = 3 +unfocused-split-opacity = 0.5 +split-divider-color = #444444 +mouse-scroll-multiplier = 2.5 +macos-option-as-alt = left +window-inherit-working-directory = false +quit-after-last-window-closed = false +title = my terminal +clipboard-read = deny +clipboard-write = ask +"#; + let (o, diags) = parse_str(src); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(o.font_style, FontStyle::BoldItalic); + assert_eq!( + o.font_feature, + vec!["-liga".to_string(), "+ss01".to_string()] + ); + assert_eq!(o.adjust_cell_width, 2); + assert_eq!(o.adjust_cell_height, -1); + assert_eq!(o.cursor_color.as_deref(), Some("#ff0000")); + assert_eq!(o.cursor_text.as_deref(), Some("#00ff00")); + assert_eq!(o.selection_foreground.as_deref(), Some("#ffffff")); + assert_eq!(o.selection_background.as_deref(), Some("#000000")); + assert!(o.bold_is_bright); + assert_eq!(o.minimum_contrast, 3.0); + assert_eq!(o.unfocused_split_opacity, 0.5); + assert_eq!(o.split_divider_color.as_deref(), Some("#444444")); + assert_eq!(o.mouse_scroll_multiplier, 2.5); + assert_eq!(o.macos_option_as_alt, OptionAsAlt::Left); + assert!(!o.window_inherit_working_directory); + assert!(!o.quit_after_last_window_closed); + assert_eq!(o.title.as_deref(), Some("my terminal")); + assert_eq!(o.clipboard_read, ClipboardAccess::Deny); + assert_eq!(o.clipboard_write, ClipboardAccess::Ask); + } + + #[test] + fn new_options_bad_values_diagnose() { + let cases = [ + "font-style = fancy", + "font-feature = no good", + "adjust-cell-width = wide", + "adjust-cell-height = 10%", + "cursor-color = red", + "cursor-text = #fff", + "selection-foreground = #12345", + "selection-background = blue", + "bold-is-bright = maybe", + "minimum-contrast = abc", + "unfocused-split-opacity = dim", + "split-divider-color = gray", + "mouse-scroll-multiplier = fast", + "macos-option-as-alt = middle", + "window-inherit-working-directory = sometimes", + "quit-after-last-window-closed = perhaps", + "clipboard-read = never", + "clipboard-write = always", + ]; + for case in cases { + let (o, diags) = parse_str(case); + assert_eq!(diags.len(), 1, "no diagnostic for `{case}`"); + assert_eq!(o, Options::default(), "value applied for `{case}`"); + } + } + + #[test] + fn new_options_empty_value_resets() { + let src = "font-style = bold\nfont-style =\n\ + font-feature = -liga\nfont-feature =\n\ + adjust-cell-width = 3\nadjust-cell-width =\n\ + adjust-cell-height = 3\nadjust-cell-height =\n\ + cursor-color = #ff0000\ncursor-color =\n\ + cursor-text = #ff0000\ncursor-text =\n\ + selection-foreground = #ff0000\nselection-foreground =\n\ + selection-background = #ff0000\nselection-background =\n\ + bold-is-bright = true\nbold-is-bright =\n\ + minimum-contrast = 4\nminimum-contrast =\n\ + unfocused-split-opacity = 0.5\nunfocused-split-opacity =\n\ + split-divider-color = #ff0000\nsplit-divider-color =\n\ + mouse-scroll-multiplier = 3\nmouse-scroll-multiplier =\n\ + macos-option-as-alt = left\nmacos-option-as-alt =\n\ + window-inherit-working-directory = false\nwindow-inherit-working-directory =\n\ + quit-after-last-window-closed = false\nquit-after-last-window-closed =\n\ + title = x\ntitle =\n\ + clipboard-read = deny\nclipboard-read =\n\ + clipboard-write = deny\nclipboard-write =\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(o, Options::default()); + } + + #[test] + fn ranged_values_clamp() { + let src = "minimum-contrast = 0.5\nunfocused-split-opacity = 0.01\n\ + mouse-scroll-multiplier = 0.001\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty()); + assert_eq!(o.minimum_contrast, 1.0); + assert_eq!(o.unfocused_split_opacity, 0.15); + assert_eq!(o.mouse_scroll_multiplier, 0.01); + + let src = "minimum-contrast = 100\nunfocused-split-opacity = 2\n\ + mouse-scroll-multiplier = 99999999\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty()); + assert_eq!(o.minimum_contrast, 21.0); + assert_eq!(o.unfocused_split_opacity, 1.0); + assert_eq!(o.mouse_scroll_multiplier, 10_000.0); + } + + #[test] + fn font_feature_accumulates_and_resets() { + let src = "font-feature = -liga\nfont-feature = ss01\n\ + font-feature =\nfont-feature = +calt\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty()); + assert_eq!(o.font_feature, vec!["+calt".to_string()]); + } + + #[test] + fn font_family_builds_a_fallback_chain() { + let src = "font-family = JetBrains Mono\nfont-family = Menlo\n\ + font-family = Apple Color Emoji\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(o.primary_font(), "JetBrains Mono"); + assert_eq!(o.font_fallbacks(), ["Menlo", "Apple Color Emoji"]); + // An empty value resets the chain back to the default. + let (o, _) = parse_str("font-family = X\nfont-family =\n"); + assert!(o.font_family.is_empty()); + assert_eq!(o.primary_font(), "Menlo"); + } diff --git a/crates/config/tests/edit.rs b/crates/config/tests/edit.rs new file mode 100644 index 0000000..44e1990 --- /dev/null +++ b/crates/config/tests/edit.rs @@ -0,0 +1,68 @@ +use super::*; + +#[test] +fn appends_when_absent() { + assert_eq!(upsert("", "theme", "nord"), "theme = nord\n"); + assert_eq!( + upsert("font-size = 14\n", "theme", "nord"), + "font-size = 14\ntheme = nord\n" + ); +} + +#[test] +fn replaces_in_place_preserving_rest() { + let src = "# my config\nfont-size = 12\ntheme = dark\ncopy-on-select = true\n"; + let got = upsert(src, "theme", "nord"); + assert_eq!( + got, + "# my config\nfont-size = 12\ntheme = nord\ncopy-on-select = true\n" + ); +} + +#[test] +fn ignores_commented_keys() { + let src = "# theme = dark\nfont-size = 12\n"; + let got = upsert(src, "theme", "nord"); + assert_eq!(got, "# theme = dark\nfont-size = 12\ntheme = nord\n"); +} + +#[test] +fn only_replaces_first_occurrence() { + let src = "theme = a\ntheme = b\n"; + assert_eq!(upsert(src, "theme", "c"), "theme = c\ntheme = b\n"); +} + +#[test] +fn set_list_appends_when_absent() { + let got = set_list( + "font-size = 14\n", + "keybind", + &["cmd+t=new_tab".into(), "cmd+w=close_surface".into()], + ); + assert_eq!( + got, + "font-size = 14\nkeybind = cmd+t=new_tab\nkeybind = cmd+w=close_surface\n" + ); +} + +#[test] +fn set_list_replaces_block_in_place() { + let src = "# top\nplugin = a\nfont-size = 12\nplugin = b\nplugin = c\n"; + let got = set_list(src, "plugin", &["x".into(), "y".into()]); + assert_eq!(got, "# top\nplugin = x\nplugin = y\nfont-size = 12\n"); +} + +#[test] +fn set_list_empty_removes_every_entry() { + let src = "keybind = cmd+t=new_tab\nfont-size = 12\nkeybind = cmd+w=quit\n"; + assert_eq!(set_list(src, "keybind", &[]), "font-size = 12\n"); +} + +#[test] +fn round_trips_through_parser() { + let text = upsert(&upsert("", "theme", "nord"), "font-size", "16"); + let (opts, diags) = crate::parse_str(&text); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(opts.theme, "nord"); + assert_eq!(opts.font_size, 16.0); +} diff --git a/crates/config/tests/keybind.rs b/crates/config/tests/keybind.rs new file mode 100644 index 0000000..c779c32 --- /dev/null +++ b/crates/config/tests/keybind.rs @@ -0,0 +1,327 @@ +use super::*; + +fn mods(ctrl: bool, shift: bool, alt: bool, cmd: bool) -> Mods { + Mods { + ctrl, + shift, + alt, + cmd, + } +} + +fn trigger(s: &str) -> (Mods, String) { + parse_trigger(s).unwrap_or_else(|e| panic!("{s}: {e}")) +} + +#[test] +fn plain_key() { + assert_eq!(trigger("a"), (Mods::default(), "a".to_string())); + assert_eq!(trigger("enter"), (Mods::default(), "enter".to_string())); +} + +#[test] +fn modifier_order_does_not_matter() { + let want = (mods(true, true, false, false), "a".to_string()); + assert_eq!(trigger("ctrl+shift+a"), want); + assert_eq!(trigger("shift+ctrl+a"), want); +} + +#[test] +fn modifier_aliases() { + let alt = (mods(false, false, true, false), "x".to_string()); + assert_eq!(trigger("alt+x"), alt); + assert_eq!(trigger("opt+x"), alt); + assert_eq!(trigger("option+x"), alt); + let cmd = (mods(false, false, false, true), "x".to_string()); + assert_eq!(trigger("cmd+x"), cmd); + assert_eq!(trigger("command+x"), cmd); + assert_eq!(trigger("super+x"), cmd); + assert_eq!(trigger("control+x"), trigger("ctrl+x")); +} + +#[test] +fn modifiers_are_case_insensitive() { + assert_eq!(trigger("Ctrl+Shift+A"), trigger("ctrl+shift+a")); +} + +#[test] +fn named_keys() { + for name in [ + "enter", + "tab", + "escape", + "backspace", + "delete", + "up", + "down", + "left", + "right", + "home", + "end", + "page_up", + "page_down", + "space", + "insert", + "f1", + "f12", + ] { + assert_eq!(trigger(&format!("ctrl+{name}")).1, name, "{name}"); + } +} + +#[test] +fn punctuation_key_names_map_to_chars() { + assert_eq!(trigger("cmd+plus").1, "+"); + assert_eq!(trigger("cmd+minus").1, "-"); + assert_eq!(trigger("cmd+equal").1, "="); + assert_eq!(trigger("cmd+comma").1, ","); + assert_eq!(trigger("cmd+bracket_left").1, "["); + assert_eq!(trigger("cmd+bracket_right").1, "]"); +} + +#[test] +fn trailing_plus_is_the_plus_key() { + assert_eq!( + trigger("cmd++"), + (mods(false, false, false, true), "+".to_string()) + ); + assert_eq!(trigger("+"), (Mods::default(), "+".to_string())); +} + +#[test] +fn single_chars_lowercase() { + assert_eq!(trigger("cmd+A").1, "a"); + assert_eq!(trigger("cmd+[").1, "["); + assert_eq!(trigger("cmd+9").1, "9"); +} + +#[test] +fn bad_triggers() { + assert!(parse_trigger("").is_err()); + assert!(parse_trigger("cmd+").is_err()); + assert!(parse_trigger("hyper+a").is_err()); + assert!(parse_trigger("cmd+foo").is_err()); +} + +#[test] +fn parse_keybind_full() { + let kb = parse_keybind("ctrl+shift+c=copy_to_clipboard").unwrap(); + assert_eq!(kb.mods, mods(true, true, false, false)); + assert_eq!(kb.key, "c"); + assert_eq!(kb.action, Action::Copy); + + let kb = parse_keybind("cmd+shift+d=new_split:down").unwrap(); + assert_eq!(kb.mods, mods(false, true, false, true)); + assert_eq!(kb.key, "d"); + assert_eq!(kb.action, Action::NewSplit(SplitDirection::Down)); + + let kb = parse_keybind(" cmd+9 = goto_tab:-1 ").unwrap(); + assert_eq!(kb.action, Action::GotoTab(-1)); +} + +#[test] +fn parse_keybind_errors() { + assert!(parse_keybind("no equals here").is_err()); + assert!(parse_keybind("cmd+t=do_a_flip").is_err()); + assert!(parse_keybind("cmd+huh=new_tab").is_err()); + // Binding `=` needs the `equal` name; `cmd+=` has no key. + assert!(parse_keybind("cmd+=copy").is_err()); + assert!(parse_keybind("cmd+equal=copy").is_ok()); +} + +#[test] +fn defaults_cover_the_hardcoded_set() { + let binds = default_keybinds(); + let cmd = mods(false, false, false, true); + let cmd_shift = mods(false, true, false, true); + let cmd_alt = mods(false, false, true, true); + let cmd_alt_shift = mods(false, true, true, true); + let find = |m: Mods, k: &str| { + binds + .iter() + .find(|b| b.mods == m && b.key == k) + .unwrap_or_else(|| panic!("missing {m:?}+{k}")) + .action + .clone() + }; + assert_eq!(find(cmd, "n"), Action::NewWindow); + assert_eq!(find(cmd, "t"), Action::NewTab); + assert_eq!(find(cmd, "w"), Action::CloseSurface); + assert_eq!(find(cmd_alt, "w"), Action::CloseTab); + assert_eq!(find(cmd_shift, "w"), Action::CloseWindow); + assert_eq!(find(cmd_alt_shift, "w"), Action::CloseAllWindows); + assert_eq!(find(cmd, "d"), Action::NewSplit(SplitDirection::Right)); + assert_eq!(find(cmd_shift, "d"), Action::NewSplit(SplitDirection::Down)); + assert_eq!(find(cmd_shift, "["), Action::PreviousTab); + assert_eq!(find(cmd_shift, "]"), Action::NextTab); + assert_eq!(find(cmd_alt, "up"), Action::GotoSplit(SplitFocus::Up)); + assert_eq!(find(cmd_alt, "down"), Action::GotoSplit(SplitFocus::Down)); + assert_eq!(find(cmd_alt, "left"), Action::GotoSplit(SplitFocus::Left)); + assert_eq!(find(cmd_alt, "right"), Action::GotoSplit(SplitFocus::Right)); + for n in 1..=9 { + assert_eq!(find(cmd, &n.to_string()), Action::GotoTab(n)); + } + assert_eq!(find(cmd, "c"), Action::Copy); + assert_eq!(find(cmd, "v"), Action::Paste); + assert_eq!(find(cmd, "+"), Action::IncreaseFontSize(1.0)); + assert_eq!(find(cmd, "="), Action::IncreaseFontSize(1.0)); + assert_eq!(find(cmd, "-"), Action::DecreaseFontSize(1.0)); + assert_eq!(find(cmd, "0"), Action::ResetFontSize); + assert_eq!(find(cmd, "k"), Action::ClearScreen); + assert_eq!(find(cmd, ","), Action::ToggleSettings); + assert_eq!(find(cmd_shift, ","), Action::ReloadConfig); + assert_eq!(find(cmd, "q"), Action::Quit); + // No duplicate triggers among defaults. + for (i, a) in binds.iter().enumerate() { + for b in &binds[i + 1..] { + assert!( + !(a.mods == b.mods && a.key == b.key), + "duplicate default trigger {a:?}" + ); + } + } +} + +#[test] +fn resolve_with_no_user_binds_is_defaults() { + let (binds, diags) = resolve(&[]); + assert_eq!(binds, default_keybinds()); + assert!(diags.is_empty()); +} + +#[test] +fn user_bind_overrides_default_with_same_trigger() { + let raw = vec!["cmd+t=quit".to_string()]; + let (binds, diags) = resolve(&raw); + assert!(diags.is_empty()); + assert_eq!(binds.len(), default_keybinds().len()); + let cmd_t: Vec<_> = binds + .iter() + .filter(|b| b.mods == mods(false, false, false, true) && b.key == "t") + .collect(); + assert_eq!(cmd_t.len(), 1); + assert_eq!(cmd_t[0].action, Action::Quit); +} + +#[test] +fn later_user_bind_wins_over_earlier() { + let raw = vec!["ctrl+x=new_tab".to_string(), "ctrl+x=quit".to_string()]; + let (binds, _) = resolve(&raw); + let hits: Vec<_> = binds + .iter() + .filter(|b| b.mods == mods(true, false, false, false) && b.key == "x") + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].action, Action::Quit); +} + +#[test] +fn new_trigger_is_appended() { + let raw = vec!["ctrl+shift+page_up=scroll_page_up".to_string()]; + let (binds, diags) = resolve(&raw); + assert!(diags.is_empty()); + assert_eq!(binds.len(), default_keybinds().len() + 1); + let kb = binds.last().unwrap(); + assert_eq!(kb.mods, mods(true, true, false, false)); + assert_eq!(kb.key, "page_up"); + assert_eq!(kb.action, Action::ScrollPageUp); +} + +#[test] +fn unbind_removes_a_default() { + let raw = vec!["cmd+q=unbind".to_string()]; + let (binds, diags) = resolve(&raw); + assert!(diags.is_empty()); + assert_eq!(binds.len(), default_keybinds().len() - 1); + assert!(!binds + .iter() + .any(|b| b.mods == mods(false, false, false, true) && b.key == "q")); +} + +#[test] +fn unbind_unknown_trigger_is_harmless() { + let raw = vec!["ctrl+alt+f9=unbind".to_string()]; + let (binds, diags) = resolve(&raw); + assert!(diags.is_empty()); + assert_eq!(binds, default_keybinds()); +} + +#[test] +fn invalid_entries_diagnose_and_skip() { + let raw = vec![ + "cmd+t=do_a_flip".to_string(), + "garbage".to_string(), + "ctrl+x=new_tab".to_string(), + ]; + let (binds, diags) = resolve(&raw); + assert_eq!(diags.len(), 2); + assert!(diags.iter().all(|d| d.key == "keybind")); + assert!(diags[0].message.contains("do_a_flip")); + // The bad cmd+t entry must not have removed the default. + assert!(binds + .iter() + .any(|b| b.key == "t" && b.action == Action::NewTab)); + assert_eq!(binds.len(), default_keybinds().len() + 1); +} + +#[test] +fn format_trigger_round_trips() { + let cmd_shift = mods(false, true, false, true); + assert_eq!(format_trigger(cmd_shift, "t"), "cmd+shift+t"); + assert_eq!(parse_trigger("cmd+shift+t").unwrap(), (cmd_shift, "t".into())); + // Punctuation keys spell out so the line never mis-splits. + assert_eq!(format_trigger(mods(false, false, false, true), "+"), "cmd+plus"); + assert_eq!(format_trigger(mods(false, false, false, true), "="), "cmd+equal"); + assert_eq!(format_trigger(mods(false, false, false, true), "["), "cmd+bracket_left"); + for key in ["plus", "equal", "bracket_left", "comma"] { + let (m, k) = parse_trigger(&format!("cmd+{key}")).unwrap(); + assert_eq!(format_trigger(m, &k), format!("cmd+{key}")); + } +} + +#[test] +fn diff_round_trips_through_resolve() { + // Start from defaults, change one action, drop one, add one new. + let mut desired = default_keybinds(); + for kb in &mut desired { + if kb.mods == mods(false, false, false, true) && kb.key == "t" { + kb.action = Action::Quit; // change cmd+t + } + } + desired.retain(|kb| !(kb.mods == mods(false, false, false, true) && kb.key == "q")); // drop cmd+q + desired.push(Keybind { + mods: mods(true, true, false, false), + key: "page_up".into(), + action: Action::ScrollPageUp, + }); // add new + + let lines = diff_from_defaults(&desired); + let (resolved, diags) = resolve(&lines); + assert!(diags.is_empty(), "{diags:?}"); + + let key = |b: &Keybind| (b.mods, b.key.clone()); + let mut got: Vec<_> = resolved.iter().map(|b| (key(b), b.action.clone())).collect(); + let mut want: Vec<_> = desired.iter().map(|b| (key(b), b.action.clone())).collect(); + got.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + want.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}"))); + assert_eq!(got, want); +} + +#[test] +fn diff_of_defaults_is_empty() { + assert!(diff_from_defaults(&default_keybinds()).is_empty()); +} + +#[test] +fn equivalent_triggers_in_different_spellings_collide() { + let raw = vec![ + "super+t=quit".to_string(), // same trigger as default cmd+t + ]; + let (binds, _) = resolve(&raw); + let hits: Vec<_> = binds + .iter() + .filter(|b| b.mods == mods(false, false, false, true) && b.key == "t") + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].action, Action::Quit); +} diff --git a/crates/config/tests/lib.rs b/crates/config/tests/lib.rs new file mode 100644 index 0000000..aaef944 --- /dev/null +++ b/crates/config/tests/lib.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn missing_file_yields_defaults() { + let (opts, diags) = load_path(std::path::Path::new("/nonexistent/prompt/config")); + assert_eq!(opts, Options::default()); + assert!(diags.is_empty()); +} + +#[test] +fn load_path_reads_file() { + let dir = std::env::temp_dir().join(format!("promptconfigtest{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("config"); + std::fs::write(&file, "font-size = 17\nbogus = 1\n").unwrap(); + let (opts, diags) = load_path(&file); + assert_eq!(opts.font_size, 17.0); + assert_eq!(diags.len(), 1); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn default_path_shape() { + // Whatever the environment, if a path comes back it must end with + // prompt/config. + if let Some(p) = default_path() { + assert!(p.ends_with("prompt/config"), "{p:?}"); + } +} diff --git a/crates/config/tests/options.rs b/crates/config/tests/options.rs new file mode 100644 index 0000000..eadb6c5 --- /dev/null +++ b/crates/config/tests/options.rs @@ -0,0 +1,92 @@ +use super::*; + +#[test] +fn defaults() { + let o = Options::default(); + assert!(o.font_family.is_empty()); + assert_eq!(o.primary_font(), "Menlo"); + assert_eq!(o.font_size, 13.0); + assert_eq!(o.theme, ""); + assert_eq!(o.background, None); + assert_eq!(o.foreground, None); + assert_eq!(o.cursor_style, CursorStyle::Block); + assert!(o.cursor_style_blink); + assert_eq!(o.scrollback_limit, 10_000); + assert_eq!(o.window_padding_x, 2); + assert_eq!(o.window_padding_y, 2); + assert_eq!(o.window_width, 0); + assert_eq!(o.window_height, 0); + assert_eq!(o.shell, None); + assert_eq!(o.working_directory, None); + assert!(!o.copy_on_select); + assert!(o.confirm_close_surface); + assert!(!o.mouse_hide_while_typing); + assert!(o.palette.is_empty()); + assert!(o.plugin.is_empty()); + assert!(o.keybind.is_empty()); + assert_eq!(o.font_style, FontStyle::Normal); + assert!(o.font_feature.is_empty()); + assert_eq!(o.adjust_cell_width, 0); + assert_eq!(o.adjust_cell_height, 0); + assert_eq!(o.cursor_color, None); + assert_eq!(o.cursor_text, None); + assert_eq!(o.selection_foreground, None); + assert_eq!(o.selection_background, None); + assert!(!o.bold_is_bright); + assert_eq!(o.minimum_contrast, 1.0); + assert_eq!(o.unfocused_split_opacity, 0.7); + assert_eq!(o.split_divider_color, None); + assert_eq!(o.mouse_scroll_multiplier, 1.0); + assert_eq!(o.macos_option_as_alt, OptionAsAlt::False); + assert!(o.window_inherit_working_directory); + assert!(o.quit_after_last_window_closed); + assert_eq!(o.title, None); + assert_eq!(o.clipboard_read, ClipboardAccess::Ask); + assert_eq!(o.clipboard_write, ClipboardAccess::Allow); +} + +#[test] +fn font_style_parse() { + assert_eq!(FontStyle::parse("normal"), Some(FontStyle::Normal)); + assert_eq!(FontStyle::parse("Bold"), Some(FontStyle::Bold)); + assert_eq!(FontStyle::parse("ITALIC"), Some(FontStyle::Italic)); + assert_eq!(FontStyle::parse("bold-italic"), Some(FontStyle::BoldItalic)); + assert_eq!(FontStyle::parse("bold italic"), None); + assert_eq!(FontStyle::parse(""), None); +} + +#[test] +fn option_as_alt_parse() { + assert_eq!(OptionAsAlt::parse("false"), Some(OptionAsAlt::False)); + assert_eq!(OptionAsAlt::parse("true"), Some(OptionAsAlt::True)); + assert_eq!(OptionAsAlt::parse("no"), Some(OptionAsAlt::False)); + assert_eq!(OptionAsAlt::parse("1"), Some(OptionAsAlt::True)); + assert_eq!(OptionAsAlt::parse("Left"), Some(OptionAsAlt::Left)); + assert_eq!(OptionAsAlt::parse("RIGHT"), Some(OptionAsAlt::Right)); + assert_eq!(OptionAsAlt::parse("middle"), None); + assert_eq!(OptionAsAlt::parse(""), None); +} + +#[test] +fn clipboard_access_parse() { + assert_eq!( + ClipboardAccess::parse("allow"), + Some(ClipboardAccess::Allow) + ); + assert_eq!(ClipboardAccess::parse("Ask"), Some(ClipboardAccess::Ask)); + assert_eq!(ClipboardAccess::parse("DENY"), Some(ClipboardAccess::Deny)); + assert_eq!(ClipboardAccess::parse("never"), None); + assert_eq!(ClipboardAccess::parse(""), None); +} + +#[test] +fn cursor_style_parse() { + assert_eq!(CursorStyle::parse("block"), Some(CursorStyle::Block)); + assert_eq!(CursorStyle::parse("Bar"), Some(CursorStyle::Bar)); + assert_eq!( + CursorStyle::parse("UNDERLINE"), + Some(CursorStyle::Underline) + ); + assert_eq!(CursorStyle::parse("beam"), None); + assert_eq!(CursorStyle::parse(""), None); +} diff --git a/crates/config/tests/parse.rs b/crates/config/tests/parse.rs new file mode 100644 index 0000000..6bd76c4 --- /dev/null +++ b/crates/config/tests/parse.rs @@ -0,0 +1,174 @@ + use super::*; + use crate::options::CursorStyle; + + #[test] + fn empty_input_is_defaults() { + let (opts, diags) = parse_str(""); + assert_eq!(opts, Options::default()); + assert!(diags.is_empty()); + } + + #[test] + fn full_sample_config() { + let src = r#" +# Prompt configuration +font-family = JetBrains Mono +font-size = 14.5 +theme = catppuccin +background = #1d1f21 +foreground = #c5c8c6 +cursor-style = bar +cursor-style-blink = false +scrollback-limit = 50000 +window-padding-x = 8 +window-padding-y = 4 +window-width = 120 +window-height = 40 +command = /bin/zsh +working-directory = /tmp +copy-on-select = true +confirm-close-surface = false +mouse-hide-while-typing = true +palette = 0=#1d1f21 +palette = 1=#cc6666 +plugin = ~/.config/prompt/plugins/tools +keybind = ctrl+shift+c=copy_to_clipboard +keybind = ctrl+shift+v=paste_from_clipboard +"#; + let (o, diags) = parse_str(src); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(o.primary_font(), "JetBrains Mono"); + assert_eq!(o.font_size, 14.5); + assert_eq!(o.theme, "catppuccin"); + assert_eq!(o.background.as_deref(), Some("#1d1f21")); + assert_eq!(o.foreground.as_deref(), Some("#c5c8c6")); + assert_eq!(o.cursor_style, CursorStyle::Bar); + assert!(!o.cursor_style_blink); + assert_eq!(o.scrollback_limit, 50_000); + assert_eq!(o.window_padding_x, 8); + assert_eq!(o.window_padding_y, 4); + assert_eq!(o.window_width, 120); + assert_eq!(o.window_height, 40); + assert_eq!(o.shell.as_deref(), Some("/bin/zsh")); + assert_eq!(o.working_directory.as_deref(), Some("/tmp")); + assert!(o.copy_on_select); + assert!(!o.confirm_close_surface); + assert!(o.mouse_hide_while_typing); + assert_eq!( + o.palette, + vec![(0, "#1d1f21".to_string()), (1, "#cc6666".to_string()),] + ); + assert_eq!(o.plugin, vec!["~/.config/prompt/plugins/tools".to_string()]); + assert_eq!( + o.keybind, + vec![ + "ctrl+shift+c=copy_to_clipboard".to_string(), + "ctrl+shift+v=paste_from_clipboard".to_string(), + ] + ); + } + + #[test] + fn comments_and_blank_lines_ignored() { + let src = "# a comment\n\n \nfont-size = 15\n# font-size = 99\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty()); + assert_eq!(o.font_size, 15.0); + } + + #[test] + fn last_wins_for_scalars() { + let (o, diags) = parse_str("font-size = 10\nfont-size = 20\n"); + assert!(diags.is_empty()); + assert_eq!(o.font_size, 20.0); + } + + #[test] + fn empty_value_resets_to_default() { + let src = "font-size = 20\nfont-size = \n\ + font-family = Foo\nfont-family =\n\ + command = /bin/fish\ncommand = \n\ + copy-on-select = true\ncopy-on-select =\n\ + palette = 0=#000000\npalette =\n\ + plugin = /tmp/plugin\nplugin =\n\ + keybind = a=b\nkeybind =\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(o, Options::default()); + } + + #[test] + fn unknown_key_diagnostic_but_continues() { + let (o, diags) = parse_str("bogus-key = 1\nfont-size = 18\n"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].line, 1); + assert_eq!(diags[0].key, "bogus-key"); + assert!(diags[0].message.contains("unknown key")); + assert_eq!(o.font_size, 18.0); + } + + #[test] + fn bad_value_diagnostic_but_continues() { + let src = "font-size = huge\ncursor-style = wedge\n\ + copy-on-select = perhaps\npalette = 0=red\nfont-size = 11\n"; + let (o, diags) = parse_str(src); + assert_eq!(diags.len(), 4); + assert_eq!(diags[0].line, 1); + assert_eq!(diags[0].key, "font-size"); + assert_eq!(diags[1].key, "cursor-style"); + assert_eq!(diags[2].key, "copy-on-select"); + assert_eq!(diags[3].key, "palette"); + assert_eq!(o.font_size, 11.0); + assert!(o.palette.is_empty()); + assert_eq!(o.cursor_style, CursorStyle::Block); + } + + #[test] + fn line_without_equals_is_diagnostic() { + let (o, diags) = parse_str("just some words\nfont-size = 12\n"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].line, 1); + assert_eq!(diags[0].key, ""); + assert_eq!(o.font_size, 12.0); + } + + #[test] + fn missing_key_is_diagnostic() { + let (_, diags) = parse_str("= value\n"); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("missing key")); + } + + #[test] + fn repeated_palette_accumulates_and_reset_clears() { + let src = "palette = 0=#000000\npalette = 1=#111111\n\ + palette =\npalette = 2=#222222\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty()); + assert_eq!(o.palette, vec![(2, "#222222".to_string())]); + } + + #[test] + fn quoted_values_are_stripped() { + let src = "font-family = \"SF Mono\"\ntheme = \"gruvbox dark\"\n\ + working-directory = \"/Users/me/My Stuff\"\n"; + let (o, diags) = parse_str(src); + assert!(diags.is_empty()); + assert_eq!(o.primary_font(), "SF Mono"); + assert_eq!(o.theme, "gruvbox dark"); + assert_eq!(o.working_directory.as_deref(), Some("/Users/me/My Stuff")); + } + + #[test] + fn values_may_contain_equals() { + let (o, diags) = parse_str("keybind = ctrl+a=select_all\n"); + assert!(diags.is_empty()); + assert_eq!(o.keybind, vec!["ctrl+a=select_all".to_string()]); + } + + #[test] + fn whitespace_around_key_and_value_is_trimmed() { + let (o, diags) = parse_str(" font-size = 16 \n"); + assert!(diags.is_empty()); + assert_eq!(o.font_size, 16.0); + } diff --git a/crates/config/tests/value.rs b/crates/config/tests/value.rs new file mode 100644 index 0000000..3881dbd --- /dev/null +++ b/crates/config/tests/value.rs @@ -0,0 +1,93 @@ +use super::*; + +#[test] +fn bools() { + assert_eq!(parse_bool("true"), Some(true)); + assert_eq!(parse_bool("TRUE"), Some(true)); + assert_eq!(parse_bool("1"), Some(true)); + assert_eq!(parse_bool("yes"), Some(true)); + assert_eq!(parse_bool("false"), Some(false)); + assert_eq!(parse_bool("0"), Some(false)); + assert_eq!(parse_bool("no"), Some(false)); + assert_eq!(parse_bool("maybe"), None); + assert_eq!(parse_bool(""), None); +} + +#[test] +fn numbers() { + assert_eq!(parse_f32("13.5"), Some(13.5)); + assert_eq!(parse_f32("13"), Some(13.0)); + assert_eq!(parse_f32("abc"), None); + assert_eq!(parse_u32("42"), Some(42)); + assert_eq!(parse_u32("-1"), None); + assert_eq!(parse_usize("10000"), Some(10000)); + assert_eq!(parse_usize("x"), None); +} + +#[test] +fn f32_ranges() { + assert_eq!(parse_f32_range("1.5", 1.0, 21.0), Some(1.5)); + assert_eq!(parse_f32_range("0.5", 1.0, 21.0), Some(1.0)); + assert_eq!(parse_f32_range("100", 1.0, 21.0), Some(21.0)); + assert_eq!(parse_f32_range("abc", 1.0, 21.0), None); + assert_eq!(parse_f32_range("NaN", 1.0, 21.0), None); + assert_eq!(parse_f32_range("inf", 1.0, 21.0), None); +} + +#[test] +fn adjusts() { + assert_eq!(parse_adjust("2"), Some(2)); + assert_eq!(parse_adjust("-1"), Some(-1)); + assert_eq!(parse_adjust("+3"), Some(3)); + assert_eq!(parse_adjust("4px"), Some(4)); + assert_eq!(parse_adjust("-2px"), Some(-2)); + assert_eq!(parse_adjust("2 px"), Some(2)); + assert_eq!(parse_adjust("10%"), None); + assert_eq!(parse_adjust("abc"), None); + assert_eq!(parse_adjust(""), None); +} + +#[test] +fn colors() { + assert_eq!(parse_color("#1d1f21"), Some("#1d1f21".to_string())); + assert_eq!(parse_color("1d1f21"), Some("#1d1f21".to_string())); + assert_eq!(parse_color("#FFAA00"), Some("#ffaa00".to_string())); + assert_eq!(parse_color("#fff"), None); + assert_eq!(parse_color("red"), None); + assert_eq!(parse_color("#12345g"), None); + assert_eq!(parse_color(""), None); +} + +#[test] +fn fontfeatures() { + assert_eq!(parse_fontfeature("liga"), Some("liga".to_string())); + assert_eq!(parse_fontfeature("-liga"), Some("-liga".to_string())); + assert_eq!(parse_fontfeature("+ss01"), Some("+ss01".to_string())); + assert_eq!(parse_fontfeature("-"), None); + assert_eq!(parse_fontfeature("no spaces"), None); + assert_eq!(parse_fontfeature(""), None); +} + +#[test] +fn unquoting() { + assert_eq!(unquote("\"hello\""), "hello"); + assert_eq!(unquote("hello"), "hello"); + assert_eq!(unquote(" \"spaced value\" "), "spaced value"); + assert_eq!(unquote("\""), "\""); + assert_eq!(unquote(""), ""); + assert_eq!(unquote("\"\""), ""); +} + +#[test] +fn palette() { + assert_eq!(parse_palette("0=#1d1f21"), Some((0, "#1d1f21".to_string()))); + assert_eq!( + parse_palette(" 15 = #FFFFFF "), + Some((15, "#FFFFFF".to_string())) + ); + assert_eq!(parse_palette("256=#000000"), None); // index overflows u8 + assert_eq!(parse_palette("0=1d1f21"), None); // missing # + assert_eq!(parse_palette("0=#zzz"), None); // bad hex + assert_eq!(parse_palette("0=#12345"), None); // short hex + assert_eq!(parse_palette("#1d1f21"), None); // missing index +} diff --git a/crates/config/tests/watch.rs b/crates/config/tests/watch.rs new file mode 100644 index 0000000..d59cedb --- /dev/null +++ b/crates/config/tests/watch.rs @@ -0,0 +1,87 @@ +use super::*; +use std::sync::atomic::AtomicUsize; +use std::time::Instant; + +fn tempfile(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("promptwatchtest{}{tag}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("config") +} + +#[test] +fn fires_on_mtime_change_and_stops_on_drop() { + let file = tempfile("fires"); + std::fs::write(&file, "font-size = 13\n").unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let handle = watch(file.clone(), Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + + // Bump the mtime until the watcher notices (bounded by a deadline). + let f = std::fs::File::options().write(true).open(&file).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut bump = 10; + while hits.load(Ordering::SeqCst) == 0 && Instant::now() < deadline { + f.set_modified(SystemTime::now() + Duration::from_secs(bump)) + .unwrap(); + bump += 10; + std::thread::sleep(Duration::from_millis(20)); + } + assert!(hits.load(Ordering::SeqCst) >= 1, "watcher never fired"); + + // After drop the thread is joined: no further callbacks. + drop(handle); + let settled = hits.load(Ordering::SeqCst); + f.set_modified(SystemTime::now() + Duration::from_secs(bump)) + .unwrap(); + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(hits.load(Ordering::SeqCst), settled); + std::fs::remove_dir_all(file.parent().unwrap()).ok(); +} + +#[test] +fn unchanged_file_does_not_fire() { + let file = tempfile("quiet"); + std::fs::write(&file, "a").unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let handle = watch(file.clone(), Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + std::thread::sleep(Duration::from_millis(150)); + drop(handle); + assert_eq!(hits.load(Ordering::SeqCst), 0); + std::fs::remove_dir_all(file.parent().unwrap()).ok(); +} + +#[test] +fn drop_is_prompt_with_long_interval() { + let file = tempfile("longint"); + std::fs::write(&file, "a").unwrap(); + let handle = watch(file.clone(), Duration::from_secs(60), || {}); + let start = Instant::now(); + drop(handle); + assert!(start.elapsed() < Duration::from_secs(1)); + std::fs::remove_dir_all(file.parent().unwrap()).ok(); +} + +#[test] +fn file_appearing_fires() { + let file = tempfile("appear"); + std::fs::remove_file(&file).ok(); + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let handle = watch(file.clone(), Duration::from_millis(10), move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + std::thread::sleep(Duration::from_millis(50)); + std::fs::write(&file, "created").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while hits.load(Ordering::SeqCst) == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(hits.load(Ordering::SeqCst) >= 1); + drop(handle); + std::fs::remove_dir_all(file.parent().unwrap()).ok(); +} diff --git a/crates/input/Cargo.toml b/crates/input/Cargo.toml index bfc8782..bbea0fb 100644 --- a/crates/input/Cargo.toml +++ b/crates/input/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "input" version.workspace = true edition.workspace = true diff --git a/crates/input/src/csi.rs b/crates/input/src/csi.rs index 701dc23..841ab99 100644 --- a/crates/input/src/csi.rs +++ b/crates/input/src/csi.rs @@ -65,52 +65,5 @@ fn push_num(out: &mut Vec, n: u8) { } #[cfg(test)] -mod tests { - use super::*; - - fn mods(shift: bool, alt: bool, ctrl: bool) -> Mods { - Mods { - shift, - alt, - ctrl, - cmd: false, - } - } - - #[test] - fn modifier_param_table() { - // (shift, alt, ctrl) -> 1 + shift*1 + alt*2 + ctrl*4 - let cases = [ - ((false, false, false), 1), - ((true, false, false), 2), - ((false, true, false), 3), - ((true, true, false), 4), - ((false, false, true), 5), - ((true, false, true), 6), - ((false, true, true), 7), - ((true, true, true), 8), - ]; - for ((s, a, c), want) in cases { - assert_eq!(modifier_param(mods(s, a, c)), want, "({s},{a},{c})"); - } - } - - #[test] - fn cursor_forms() { - assert_eq!(cursor(b'A', false), b"\x1b[A"); - assert_eq!(cursor(b'A', true), b"\x1bOA"); - assert_eq!(cursor_modified(b'A', mods(true, false, true)), b"\x1b[1;6A"); - } - - #[test] - fn tilde_forms() { - assert_eq!(tilde(3, mods(false, false, false)), b"\x1b[3~"); - assert_eq!(tilde(3, mods(false, false, true)), b"\x1b[3;5~"); - assert_eq!(tilde(15, mods(true, true, true)), b"\x1b[15;8~"); - } - - #[test] - fn ss3_form() { - assert_eq!(ss3(b'P'), b"\x1bOP"); - } -} +#[path = "../tests/csi.rs"] +mod tests; diff --git a/crates/input/src/encode.rs b/crates/input/src/encode.rs index 61ec9ab..07a0422 100644 --- a/crates/input/src/encode.rs +++ b/crates/input/src/encode.rs @@ -154,347 +154,5 @@ fn alt_prefixed(mods: Mods, mut bytes: Vec) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - const NONE: Mods = Mods { - shift: false, - alt: false, - ctrl: false, - cmd: false, - }; - const SHIFT: Mods = Mods { - shift: true, - ..NONE - }; - const ALT: Mods = Mods { alt: true, ..NONE }; - const CTRL: Mods = Mods { ctrl: true, ..NONE }; - const CMD: Mods = Mods { cmd: true, ..NONE }; - const CTRL_ALT: Mods = Mods { - ctrl: true, - alt: true, - ..NONE - }; - const CTRL_SHIFT: Mods = Mods { - ctrl: true, - shift: true, - ..NONE - }; - const ALT_SHIFT: Mods = Mods { - alt: true, - shift: true, - ..NONE - }; - const ALL: Mods = Mods { - shift: true, - alt: true, - ctrl: true, - cmd: false, - }; - - const NORMAL: TermState = TermState { - cursor_keys_app: false, - keypad_app: false, - bracketed_paste: false, - kitty_flags: 0, - }; - const APP: TermState = TermState { - cursor_keys_app: true, - keypad_app: true, - bracketed_paste: false, - kitty_flags: 0, - }; - - fn enc(key: &str, text: Option<&str>, mods: Mods, state: TermState) -> Option> { - encode_key(key, text, mods, state) - } - - #[test] - fn cmd_chords_return_none() { - for key in ["a", "enter", "up", "f5", "delete", "space"] { - assert_eq!(enc(key, Some("a"), CMD, NORMAL), None, "{key}"); - let with_ctrl = Mods { ctrl: true, ..CMD }; - assert_eq!(enc(key, Some("a"), with_ctrl, NORMAL), None, "{key}"); - } - } - - #[test] - fn printable_emits_text() { - let cases: &[(&str, Option<&str>, Mods, &[u8])] = &[ - ("a", Some("a"), NONE, b"a"), - ("a", Some("A"), SHIFT, b"A"), // shift pre-resolved by platform - ("1", Some("1"), NONE, b"1"), - ("1", Some("!"), SHIFT, b"!"), - ("/", Some("/"), NONE, b"/"), - ("space", Some(" "), NONE, b" "), - ("e", Some("\u{e9}"), NONE, "é".as_bytes()), // dead-key result - // Fallbacks when the platform supplies no text. - ("/", None, NONE, b"/"), - ("space", None, NONE, b" "), - ("z", None, NONE, b"z"), - ]; - for (key, text, mods, want) in cases { - assert_eq!( - enc(key, *text, *mods, NORMAL).as_deref(), - Some(*want), - "{key} {text:?}" - ); - } - } - - #[test] - fn alt_printable_prefixes_esc_for_single_ascii() { - let cases: &[(&str, Option<&str>, Mods, &[u8])] = &[ - ("a", Some("a"), ALT, b"\x1ba"), - ("x", Some("X"), ALT_SHIFT, b"\x1bX"), - ("space", Some(" "), ALT, b"\x1b "), - ("1", Some("1"), ALT, b"\x1b1"), - // Non-ASCII alt text passes through unchanged (macOS option). - ("e", Some("\u{e9}"), ALT, "é".as_bytes()), - ("o", Some("\u{f8}"), ALT, "ø".as_bytes()), - // Multi-char text passes through unchanged. - ("a", Some("ab"), ALT, b"ab"), - ]; - for (key, text, mods, want) in cases { - assert_eq!( - enc(key, *text, *mods, NORMAL).as_deref(), - Some(*want), - "{key} {text:?}" - ); - } - } - - #[test] - fn ctrl_letter_full_table() { - for (i, key) in ('a'..='z').enumerate() { - let want = vec![i as u8 + 1]; - assert_eq!( - enc(&key.to_string(), Some(&key.to_string()), CTRL, NORMAL), - Some(want.clone()), - "ctrl+{key}" - ); - // shift+ctrl+letter encodes the same byte. - assert_eq!( - enc( - &key.to_string(), - Some(&key.to_ascii_uppercase().to_string()), - CTRL_SHIFT, - NORMAL - ), - Some(want), - "ctrl+shift+{key}" - ); - } - // The two overlaps with C0 names are intentional. - assert_eq!(enc("i", Some("i"), CTRL, NORMAL).unwrap(), b"\t"); - assert_eq!(enc("m", Some("m"), CTRL, NORMAL).unwrap(), b"\r"); - } - - #[test] - fn ctrl_punctuation_table() { - let cases: &[(&str, u8)] = &[ - ("space", 0x00), - ("@", 0x00), - ("[", 0x1b), - ("\\", 0x1c), - ("]", 0x1d), - ("^", 0x1e), - ("_", 0x1f), - ("-", 0x1f), - ("?", 0x7f), - ("8", 0x7f), - ]; - for (key, byte) in cases { - assert_eq!( - enc(key, Some(key), CTRL, NORMAL), - Some(vec![*byte]), - "ctrl+{key}" - ); - } - } - - #[test] - fn ctrl_alt_prefixes_esc() { - assert_eq!(enc("a", Some("a"), CTRL_ALT, NORMAL).unwrap(), b"\x1b\x01"); - assert_eq!( - enc("space", Some(" "), CTRL_ALT, NORMAL).unwrap(), - b"\x1b\x00" - ); - } - - #[test] - fn ctrl_unmapped_falls_back_to_text() { - // xterm sends the plain character for ctrl+digit outside the table. - assert_eq!(enc("1", Some("1"), CTRL, NORMAL).unwrap(), b"1"); - } - - #[test] - fn enter_tab_escape_backspace() { - let cases: &[(&str, Mods, &[u8])] = &[ - ("enter", NONE, b"\r"), - ("enter", ALT, b"\x1b\r"), - ("tab", NONE, b"\t"), - ("tab", SHIFT, b"\x1b[Z"), - ("tab", ALT, b"\x1b\t"), - ("tab", ALT_SHIFT, b"\x1b\x1b[Z"), - ("escape", NONE, b"\x1b"), - ("escape", ALT, b"\x1b\x1b"), - ("backspace", NONE, b"\x7f"), - ("backspace", CTRL, b"\x08"), - ("backspace", ALT, b"\x1b\x7f"), - ("backspace", CTRL_ALT, b"\x1b\x08"), - ]; - for (key, mods, want) in cases { - assert_eq!( - enc(key, None, *mods, NORMAL).as_deref(), - Some(*want), - "{key} {mods:?}" - ); - } - } - - #[test] - fn arrows_normal_and_app_mode() { - let cases: &[(&str, u8)] = &[ - ("up", b'A'), - ("down", b'B'), - ("right", b'C'), - ("left", b'D'), - ]; - for (key, fin) in cases { - assert_eq!( - enc(key, None, NONE, NORMAL).unwrap(), - vec![0x1b, b'[', *fin], - "{key} normal" - ); - assert_eq!( - enc(key, None, NONE, APP).unwrap(), - vec![0x1b, b'O', *fin], - "{key} app" - ); - } - } - - #[test] - fn modified_arrows_all_combos_force_csi() { - let combos: &[(Mods, &str)] = &[ - (SHIFT, "2"), - (ALT, "3"), - (ALT_SHIFT, "4"), - (CTRL, "5"), - (CTRL_SHIFT, "6"), - (CTRL_ALT, "7"), - (ALL, "8"), - ]; - for (mods, m) in combos { - let want = format!("\x1b[1;{m}A").into_bytes(); - // Same bytes in normal and app mode: modifiers force CSI. - assert_eq!(enc("up", None, *mods, NORMAL).unwrap(), want, "{mods:?}"); - assert_eq!(enc("up", None, *mods, APP).unwrap(), want, "{mods:?} app"); - } - assert_eq!(enc("down", None, CTRL, APP).unwrap(), b"\x1b[1;5B"); - assert_eq!(enc("right", None, SHIFT, APP).unwrap(), b"\x1b[1;2C"); - assert_eq!(enc("left", None, ALT, APP).unwrap(), b"\x1b[1;3D"); - } - - #[test] - fn home_end_forms() { - assert_eq!(enc("home", None, NONE, NORMAL).unwrap(), b"\x1b[H"); - assert_eq!(enc("end", None, NONE, NORMAL).unwrap(), b"\x1b[F"); - assert_eq!(enc("home", None, NONE, APP).unwrap(), b"\x1bOH"); - assert_eq!(enc("end", None, NONE, APP).unwrap(), b"\x1bOF"); - assert_eq!(enc("home", None, CTRL, APP).unwrap(), b"\x1b[1;5H"); - assert_eq!(enc("end", None, ALL, NORMAL).unwrap(), b"\x1b[1;8F"); - } - - #[test] - fn tilde_keys() { - let cases: &[(&str, u8)] = &[("insert", 2), ("delete", 3), ("pageup", 5), ("pagedown", 6)]; - for (key, n) in cases { - assert_eq!( - enc(key, None, NONE, NORMAL).unwrap(), - format!("\x1b[{n}~").into_bytes(), - "{key}" - ); - // App-mode state never changes tilde keys. - assert_eq!( - enc(key, None, NONE, APP).unwrap(), - format!("\x1b[{n}~").into_bytes(), - "{key} app" - ); - } - let combos: &[(Mods, &str)] = &[ - (SHIFT, "2"), - (ALT, "3"), - (ALT_SHIFT, "4"), - (CTRL, "5"), - (CTRL_SHIFT, "6"), - (CTRL_ALT, "7"), - (ALL, "8"), - ]; - for (mods, m) in combos { - assert_eq!( - enc("delete", None, *mods, NORMAL).unwrap(), - format!("\x1b[3;{m}~").into_bytes(), - "delete {mods:?}" - ); - } - assert_eq!(enc("pageup", None, SHIFT, NORMAL).unwrap(), b"\x1b[5;2~"); - assert_eq!(enc("pagedown", None, CTRL, NORMAL).unwrap(), b"\x1b[6;5~"); - assert_eq!(enc("insert", None, ALT, NORMAL).unwrap(), b"\x1b[2;3~"); - } - - #[test] - fn fkeys_f1_to_f4() { - let cases: &[(&str, u8)] = &[("f1", b'P'), ("f2", b'Q'), ("f3", b'R'), ("f4", b'S')]; - for (key, fin) in cases { - assert_eq!( - enc(key, None, NONE, NORMAL).unwrap(), - vec![0x1b, b'O', *fin], - "{key}" - ); - assert_eq!( - enc(key, None, CTRL, NORMAL).unwrap(), - format!("\x1b[1;5{}", *fin as char).into_bytes(), - "ctrl+{key}" - ); - assert_eq!( - enc(key, None, SHIFT, NORMAL).unwrap(), - format!("\x1b[1;2{}", *fin as char).into_bytes(), - "shift+{key}" - ); - } - } - - #[test] - fn fkeys_f5_to_f12() { - let cases: &[(&str, u8)] = &[ - ("f5", 15), - ("f6", 17), - ("f7", 18), - ("f8", 19), - ("f9", 20), - ("f10", 21), - ("f11", 23), - ("f12", 24), - ]; - for (key, n) in cases { - assert_eq!( - enc(key, None, NONE, NORMAL).unwrap(), - format!("\x1b[{n}~").into_bytes(), - "{key}" - ); - assert_eq!( - enc(key, None, CTRL_SHIFT, NORMAL).unwrap(), - format!("\x1b[{n};6~").into_bytes(), - "ctrl+shift+{key}" - ); - } - } - - #[test] - fn unknown_key_without_text_is_none() { - assert_eq!(enc("f13", None, NONE, NORMAL), None); - assert_eq!(enc("menu", None, NONE, NORMAL), None); - } -} +#[path = "../tests/encode.rs"] +mod tests; diff --git a/crates/input/src/kitty.rs b/crates/input/src/kitty.rs index 28bf2a4..b250b33 100644 --- a/crates/input/src/kitty.rs +++ b/crates/input/src/kitty.rs @@ -60,80 +60,5 @@ fn csi_u(codepoint: u32, mods: Mods) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - const NONE: Mods = Mods { - shift: false, - alt: false, - ctrl: false, - cmd: false, - }; - const SHIFT: Mods = Mods { - shift: true, - ..NONE - }; - const CTRL: Mods = Mods { ctrl: true, ..NONE }; - const ALT: Mods = Mods { alt: true, ..NONE }; - const CTRL_SHIFT: Mods = Mods { - ctrl: true, - shift: true, - ..NONE - }; - - const DIS: u8 = kitty_flags::DISAMBIGUATE; - const ALL: u8 = kitty_flags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; - - #[test] - fn escape_always_disambiguated() { - assert_eq!(encode("escape", NONE, DIS).unwrap(), b"\x1b[27u"); - assert_eq!(encode("escape", SHIFT, DIS).unwrap(), b"\x1b[27;2u"); - } - - #[test] - fn ctrl_letter_becomes_csi_u() { - // ctrl+a: codepoint 97, modifiers 1+ctrl(4) = 5. - assert_eq!(encode("a", CTRL, DIS).unwrap(), b"\x1b[97;5u"); - // ctrl+shift+a: 1+shift(1)+ctrl(4) = 6. - assert_eq!(encode("a", CTRL_SHIFT, DIS).unwrap(), b"\x1b[97;6u"); - } - - #[test] - fn alt_letter_becomes_csi_u() { - // alt+a: 1+alt(2) = 3. - assert_eq!(encode("a", ALT, DIS).unwrap(), b"\x1b[97;3u"); - } - - #[test] - fn plain_and_shift_text_defer_to_legacy_unless_all_keys() { - // Disambiguate only: plain/shifted printable returns None (legacy). - assert_eq!(encode("a", NONE, DIS), None); - assert_eq!(encode("a", SHIFT, DIS), None); - assert_eq!(encode("1", NONE, DIS), None); - // All-keys mode upgrades them to CSI u. - assert_eq!(encode("a", NONE, ALL).unwrap(), b"\x1b[97u"); - assert_eq!(encode("a", SHIFT, ALL).unwrap(), b"\x1b[97;2u"); - } - - #[test] - fn enter_tab_backspace_disambiguate_only_when_modified_or_all() { - // Plain ones defer to legacy in disambiguate mode. - assert_eq!(encode("enter", NONE, DIS), None); - assert_eq!(encode("tab", NONE, DIS), None); - assert_eq!(encode("backspace", NONE, DIS), None); - // All-keys upgrades them. - assert_eq!(encode("enter", NONE, ALL).unwrap(), b"\x1b[13u"); - assert_eq!(encode("tab", NONE, ALL).unwrap(), b"\x1b[9u"); - assert_eq!(encode("backspace", NONE, ALL).unwrap(), b"\x1b[127u"); - // Ctrl modified -> CSI u even in disambiguate mode. - assert_eq!(encode("enter", CTRL, DIS).unwrap(), b"\x1b[13;5u"); - } - - #[test] - fn navigation_and_function_keys_defer_to_legacy() { - for key in ["up", "down", "home", "delete", "pageup", "f1", "f12"] { - assert_eq!(encode(key, NONE, ALL), None, "{key}"); - assert_eq!(encode(key, CTRL, DIS), None, "{key}"); - } - } -} +#[path = "../tests/kitty.rs"] +mod tests; diff --git a/crates/input/src/mouse.rs b/crates/input/src/mouse.rs index f1a794a..4154b7a 100644 --- a/crates/input/src/mouse.rs +++ b/crates/input/src/mouse.rs @@ -114,167 +114,5 @@ pub fn encode_scroll_arrows(up: bool, lines: u32, cursor_keys_app: bool) -> Vec< } #[cfg(test)] -mod tests { - use super::*; - use MouseAction::{Motion, Press, Release}; - use MouseButton::{Left, Middle, Right, WheelDown, WheelUp}; - - const NONE: Mods = Mods { - shift: false, - alt: false, - ctrl: false, - cmd: false, - }; - const SHIFT: Mods = Mods { - shift: true, - ..NONE - }; - const ALT: Mods = Mods { alt: true, ..NONE }; - const CTRL: Mods = Mods { ctrl: true, ..NONE }; - const ALL: Mods = Mods { - shift: true, - alt: true, - ctrl: true, - cmd: false, - }; - - #[test] - fn sgr_press_release_motion() { - let cases: &[(MouseAction, MouseButton, u32, u32, Mods, &str)] = &[ - (Press, Left, 1, 1, NONE, "\x1b[<0;1;1M"), - (Press, Middle, 10, 5, NONE, "\x1b[<1;10;5M"), - (Press, Right, 80, 24, NONE, "\x1b[<2;80;24M"), - (Release, Left, 1, 1, NONE, "\x1b[<0;1;1m"), - (Release, Right, 80, 24, NONE, "\x1b[<2;80;24m"), - (Motion, Left, 3, 4, NONE, "\x1b[<32;3;4M"), - (Motion, MouseButton::None, 7, 8, NONE, "\x1b[<35;7;8M"), - // Coordinates beyond the legacy clamp pass through in SGR. - (Press, Left, 500, 400, NONE, "\x1b[<0;500;400M"), - ]; - for (action, button, col, row, mods, want) in cases { - assert_eq!( - encode_mouse(*action, *button, *col, *row, *mods, true).as_deref(), - Some(want.as_bytes()), - "{action:?} {button:?} ({col},{row})" - ); - } - } - - #[test] - fn sgr_modifier_sums() { - let cases: &[(MouseAction, MouseButton, Mods, &str)] = &[ - (Press, Left, SHIFT, "\x1b[<4;1;1M"), - (Press, Left, ALT, "\x1b[<8;1;1M"), - (Press, Left, CTRL, "\x1b[<16;1;1M"), - (Press, Left, ALL, "\x1b[<28;1;1M"), - (Press, Right, CTRL, "\x1b[<18;1;1M"), - (Release, Middle, ALT, "\x1b[<9;1;1m"), - (Motion, Left, CTRL, "\x1b[<48;1;1M"), - (Motion, MouseButton::None, SHIFT, "\x1b[<39;1;1M"), - ]; - for (action, button, mods, want) in cases { - assert_eq!( - encode_mouse(*action, *button, 1, 1, *mods, true).as_deref(), - Some(want.as_bytes()), - "{action:?} {button:?} {mods:?}" - ); - } - } - - #[test] - fn sgr_wheel() { - let cases: &[(MouseButton, Mods, &str)] = &[ - (WheelUp, NONE, "\x1b[<64;10;5M"), - (WheelDown, NONE, "\x1b[<65;10;5M"), - (WheelUp, SHIFT, "\x1b[<68;10;5M"), - (WheelDown, ALL, "\x1b[<93;10;5M"), - ]; - for (button, mods, want) in cases { - assert_eq!( - encode_mouse(Press, *button, 10, 5, *mods, true).as_deref(), - Some(want.as_bytes()), - "{button:?} {mods:?}" - ); - } - } - - #[test] - fn wheel_release_is_none_both_encodings() { - for button in [WheelUp, WheelDown] { - assert_eq!(encode_mouse(Release, button, 1, 1, NONE, true), None); - assert_eq!(encode_mouse(Release, button, 1, 1, NONE, false), None); - } - } - - #[test] - fn legacy_bytes() { - let cases: &[(MouseAction, MouseButton, u32, u32, Mods, [u8; 6])] = &[ - // 32+code, 32+col, 32+row - (Press, Left, 1, 1, NONE, [0x1b, b'[', b'M', 32, 33, 33]), - (Press, Middle, 10, 5, NONE, [0x1b, b'[', b'M', 33, 42, 37]), - (Press, Right, 1, 1, NONE, [0x1b, b'[', b'M', 34, 33, 33]), - // Modifiers: shift 4, alt 8, ctrl 16. - (Press, Left, 1, 1, CTRL, [0x1b, b'[', b'M', 48, 33, 33]), - (Press, Left, 1, 1, ALL, [0x1b, b'[', b'M', 60, 33, 33]), - // Release is always button 3 (+ mods), regardless of button. - (Release, Left, 1, 1, NONE, [0x1b, b'[', b'M', 35, 33, 33]), - (Release, Right, 1, 1, NONE, [0x1b, b'[', b'M', 35, 33, 33]), - (Release, Middle, 1, 1, CTRL, [0x1b, b'[', b'M', 51, 33, 33]), - // Motion adds 32. - (Motion, Left, 2, 3, NONE, [0x1b, b'[', b'M', 64, 34, 35]), - ( - Motion, - MouseButton::None, - 2, - 3, - NONE, - [0x1b, b'[', b'M', 67, 34, 35], - ), - // Wheel presses: 64/65. - (Press, WheelUp, 1, 1, NONE, [0x1b, b'[', b'M', 96, 33, 33]), - (Press, WheelDown, 1, 1, NONE, [0x1b, b'[', b'M', 97, 33, 33]), - ]; - for (action, button, col, row, mods, want) in cases { - assert_eq!( - encode_mouse(*action, *button, *col, *row, *mods, false).as_deref(), - Some(&want[..]), - "{action:?} {button:?} ({col},{row}) {mods:?}" - ); - } - } - - #[test] - fn legacy_clamps_coordinates() { - // 223 -> byte 255; anything larger clamps, never None. - let cases: &[(u32, u32, u8, u8)] = &[ - (223, 223, 255, 255), - (224, 224, 255, 255), - (10_000, 1, 255, 33), - (1, 10_000, 33, 255), - // 1-based floor: 0 clamps up to 1. - (0, 0, 33, 33), - ]; - for (col, row, want_col, want_row) in cases { - assert_eq!( - encode_mouse(Press, Left, *col, *row, NONE, false).unwrap(), - vec![0x1b, b'[', b'M', 32, *want_col, *want_row], - "({col},{row})" - ); - } - } - - #[test] - fn scroll_arrows_normal_mode() { - assert_eq!(encode_scroll_arrows(true, 1, false), b"\x1b[A"); - assert_eq!(encode_scroll_arrows(true, 3, false), b"\x1b[A\x1b[A\x1b[A"); - assert_eq!(encode_scroll_arrows(false, 2, false), b"\x1b[B\x1b[B"); - assert_eq!(encode_scroll_arrows(true, 0, false), b""); - } - - #[test] - fn scroll_arrows_app_mode() { - assert_eq!(encode_scroll_arrows(true, 1, true), b"\x1bOA"); - assert_eq!(encode_scroll_arrows(false, 3, true), b"\x1bOB\x1bOB\x1bOB"); - assert_eq!(encode_scroll_arrows(false, 0, true), b""); - } -} +#[path = "../tests/mouse.rs"] +mod tests; diff --git a/crates/input/src/paste.rs b/crates/input/src/paste.rs index c881f31..9edcdd7 100644 --- a/crates/input/src/paste.rs +++ b/crates/input/src/paste.rs @@ -20,45 +20,5 @@ pub fn encode_paste(text: &str, bracketed: bool) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bracketed_wraps_text() { - assert_eq!(encode_paste("hello", true), b"\x1b[200~hello\x1b[201~"); - assert_eq!(encode_paste("", true), b"\x1b[200~\x1b[201~"); - } - - #[test] - fn bracketed_preserves_newlines_verbatim() { - assert_eq!(encode_paste("a\nb", true), b"\x1b[200~a\nb\x1b[201~"); - } - - #[test] - fn bracketed_strips_embedded_end_marker() { - assert_eq!( - encode_paste("evil\x1b[201~rm -rf\x1b[201~ /", true), - b"\x1b[200~evilrm -rf /\x1b[201~" - ); - // Other escapes pass through; only the end marker is removed. - assert_eq!( - encode_paste("ok\x1b[200~still", true), - b"\x1b[200~ok\x1b[200~still\x1b[201~" - ); - } - - #[test] - fn plain_normalizes_newlines_to_cr() { - let cases: &[(&str, &[u8])] = &[ - ("hello", b"hello"), - ("a\nb", b"a\rb"), - ("a\r\nb", b"a\rb"), - ("a\rb", b"a\rb"), - ("\n\n", b"\r\r"), - ("", b""), - ]; - for (text, want) in cases { - assert_eq!(encode_paste(text, false), *want, "{text:?}"); - } - } -} +#[path = "../tests/paste.rs"] +mod tests; diff --git a/crates/input/tests/csi.rs b/crates/input/tests/csi.rs new file mode 100644 index 0000000..3b11474 --- /dev/null +++ b/crates/input/tests/csi.rs @@ -0,0 +1,47 @@ +use super::*; + +fn mods(shift: bool, alt: bool, ctrl: bool) -> Mods { + Mods { + shift, + alt, + ctrl, + cmd: false, + } +} + +#[test] +fn modifier_param_table() { + // (shift, alt, ctrl) -> 1 + shift*1 + alt*2 + ctrl*4 + let cases = [ + ((false, false, false), 1), + ((true, false, false), 2), + ((false, true, false), 3), + ((true, true, false), 4), + ((false, false, true), 5), + ((true, false, true), 6), + ((false, true, true), 7), + ((true, true, true), 8), + ]; + for ((s, a, c), want) in cases { + assert_eq!(modifier_param(mods(s, a, c)), want, "({s},{a},{c})"); + } +} + +#[test] +fn cursor_forms() { + assert_eq!(cursor(b'A', false), b"\x1b[A"); + assert_eq!(cursor(b'A', true), b"\x1bOA"); + assert_eq!(cursor_modified(b'A', mods(true, false, true)), b"\x1b[1;6A"); +} + +#[test] +fn tilde_forms() { + assert_eq!(tilde(3, mods(false, false, false)), b"\x1b[3~"); + assert_eq!(tilde(3, mods(false, false, true)), b"\x1b[3;5~"); + assert_eq!(tilde(15, mods(true, true, true)), b"\x1b[15;8~"); +} + +#[test] +fn ss3_form() { + assert_eq!(ss3(b'P'), b"\x1bOP"); +} diff --git a/crates/input/tests/encode.rs b/crates/input/tests/encode.rs new file mode 100644 index 0000000..f125433 --- /dev/null +++ b/crates/input/tests/encode.rs @@ -0,0 +1,342 @@ +use super::*; + +const NONE: Mods = Mods { + shift: false, + alt: false, + ctrl: false, + cmd: false, +}; +const SHIFT: Mods = Mods { + shift: true, + ..NONE +}; +const ALT: Mods = Mods { alt: true, ..NONE }; +const CTRL: Mods = Mods { ctrl: true, ..NONE }; +const CMD: Mods = Mods { cmd: true, ..NONE }; +const CTRL_ALT: Mods = Mods { + ctrl: true, + alt: true, + ..NONE +}; +const CTRL_SHIFT: Mods = Mods { + ctrl: true, + shift: true, + ..NONE +}; +const ALT_SHIFT: Mods = Mods { + alt: true, + shift: true, + ..NONE +}; +const ALL: Mods = Mods { + shift: true, + alt: true, + ctrl: true, + cmd: false, +}; + +const NORMAL: TermState = TermState { + cursor_keys_app: false, + keypad_app: false, + bracketed_paste: false, + kitty_flags: 0, +}; +const APP: TermState = TermState { + cursor_keys_app: true, + keypad_app: true, + bracketed_paste: false, + kitty_flags: 0, +}; + +fn enc(key: &str, text: Option<&str>, mods: Mods, state: TermState) -> Option> { + encode_key(key, text, mods, state) +} + +#[test] +fn cmd_chords_return_none() { + for key in ["a", "enter", "up", "f5", "delete", "space"] { + assert_eq!(enc(key, Some("a"), CMD, NORMAL), None, "{key}"); + let with_ctrl = Mods { ctrl: true, ..CMD }; + assert_eq!(enc(key, Some("a"), with_ctrl, NORMAL), None, "{key}"); + } +} + +#[test] +fn printable_emits_text() { + let cases: &[(&str, Option<&str>, Mods, &[u8])] = &[ + ("a", Some("a"), NONE, b"a"), + ("a", Some("A"), SHIFT, b"A"), // shift pre-resolved by platform + ("1", Some("1"), NONE, b"1"), + ("1", Some("!"), SHIFT, b"!"), + ("/", Some("/"), NONE, b"/"), + ("space", Some(" "), NONE, b" "), + ("e", Some("\u{e9}"), NONE, "é".as_bytes()), // dead-key result + // Fallbacks when the platform supplies no text. + ("/", None, NONE, b"/"), + ("space", None, NONE, b" "), + ("z", None, NONE, b"z"), + ]; + for (key, text, mods, want) in cases { + assert_eq!( + enc(key, *text, *mods, NORMAL).as_deref(), + Some(*want), + "{key} {text:?}" + ); + } +} + +#[test] +fn alt_printable_prefixes_esc_for_single_ascii() { + let cases: &[(&str, Option<&str>, Mods, &[u8])] = &[ + ("a", Some("a"), ALT, b"\x1ba"), + ("x", Some("X"), ALT_SHIFT, b"\x1bX"), + ("space", Some(" "), ALT, b"\x1b "), + ("1", Some("1"), ALT, b"\x1b1"), + // Non-ASCII alt text passes through unchanged (macOS option). + ("e", Some("\u{e9}"), ALT, "é".as_bytes()), + ("o", Some("\u{f8}"), ALT, "ø".as_bytes()), + // Multi-char text passes through unchanged. + ("a", Some("ab"), ALT, b"ab"), + ]; + for (key, text, mods, want) in cases { + assert_eq!( + enc(key, *text, *mods, NORMAL).as_deref(), + Some(*want), + "{key} {text:?}" + ); + } +} + +#[test] +fn ctrl_letter_full_table() { + for (i, key) in ('a'..='z').enumerate() { + let want = vec![i as u8 + 1]; + assert_eq!( + enc(&key.to_string(), Some(&key.to_string()), CTRL, NORMAL), + Some(want.clone()), + "ctrl+{key}" + ); + // shift+ctrl+letter encodes the same byte. + assert_eq!( + enc( + &key.to_string(), + Some(&key.to_ascii_uppercase().to_string()), + CTRL_SHIFT, + NORMAL + ), + Some(want), + "ctrl+shift+{key}" + ); + } + // The two overlaps with C0 names are intentional. + assert_eq!(enc("i", Some("i"), CTRL, NORMAL).unwrap(), b"\t"); + assert_eq!(enc("m", Some("m"), CTRL, NORMAL).unwrap(), b"\r"); +} + +#[test] +fn ctrl_punctuation_table() { + let cases: &[(&str, u8)] = &[ + ("space", 0x00), + ("@", 0x00), + ("[", 0x1b), + ("\\", 0x1c), + ("]", 0x1d), + ("^", 0x1e), + ("_", 0x1f), + ("-", 0x1f), + ("?", 0x7f), + ("8", 0x7f), + ]; + for (key, byte) in cases { + assert_eq!( + enc(key, Some(key), CTRL, NORMAL), + Some(vec![*byte]), + "ctrl+{key}" + ); + } +} + +#[test] +fn ctrl_alt_prefixes_esc() { + assert_eq!(enc("a", Some("a"), CTRL_ALT, NORMAL).unwrap(), b"\x1b\x01"); + assert_eq!( + enc("space", Some(" "), CTRL_ALT, NORMAL).unwrap(), + b"\x1b\x00" + ); +} + +#[test] +fn ctrl_unmapped_falls_back_to_text() { + // xterm sends the plain character for ctrl+digit outside the table. + assert_eq!(enc("1", Some("1"), CTRL, NORMAL).unwrap(), b"1"); +} + +#[test] +fn enter_tab_escape_backspace() { + let cases: &[(&str, Mods, &[u8])] = &[ + ("enter", NONE, b"\r"), + ("enter", ALT, b"\x1b\r"), + ("tab", NONE, b"\t"), + ("tab", SHIFT, b"\x1b[Z"), + ("tab", ALT, b"\x1b\t"), + ("tab", ALT_SHIFT, b"\x1b\x1b[Z"), + ("escape", NONE, b"\x1b"), + ("escape", ALT, b"\x1b\x1b"), + ("backspace", NONE, b"\x7f"), + ("backspace", CTRL, b"\x08"), + ("backspace", ALT, b"\x1b\x7f"), + ("backspace", CTRL_ALT, b"\x1b\x08"), + ]; + for (key, mods, want) in cases { + assert_eq!( + enc(key, None, *mods, NORMAL).as_deref(), + Some(*want), + "{key} {mods:?}" + ); + } +} + +#[test] +fn arrows_normal_and_app_mode() { + let cases: &[(&str, u8)] = &[ + ("up", b'A'), + ("down", b'B'), + ("right", b'C'), + ("left", b'D'), + ]; + for (key, fin) in cases { + assert_eq!( + enc(key, None, NONE, NORMAL).unwrap(), + vec![0x1b, b'[', *fin], + "{key} normal" + ); + assert_eq!( + enc(key, None, NONE, APP).unwrap(), + vec![0x1b, b'O', *fin], + "{key} app" + ); + } +} + +#[test] +fn modified_arrows_all_combos_force_csi() { + let combos: &[(Mods, &str)] = &[ + (SHIFT, "2"), + (ALT, "3"), + (ALT_SHIFT, "4"), + (CTRL, "5"), + (CTRL_SHIFT, "6"), + (CTRL_ALT, "7"), + (ALL, "8"), + ]; + for (mods, m) in combos { + let want = format!("\x1b[1;{m}A").into_bytes(); + // Same bytes in normal and app mode: modifiers force CSI. + assert_eq!(enc("up", None, *mods, NORMAL).unwrap(), want, "{mods:?}"); + assert_eq!(enc("up", None, *mods, APP).unwrap(), want, "{mods:?} app"); + } + assert_eq!(enc("down", None, CTRL, APP).unwrap(), b"\x1b[1;5B"); + assert_eq!(enc("right", None, SHIFT, APP).unwrap(), b"\x1b[1;2C"); + assert_eq!(enc("left", None, ALT, APP).unwrap(), b"\x1b[1;3D"); +} + +#[test] +fn home_end_forms() { + assert_eq!(enc("home", None, NONE, NORMAL).unwrap(), b"\x1b[H"); + assert_eq!(enc("end", None, NONE, NORMAL).unwrap(), b"\x1b[F"); + assert_eq!(enc("home", None, NONE, APP).unwrap(), b"\x1bOH"); + assert_eq!(enc("end", None, NONE, APP).unwrap(), b"\x1bOF"); + assert_eq!(enc("home", None, CTRL, APP).unwrap(), b"\x1b[1;5H"); + assert_eq!(enc("end", None, ALL, NORMAL).unwrap(), b"\x1b[1;8F"); +} + +#[test] +fn tilde_keys() { + let cases: &[(&str, u8)] = &[("insert", 2), ("delete", 3), ("pageup", 5), ("pagedown", 6)]; + for (key, n) in cases { + assert_eq!( + enc(key, None, NONE, NORMAL).unwrap(), + format!("\x1b[{n}~").into_bytes(), + "{key}" + ); + // App-mode state never changes tilde keys. + assert_eq!( + enc(key, None, NONE, APP).unwrap(), + format!("\x1b[{n}~").into_bytes(), + "{key} app" + ); + } + let combos: &[(Mods, &str)] = &[ + (SHIFT, "2"), + (ALT, "3"), + (ALT_SHIFT, "4"), + (CTRL, "5"), + (CTRL_SHIFT, "6"), + (CTRL_ALT, "7"), + (ALL, "8"), + ]; + for (mods, m) in combos { + assert_eq!( + enc("delete", None, *mods, NORMAL).unwrap(), + format!("\x1b[3;{m}~").into_bytes(), + "delete {mods:?}" + ); + } + assert_eq!(enc("pageup", None, SHIFT, NORMAL).unwrap(), b"\x1b[5;2~"); + assert_eq!(enc("pagedown", None, CTRL, NORMAL).unwrap(), b"\x1b[6;5~"); + assert_eq!(enc("insert", None, ALT, NORMAL).unwrap(), b"\x1b[2;3~"); +} + +#[test] +fn fkeys_f1_to_f4() { + let cases: &[(&str, u8)] = &[("f1", b'P'), ("f2", b'Q'), ("f3", b'R'), ("f4", b'S')]; + for (key, fin) in cases { + assert_eq!( + enc(key, None, NONE, NORMAL).unwrap(), + vec![0x1b, b'O', *fin], + "{key}" + ); + assert_eq!( + enc(key, None, CTRL, NORMAL).unwrap(), + format!("\x1b[1;5{}", *fin as char).into_bytes(), + "ctrl+{key}" + ); + assert_eq!( + enc(key, None, SHIFT, NORMAL).unwrap(), + format!("\x1b[1;2{}", *fin as char).into_bytes(), + "shift+{key}" + ); + } +} + +#[test] +fn fkeys_f5_to_f12() { + let cases: &[(&str, u8)] = &[ + ("f5", 15), + ("f6", 17), + ("f7", 18), + ("f8", 19), + ("f9", 20), + ("f10", 21), + ("f11", 23), + ("f12", 24), + ]; + for (key, n) in cases { + assert_eq!( + enc(key, None, NONE, NORMAL).unwrap(), + format!("\x1b[{n}~").into_bytes(), + "{key}" + ); + assert_eq!( + enc(key, None, CTRL_SHIFT, NORMAL).unwrap(), + format!("\x1b[{n};6~").into_bytes(), + "ctrl+shift+{key}" + ); + } +} + +#[test] +fn unknown_key_without_text_is_none() { + assert_eq!(enc("f13", None, NONE, NORMAL), None); + assert_eq!(enc("menu", None, NONE, NORMAL), None); +} diff --git a/crates/input/tests/kitty.rs b/crates/input/tests/kitty.rs new file mode 100644 index 0000000..b5b5d04 --- /dev/null +++ b/crates/input/tests/kitty.rs @@ -0,0 +1,75 @@ +use super::*; + +const NONE: Mods = Mods { + shift: false, + alt: false, + ctrl: false, + cmd: false, +}; +const SHIFT: Mods = Mods { + shift: true, + ..NONE +}; +const CTRL: Mods = Mods { ctrl: true, ..NONE }; +const ALT: Mods = Mods { alt: true, ..NONE }; +const CTRL_SHIFT: Mods = Mods { + ctrl: true, + shift: true, + ..NONE +}; + +const DIS: u8 = kitty_flags::DISAMBIGUATE; +const ALL: u8 = kitty_flags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; + +#[test] +fn escape_always_disambiguated() { + assert_eq!(encode("escape", NONE, DIS).unwrap(), b"\x1b[27u"); + assert_eq!(encode("escape", SHIFT, DIS).unwrap(), b"\x1b[27;2u"); +} + +#[test] +fn ctrl_letter_becomes_csi_u() { + // ctrl+a: codepoint 97, modifiers 1+ctrl(4) = 5. + assert_eq!(encode("a", CTRL, DIS).unwrap(), b"\x1b[97;5u"); + // ctrl+shift+a: 1+shift(1)+ctrl(4) = 6. + assert_eq!(encode("a", CTRL_SHIFT, DIS).unwrap(), b"\x1b[97;6u"); +} + +#[test] +fn alt_letter_becomes_csi_u() { + // alt+a: 1+alt(2) = 3. + assert_eq!(encode("a", ALT, DIS).unwrap(), b"\x1b[97;3u"); +} + +#[test] +fn plain_and_shift_text_defer_to_legacy_unless_all_keys() { + // Disambiguate only: plain/shifted printable returns None (legacy). + assert_eq!(encode("a", NONE, DIS), None); + assert_eq!(encode("a", SHIFT, DIS), None); + assert_eq!(encode("1", NONE, DIS), None); + // All-keys mode upgrades them to CSI u. + assert_eq!(encode("a", NONE, ALL).unwrap(), b"\x1b[97u"); + assert_eq!(encode("a", SHIFT, ALL).unwrap(), b"\x1b[97;2u"); +} + +#[test] +fn enter_tab_backspace_disambiguate_only_when_modified_or_all() { + // Plain ones defer to legacy in disambiguate mode. + assert_eq!(encode("enter", NONE, DIS), None); + assert_eq!(encode("tab", NONE, DIS), None); + assert_eq!(encode("backspace", NONE, DIS), None); + // All-keys upgrades them. + assert_eq!(encode("enter", NONE, ALL).unwrap(), b"\x1b[13u"); + assert_eq!(encode("tab", NONE, ALL).unwrap(), b"\x1b[9u"); + assert_eq!(encode("backspace", NONE, ALL).unwrap(), b"\x1b[127u"); + // Ctrl modified -> CSI u even in disambiguate mode. + assert_eq!(encode("enter", CTRL, DIS).unwrap(), b"\x1b[13;5u"); +} + +#[test] +fn navigation_and_function_keys_defer_to_legacy() { + for key in ["up", "down", "home", "delete", "pageup", "f1", "f12"] { + assert_eq!(encode(key, NONE, ALL), None, "{key}"); + assert_eq!(encode(key, CTRL, DIS), None, "{key}"); + } +} diff --git a/crates/input/tests/mouse.rs b/crates/input/tests/mouse.rs new file mode 100644 index 0000000..dbfec86 --- /dev/null +++ b/crates/input/tests/mouse.rs @@ -0,0 +1,162 @@ +use super::*; +use MouseAction::{Motion, Press, Release}; +use MouseButton::{Left, Middle, Right, WheelDown, WheelUp}; + +const NONE: Mods = Mods { + shift: false, + alt: false, + ctrl: false, + cmd: false, +}; +const SHIFT: Mods = Mods { + shift: true, + ..NONE +}; +const ALT: Mods = Mods { alt: true, ..NONE }; +const CTRL: Mods = Mods { ctrl: true, ..NONE }; +const ALL: Mods = Mods { + shift: true, + alt: true, + ctrl: true, + cmd: false, +}; + +#[test] +fn sgr_press_release_motion() { + let cases: &[(MouseAction, MouseButton, u32, u32, Mods, &str)] = &[ + (Press, Left, 1, 1, NONE, "\x1b[<0;1;1M"), + (Press, Middle, 10, 5, NONE, "\x1b[<1;10;5M"), + (Press, Right, 80, 24, NONE, "\x1b[<2;80;24M"), + (Release, Left, 1, 1, NONE, "\x1b[<0;1;1m"), + (Release, Right, 80, 24, NONE, "\x1b[<2;80;24m"), + (Motion, Left, 3, 4, NONE, "\x1b[<32;3;4M"), + (Motion, MouseButton::None, 7, 8, NONE, "\x1b[<35;7;8M"), + // Coordinates beyond the legacy clamp pass through in SGR. + (Press, Left, 500, 400, NONE, "\x1b[<0;500;400M"), + ]; + for (action, button, col, row, mods, want) in cases { + assert_eq!( + encode_mouse(*action, *button, *col, *row, *mods, true).as_deref(), + Some(want.as_bytes()), + "{action:?} {button:?} ({col},{row})" + ); + } +} + +#[test] +fn sgr_modifier_sums() { + let cases: &[(MouseAction, MouseButton, Mods, &str)] = &[ + (Press, Left, SHIFT, "\x1b[<4;1;1M"), + (Press, Left, ALT, "\x1b[<8;1;1M"), + (Press, Left, CTRL, "\x1b[<16;1;1M"), + (Press, Left, ALL, "\x1b[<28;1;1M"), + (Press, Right, CTRL, "\x1b[<18;1;1M"), + (Release, Middle, ALT, "\x1b[<9;1;1m"), + (Motion, Left, CTRL, "\x1b[<48;1;1M"), + (Motion, MouseButton::None, SHIFT, "\x1b[<39;1;1M"), + ]; + for (action, button, mods, want) in cases { + assert_eq!( + encode_mouse(*action, *button, 1, 1, *mods, true).as_deref(), + Some(want.as_bytes()), + "{action:?} {button:?} {mods:?}" + ); + } +} + +#[test] +fn sgr_wheel() { + let cases: &[(MouseButton, Mods, &str)] = &[ + (WheelUp, NONE, "\x1b[<64;10;5M"), + (WheelDown, NONE, "\x1b[<65;10;5M"), + (WheelUp, SHIFT, "\x1b[<68;10;5M"), + (WheelDown, ALL, "\x1b[<93;10;5M"), + ]; + for (button, mods, want) in cases { + assert_eq!( + encode_mouse(Press, *button, 10, 5, *mods, true).as_deref(), + Some(want.as_bytes()), + "{button:?} {mods:?}" + ); + } +} + +#[test] +fn wheel_release_is_none_both_encodings() { + for button in [WheelUp, WheelDown] { + assert_eq!(encode_mouse(Release, button, 1, 1, NONE, true), None); + assert_eq!(encode_mouse(Release, button, 1, 1, NONE, false), None); + } +} + +#[test] +fn legacy_bytes() { + let cases: &[(MouseAction, MouseButton, u32, u32, Mods, [u8; 6])] = &[ + // 32+code, 32+col, 32+row + (Press, Left, 1, 1, NONE, [0x1b, b'[', b'M', 32, 33, 33]), + (Press, Middle, 10, 5, NONE, [0x1b, b'[', b'M', 33, 42, 37]), + (Press, Right, 1, 1, NONE, [0x1b, b'[', b'M', 34, 33, 33]), + // Modifiers: shift 4, alt 8, ctrl 16. + (Press, Left, 1, 1, CTRL, [0x1b, b'[', b'M', 48, 33, 33]), + (Press, Left, 1, 1, ALL, [0x1b, b'[', b'M', 60, 33, 33]), + // Release is always button 3 (+ mods), regardless of button. + (Release, Left, 1, 1, NONE, [0x1b, b'[', b'M', 35, 33, 33]), + (Release, Right, 1, 1, NONE, [0x1b, b'[', b'M', 35, 33, 33]), + (Release, Middle, 1, 1, CTRL, [0x1b, b'[', b'M', 51, 33, 33]), + // Motion adds 32. + (Motion, Left, 2, 3, NONE, [0x1b, b'[', b'M', 64, 34, 35]), + ( + Motion, + MouseButton::None, + 2, + 3, + NONE, + [0x1b, b'[', b'M', 67, 34, 35], + ), + // Wheel presses: 64/65. + (Press, WheelUp, 1, 1, NONE, [0x1b, b'[', b'M', 96, 33, 33]), + (Press, WheelDown, 1, 1, NONE, [0x1b, b'[', b'M', 97, 33, 33]), + ]; + for (action, button, col, row, mods, want) in cases { + assert_eq!( + encode_mouse(*action, *button, *col, *row, *mods, false).as_deref(), + Some(&want[..]), + "{action:?} {button:?} ({col},{row}) {mods:?}" + ); + } +} + +#[test] +fn legacy_clamps_coordinates() { + // 223 -> byte 255; anything larger clamps, never None. + let cases: &[(u32, u32, u8, u8)] = &[ + (223, 223, 255, 255), + (224, 224, 255, 255), + (10_000, 1, 255, 33), + (1, 10_000, 33, 255), + // 1-based floor: 0 clamps up to 1. + (0, 0, 33, 33), + ]; + for (col, row, want_col, want_row) in cases { + assert_eq!( + encode_mouse(Press, Left, *col, *row, NONE, false).unwrap(), + vec![0x1b, b'[', b'M', 32, *want_col, *want_row], + "({col},{row})" + ); + } +} + +#[test] +fn scroll_arrows_normal_mode() { + assert_eq!(encode_scroll_arrows(true, 1, false), b"\x1b[A"); + assert_eq!(encode_scroll_arrows(true, 3, false), b"\x1b[A\x1b[A\x1b[A"); + assert_eq!(encode_scroll_arrows(false, 2, false), b"\x1b[B\x1b[B"); + assert_eq!(encode_scroll_arrows(true, 0, false), b""); +} + +#[test] +fn scroll_arrows_app_mode() { + assert_eq!(encode_scroll_arrows(true, 1, true), b"\x1bOA"); + assert_eq!(encode_scroll_arrows(false, 3, true), b"\x1bOB\x1bOB\x1bOB"); + assert_eq!(encode_scroll_arrows(false, 0, true), b""); +} diff --git a/crates/input/tests/paste.rs b/crates/input/tests/paste.rs new file mode 100644 index 0000000..5bf8cff --- /dev/null +++ b/crates/input/tests/paste.rs @@ -0,0 +1,40 @@ +use super::*; + +#[test] +fn bracketed_wraps_text() { + assert_eq!(encode_paste("hello", true), b"\x1b[200~hello\x1b[201~"); + assert_eq!(encode_paste("", true), b"\x1b[200~\x1b[201~"); +} + +#[test] +fn bracketed_preserves_newlines_verbatim() { + assert_eq!(encode_paste("a\nb", true), b"\x1b[200~a\nb\x1b[201~"); +} + +#[test] +fn bracketed_strips_embedded_end_marker() { + assert_eq!( + encode_paste("evil\x1b[201~rm -rf\x1b[201~ /", true), + b"\x1b[200~evilrm -rf /\x1b[201~" + ); + // Other escapes pass through; only the end marker is removed. + assert_eq!( + encode_paste("ok\x1b[200~still", true), + b"\x1b[200~ok\x1b[200~still\x1b[201~" + ); +} + +#[test] +fn plain_normalizes_newlines_to_cr() { + let cases: &[(&str, &[u8])] = &[ + ("hello", b"hello"), + ("a\nb", b"a\rb"), + ("a\r\nb", b"a\rb"), + ("a\rb", b"a\rb"), + ("\n\n", b"\r\r"), + ("", b""), + ]; + for (text, want) in cases { + assert_eq!(encode_paste(text, false), *want, "{text:?}"); + } +} diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index 61e7197..dd65346 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "macros" version.workspace = true edition.workspace = true diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 5ae72c2..454b13e 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -68,26 +68,5 @@ pub fn sanitize_name(input: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn name_validation() { - assert!(valid_name("deploy")); - assert!(valid_name("build.all")); - assert!(valid_name("step-1")); - assert!(!valid_name("")); - assert!(!valid_name("Deploy")); - assert!(!valid_name("build all")); - } - - #[test] - fn sanitize_coerces_free_text() { - assert_eq!(sanitize_name("Build And Test").as_deref(), Some("build-and-test")); - assert_eq!(sanitize_name("deploy_prod").as_deref(), Some("deploy-prod")); - assert_eq!(sanitize_name(" spaced ").as_deref(), Some("spaced")); - assert_eq!(sanitize_name("a--b").as_deref(), Some("a-b")); - assert_eq!(sanitize_name("***"), None); - assert_eq!(sanitize_name(""), None); - } -} +#[path = "../tests/lib.rs"] +mod tests; diff --git a/crates/macros/src/record.rs b/crates/macros/src/record.rs index 3b8a7be..f4cc30d 100644 --- a/crates/macros/src/record.rs +++ b/crates/macros/src/record.rs @@ -94,75 +94,5 @@ impl Recorder { } #[cfg(test)] -mod tests { - use super::*; - - fn type_str(rec: &mut Recorder, s: &str) { - for ch in s.chars() { - let mut buf = [0u8; 4]; - rec.key("", Some(ch.encode_utf8(&mut buf))); - } - } - - #[test] - fn captures_lines_on_enter() { - let mut rec = Recorder::new(); - rec.start(); - type_str(&mut rec, "echo hi"); - rec.key("enter", None); - type_str(&mut rec, "ls -la"); - rec.key("enter", None); - assert_eq!(rec.len(), 2); - assert_eq!(rec.finish(), vec!["echo hi".to_string(), "ls -la".to_string()]); - assert!(!rec.is_active()); - } - - #[test] - fn backspace_edits_current_line() { - let mut rec = Recorder::new(); - rec.start(); - type_str(&mut rec, "echoo"); - rec.key("backspace", None); - type_str(&mut rec, " hi"); - rec.key("enter", None); - assert_eq!(rec.finish(), vec!["echo hi".to_string()]); - } - - #[test] - fn trailing_unsubmitted_line_is_kept() { - let mut rec = Recorder::new(); - rec.start(); - type_str(&mut rec, "pending"); - assert_eq!(rec.finish(), vec!["pending".to_string()]); - } - - #[test] - fn blank_lines_and_control_text_are_ignored() { - let mut rec = Recorder::new(); - rec.start(); - rec.key("enter", None); // blank submission - rec.key("", Some("\u{1b}[A")); // an arrow escape: dropped - type_str(&mut rec, " real "); - rec.key("enter", None); - assert_eq!(rec.finish(), vec!["real".to_string()]); - } - - #[test] - fn inactive_recorder_ignores_input() { - let mut rec = Recorder::new(); - type_str(&mut rec, "ignored"); - rec.key("enter", None); - assert!(rec.finish().is_empty()); - } - - #[test] - fn cancel_drops_everything() { - let mut rec = Recorder::new(); - rec.start(); - type_str(&mut rec, "echo hi"); - rec.key("enter", None); - rec.cancel(); - assert!(!rec.is_active()); - assert!(rec.finish().is_empty()); - } -} +#[path = "../tests/record.rs"] +mod tests; diff --git a/crates/macros/src/store.rs b/crates/macros/src/store.rs index 592cf85..1b4f910 100644 --- a/crates/macros/src/store.rs +++ b/crates/macros/src/store.rs @@ -108,56 +108,5 @@ fn parse(text: &str) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - fn tempdir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("promptmacrotest{}{}", std::process::id(), name)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn save_then_load_roundtrips() { - let dir = tempdir("roundtrip"); - let mac = Macro::new("deploy", vec!["git pull".into(), "bun run build".into()]); - save(&dir, &mac).unwrap(); - let loaded = load(&dir); - assert_eq!(loaded, vec![mac]); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn load_sorts_and_skips_comments_and_blanks() { - let dir = tempdir("parse"); - std::fs::write(dir.join("b.macro"), "# note\n\nls\n").unwrap(); - std::fs::write(dir.join("a.macro"), "echo a\n").unwrap(); - std::fs::write(dir.join("ignore.txt"), "echo nope\n").unwrap(); - let loaded = load(&dir); - assert_eq!(loaded.len(), 2); - assert_eq!(loaded[0].name, "a"); - assert_eq!(loaded[1], Macro::new("b", vec!["ls".into()])); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn rename_and_delete() { - let dir = tempdir("rename"); - save(&dir, &Macro::new("old", vec!["echo hi".into()])).unwrap(); - rename(&dir, "old", "new").unwrap(); - assert_eq!(load(&dir)[0].name, "new"); - delete(&dir, "new").unwrap(); - assert!(load(&dir).is_empty()); - // Deleting a missing macro is a no-op. - delete(&dir, "gone").unwrap(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn save_rejects_bad_name() { - let dir = tempdir("badname"); - assert!(save(&dir, &Macro::new("Bad Name", vec!["x".into()])).is_err()); - let _ = std::fs::remove_dir_all(dir); - } -} +#[path = "../tests/store.rs"] +mod tests; diff --git a/crates/macros/tests/lib.rs b/crates/macros/tests/lib.rs new file mode 100644 index 0000000..005cd80 --- /dev/null +++ b/crates/macros/tests/lib.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn name_validation() { + assert!(valid_name("deploy")); + assert!(valid_name("build.all")); + assert!(valid_name("step-1")); + assert!(!valid_name("")); + assert!(!valid_name("Deploy")); + assert!(!valid_name("build all")); +} + +#[test] +fn sanitize_coerces_free_text() { + assert_eq!(sanitize_name("Build And Test").as_deref(), Some("build-and-test")); + assert_eq!(sanitize_name("deploy_prod").as_deref(), Some("deploy-prod")); + assert_eq!(sanitize_name(" spaced ").as_deref(), Some("spaced")); + assert_eq!(sanitize_name("a--b").as_deref(), Some("a-b")); + assert_eq!(sanitize_name("***"), None); + assert_eq!(sanitize_name(""), None); +} diff --git a/crates/macros/tests/record.rs b/crates/macros/tests/record.rs new file mode 100644 index 0000000..d9096ce --- /dev/null +++ b/crates/macros/tests/record.rs @@ -0,0 +1,70 @@ +use super::*; + +fn type_str(rec: &mut Recorder, s: &str) { + for ch in s.chars() { + let mut buf = [0u8; 4]; + rec.key("", Some(ch.encode_utf8(&mut buf))); + } +} + +#[test] +fn captures_lines_on_enter() { + let mut rec = Recorder::new(); + rec.start(); + type_str(&mut rec, "echo hi"); + rec.key("enter", None); + type_str(&mut rec, "ls -la"); + rec.key("enter", None); + assert_eq!(rec.len(), 2); + assert_eq!(rec.finish(), vec!["echo hi".to_string(), "ls -la".to_string()]); + assert!(!rec.is_active()); +} + +#[test] +fn backspace_edits_current_line() { + let mut rec = Recorder::new(); + rec.start(); + type_str(&mut rec, "echoo"); + rec.key("backspace", None); + type_str(&mut rec, " hi"); + rec.key("enter", None); + assert_eq!(rec.finish(), vec!["echo hi".to_string()]); +} + +#[test] +fn trailing_unsubmitted_line_is_kept() { + let mut rec = Recorder::new(); + rec.start(); + type_str(&mut rec, "pending"); + assert_eq!(rec.finish(), vec!["pending".to_string()]); +} + +#[test] +fn blank_lines_and_control_text_are_ignored() { + let mut rec = Recorder::new(); + rec.start(); + rec.key("enter", None); // blank submission + rec.key("", Some("\u{1b}[A")); // an arrow escape: dropped + type_str(&mut rec, " real "); + rec.key("enter", None); + assert_eq!(rec.finish(), vec!["real".to_string()]); +} + +#[test] +fn inactive_recorder_ignores_input() { + let mut rec = Recorder::new(); + type_str(&mut rec, "ignored"); + rec.key("enter", None); + assert!(rec.finish().is_empty()); +} + +#[test] +fn cancel_drops_everything() { + let mut rec = Recorder::new(); + rec.start(); + type_str(&mut rec, "echo hi"); + rec.key("enter", None); + rec.cancel(); + assert!(!rec.is_active()); + assert!(rec.finish().is_empty()); +} diff --git a/crates/macros/tests/store.rs b/crates/macros/tests/store.rs new file mode 100644 index 0000000..eb2ccd3 --- /dev/null +++ b/crates/macros/tests/store.rs @@ -0,0 +1,51 @@ +use super::*; + +fn tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("promptmacrotest{}{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn save_then_load_roundtrips() { + let dir = tempdir("roundtrip"); + let mac = Macro::new("deploy", vec!["git pull".into(), "bun run build".into()]); + save(&dir, &mac).unwrap(); + let loaded = load(&dir); + assert_eq!(loaded, vec![mac]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn load_sorts_and_skips_comments_and_blanks() { + let dir = tempdir("parse"); + std::fs::write(dir.join("b.macro"), "# note\n\nls\n").unwrap(); + std::fs::write(dir.join("a.macro"), "echo a\n").unwrap(); + std::fs::write(dir.join("ignore.txt"), "echo nope\n").unwrap(); + let loaded = load(&dir); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].name, "a"); + assert_eq!(loaded[1], Macro::new("b", vec!["ls".into()])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn rename_and_delete() { + let dir = tempdir("rename"); + save(&dir, &Macro::new("old", vec!["echo hi".into()])).unwrap(); + rename(&dir, "old", "new").unwrap(); + assert_eq!(load(&dir)[0].name, "new"); + delete(&dir, "new").unwrap(); + assert!(load(&dir).is_empty()); + // Deleting a missing macro is a no-op. + delete(&dir, "gone").unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn save_rejects_bad_name() { + let dir = tempdir("badname"); + assert!(save(&dir, &Macro::new("Bad Name", vec!["x".into()])).is_err()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 5c52bc4..aa566ef 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "mcp" version.workspace = true edition.workspace = true diff --git a/crates/mcp/src/serve.rs b/crates/mcp/src/serve.rs index e2fd0ea..c61890e 100644 --- a/crates/mcp/src/serve.rs +++ b/crates/mcp/src/serve.rs @@ -113,77 +113,5 @@ fn err(id: &Value, code: i64, message: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn tools() -> Vec { - vec![Tool::new("echo", "echoes input", json!({ "type": "object" }))] - } - - fn handle(name: &str, args: &Value) -> Result { - match name { - "echo" => Ok(args.clone()), - _ => Err(format!("unknown tool {name}")), - } - } - - #[test] - fn initialize_advertises_protocol_and_tools_capability() { - let msg = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" }); - let reply: Value = serde_json::from_str( - &dispatch(&msg, &tools(), &json!({ "name": "prompt" }), &handle).unwrap(), - ) - .unwrap(); - assert_eq!(reply["result"]["protocolVersion"], PROTOCOL_VERSION); - assert!(reply["result"]["capabilities"]["tools"].is_object()); - } - - #[test] - fn notifications_get_no_reply() { - let msg = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); - assert!(dispatch(&msg, &tools(), &json!({}), &handle).is_none()); - } - - #[test] - fn tools_list_returns_registered_tools() { - let msg = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }); - let reply: Value = - serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); - let list = reply["result"]["tools"].as_array().unwrap(); - assert_eq!(list.len(), 1); - assert_eq!(list[0]["name"], "echo"); - } - - #[test] - fn tools_call_wraps_handler_result_as_text_content() { - let msg = json!({ - "jsonrpc": "2.0", "id": 3, "method": "tools/call", - "params": { "name": "echo", "arguments": { "hi": 1 } } - }); - let reply: Value = - serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); - assert_eq!(reply["result"]["isError"], false); - let text = reply["result"]["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("\"hi\"")); - } - - #[test] - fn handler_error_is_a_failed_tool_call_not_a_protocol_error() { - let msg = json!({ - "jsonrpc": "2.0", "id": 4, "method": "tools/call", - "params": { "name": "missing" } - }); - let reply: Value = - serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); - assert!(reply["result"]["isError"].as_bool().unwrap()); - assert!(reply.get("error").is_none()); - } - - #[test] - fn unknown_method_is_a_protocol_error() { - let msg = json!({ "jsonrpc": "2.0", "id": 5, "method": "frobnicate" }); - let reply: Value = - serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); - assert_eq!(reply["error"]["code"], -32601); - } -} +#[path = "../tests/serve.rs"] +mod tests; diff --git a/crates/mcp/tests/serve.rs b/crates/mcp/tests/serve.rs new file mode 100644 index 0000000..ed8dc6a --- /dev/null +++ b/crates/mcp/tests/serve.rs @@ -0,0 +1,72 @@ +use super::*; + +fn tools() -> Vec { + vec![Tool::new("echo", "echoes input", json!({ "type": "object" }))] +} + +fn handle(name: &str, args: &Value) -> Result { + match name { + "echo" => Ok(args.clone()), + _ => Err(format!("unknown tool {name}")), + } +} + +#[test] +fn initialize_advertises_protocol_and_tools_capability() { + let msg = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" }); + let reply: Value = serde_json::from_str( + &dispatch(&msg, &tools(), &json!({ "name": "prompt" }), &handle).unwrap(), + ) + .unwrap(); + assert_eq!(reply["result"]["protocolVersion"], PROTOCOL_VERSION); + assert!(reply["result"]["capabilities"]["tools"].is_object()); +} + +#[test] +fn notifications_get_no_reply() { + let msg = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }); + assert!(dispatch(&msg, &tools(), &json!({}), &handle).is_none()); +} + +#[test] +fn tools_list_returns_registered_tools() { + let msg = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }); + let reply: Value = + serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); + let list = reply["result"]["tools"].as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["name"], "echo"); +} + +#[test] +fn tools_call_wraps_handler_result_as_text_content() { + let msg = json!({ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": { "name": "echo", "arguments": { "hi": 1 } } + }); + let reply: Value = + serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); + assert_eq!(reply["result"]["isError"], false); + let text = reply["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("\"hi\"")); +} + +#[test] +fn handler_error_is_a_failed_tool_call_not_a_protocol_error() { + let msg = json!({ + "jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": { "name": "missing" } + }); + let reply: Value = + serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); + assert!(reply["result"]["isError"].as_bool().unwrap()); + assert!(reply.get("error").is_none()); +} + +#[test] +fn unknown_method_is_a_protocol_error() { + let msg = json!({ "jsonrpc": "2.0", "id": 5, "method": "frobnicate" }); + let reply: Value = + serde_json::from_str(&dispatch(&msg, &tools(), &json!({}), &handle).unwrap()).unwrap(); + assert_eq!(reply["error"]["code"], -32601); +} diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index b5c9d97..455a6a1 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "plugin" version.workspace = true edition.workspace = true diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 8a40149..91a3285 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -52,30 +52,5 @@ pub fn command<'a>(plugins: &'a [Plugin], id: &str) -> Option<(&'a Plugin, &'a C } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keybind_entries_use_plugin_actions() { - let plugins = vec![Plugin { - id: "tools".to_string(), - name: "Tools".to_string(), - version: "0.1.0".to_string(), - description: None, - path: std::path::PathBuf::from("/tmp/tools"), - commands: vec![Command { - id: "top".to_string(), - title: "Top".to_string(), - run: "top".to_string(), - mode: CommandMode::Tab, - keybind: Some("cmd+shift+t".to_string()), - }], - }]; - assert_eq!( - keybinds(&plugins), - vec!["cmd+shift+t=plugin_command:tools/top".to_string()] - ); - assert_eq!(command(&plugins, "tools/top").unwrap().1.run, "top"); - assert!(command(&plugins, "tools/missing").is_none()); - } -} +#[path = "../tests/lib.rs"] +mod tests; diff --git a/crates/plugin/src/load.rs b/crates/plugin/src/load.rs index 739b8ca..2176c74 100644 --- a/crates/plugin/src/load.rs +++ b/crates/plugin/src/load.rs @@ -101,35 +101,5 @@ fn home() -> Option { } #[cfg(test)] -mod tests { - use super::*; - - fn tempdir(name: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("promptplugintest{}{}", std::process::id(), name)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn explicit_path_can_be_directory_or_manifest() { - let dir = tempdir("explicit"); - let manifest = dir.join(MANIFEST); - std::fs::write(&manifest, "id = tools\n[[command]]\nid = top\nrun = top\n").unwrap(); - let (plugins, diags) = loadmanifests(vec![dir.clone(), manifest]); - assert!(diags.is_empty(), "{diags:?}"); - assert_eq!(plugins.len(), 1); - assert_eq!(plugins[0].id, "tools"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn unreadable_manifest_reports_diagnostic() { - let (plugins, diags) = - loadmanifests(vec![PathBuf::from("/definitely/missing/plugin.toml")]); - assert!(plugins.is_empty()); - assert_eq!(diags.len(), 1); - assert!(diags[0].message.contains("failed to read")); - } -} +#[path = "../tests/load.rs"] +mod tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 4b4faec..40a44fd 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -285,86 +285,5 @@ fn diag(path: &std::path::Path, line: usize, message: &str) -> Diagnostic { } #[cfg(test)] -mod tests { - use super::*; - - fn path() -> PathBuf { - PathBuf::from("/tmp/tool/plugin.toml") - } - - #[test] - fn parses_plugin_commands() { - let src = r#" -id = "tools" -name = "Tools" -version = "1.2.3" -description = "Useful commands" - -[[command]] -id = "logs" -title = "Tail logs" -run = "tail -f /tmp/app.log" -mode = "split-right" -keybind = "cmd+shift+l" -"#; - let (plugin, diags) = parse(path(), src); - assert!(diags.is_empty(), "{diags:?}"); - let plugin = plugin.unwrap(); - assert_eq!(plugin.id, "tools"); - assert_eq!(plugin.name, "Tools"); - assert_eq!(plugin.version, "1.2.3"); - assert_eq!(plugin.description.as_deref(), Some("Useful commands")); - assert_eq!(plugin.path, PathBuf::from("/tmp/tool")); - assert_eq!(plugin.commands.len(), 1); - assert_eq!(plugin.commands[0].id, "logs"); - assert_eq!(plugin.commands[0].mode, CommandMode::SplitRight); - assert_eq!(plugin.commands[0].keybind.as_deref(), Some("cmd+shift+l")); - } - - #[test] - fn defaults_optional_fields() { - let (plugin, diags) = parse( - path(), - r#" -id = tools -[[command]] -id = top -run = top -"#, - ); - assert!(diags.is_empty(), "{diags:?}"); - let plugin = plugin.unwrap(); - assert_eq!(plugin.name, "tools"); - assert_eq!(plugin.version, "0.0.0"); - assert_eq!(plugin.commands[0].title, "top"); - assert_eq!(plugin.commands[0].mode, CommandMode::Pane); - } - - #[test] - fn reports_bad_manifest_but_keeps_good_commands() { - let (plugin, diags) = parse( - path(), - r#" -id = tools -bogus = true -[[command]] -id = ok -run = echo ok -[[command]] -id = Bad -run = echo bad -"#, - ); - assert_eq!(diags.len(), 2); - let plugin = plugin.unwrap(); - assert_eq!(plugin.commands.len(), 1); - assert_eq!(plugin.commands[0].id, "ok"); - } - - #[test] - fn missing_plugin_id_skips_plugin() { - let (plugin, diags) = parse(path(), "[[command]]\nid = ok\nrun = echo ok\n"); - assert!(plugin.is_none()); - assert!(diags.iter().any(|d| d.message == "missing id")); - } -} +#[path = "../tests/manifest.rs"] +mod tests; diff --git a/crates/plugin/tests/lib.rs b/crates/plugin/tests/lib.rs new file mode 100644 index 0000000..56c38e5 --- /dev/null +++ b/crates/plugin/tests/lib.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn keybind_entries_use_plugin_actions() { + let plugins = vec![Plugin { + id: "tools".to_string(), + name: "Tools".to_string(), + version: "0.1.0".to_string(), + description: None, + path: std::path::PathBuf::from("/tmp/tools"), + commands: vec![Command { + id: "top".to_string(), + title: "Top".to_string(), + run: "top".to_string(), + mode: CommandMode::Tab, + keybind: Some("cmd+shift+t".to_string()), + }], + }]; + assert_eq!( + keybinds(&plugins), + vec!["cmd+shift+t=plugin_command:tools/top".to_string()] + ); + assert_eq!(command(&plugins, "tools/top").unwrap().1.run, "top"); + assert!(command(&plugins, "tools/missing").is_none()); +} diff --git a/crates/plugin/tests/load.rs b/crates/plugin/tests/load.rs new file mode 100644 index 0000000..e46133f --- /dev/null +++ b/crates/plugin/tests/load.rs @@ -0,0 +1,30 @@ +use super::*; + +fn tempdir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("promptplugintest{}{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn explicit_path_can_be_directory_or_manifest() { + let dir = tempdir("explicit"); + let manifest = dir.join(MANIFEST); + std::fs::write(&manifest, "id = tools\n[[command]]\nid = top\nrun = top\n").unwrap(); + let (plugins, diags) = loadmanifests(vec![dir.clone(), manifest]); + assert!(diags.is_empty(), "{diags:?}"); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].id, "tools"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn unreadable_manifest_reports_diagnostic() { + let (plugins, diags) = + loadmanifests(vec![PathBuf::from("/definitely/missing/plugin.toml")]); + assert!(plugins.is_empty()); + assert_eq!(diags.len(), 1); + assert!(diags[0].message.contains("failed to read")); +} diff --git a/crates/plugin/tests/manifest.rs b/crates/plugin/tests/manifest.rs new file mode 100644 index 0000000..3b2e43a --- /dev/null +++ b/crates/plugin/tests/manifest.rs @@ -0,0 +1,81 @@ + use super::*; + + fn path() -> PathBuf { + PathBuf::from("/tmp/tool/plugin.toml") + } + + #[test] + fn parses_plugin_commands() { + let src = r#" +id = "tools" +name = "Tools" +version = "1.2.3" +description = "Useful commands" + +[[command]] +id = "logs" +title = "Tail logs" +run = "tail -f /tmp/app.log" +mode = "split-right" +keybind = "cmd+shift+l" +"#; + let (plugin, diags) = parse(path(), src); + assert!(diags.is_empty(), "{diags:?}"); + let plugin = plugin.unwrap(); + assert_eq!(plugin.id, "tools"); + assert_eq!(plugin.name, "Tools"); + assert_eq!(plugin.version, "1.2.3"); + assert_eq!(plugin.description.as_deref(), Some("Useful commands")); + assert_eq!(plugin.path, PathBuf::from("/tmp/tool")); + assert_eq!(plugin.commands.len(), 1); + assert_eq!(plugin.commands[0].id, "logs"); + assert_eq!(plugin.commands[0].mode, CommandMode::SplitRight); + assert_eq!(plugin.commands[0].keybind.as_deref(), Some("cmd+shift+l")); + } + + #[test] + fn defaults_optional_fields() { + let (plugin, diags) = parse( + path(), + r#" +id = tools +[[command]] +id = top +run = top +"#, + ); + assert!(diags.is_empty(), "{diags:?}"); + let plugin = plugin.unwrap(); + assert_eq!(plugin.name, "tools"); + assert_eq!(plugin.version, "0.0.0"); + assert_eq!(plugin.commands[0].title, "top"); + assert_eq!(plugin.commands[0].mode, CommandMode::Pane); + } + + #[test] + fn reports_bad_manifest_but_keeps_good_commands() { + let (plugin, diags) = parse( + path(), + r#" +id = tools +bogus = true +[[command]] +id = ok +run = echo ok +[[command]] +id = Bad +run = echo bad +"#, + ); + assert_eq!(diags.len(), 2); + let plugin = plugin.unwrap(); + assert_eq!(plugin.commands.len(), 1); + assert_eq!(plugin.commands[0].id, "ok"); + } + + #[test] + fn missing_plugin_id_skips_plugin() { + let (plugin, diags) = parse(path(), "[[command]]\nid = ok\nrun = echo ok\n"); + assert!(plugin.is_none()); + assert!(diags.iter().any(|d| d.message == "missing id")); + } diff --git a/crates/pty/Cargo.toml b/crates/pty/Cargo.toml index c4fbf52..f8ac371 100644 --- a/crates/pty/Cargo.toml +++ b/crates/pty/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "pty" version.workspace = true edition.workspace = true diff --git a/crates/pty/src/session.rs b/crates/pty/src/session.rs index 469d8b9..0f862fd 100644 --- a/crates/pty/src/session.rs +++ b/crates/pty/src/session.rs @@ -81,119 +81,5 @@ impl Pty { // The master OwnedFd closes itself when Pty drops; no explicit Drop needed. #[cfg(all(test, unix))] -mod tests { - use super::*; - - /// Read from the master until EOF or EIO (Linux reports EIO once the - /// child side of the pty is gone). - fn read_to_end(pty: &Pty) -> String { - let mut out = Vec::new(); - let mut buf = [0u8; 4096]; - loop { - match pty.read(&mut buf) { - Ok(0) => break, - Ok(n) => out.extend_from_slice(&buf[..n]), - Err(e) if e.raw_os_error() == Some(rustix::io::Errno::IO.raw_os_error()) => break, - Err(e) => panic!("read failed: {e}"), - } - } - String::from_utf8_lossy(&out).replace('\r', "") - } - - #[test] - fn echo_hello_round_trips() { - let opts = SpawnOptions { - winsize: Winsize::new(20, 5), - ..SpawnOptions::command(vec!["/bin/echo".to_string(), "hello".to_string()]) - }; - let mut pty = Pty::spawn(&opts).expect("spawn echo"); - assert!(pty.child_pid() > 0); - let output = read_to_end(&pty); - let status = pty.wait().expect("wait echo"); - assert!(status.success()); - assert!(output.contains("hello"), "output was: {output:?}"); - } - - #[test] - fn stty_reports_initial_winsize() { - let opts = SpawnOptions { - winsize: Winsize::new(80, 24), - ..SpawnOptions::command(vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "stty size".to_string(), - ]) - }; - let mut pty = Pty::spawn(&opts).expect("spawn stty"); - let output = read_to_end(&pty); - let status = pty.wait().expect("wait stty"); - assert!(status.success()); - assert!(output.contains("24 80"), "output was: {output:?}"); - } - - #[test] - fn write_reaches_child_stdin() { - let opts = SpawnOptions { - winsize: Winsize::new(80, 24), - ..SpawnOptions::command(vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "read line; echo got:$line".to_string(), - ]) - }; - let mut pty = Pty::spawn(&opts).expect("spawn reader"); - pty.write(b"ping\n").expect("write to pty"); - let output = read_to_end(&pty); - pty.wait().expect("wait reader"); - assert!(output.contains("got:ping"), "output was: {output:?}"); - } - - #[test] - fn resize_is_visible_to_child() { - let opts = SpawnOptions { - winsize: Winsize::new(80, 24), - ..SpawnOptions::command(vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "sleep 1; stty size".to_string(), - ]) - }; - let mut pty = Pty::spawn(&opts).expect("spawn sleeper"); - pty.resize(Winsize::new(132, 43)).expect("resize"); - let output = read_to_end(&pty); - pty.wait().expect("wait sleeper"); - assert!(output.contains("43 132"), "output was: {output:?}"); - } - - #[test] - fn kill_terminates_child() { - let opts = SpawnOptions::command(vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "sleep 30".to_string(), - ]); - let mut pty = Pty::spawn(&opts).expect("spawn sleeper"); - pty.kill().expect("kill child"); - let status = pty.wait().expect("wait killed child"); - assert!(!status.success()); - } - - #[test] - fn cloned_reader_reads_output() { - use std::io::Read; - let opts = SpawnOptions::command(vec!["/bin/echo".to_string(), "clone".to_string()]); - let pty = Pty::spawn(&opts).expect("spawn echo"); - let mut reader = pty.try_clone_reader().expect("clone reader"); - let mut out = Vec::new(); - let mut buf = [0u8; 1024]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => out.extend_from_slice(&buf[..n]), - Err(_) => break, - } - } - let text = String::from_utf8_lossy(&out).replace('\r', ""); - assert!(text.contains("clone"), "output was: {text:?}"); - } -} +#[path = "../tests/session.rs"] +mod tests; diff --git a/crates/pty/src/spawn.rs b/crates/pty/src/spawn.rs index 70a5d6e..744acb5 100644 --- a/crates/pty/src/spawn.rs +++ b/crates/pty/src/spawn.rs @@ -72,56 +72,5 @@ impl SpawnOptions { } #[cfg(all(test, unix))] -mod tests { - use super::*; - - #[test] - fn default_shell_falls_back_to_zsh() { - let shell = default_shell(); - assert!(!shell.is_empty()); - if std::env::var("SHELL").map(|s| s.is_empty()).unwrap_or(true) { - assert_eq!(shell, "/bin/zsh"); - } - } - - #[test] - fn default_env_sets_term_and_colorterm() { - let env = default_env(); - assert!(env.contains(&("TERM".to_string(), "xterm-256color".to_string()))); - assert!(env.contains(&("COLORTERM".to_string(), "truecolor".to_string()))); - } - - #[test] - fn default_options_are_login_shell() { - let opts = SpawnOptions::default(); - assert!(opts.login); - assert_eq!(opts.argv.len(), 1); - assert!(opts.argv0().starts_with('-')); - } - - #[test] - fn login_argv0_is_dash_basename() { - let opts = SpawnOptions { - argv: vec!["/bin/zsh".to_string()], - login: true, - ..SpawnOptions::default() - }; - assert_eq!(opts.argv0(), "-zsh"); - } - - #[test] - fn command_argv0_is_unchanged() { - let opts = SpawnOptions::command(vec!["/bin/echo".to_string(), "hi".to_string()]); - assert!(!opts.login); - assert_eq!(opts.argv0(), "/bin/echo"); - } - - #[test] - fn empty_argv0_is_safe() { - let opts = SpawnOptions { - argv: vec![], - ..SpawnOptions::default() - }; - assert_eq!(opts.argv0(), "-"); - } -} +#[path = "../tests/spawn.rs"] +mod tests; diff --git a/crates/pty/src/unix.rs b/crates/pty/src/unix.rs index 7097263..ddd102b 100644 --- a/crates/pty/src/unix.rs +++ b/crates/pty/src/unix.rs @@ -70,34 +70,5 @@ pub fn spawn_child(opts: &SpawnOptions, pair: &PtyPair) -> io::Result { } #[cfg(all(test, unix))] -mod tests { - use super::*; - - #[test] - fn open_pair_yields_a_tty_slave() { - let pair = open_pair().expect("open pty pair"); - assert!(rustix::termios::isatty(&pair.slave)); - assert!(rustix::termios::isatty(&pair.master)); - } - - #[test] - fn winsize_round_trips_through_slave() { - let pair = open_pair().expect("open pty pair"); - let ws = crate::winsize::Winsize::new(101, 31); - rustix::termios::tcsetwinsize(&pair.slave, ws.to_termios()).expect("set winsize"); - let got = rustix::termios::tcgetwinsize(&pair.master).expect("get winsize"); - assert_eq!(got.ws_col, 101); - assert_eq!(got.ws_row, 31); - } - - #[test] - fn spawn_child_rejects_empty_argv() { - let pair = open_pair().expect("open pty pair"); - let opts = SpawnOptions { - argv: vec![], - ..SpawnOptions::default() - }; - let err = spawn_child(&opts, &pair).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::InvalidInput); - } -} +#[path = "../tests/unix.rs"] +mod tests; diff --git a/crates/pty/src/winsize.rs b/crates/pty/src/winsize.rs index 0006bd9..c416fcd 100644 --- a/crates/pty/src/winsize.rs +++ b/crates/pty/src/winsize.rs @@ -53,38 +53,5 @@ impl Default for Winsize { } #[cfg(all(test, unix))] -mod tests { - use super::*; - - #[test] - fn default_is_80x24() { - let ws = Winsize::default(); - assert_eq!(ws.cols, 80); - assert_eq!(ws.rows, 24); - assert_eq!(ws.cell_width, 0); - assert_eq!(ws.cell_height, 0); - } - - #[test] - fn converts_grid_to_termios() { - let ws = Winsize::new(120, 40).to_termios(); - assert_eq!(ws.ws_col, 120); - assert_eq!(ws.ws_row, 40); - assert_eq!(ws.ws_xpixel, 0); - assert_eq!(ws.ws_ypixel, 0); - } - - #[test] - fn converts_pixels_as_total_window_size() { - let ws = Winsize::with_cell_size(100, 50, 8, 16).to_termios(); - assert_eq!(ws.ws_xpixel, 800); - assert_eq!(ws.ws_ypixel, 800); - } - - #[test] - fn pixel_conversion_saturates() { - let ws = Winsize::with_cell_size(u16::MAX, u16::MAX, u16::MAX, u16::MAX).to_termios(); - assert_eq!(ws.ws_xpixel, u16::MAX); - assert_eq!(ws.ws_ypixel, u16::MAX); - } -} +#[path = "../tests/winsize.rs"] +mod tests; diff --git a/crates/pty/tests/session.rs b/crates/pty/tests/session.rs new file mode 100644 index 0000000..1e8b040 --- /dev/null +++ b/crates/pty/tests/session.rs @@ -0,0 +1,114 @@ +use super::*; + +/// Read from the master until EOF or EIO (Linux reports EIO once the +/// child side of the pty is gone). +fn read_to_end(pty: &Pty) -> String { + let mut out = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match pty.read(&mut buf) { + Ok(0) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + Err(e) if e.raw_os_error() == Some(rustix::io::Errno::IO.raw_os_error()) => break, + Err(e) => panic!("read failed: {e}"), + } + } + String::from_utf8_lossy(&out).replace('\r', "") +} + +#[test] +fn echo_hello_round_trips() { + let opts = SpawnOptions { + winsize: Winsize::new(20, 5), + ..SpawnOptions::command(vec!["/bin/echo".to_string(), "hello".to_string()]) + }; + let mut pty = Pty::spawn(&opts).expect("spawn echo"); + assert!(pty.child_pid() > 0); + let output = read_to_end(&pty); + let status = pty.wait().expect("wait echo"); + assert!(status.success()); + assert!(output.contains("hello"), "output was: {output:?}"); +} + +#[test] +fn stty_reports_initial_winsize() { + let opts = SpawnOptions { + winsize: Winsize::new(80, 24), + ..SpawnOptions::command(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "stty size".to_string(), + ]) + }; + let mut pty = Pty::spawn(&opts).expect("spawn stty"); + let output = read_to_end(&pty); + let status = pty.wait().expect("wait stty"); + assert!(status.success()); + assert!(output.contains("24 80"), "output was: {output:?}"); +} + +#[test] +fn write_reaches_child_stdin() { + let opts = SpawnOptions { + winsize: Winsize::new(80, 24), + ..SpawnOptions::command(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "read line; echo got:$line".to_string(), + ]) + }; + let mut pty = Pty::spawn(&opts).expect("spawn reader"); + pty.write(b"ping\n").expect("write to pty"); + let output = read_to_end(&pty); + pty.wait().expect("wait reader"); + assert!(output.contains("got:ping"), "output was: {output:?}"); +} + +#[test] +fn resize_is_visible_to_child() { + let opts = SpawnOptions { + winsize: Winsize::new(80, 24), + ..SpawnOptions::command(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 1; stty size".to_string(), + ]) + }; + let mut pty = Pty::spawn(&opts).expect("spawn sleeper"); + pty.resize(Winsize::new(132, 43)).expect("resize"); + let output = read_to_end(&pty); + pty.wait().expect("wait sleeper"); + assert!(output.contains("43 132"), "output was: {output:?}"); +} + +#[test] +fn kill_terminates_child() { + let opts = SpawnOptions::command(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]); + let mut pty = Pty::spawn(&opts).expect("spawn sleeper"); + pty.kill().expect("kill child"); + let status = pty.wait().expect("wait killed child"); + assert!(!status.success()); +} + +#[test] +fn cloned_reader_reads_output() { + use std::io::Read; + let opts = SpawnOptions::command(vec!["/bin/echo".to_string(), "clone".to_string()]); + let pty = Pty::spawn(&opts).expect("spawn echo"); + let mut reader = pty.try_clone_reader().expect("clone reader"); + let mut out = Vec::new(); + let mut buf = [0u8; 1024]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + let text = String::from_utf8_lossy(&out).replace('\r', ""); + assert!(text.contains("clone"), "output was: {text:?}"); +} diff --git a/crates/pty/tests/spawn.rs b/crates/pty/tests/spawn.rs new file mode 100644 index 0000000..8acbf2f --- /dev/null +++ b/crates/pty/tests/spawn.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn default_shell_falls_back_to_zsh() { + let shell = default_shell(); + assert!(!shell.is_empty()); + if std::env::var("SHELL").map(|s| s.is_empty()).unwrap_or(true) { + assert_eq!(shell, "/bin/zsh"); + } +} + +#[test] +fn default_env_sets_term_and_colorterm() { + let env = default_env(); + assert!(env.contains(&("TERM".to_string(), "xterm-256color".to_string()))); + assert!(env.contains(&("COLORTERM".to_string(), "truecolor".to_string()))); +} + +#[test] +fn default_options_are_login_shell() { + let opts = SpawnOptions::default(); + assert!(opts.login); + assert_eq!(opts.argv.len(), 1); + assert!(opts.argv0().starts_with('-')); +} + +#[test] +fn login_argv0_is_dash_basename() { + let opts = SpawnOptions { + argv: vec!["/bin/zsh".to_string()], + login: true, + ..SpawnOptions::default() + }; + assert_eq!(opts.argv0(), "-zsh"); +} + +#[test] +fn command_argv0_is_unchanged() { + let opts = SpawnOptions::command(vec!["/bin/echo".to_string(), "hi".to_string()]); + assert!(!opts.login); + assert_eq!(opts.argv0(), "/bin/echo"); +} + +#[test] +fn empty_argv0_is_safe() { + let opts = SpawnOptions { + argv: vec![], + ..SpawnOptions::default() + }; + assert_eq!(opts.argv0(), "-"); +} diff --git a/crates/pty/tests/unix.rs b/crates/pty/tests/unix.rs new file mode 100644 index 0000000..f8b1670 --- /dev/null +++ b/crates/pty/tests/unix.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn open_pair_yields_a_tty_slave() { + let pair = open_pair().expect("open pty pair"); + assert!(rustix::termios::isatty(&pair.slave)); + assert!(rustix::termios::isatty(&pair.master)); +} + +#[test] +fn winsize_round_trips_through_slave() { + let pair = open_pair().expect("open pty pair"); + let ws = crate::winsize::Winsize::new(101, 31); + rustix::termios::tcsetwinsize(&pair.slave, ws.to_termios()).expect("set winsize"); + let got = rustix::termios::tcgetwinsize(&pair.master).expect("get winsize"); + assert_eq!(got.ws_col, 101); + assert_eq!(got.ws_row, 31); +} + +#[test] +fn spawn_child_rejects_empty_argv() { + let pair = open_pair().expect("open pty pair"); + let opts = SpawnOptions { + argv: vec![], + ..SpawnOptions::default() + }; + let err = spawn_child(&opts, &pair).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); +} diff --git a/crates/pty/tests/winsize.rs b/crates/pty/tests/winsize.rs new file mode 100644 index 0000000..05cf2e0 --- /dev/null +++ b/crates/pty/tests/winsize.rs @@ -0,0 +1,33 @@ +use super::*; + +#[test] +fn default_is_80x24() { + let ws = Winsize::default(); + assert_eq!(ws.cols, 80); + assert_eq!(ws.rows, 24); + assert_eq!(ws.cell_width, 0); + assert_eq!(ws.cell_height, 0); +} + +#[test] +fn converts_grid_to_termios() { + let ws = Winsize::new(120, 40).to_termios(); + assert_eq!(ws.ws_col, 120); + assert_eq!(ws.ws_row, 40); + assert_eq!(ws.ws_xpixel, 0); + assert_eq!(ws.ws_ypixel, 0); +} + +#[test] +fn converts_pixels_as_total_window_size() { + let ws = Winsize::with_cell_size(100, 50, 8, 16).to_termios(); + assert_eq!(ws.ws_xpixel, 800); + assert_eq!(ws.ws_ypixel, 800); +} + +#[test] +fn pixel_conversion_saturates() { + let ws = Winsize::with_cell_size(u16::MAX, u16::MAX, u16::MAX, u16::MAX).to_termios(); + assert_eq!(ws.ws_xpixel, u16::MAX); + assert_eq!(ws.ws_ypixel, u16::MAX); +} diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index f2046cf..bd64a3b 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "terminal" version.workspace = true edition.workspace = true @@ -7,3 +8,9 @@ license.workspace = true [dependencies] pty = { path = "../pty" } vt = { path = "../vt" } + +# End-to-end integration test (unit tests are compiled in-crate via #[path], +# so auto-discovery is off; declare this explicitly). +[[test]] +name = "e2e" +path = "tests/e2e.rs" diff --git a/crates/terminal/src/options.rs b/crates/terminal/src/options.rs index 148c1ec..dc5c348 100644 --- a/crates/terminal/src/options.rs +++ b/crates/terminal/src/options.rs @@ -40,23 +40,5 @@ impl SessionOptions { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_is_80x24_login_shell() { - let opts = SessionOptions::default(); - assert_eq!(opts.cols, 80); - assert_eq!(opts.rows, 24); - assert_eq!(opts.scrollback_limit, vt::DEFAULT_SCROLLBACK); - assert!(opts.spawn.login); - } - - #[test] - fn command_sets_argv_without_login() { - let opts = SessionOptions::command(vec!["/bin/echo".to_string(), "hi".to_string()]); - assert_eq!(opts.spawn.argv[0], "/bin/echo"); - assert!(!opts.spawn.login); - assert_eq!(opts.cols, 80); - } -} +#[path = "../tests/options.rs"] +mod tests; diff --git a/crates/terminal/src/session.rs b/crates/terminal/src/session.rs index c84909b..9105789 100644 --- a/crates/terminal/src/session.rs +++ b/crates/terminal/src/session.rs @@ -210,123 +210,5 @@ fn reap(pty: &Mutex) -> Option { } #[cfg(all(test, unix))] -mod tests { - use super::*; - use std::time::Instant; - - fn command(args: &[&str]) -> SessionOptions { - SessionOptions::command(args.iter().map(|s| s.to_string()).collect()) - } - - /// Collect events until `Exit`; returns the others plus the exit code. - fn drain_until_exit(rx: &Receiver) -> (Vec, Option) { - let deadline = Instant::now() + Duration::from_secs(10); - let mut seen = Vec::new(); - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - match rx.recv_timeout(remaining) { - Ok(Event::Exit(code)) => return (seen, code), - Ok(event) => seen.push(event), - Err(e) => panic!("no Exit before timeout; saw {seen:?}: {e}"), - } - } - } - - #[test] - fn exit_event_carries_exit_code() { - let (_session, rx) = Session::spawn(command(&["/bin/sh", "-c", "exit 7"])).expect("spawn"); - let (_, code) = drain_until_exit(&rx); - assert_eq!(code, Some(7)); - } - - #[test] - fn write_reaches_child() { - let (session, rx) = - Session::spawn(command(&["/bin/sh", "-c", "read line; exit 5"])).expect("spawn"); - session.write(b"go\n").expect("write"); - let (_, code) = drain_until_exit(&rx); - assert_eq!(code, Some(5)); - } - - #[test] - fn wakeup_is_coalesced_until_term_access() { - // Two output bursts, but the embedder never observes the terminal, - // so exactly one Wakeup may be queued. - let (_session, rx) = - Session::spawn(command(&["/bin/sh", "-c", "printf a; sleep 1; printf b"])) - .expect("spawn"); - let (seen, _) = drain_until_exit(&rx); - let wakeups = seen.iter().filter(|e| **e == Event::Wakeup).count(); - assert_eq!(wakeups, 1, "events: {seen:?}"); - } - - #[test] - fn with_term_rearms_wakeup() { - let (session, rx) = Session::spawn(command(&[ - "/bin/sh", - "-c", - "printf a; sleep 1; printf b; sleep 1", - ])) - .expect("spawn"); - assert_eq!( - rx.recv_timeout(Duration::from_secs(10)) - .expect("first wakeup"), - Event::Wakeup - ); - session.with_term(|_| ()); // re-arms the pending flag - assert_eq!( - rx.recv_timeout(Duration::from_secs(10)) - .expect("second wakeup"), - Event::Wakeup - ); - } - - #[test] - fn title_and_bell_events() { - let (_session, rx) = Session::spawn(command(&[ - "/bin/sh", - "-c", - "printf '\\033]2;mytitle\\007'; printf '\\007'", - ])) - .expect("spawn"); - let (seen, _) = drain_until_exit(&rx); - assert!( - seen.contains(&Event::TitleChanged("mytitle".to_string())), - "events: {seen:?}" - ); - assert!(seen.contains(&Event::Bell), "events: {seen:?}"); - } - - #[test] - fn osc52_surfaces_clipboard_event() { - // base64("hi") = "aGk=" - let (_session, rx) = - Session::spawn(command(&["/bin/sh", "-c", "printf '\\033]52;c;aGk=\\007'"])) - .expect("spawn"); - let (seen, _) = drain_until_exit(&rx); - assert!( - seen.contains(&Event::Clipboard { - kind: "c".to_string(), - data: b"hi".to_vec(), - }), - "events: {seen:?}" - ); - } - - #[test] - fn resize_updates_grid_immediately() { - let (session, _rx) = - Session::spawn(command(&["/bin/sh", "-c", "sleep 30"])).expect("spawn"); - session.resize(90, 28).expect("resize"); - let size = session.with_term(|t| (t.cols(), t.rows())); - assert_eq!(size, (90, 28)); - } - - #[test] - fn shutdown_kills_and_reports_exit() { - let (session, rx) = Session::spawn(command(&["/bin/sh", "-c", "sleep 30"])).expect("spawn"); - session.shutdown(); - let (_, code) = drain_until_exit(&rx); - assert_eq!(code, None); // killed by signal, no exit code - } -} +#[path = "../tests/session.rs"] +mod tests; diff --git a/crates/terminal/tests/e2e.rs b/crates/terminal/tests/e2e.rs new file mode 100644 index 0000000..ea906ec --- /dev/null +++ b/crates/terminal/tests/e2e.rs @@ -0,0 +1,88 @@ +//! End-to-end session tests: real shells on a real pty. + +#![cfg(unix)] + +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +use terminal::{Event, Session, SessionOptions}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn shell(args: &[&str]) -> SessionOptions { + SessionOptions::command(args.iter().map(|s| s.to_string()).collect()) +} + +/// Flatten the visible grid rows into one newline-joined string. +fn grid_text(session: &Session) -> String { + session.with_term(|term| { + term.visible_rows() + .map(|row| row.text()) + .collect::>() + .join("\n") + }) +} + +/// Poll the grid until `ok` accepts its text; panic with a dump on timeout. +fn wait_for(session: &Session, what: &str, ok: impl Fn(&str) -> bool) { + let deadline = Instant::now() + TIMEOUT; + loop { + let text = grid_text(session); + if ok(&text) { + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {what}; grid was:\n{text}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// Block until an `Exit` event arrives; panic on timeout. +fn wait_for_exit(events: &Receiver) -> Option { + let deadline = Instant::now() + TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + match events.recv_timeout(remaining) { + Ok(Event::Exit(code)) => return code, + Ok(_) => {} + Err(e) => panic!("no Exit event before timeout: {e}"), + } + } +} + +#[test] +fn oneshot_output_lands_in_grid() { + let (session, _events) = + Session::spawn(shell(&["/bin/sh", "-c", "printf 'hello terminal'"])).expect("spawn"); + wait_for(&session, "hello terminal", |text| { + text.contains("hello terminal") + }); +} + +#[test] +fn interactive_echo_and_exit() { + let (session, events) = Session::spawn(shell(&["/bin/sh"])).expect("spawn"); + session.write(b"echo marker42\n").expect("write echo"); + // The command-output line holds "marker42" without the "echo" that the + // tty-echoed input line carries. + wait_for(&session, "marker42 output line", |text| { + text.lines() + .any(|line| line.contains("marker42") && !line.contains("echo")) + }); + session.write(b"exit\n").expect("write exit"); + wait_for_exit(&events); +} + +#[test] +fn resize_reaches_child_and_grid() { + let (session, _events) = Session::spawn(shell(&["/bin/sh"])).expect("spawn"); + session.resize(100, 30).expect("resize"); + let size = session.with_term(|t| (t.cols(), t.rows())); + assert_eq!(size, (100, 30)); + session.write(b"stty size\n").expect("write stty"); + wait_for(&session, "stty to report 30 100", |text| { + text.contains("30 100") + }); +} diff --git a/crates/terminal/tests/options.rs b/crates/terminal/tests/options.rs new file mode 100644 index 0000000..36246ac --- /dev/null +++ b/crates/terminal/tests/options.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn default_is_80x24_login_shell() { + let opts = SessionOptions::default(); + assert_eq!(opts.cols, 80); + assert_eq!(opts.rows, 24); + assert_eq!(opts.scrollback_limit, vt::DEFAULT_SCROLLBACK); + assert!(opts.spawn.login); +} + +#[test] +fn command_sets_argv_without_login() { + let opts = SessionOptions::command(vec!["/bin/echo".to_string(), "hi".to_string()]); + assert_eq!(opts.spawn.argv[0], "/bin/echo"); + assert!(!opts.spawn.login); + assert_eq!(opts.cols, 80); +} diff --git a/crates/terminal/tests/session.rs b/crates/terminal/tests/session.rs index ea906ec..7047dd1 100644 --- a/crates/terminal/tests/session.rs +++ b/crates/terminal/tests/session.rs @@ -1,88 +1,118 @@ -//! End-to-end session tests: real shells on a real pty. +use super::*; +use std::time::Instant; -#![cfg(unix)] - -use std::sync::mpsc::Receiver; -use std::time::{Duration, Instant}; - -use terminal::{Event, Session, SessionOptions}; - -const TIMEOUT: Duration = Duration::from_secs(10); - -fn shell(args: &[&str]) -> SessionOptions { +fn command(args: &[&str]) -> SessionOptions { SessionOptions::command(args.iter().map(|s| s.to_string()).collect()) } -/// Flatten the visible grid rows into one newline-joined string. -fn grid_text(session: &Session) -> String { - session.with_term(|term| { - term.visible_rows() - .map(|row| row.text()) - .collect::>() - .join("\n") - }) -} - -/// Poll the grid until `ok` accepts its text; panic with a dump on timeout. -fn wait_for(session: &Session, what: &str, ok: impl Fn(&str) -> bool) { - let deadline = Instant::now() + TIMEOUT; +/// Collect events until `Exit`; returns the others plus the exit code. +fn drain_until_exit(rx: &Receiver) -> (Vec, Option) { + let deadline = Instant::now() + Duration::from_secs(10); + let mut seen = Vec::new(); loop { - let text = grid_text(session); - if ok(&text) { - return; + let remaining = deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(remaining) { + Ok(Event::Exit(code)) => return (seen, code), + Ok(event) => seen.push(event), + Err(e) => panic!("no Exit before timeout; saw {seen:?}: {e}"), } - assert!( - Instant::now() < deadline, - "timed out waiting for {what}; grid was:\n{text}" - ); - std::thread::sleep(Duration::from_millis(25)); } } -/// Block until an `Exit` event arrives; panic on timeout. -fn wait_for_exit(events: &Receiver) -> Option { - let deadline = Instant::now() + TIMEOUT; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - match events.recv_timeout(remaining) { - Ok(Event::Exit(code)) => return code, - Ok(_) => {} - Err(e) => panic!("no Exit event before timeout: {e}"), - } - } +#[test] +fn exit_event_carries_exit_code() { + let (_session, rx) = Session::spawn(command(&["/bin/sh", "-c", "exit 7"])).expect("spawn"); + let (_, code) = drain_until_exit(&rx); + assert_eq!(code, Some(7)); } #[test] -fn oneshot_output_lands_in_grid() { - let (session, _events) = - Session::spawn(shell(&["/bin/sh", "-c", "printf 'hello terminal'"])).expect("spawn"); - wait_for(&session, "hello terminal", |text| { - text.contains("hello terminal") - }); +fn write_reaches_child() { + let (session, rx) = + Session::spawn(command(&["/bin/sh", "-c", "read line; exit 5"])).expect("spawn"); + session.write(b"go\n").expect("write"); + let (_, code) = drain_until_exit(&rx); + assert_eq!(code, Some(5)); } #[test] -fn interactive_echo_and_exit() { - let (session, events) = Session::spawn(shell(&["/bin/sh"])).expect("spawn"); - session.write(b"echo marker42\n").expect("write echo"); - // The command-output line holds "marker42" without the "echo" that the - // tty-echoed input line carries. - wait_for(&session, "marker42 output line", |text| { - text.lines() - .any(|line| line.contains("marker42") && !line.contains("echo")) - }); - session.write(b"exit\n").expect("write exit"); - wait_for_exit(&events); +fn wakeup_is_coalesced_until_term_access() { + // Two output bursts, but the embedder never observes the terminal, + // so exactly one Wakeup may be queued. + let (_session, rx) = + Session::spawn(command(&["/bin/sh", "-c", "printf a; sleep 1; printf b"])) + .expect("spawn"); + let (seen, _) = drain_until_exit(&rx); + let wakeups = seen.iter().filter(|e| **e == Event::Wakeup).count(); + assert_eq!(wakeups, 1, "events: {seen:?}"); } #[test] -fn resize_reaches_child_and_grid() { - let (session, _events) = Session::spawn(shell(&["/bin/sh"])).expect("spawn"); - session.resize(100, 30).expect("resize"); +fn with_term_rearms_wakeup() { + let (session, rx) = Session::spawn(command(&[ + "/bin/sh", + "-c", + "printf a; sleep 1; printf b; sleep 1", + ])) + .expect("spawn"); + assert_eq!( + rx.recv_timeout(Duration::from_secs(10)) + .expect("first wakeup"), + Event::Wakeup + ); + session.with_term(|_| ()); // re-arms the pending flag + assert_eq!( + rx.recv_timeout(Duration::from_secs(10)) + .expect("second wakeup"), + Event::Wakeup + ); +} + +#[test] +fn title_and_bell_events() { + let (_session, rx) = Session::spawn(command(&[ + "/bin/sh", + "-c", + "printf '\\033]2;mytitle\\007'; printf '\\007'", + ])) + .expect("spawn"); + let (seen, _) = drain_until_exit(&rx); + assert!( + seen.contains(&Event::TitleChanged("mytitle".to_string())), + "events: {seen:?}" + ); + assert!(seen.contains(&Event::Bell), "events: {seen:?}"); +} + +#[test] +fn osc52_surfaces_clipboard_event() { + // base64("hi") = "aGk=" + let (_session, rx) = + Session::spawn(command(&["/bin/sh", "-c", "printf '\\033]52;c;aGk=\\007'"])) + .expect("spawn"); + let (seen, _) = drain_until_exit(&rx); + assert!( + seen.contains(&Event::Clipboard { + kind: "c".to_string(), + data: b"hi".to_vec(), + }), + "events: {seen:?}" + ); +} + +#[test] +fn resize_updates_grid_immediately() { + let (session, _rx) = + Session::spawn(command(&["/bin/sh", "-c", "sleep 30"])).expect("spawn"); + session.resize(90, 28).expect("resize"); let size = session.with_term(|t| (t.cols(), t.rows())); - assert_eq!(size, (100, 30)); - session.write(b"stty size\n").expect("write stty"); - wait_for(&session, "stty to report 30 100", |text| { - text.contains("30 100") - }); + assert_eq!(size, (90, 28)); +} + +#[test] +fn shutdown_kills_and_reports_exit() { + let (session, rx) = Session::spawn(command(&["/bin/sh", "-c", "sleep 30"])).expect("spawn"); + session.shutdown(); + let (_, code) = drain_until_exit(&rx); + assert_eq!(code, None); // killed by signal, no exit code } diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 6de1378..71506d2 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "theme" version.workspace = true edition.workspace = true diff --git a/crates/theme/src/builtin/dark.rs b/crates/theme/src/builtin/dark.rs index e39d7c4..5cb963c 100644 --- a/crates/theme/src/builtin/dark.rs +++ b/crates/theme/src/builtin/dark.rs @@ -37,21 +37,5 @@ pub static DARK: Scheme = Scheme { }; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn background_matches_spec() { - assert_eq!(DARK.background.to_hex(), "#282c34"); - } - - #[test] - fn is_dark() { - assert!(DARK.is_dark()); - } - - #[test] - fn bright_white_is_white() { - assert_eq!(DARK.ansi[15], Rgb::new(0xff, 0xff, 0xff)); - } -} +#[path = "../../tests/builtin/dark.rs"] +mod tests; diff --git a/crates/theme/src/builtin/light.rs b/crates/theme/src/builtin/light.rs index 10361bb..b1a4cf9 100644 --- a/crates/theme/src/builtin/light.rs +++ b/crates/theme/src/builtin/light.rs @@ -37,21 +37,5 @@ pub static LIGHT: Scheme = Scheme { }; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn background_is_white() { - assert_eq!(LIGHT.background, Rgb::new(0xff, 0xff, 0xff)); - } - - #[test] - fn is_not_dark() { - assert!(!LIGHT.is_dark()); - } - - #[test] - fn foreground_contrasts_background() { - assert!(LIGHT.background.luminance() - LIGHT.foreground.luminance() > 0.5); - } -} +#[path = "../../tests/builtin/light.rs"] +mod tests; diff --git a/crates/theme/src/builtin/mod.rs b/crates/theme/src/builtin/mod.rs index 6a68e8e..f88e056 100644 --- a/crates/theme/src/builtin/mod.rs +++ b/crates/theme/src/builtin/mod.rs @@ -104,175 +104,5 @@ pub fn default_scheme() -> &'static Scheme { } #[cfg(test)] -mod tests { - use super::*; - use crate::rgb::Rgb; - - fn hex(s: &str) -> Rgb { - s.parse().unwrap() - } - - #[test] - fn lookup_dark_and_light() { - assert_eq!(builtin("dark").unwrap().name, "dark"); - assert_eq!(builtin("light").unwrap().name, "light"); - } - - #[test] - fn lookup_is_case_insensitive() { - assert_eq!(builtin("DARK").unwrap().name, "dark"); - assert_eq!(builtin("DRACULA").unwrap().name, "Dracula"); - assert_eq!(builtin("nord").unwrap().name, "Nord"); - } - - #[test] - fn lookup_ignores_separators() { - for query in [ - "Catppuccin Mocha", - "catppuccin-mocha", - "catppuccin_mocha", - "catppuccinmocha", - "CATPPUCCIN-MOCHA", - ] { - assert_eq!(builtin(query).unwrap().name, "Catppuccin Mocha", "{query}"); - } - assert_eq!(builtin("gruvbox dark").unwrap().name, "Gruvbox Dark"); - assert_eq!(builtin("tokyo-night").unwrap().name, "Tokyo Night"); - assert_eq!(builtin("one_dark").unwrap().name, "One Dark"); - assert_eq!(builtin("github-light").unwrap().name, "GitHub Light"); - assert_eq!(builtin("rose pine").unwrap().name, "Rose Pine"); - } - - #[test] - fn lookup_unknown_is_none() { - assert!(builtin("solarized").is_none()); - assert!(builtin("gruvbox").is_none()); - assert!(builtin("").is_none()); - } - - #[test] - fn names_sorted_and_complete() { - let n = names(); - assert_eq!(n.len(), ALL.len()); - assert_eq!(n.len(), 22); - let mut sorted = n.clone(); - sorted.sort_unstable(); - assert_eq!(n, sorted); - assert!(n.contains(&"Dracula")); - assert!(n.contains(&"dark")); - } - - #[test] - fn normalized_names_are_unique() { - for (i, a) in ALL.iter().enumerate() { - for b in &ALL[i + 1..] { - assert_ne!( - normalize(a.name), - normalize(b.name), - "{} vs {}", - a.name, - b.name - ); - } - } - } - - #[test] - fn default_is_dark() { - assert_eq!(default_scheme().name, "dark"); - assert!(default_scheme().is_dark()); - } - - #[test] - fn background_spot_checks() { - let cases = [ - ("dracula", "#282a36"), - ("nord", "#2e3440"), - ("gruvboxdark", "#282828"), - ("gruvboxlight", "#fbf1c7"), - ("solarizeddark", "#002b36"), - ("solarizedlight", "#fdf6e3"), - ("catppuccinmocha", "#1e1e2e"), - ("catppuccinlatte", "#eff1f5"), - ("tokyonight", "#1a1b26"), - ("onedark", "#282c34"), - ("monokai", "#272822"), - ("ayudark", "#0a0e14"), - ("rosepine", "#191724"), - ("kanagawa", "#1f1f28"), - ("everforest", "#2d353b"), - ("githubdark", "#0d1117"), - ("githublight", "#ffffff"), - ("materialdark", "#263238"), - ("palenight", "#292d3e"), - ("zenburn", "#3f3f3f"), - ]; - for (name, bg) in cases { - assert_eq!(builtin(name).unwrap().background, hex(bg), "{name}"); - } - } - - #[test] - fn foreground_spot_checks() { - let cases = [ - ("dracula", "#f8f8f2"), - ("nord", "#d8dee9"), - ("gruvboxdark", "#ebdbb2"), - ("solarizeddark", "#839496"), - ("catppuccinmocha", "#cdd6f4"), - ("tokyonight", "#c0caf5"), - ("onedark", "#abb2bf"), - ("rosepine", "#e0def4"), - ("kanagawa", "#dcd7ba"), - ("everforest", "#d3c6aa"), - ("zenburn", "#dcdccc"), - ]; - for (name, fg) in cases { - assert_eq!(builtin(name).unwrap().foreground, hex(fg), "{name}"); - } - } - - #[test] - fn ansi_red_spot_checks() { - let cases = [ - ("dracula", "#ff5555"), - ("nord", "#bf616a"), - ("gruvboxdark", "#cc241d"), - ("solarizeddark", "#dc322f"), - ("catppuccinmocha", "#f38ba8"), - ("catppuccinlatte", "#d20f39"), - ("tokyonight", "#f7768e"), - ("onedark", "#e06c75"), - ("monokai", "#f92672"), - ("ayudark", "#ea6c73"), - ("rosepine", "#eb6f92"), - ("kanagawa", "#c34043"), - ("everforest", "#e67e80"), - ("githubdark", "#ff7b72"), - ("githublight", "#cf222e"), - ("materialdark", "#ff5370"), - ("palenight", "#f07178"), - ("zenburn", "#705050"), - ]; - for (name, red) in cases { - assert_eq!(builtin(name).unwrap().ansi[1], hex(red), "{name}"); - } - } - - #[test] - fn darkness_matches_variant() { - let lights = [ - "light", - "gruvboxlight", - "solarizedlight", - "catppuccinlatte", - "githublight", - ]; - for scheme in ALL { - let expect_light = lights - .iter() - .any(|l| builtin(l).unwrap().name == scheme.name); - assert_eq!(scheme.is_dark(), !expect_light, "{}", scheme.name); - } - } -} +#[path = "../../tests/builtin/mod.rs"] +mod tests; diff --git a/crates/theme/src/overrides.rs b/crates/theme/src/overrides.rs index 1829f2f..cb179c5 100644 --- a/crates/theme/src/overrides.rs +++ b/crates/theme/src/overrides.rs @@ -89,103 +89,5 @@ pub fn apply_overrides( } #[cfg(test)] -mod tests { - use super::*; - use crate::builtin; - - fn base() -> &'static Scheme { - builtin::builtin("dracula").unwrap() - } - - fn hex(s: &str) -> Rgb { - s.parse().unwrap() - } - - #[test] - fn no_overrides_is_identity() { - let out = apply_overrides(base(), None, None, None, None, None, None, &[]).unwrap(); - assert_eq!(out, *base()); - } - - #[test] - fn field_overrides_apply() { - let out = apply_overrides( - base(), - Some("#000000"), - Some("#ffffff"), - Some("#ff0000"), - Some("00ff00"), - Some("#abc"), - Some("#123456"), - &[], - ) - .unwrap(); - assert_eq!(out.background, hex("#000000")); - assert_eq!(out.foreground, hex("#ffffff")); - assert_eq!(out.cursor, hex("#ff0000")); - assert_eq!(out.cursor_text, hex("#00ff00")); - assert_eq!(out.selection_foreground, hex("#aabbcc")); - assert_eq!(out.selection_background, hex("#123456")); - // Untouched fields keep base values. - assert_eq!(out.ansi, base().ansi); - assert_eq!(out.name, base().name); - } - - #[test] - fn partial_override_leaves_rest() { - let out = - apply_overrides(base(), Some("#101010"), None, None, None, None, None, &[]).unwrap(); - assert_eq!(out.background, hex("#101010")); - assert_eq!(out.foreground, base().foreground); - assert_eq!(out.cursor, base().cursor); - } - - #[test] - fn ansi_overrides_apply() { - let ansi = [(1u8, "#000001".to_string()), (15u8, "#0f0f0f".to_string())]; - let out = apply_overrides(base(), None, None, None, None, None, None, &ansi).unwrap(); - assert_eq!(out.ansi[1], hex("#000001")); - assert_eq!(out.ansi[15], hex("#0f0f0f")); - assert_eq!(out.ansi[0], base().ansi[0]); - assert_eq!(out.ansi[14], base().ansi[14]); - } - - #[test] - fn bad_hex_is_reported() { - let err = - apply_overrides(base(), Some("nope"), None, None, None, None, None, &[]).unwrap_err(); - match err { - OverrideError::Hex { field, value, .. } => { - assert_eq!(field, "background"); - assert_eq!(value, "nope"); - } - other => panic!("unexpected error: {other:?}"), - } - } - - #[test] - fn bad_ansi_hex_is_reported() { - let ansi = [(3u8, "#zzz".to_string())]; - let err = apply_overrides(base(), None, None, None, None, None, None, &ansi).unwrap_err(); - match err { - OverrideError::Hex { field, .. } => assert_eq!(field, "ansi[3]"), - other => panic!("unexpected error: {other:?}"), - } - } - - #[test] - fn out_of_range_index_is_reported() { - let ansi = [(16u8, "#ffffff".to_string())]; - let err = apply_overrides(base(), None, None, None, None, None, None, &ansi).unwrap_err(); - assert_eq!(err, OverrideError::Index(16)); - } - - #[test] - fn errors_display() { - assert!(OverrideError::Index(200).to_string().contains("200")); - let err = - apply_overrides(base(), None, Some("xx"), None, None, None, None, &[]).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("foreground") && msg.contains("xx")); - } -} +#[path = "../tests/overrides.rs"] +mod tests; diff --git a/crates/theme/src/palette.rs b/crates/theme/src/palette.rs index 2101b71..0b7a6ea 100644 --- a/crates/theme/src/palette.rs +++ b/crates/theme/src/palette.rs @@ -63,87 +63,5 @@ impl Palette { } #[cfg(test)] -mod tests { - use super::*; - use crate::builtin; - - fn palette() -> Palette { - Palette::from_scheme(builtin::builtin("dark").unwrap()) - } - - #[test] - fn ansi_slots_come_from_scheme() { - let scheme = builtin::builtin("dark").unwrap(); - let p = palette(); - for i in 0..16 { - assert_eq!(p.get(i as u8), scheme.ansi[i]); - } - } - - #[test] - fn cube_known_values() { - let p = palette(); - assert_eq!(p.get(16), "#000000".parse().unwrap()); - assert_eq!(p.get(21), "#0000ff".parse().unwrap()); - assert_eq!(p.get(46), "#00ff00".parse().unwrap()); - assert_eq!(p.get(196), "#ff0000".parse().unwrap()); - assert_eq!(p.get(231), "#ffffff".parse().unwrap()); - // 16 + 36*1 + 6*2 + 3 = 67 -> (95, 135, 175) - assert_eq!(p.get(67), Rgb::new(95, 135, 175)); - } - - #[test] - fn cube_uses_standard_steps() { - let p = palette(); - for i in 16..=231u8 { - let c = p.get(i); - for v in [c.r, c.g, c.b] { - assert!(CUBE_STEPS.contains(&v), "index {i} component {v}"); - } - } - } - - #[test] - fn grayscale_ramp() { - let p = palette(); - assert_eq!(p.get(232), "#080808".parse().unwrap()); - assert_eq!(p.get(244), "#808080".parse().unwrap()); - assert_eq!(p.get(255), "#eeeeee".parse().unwrap()); - for i in 232..=255u8 { - let c = p.get(i); - let v = 8 + 10 * (i - 232); - assert_eq!(c, Rgb::new(v, v, v)); - } - } - - #[test] - fn overrides_apply_on_top() { - let scheme = builtin::builtin("dark").unwrap(); - let red = Rgb::new(0xff, 0x00, 0x00); - let teal = Rgb::new(0x00, 0x80, 0x80); - let p = build(scheme, &[(0, red), (231, teal), (255, red)]); - assert_eq!(p.get(0), red); - assert_eq!(p.get(231), teal); - assert_eq!(p.get(255), red); - // Untouched indices keep their computed values. - assert_eq!(p.get(1), scheme.ansi[1]); - assert_eq!(p.get(232), "#080808".parse().unwrap()); - } - - #[test] - fn later_overrides_win() { - let scheme = builtin::builtin("dark").unwrap(); - let a = Rgb::new(1, 1, 1); - let b = Rgb::new(2, 2, 2); - let p = build(scheme, &[(42, a), (42, b)]); - assert_eq!(p.get(42), b); - } - - #[test] - fn set_mutates() { - let mut p = palette(); - let c = Rgb::new(9, 9, 9); - p.set(100, c); - assert_eq!(p.get(100), c); - } -} +#[path = "../tests/palette.rs"] +mod tests; diff --git a/crates/theme/src/rgb.rs b/crates/theme/src/rgb.rs index 90d4109..57dd8d6 100644 --- a/crates/theme/src/rgb.rs +++ b/crates/theme/src/rgb.rs @@ -85,71 +85,5 @@ impl fmt::Display for Rgb { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_long_form_with_hash() { - assert_eq!( - "#1a2b3c".parse::().unwrap(), - Rgb::new(0x1a, 0x2b, 0x3c) - ); - } - - #[test] - fn parses_long_form_without_hash() { - assert_eq!("ffcc00".parse::().unwrap(), Rgb::new(0xff, 0xcc, 0x00)); - } - - #[test] - fn parses_short_form() { - assert_eq!("#abc".parse::().unwrap(), Rgb::new(0xaa, 0xbb, 0xcc)); - assert_eq!("#fff".parse::().unwrap(), Rgb::new(0xff, 0xff, 0xff)); - assert_eq!("#000".parse::().unwrap(), Rgb::new(0, 0, 0)); - } - - #[test] - fn parses_uppercase() { - assert_eq!( - "#1A2B3C".parse::().unwrap(), - Rgb::new(0x1a, 0x2b, 0x3c) - ); - } - - #[test] - fn rejects_bad_length() { - assert_eq!("".parse::(), Err(ParseRgbError::Length)); - assert_eq!("#".parse::(), Err(ParseRgbError::Length)); - assert_eq!("#12345".parse::(), Err(ParseRgbError::Length)); - assert_eq!("#1234567".parse::(), Err(ParseRgbError::Length)); - } - - #[test] - fn rejects_bad_digits() { - assert_eq!("#gggggg".parse::(), Err(ParseRgbError::Digit)); - assert_eq!("xyz".parse::(), Err(ParseRgbError::Digit)); - assert_eq!("#12 45 ".parse::(), Err(ParseRgbError::Digit)); - } - - #[test] - fn rejects_non_ascii() { - // 3 bytes, but not hex digits; must not panic on char boundaries. - assert_eq!("ä1".parse::(), Err(ParseRgbError::Digit)); - } - - #[test] - fn to_hex_roundtrip() { - let c = Rgb::new(0x28, 0x2c, 0x34); - assert_eq!(c.to_hex(), "#282c34"); - assert_eq!(c.to_hex().parse::().unwrap(), c); - assert_eq!(format!("{c}"), "#282c34"); - } - - #[test] - fn luminance_extremes() { - assert_eq!(Rgb::new(0, 0, 0).luminance(), 0.0); - assert!((Rgb::new(255, 255, 255).luminance() - 1.0).abs() < 1e-9); - let mid = Rgb::new(128, 128, 128).luminance(); - assert!(mid > 0.4 && mid < 0.6); - } -} +#[path = "../tests/rgb.rs"] +mod tests; diff --git a/crates/theme/src/scheme.rs b/crates/theme/src/scheme.rs index 022c96a..74472ad 100644 --- a/crates/theme/src/scheme.rs +++ b/crates/theme/src/scheme.rs @@ -22,35 +22,5 @@ impl Scheme { } #[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Scheme { - Scheme { - name: "sample", - ansi: [Rgb::new(1, 2, 3); 16], - foreground: Rgb::new(0xee, 0xee, 0xee), - background: Rgb::new(0x10, 0x10, 0x10), - cursor: Rgb::new(0xff, 0xff, 0xff), - cursor_text: Rgb::new(0, 0, 0), - selection_foreground: Rgb::new(0xff, 0xff, 0xff), - selection_background: Rgb::new(0x33, 0x44, 0x55), - } - } - - #[test] - fn fields_accessible() { - let s = sample(); - assert_eq!(s.name, "sample"); - assert_eq!(s.ansi[15], Rgb::new(1, 2, 3)); - assert_eq!(s.selection_background, Rgb::new(0x33, 0x44, 0x55)); - } - - #[test] - fn dark_detection() { - let mut s = sample(); - assert!(s.is_dark()); - s.background = Rgb::new(0xff, 0xff, 0xff); - assert!(!s.is_dark()); - } -} +#[path = "../tests/scheme.rs"] +mod tests; diff --git a/crates/theme/tests/builtin/dark.rs b/crates/theme/tests/builtin/dark.rs new file mode 100644 index 0000000..8cdbed6 --- /dev/null +++ b/crates/theme/tests/builtin/dark.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn background_matches_spec() { + assert_eq!(DARK.background.to_hex(), "#282c34"); +} + +#[test] +fn is_dark() { + assert!(DARK.is_dark()); +} + +#[test] +fn bright_white_is_white() { + assert_eq!(DARK.ansi[15], Rgb::new(0xff, 0xff, 0xff)); +} diff --git a/crates/theme/tests/builtin/light.rs b/crates/theme/tests/builtin/light.rs new file mode 100644 index 0000000..a9fe798 --- /dev/null +++ b/crates/theme/tests/builtin/light.rs @@ -0,0 +1,16 @@ +use super::*; + +#[test] +fn background_is_white() { + assert_eq!(LIGHT.background, Rgb::new(0xff, 0xff, 0xff)); +} + +#[test] +fn is_not_dark() { + assert!(!LIGHT.is_dark()); +} + +#[test] +fn foreground_contrasts_background() { + assert!(LIGHT.background.luminance() - LIGHT.foreground.luminance() > 0.5); +} diff --git a/crates/theme/tests/builtin/mod.rs b/crates/theme/tests/builtin/mod.rs new file mode 100644 index 0000000..97cec49 --- /dev/null +++ b/crates/theme/tests/builtin/mod.rs @@ -0,0 +1,170 @@ +use super::*; +use crate::rgb::Rgb; + +fn hex(s: &str) -> Rgb { + s.parse().unwrap() +} + +#[test] +fn lookup_dark_and_light() { + assert_eq!(builtin("dark").unwrap().name, "dark"); + assert_eq!(builtin("light").unwrap().name, "light"); +} + +#[test] +fn lookup_is_case_insensitive() { + assert_eq!(builtin("DARK").unwrap().name, "dark"); + assert_eq!(builtin("DRACULA").unwrap().name, "Dracula"); + assert_eq!(builtin("nord").unwrap().name, "Nord"); +} + +#[test] +fn lookup_ignores_separators() { + for query in [ + "Catppuccin Mocha", + "catppuccin-mocha", + "catppuccin_mocha", + "catppuccinmocha", + "CATPPUCCIN-MOCHA", + ] { + assert_eq!(builtin(query).unwrap().name, "Catppuccin Mocha", "{query}"); + } + assert_eq!(builtin("gruvbox dark").unwrap().name, "Gruvbox Dark"); + assert_eq!(builtin("tokyo-night").unwrap().name, "Tokyo Night"); + assert_eq!(builtin("one_dark").unwrap().name, "One Dark"); + assert_eq!(builtin("github-light").unwrap().name, "GitHub Light"); + assert_eq!(builtin("rose pine").unwrap().name, "Rose Pine"); +} + +#[test] +fn lookup_unknown_is_none() { + assert!(builtin("solarized").is_none()); + assert!(builtin("gruvbox").is_none()); + assert!(builtin("").is_none()); +} + +#[test] +fn names_sorted_and_complete() { + let n = names(); + assert_eq!(n.len(), ALL.len()); + assert_eq!(n.len(), 22); + let mut sorted = n.clone(); + sorted.sort_unstable(); + assert_eq!(n, sorted); + assert!(n.contains(&"Dracula")); + assert!(n.contains(&"dark")); +} + +#[test] +fn normalized_names_are_unique() { + for (i, a) in ALL.iter().enumerate() { + for b in &ALL[i + 1..] { + assert_ne!( + normalize(a.name), + normalize(b.name), + "{} vs {}", + a.name, + b.name + ); + } + } +} + +#[test] +fn default_is_dark() { + assert_eq!(default_scheme().name, "dark"); + assert!(default_scheme().is_dark()); +} + +#[test] +fn background_spot_checks() { + let cases = [ + ("dracula", "#282a36"), + ("nord", "#2e3440"), + ("gruvboxdark", "#282828"), + ("gruvboxlight", "#fbf1c7"), + ("solarizeddark", "#002b36"), + ("solarizedlight", "#fdf6e3"), + ("catppuccinmocha", "#1e1e2e"), + ("catppuccinlatte", "#eff1f5"), + ("tokyonight", "#1a1b26"), + ("onedark", "#282c34"), + ("monokai", "#272822"), + ("ayudark", "#0a0e14"), + ("rosepine", "#191724"), + ("kanagawa", "#1f1f28"), + ("everforest", "#2d353b"), + ("githubdark", "#0d1117"), + ("githublight", "#ffffff"), + ("materialdark", "#263238"), + ("palenight", "#292d3e"), + ("zenburn", "#3f3f3f"), + ]; + for (name, bg) in cases { + assert_eq!(builtin(name).unwrap().background, hex(bg), "{name}"); + } +} + +#[test] +fn foreground_spot_checks() { + let cases = [ + ("dracula", "#f8f8f2"), + ("nord", "#d8dee9"), + ("gruvboxdark", "#ebdbb2"), + ("solarizeddark", "#839496"), + ("catppuccinmocha", "#cdd6f4"), + ("tokyonight", "#c0caf5"), + ("onedark", "#abb2bf"), + ("rosepine", "#e0def4"), + ("kanagawa", "#dcd7ba"), + ("everforest", "#d3c6aa"), + ("zenburn", "#dcdccc"), + ]; + for (name, fg) in cases { + assert_eq!(builtin(name).unwrap().foreground, hex(fg), "{name}"); + } +} + +#[test] +fn ansi_red_spot_checks() { + let cases = [ + ("dracula", "#ff5555"), + ("nord", "#bf616a"), + ("gruvboxdark", "#cc241d"), + ("solarizeddark", "#dc322f"), + ("catppuccinmocha", "#f38ba8"), + ("catppuccinlatte", "#d20f39"), + ("tokyonight", "#f7768e"), + ("onedark", "#e06c75"), + ("monokai", "#f92672"), + ("ayudark", "#ea6c73"), + ("rosepine", "#eb6f92"), + ("kanagawa", "#c34043"), + ("everforest", "#e67e80"), + ("githubdark", "#ff7b72"), + ("githublight", "#cf222e"), + ("materialdark", "#ff5370"), + ("palenight", "#f07178"), + ("zenburn", "#705050"), + ]; + for (name, red) in cases { + assert_eq!(builtin(name).unwrap().ansi[1], hex(red), "{name}"); + } +} + +#[test] +fn darkness_matches_variant() { + let lights = [ + "light", + "gruvboxlight", + "solarizedlight", + "catppuccinlatte", + "githublight", + ]; + for scheme in ALL { + let expect_light = lights + .iter() + .any(|l| builtin(l).unwrap().name == scheme.name); + assert_eq!(scheme.is_dark(), !expect_light, "{}", scheme.name); + } +} diff --git a/crates/theme/tests/overrides.rs b/crates/theme/tests/overrides.rs new file mode 100644 index 0000000..001f3d8 --- /dev/null +++ b/crates/theme/tests/overrides.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::builtin; + +fn base() -> &'static Scheme { + builtin::builtin("dracula").unwrap() +} + +fn hex(s: &str) -> Rgb { + s.parse().unwrap() +} + +#[test] +fn no_overrides_is_identity() { + let out = apply_overrides(base(), None, None, None, None, None, None, &[]).unwrap(); + assert_eq!(out, *base()); +} + +#[test] +fn field_overrides_apply() { + let out = apply_overrides( + base(), + Some("#000000"), + Some("#ffffff"), + Some("#ff0000"), + Some("00ff00"), + Some("#abc"), + Some("#123456"), + &[], + ) + .unwrap(); + assert_eq!(out.background, hex("#000000")); + assert_eq!(out.foreground, hex("#ffffff")); + assert_eq!(out.cursor, hex("#ff0000")); + assert_eq!(out.cursor_text, hex("#00ff00")); + assert_eq!(out.selection_foreground, hex("#aabbcc")); + assert_eq!(out.selection_background, hex("#123456")); + // Untouched fields keep base values. + assert_eq!(out.ansi, base().ansi); + assert_eq!(out.name, base().name); +} + +#[test] +fn partial_override_leaves_rest() { + let out = + apply_overrides(base(), Some("#101010"), None, None, None, None, None, &[]).unwrap(); + assert_eq!(out.background, hex("#101010")); + assert_eq!(out.foreground, base().foreground); + assert_eq!(out.cursor, base().cursor); +} + +#[test] +fn ansi_overrides_apply() { + let ansi = [(1u8, "#000001".to_string()), (15u8, "#0f0f0f".to_string())]; + let out = apply_overrides(base(), None, None, None, None, None, None, &ansi).unwrap(); + assert_eq!(out.ansi[1], hex("#000001")); + assert_eq!(out.ansi[15], hex("#0f0f0f")); + assert_eq!(out.ansi[0], base().ansi[0]); + assert_eq!(out.ansi[14], base().ansi[14]); +} + +#[test] +fn bad_hex_is_reported() { + let err = + apply_overrides(base(), Some("nope"), None, None, None, None, None, &[]).unwrap_err(); + match err { + OverrideError::Hex { field, value, .. } => { + assert_eq!(field, "background"); + assert_eq!(value, "nope"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn bad_ansi_hex_is_reported() { + let ansi = [(3u8, "#zzz".to_string())]; + let err = apply_overrides(base(), None, None, None, None, None, None, &ansi).unwrap_err(); + match err { + OverrideError::Hex { field, .. } => assert_eq!(field, "ansi[3]"), + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn out_of_range_index_is_reported() { + let ansi = [(16u8, "#ffffff".to_string())]; + let err = apply_overrides(base(), None, None, None, None, None, None, &ansi).unwrap_err(); + assert_eq!(err, OverrideError::Index(16)); +} + +#[test] +fn errors_display() { + assert!(OverrideError::Index(200).to_string().contains("200")); + let err = + apply_overrides(base(), None, Some("xx"), None, None, None, None, &[]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("foreground") && msg.contains("xx")); +} diff --git a/crates/theme/tests/palette.rs b/crates/theme/tests/palette.rs new file mode 100644 index 0000000..d7f96e7 --- /dev/null +++ b/crates/theme/tests/palette.rs @@ -0,0 +1,82 @@ +use super::*; +use crate::builtin; + +fn palette() -> Palette { + Palette::from_scheme(builtin::builtin("dark").unwrap()) +} + +#[test] +fn ansi_slots_come_from_scheme() { + let scheme = builtin::builtin("dark").unwrap(); + let p = palette(); + for i in 0..16 { + assert_eq!(p.get(i as u8), scheme.ansi[i]); + } +} + +#[test] +fn cube_known_values() { + let p = palette(); + assert_eq!(p.get(16), "#000000".parse().unwrap()); + assert_eq!(p.get(21), "#0000ff".parse().unwrap()); + assert_eq!(p.get(46), "#00ff00".parse().unwrap()); + assert_eq!(p.get(196), "#ff0000".parse().unwrap()); + assert_eq!(p.get(231), "#ffffff".parse().unwrap()); + // 16 + 36*1 + 6*2 + 3 = 67 -> (95, 135, 175) + assert_eq!(p.get(67), Rgb::new(95, 135, 175)); +} + +#[test] +fn cube_uses_standard_steps() { + let p = palette(); + for i in 16..=231u8 { + let c = p.get(i); + for v in [c.r, c.g, c.b] { + assert!(CUBE_STEPS.contains(&v), "index {i} component {v}"); + } + } +} + +#[test] +fn grayscale_ramp() { + let p = palette(); + assert_eq!(p.get(232), "#080808".parse().unwrap()); + assert_eq!(p.get(244), "#808080".parse().unwrap()); + assert_eq!(p.get(255), "#eeeeee".parse().unwrap()); + for i in 232..=255u8 { + let c = p.get(i); + let v = 8 + 10 * (i - 232); + assert_eq!(c, Rgb::new(v, v, v)); + } +} + +#[test] +fn overrides_apply_on_top() { + let scheme = builtin::builtin("dark").unwrap(); + let red = Rgb::new(0xff, 0x00, 0x00); + let teal = Rgb::new(0x00, 0x80, 0x80); + let p = build(scheme, &[(0, red), (231, teal), (255, red)]); + assert_eq!(p.get(0), red); + assert_eq!(p.get(231), teal); + assert_eq!(p.get(255), red); + // Untouched indices keep their computed values. + assert_eq!(p.get(1), scheme.ansi[1]); + assert_eq!(p.get(232), "#080808".parse().unwrap()); +} + +#[test] +fn later_overrides_win() { + let scheme = builtin::builtin("dark").unwrap(); + let a = Rgb::new(1, 1, 1); + let b = Rgb::new(2, 2, 2); + let p = build(scheme, &[(42, a), (42, b)]); + assert_eq!(p.get(42), b); +} + +#[test] +fn set_mutates() { + let mut p = palette(); + let c = Rgb::new(9, 9, 9); + p.set(100, c); + assert_eq!(p.get(100), c); +} diff --git a/crates/theme/tests/rgb.rs b/crates/theme/tests/rgb.rs new file mode 100644 index 0000000..700faa4 --- /dev/null +++ b/crates/theme/tests/rgb.rs @@ -0,0 +1,66 @@ +use super::*; + +#[test] +fn parses_long_form_with_hash() { + assert_eq!( + "#1a2b3c".parse::().unwrap(), + Rgb::new(0x1a, 0x2b, 0x3c) + ); +} + +#[test] +fn parses_long_form_without_hash() { + assert_eq!("ffcc00".parse::().unwrap(), Rgb::new(0xff, 0xcc, 0x00)); +} + +#[test] +fn parses_short_form() { + assert_eq!("#abc".parse::().unwrap(), Rgb::new(0xaa, 0xbb, 0xcc)); + assert_eq!("#fff".parse::().unwrap(), Rgb::new(0xff, 0xff, 0xff)); + assert_eq!("#000".parse::().unwrap(), Rgb::new(0, 0, 0)); +} + +#[test] +fn parses_uppercase() { + assert_eq!( + "#1A2B3C".parse::().unwrap(), + Rgb::new(0x1a, 0x2b, 0x3c) + ); +} + +#[test] +fn rejects_bad_length() { + assert_eq!("".parse::(), Err(ParseRgbError::Length)); + assert_eq!("#".parse::(), Err(ParseRgbError::Length)); + assert_eq!("#12345".parse::(), Err(ParseRgbError::Length)); + assert_eq!("#1234567".parse::(), Err(ParseRgbError::Length)); +} + +#[test] +fn rejects_bad_digits() { + assert_eq!("#gggggg".parse::(), Err(ParseRgbError::Digit)); + assert_eq!("xyz".parse::(), Err(ParseRgbError::Digit)); + assert_eq!("#12 45 ".parse::(), Err(ParseRgbError::Digit)); +} + +#[test] +fn rejects_non_ascii() { + // 3 bytes, but not hex digits; must not panic on char boundaries. + assert_eq!("ä1".parse::(), Err(ParseRgbError::Digit)); +} + +#[test] +fn to_hex_roundtrip() { + let c = Rgb::new(0x28, 0x2c, 0x34); + assert_eq!(c.to_hex(), "#282c34"); + assert_eq!(c.to_hex().parse::().unwrap(), c); + assert_eq!(format!("{c}"), "#282c34"); +} + +#[test] +fn luminance_extremes() { + assert_eq!(Rgb::new(0, 0, 0).luminance(), 0.0); + assert!((Rgb::new(255, 255, 255).luminance() - 1.0).abs() < 1e-9); + let mid = Rgb::new(128, 128, 128).luminance(); + assert!(mid > 0.4 && mid < 0.6); +} diff --git a/crates/theme/tests/scheme.rs b/crates/theme/tests/scheme.rs new file mode 100644 index 0000000..545e958 --- /dev/null +++ b/crates/theme/tests/scheme.rs @@ -0,0 +1,30 @@ +use super::*; + +fn sample() -> Scheme { + Scheme { + name: "sample", + ansi: [Rgb::new(1, 2, 3); 16], + foreground: Rgb::new(0xee, 0xee, 0xee), + background: Rgb::new(0x10, 0x10, 0x10), + cursor: Rgb::new(0xff, 0xff, 0xff), + cursor_text: Rgb::new(0, 0, 0), + selection_foreground: Rgb::new(0xff, 0xff, 0xff), + selection_background: Rgb::new(0x33, 0x44, 0x55), + } +} + +#[test] +fn fields_accessible() { + let s = sample(); + assert_eq!(s.name, "sample"); + assert_eq!(s.ansi[15], Rgb::new(1, 2, 3)); + assert_eq!(s.selection_background, Rgb::new(0x33, 0x44, 0x55)); +} + +#[test] +fn dark_detection() { + let mut s = sample(); + assert!(s.is_dark()); + s.background = Rgb::new(0xff, 0xff, 0xff); + assert!(!s.is_dark()); +} diff --git a/crates/vt/Cargo.toml b/crates/vt/Cargo.toml index 54ef103..289a3b0 100644 --- a/crates/vt/Cargo.toml +++ b/crates/vt/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "vt" version.workspace = true edition.workspace = true @@ -8,3 +9,13 @@ license.workspace = true bitflags = "2" unicode-width = "0.2" vte = "0.13" + +# Integration tests (the moved unit tests are compiled in-crate via #[path], +# so auto-discovery is off; declare the real integration tests explicitly). +[[test]] +name = "sequences" +path = "tests/sequences.rs" + +[[test]] +name = "throughput" +path = "tests/throughput.rs" diff --git a/crates/vt/src/cell.rs b/crates/vt/src/cell.rs index 51f7d63..2136a69 100644 --- a/crates/vt/src/cell.rs +++ b/crates/vt/src/cell.rs @@ -81,43 +81,5 @@ impl Cell { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_cell_is_blank() { - let c = Cell::default(); - assert_eq!(c.ch, ' '); - assert_eq!(c.fg, Color::Default); - assert_eq!(c.bg, Color::Default); - assert!(c.flags.is_empty()); - } - - #[test] - fn erased_keeps_pen_background_only() { - let mut pen = Cell::default(); - pen.bg = Color::Indexed(4); - pen.fg = Color::Indexed(1); - pen.flags = CellFlags::BOLD | CellFlags::UNDERLINE; - let e = Cell::erased(pen); - assert_eq!(e.bg, Color::Indexed(4)); - assert_eq!(e.fg, Color::Default); - assert_eq!(e.ch, ' '); - assert!(e.flags.is_empty()); - } - - #[test] - fn any_underline_covers_all_styles() { - assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::UNDERLINE)); - assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::DOUBLE_UNDERLINE)); - assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::CURLY_UNDERLINE)); - assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::DOTTED_UNDERLINE)); - assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::DASHED_UNDERLINE)); - assert!(!CellFlags::ANY_UNDERLINE.contains(CellFlags::STRIKETHROUGH)); - } - - #[test] - fn cell_is_small() { - assert!(std::mem::size_of::() <= 24); - } -} +#[path = "../tests/cell.rs"] +mod tests; diff --git a/crates/vt/src/charset.rs b/crates/vt/src/charset.rs index 405d663..8844afa 100644 --- a/crates/vt/src/charset.rs +++ b/crates/vt/src/charset.rs @@ -85,46 +85,5 @@ pub fn dec_special(c: char) -> char { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ascii_passes_through() { - let cs = Charsets::default(); - assert_eq!(cs.map('q'), 'q'); - assert_eq!(cs.map('A'), 'A'); - } - - #[test] - fn dec_special_maps_line_drawing() { - let cs = Charsets { - g0: Charset::DecSpecial, - ..Default::default() - }; - assert_eq!(cs.map('q'), '─'); - assert_eq!(cs.map('x'), '│'); - assert_eq!(cs.map('l'), '┌'); - assert_eq!(cs.map('j'), '┘'); - assert_eq!(cs.map('A'), 'A'); - } - - #[test] - fn shift_out_selects_g1() { - let mut cs = Charsets { - g1: Charset::DecSpecial, - ..Default::default() - }; - assert_eq!(cs.map('q'), 'q'); - cs.shifted = true; - assert_eq!(cs.map('q'), '─'); - cs.shifted = false; - assert_eq!(cs.map('q'), 'q'); - } - - #[test] - fn final_byte_designation() { - assert_eq!(from_final(b'0'), Charset::DecSpecial); - assert_eq!(from_final(b'B'), Charset::Ascii); - assert_eq!(from_final(b'A'), Charset::Ascii); - } -} +#[path = "../tests/charset.rs"] +mod tests; diff --git a/crates/vt/src/color.rs b/crates/vt/src/color.rs index 39d5f75..7a47666 100644 --- a/crates/vt/src/color.rs +++ b/crates/vt/src/color.rs @@ -49,19 +49,5 @@ impl From for Color { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_is_default_variant() { - assert_eq!(Color::default(), Color::Default); - } - - #[test] - fn named_maps_to_indexed() { - assert_eq!(Color::from(NamedColor::Black), Color::Indexed(0)); - assert_eq!(Color::from(NamedColor::Red), Color::Indexed(1)); - assert_eq!(Color::from(NamedColor::BrightWhite), Color::Indexed(15)); - assert_eq!(NamedColor::BrightBlack.index(), 8); - } -} +#[path = "../tests/color.rs"] +mod tests; diff --git a/crates/vt/src/cursor.rs b/crates/vt/src/cursor.rs index 3d79171..300e04b 100644 --- a/crates/vt/src/cursor.rs +++ b/crates/vt/src/cursor.rs @@ -56,50 +56,5 @@ impl CursorStyle { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_cursor_is_home() { - let c = Cursor::default(); - assert_eq!((c.row, c.col), (0, 0)); - assert!(!c.pending_wrap); - } - - #[test] - fn decscusr_mapping() { - assert_eq!( - CursorStyle::from_decscusr(0), - Some(CursorStyle::BlinkingBlock) - ); - assert_eq!( - CursorStyle::from_decscusr(1), - Some(CursorStyle::BlinkingBlock) - ); - assert_eq!( - CursorStyle::from_decscusr(2), - Some(CursorStyle::SteadyBlock) - ); - assert_eq!( - CursorStyle::from_decscusr(3), - Some(CursorStyle::BlinkingUnderline) - ); - assert_eq!( - CursorStyle::from_decscusr(4), - Some(CursorStyle::SteadyUnderline) - ); - assert_eq!( - CursorStyle::from_decscusr(5), - Some(CursorStyle::BlinkingBar) - ); - assert_eq!(CursorStyle::from_decscusr(6), Some(CursorStyle::SteadyBar)); - assert_eq!(CursorStyle::from_decscusr(7), None); - } - - #[test] - fn saved_cursor_default_is_home() { - let s = SavedCursor::default(); - assert_eq!((s.row, s.col), (0, 0)); - assert!(!s.origin); - } -} +#[path = "../tests/cursor.rs"] +mod tests; diff --git a/crates/vt/src/grid/damage.rs b/crates/vt/src/grid/damage.rs index ad83862..11b657d 100644 --- a/crates/vt/src/grid/damage.rs +++ b/crates/vt/src/grid/damage.rs @@ -74,50 +74,5 @@ impl Default for DamageTracker { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fresh_tracker_is_full() { - let mut d = DamageTracker::new(); - assert!(d.is_full()); - assert_eq!(d.take(), Damage::Full); - assert_eq!(d.take(), Damage::Rows(vec![])); - } - - #[test] - fn rows_accumulate_sorted_and_deduped() { - let mut d = DamageTracker::new(); - d.take(); - d.mark_row(5); - d.mark_row(1); - d.mark_row(5); - assert_eq!(d.take(), Damage::Rows(vec![1, 5])); - assert_eq!(d.take(), Damage::Rows(vec![])); - } - - #[test] - fn full_overrides_rows() { - let mut d = DamageTracker::new(); - d.take(); - d.mark_row(2); - d.mark_full(); - assert_eq!(d.take(), Damage::Full); - assert_eq!(d.take(), Damage::Rows(vec![])); - } - - #[test] - fn mark_row_while_full_is_noop() { - let mut d = DamageTracker::new(); - d.mark_row(3); - assert_eq!(d.take(), Damage::Full); - assert_eq!(d.take(), Damage::Rows(vec![])); - } - - #[test] - fn is_empty() { - assert!(Damage::Rows(vec![]).is_empty()); - assert!(!Damage::Rows(vec![0]).is_empty()); - assert!(!Damage::Full.is_empty()); - } -} +#[path = "../../tests/grid/damage.rs"] +mod tests; diff --git a/crates/vt/src/grid/mod.rs b/crates/vt/src/grid/mod.rs index 90b6911..708db1f 100644 --- a/crates/vt/src/grid/mod.rs +++ b/crates/vt/src/grid/mod.rs @@ -164,127 +164,5 @@ impl Grid { } #[cfg(test)] -mod tests { - use super::*; - - fn grid_with_letters(rows: usize) -> Grid { - let mut g = Grid::new(4, rows, 100); - for r in 0..rows { - g.cell_mut(r, 0).ch = (b'a' + r as u8) as char; - } - g - } - - #[test] - fn new_grid_dimensions() { - let g = Grid::new(80, 24, 10); - assert_eq!(g.cols(), 80); - assert_eq!(g.rows(), 24); - assert_eq!(g.row(0).len(), 80); - } - - #[test] - fn scroll_up_moves_rows_and_blanks_bottom() { - let mut g = grid_with_letters(3); - g.scroll_up(0, 2, 1, false, Cell::default()); - assert_eq!(g.row(0).text(), "b"); - assert_eq!(g.row(1).text(), "c"); - assert_eq!(g.row(2).text(), ""); - } - - #[test] - fn scroll_up_saves_to_scrollback() { - let mut g = grid_with_letters(3); - g.scroll_up(0, 2, 2, true, Cell::default()); - assert_eq!(g.scrollback().len(), 2); - assert_eq!(g.scrollback().get(0).unwrap().text(), "a"); - assert_eq!(g.scrollback().get(1).unwrap().text(), "b"); - assert_eq!(g.row(0).text(), "c"); - } - - #[test] - fn scroll_up_region_only() { - let mut g = grid_with_letters(4); - g.scroll_up(1, 2, 1, false, Cell::default()); - assert_eq!(g.row(0).text(), "a"); - assert_eq!(g.row(1).text(), "c"); - assert_eq!(g.row(2).text(), ""); - assert_eq!(g.row(3).text(), "d"); - } - - #[test] - fn scroll_down_moves_rows_and_blanks_top() { - let mut g = grid_with_letters(3); - g.scroll_down(0, 2, 1, Cell::default()); - assert_eq!(g.row(0).text(), ""); - assert_eq!(g.row(1).text(), "a"); - assert_eq!(g.row(2).text(), "b"); - } - - #[test] - fn scroll_clamps_oversized_count() { - let mut g = grid_with_letters(3); - g.scroll_up(0, 2, 99, false, Cell::default()); - assert_eq!(g.row(0).text(), ""); - assert_eq!(g.row(2).text(), ""); - } - - #[test] - fn resize_grows_and_shrinks() { - let mut g = grid_with_letters(3); - g.resize(8, 5); - assert_eq!(g.cols(), 8); - assert_eq!(g.rows(), 5); - assert_eq!(g.row(0).text(), "a"); - assert_eq!(g.row(4).text(), ""); - g.resize(2, 2); - assert_eq!(g.cols(), 2); - assert_eq!(g.rows(), 2); - assert_eq!(g.row(1).text(), "b"); - } - - #[test] - fn resize_clamps_to_one() { - let mut g = Grid::new(4, 4, 0); - g.resize(0, 0); - assert_eq!(g.cols(), 1); - assert_eq!(g.rows(), 1); - } - - #[test] - fn fresh_grid_is_fully_damaged() { - let mut g = Grid::new(4, 4, 0); - assert_eq!(g.take_damage(), Damage::Full); - assert_eq!(g.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn cell_and_row_mutation_mark_rows() { - let mut g = Grid::new(4, 4, 0); - g.take_damage(); - g.cell_mut(2, 1).ch = 'x'; - g.row_mut(0).fill(Cell::default()); - assert_eq!(g.take_damage(), Damage::Rows(vec![0, 2])); - assert_eq!(g.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn scroll_escalates_to_full() { - let mut g = grid_with_letters(3); - g.take_damage(); - g.scroll_up(0, 2, 1, false, Cell::default()); - assert_eq!(g.take_damage(), Damage::Full); - g.scroll_down(0, 2, 1, Cell::default()); - assert_eq!(g.take_damage(), Damage::Full); - } - - #[test] - fn resize_and_scrollback_clear_escalate_to_full() { - let mut g = grid_with_letters(3); - g.take_damage(); - g.resize(8, 5); - assert_eq!(g.take_damage(), Damage::Full); - g.clear_scrollback(); - assert_eq!(g.take_damage(), Damage::Full); - } -} +#[path = "../../tests/grid/mod.rs"] +mod tests; diff --git a/crates/vt/src/grid/row.rs b/crates/vt/src/grid/row.rs index 7b3c8fd..24aaa8d 100644 --- a/crates/vt/src/grid/row.rs +++ b/crates/vt/src/grid/row.rs @@ -64,46 +64,5 @@ impl Row { } #[cfg(test)] -mod tests { - use super::*; - use crate::cell::CellFlags; - use crate::color::Color; - - #[test] - fn new_row_is_blank() { - let r = Row::new(5); - assert_eq!(r.len(), 5); - assert_eq!(r.text(), ""); - assert!(!r.wrapped); - } - - #[test] - fn fill_resets_wrap() { - let mut r = Row::new(3); - r.wrapped = true; - r.fill(Cell::default()); - assert!(!r.wrapped); - } - - #[test] - fn resize_pads_and_truncates() { - let mut r = Row::new(3); - let mut blank = Cell::default(); - blank.bg = Color::Indexed(2); - r.resize(6, blank); - assert_eq!(r.len(), 6); - assert_eq!(r.cells[5].bg, Color::Indexed(2)); - r.resize(2, blank); - assert_eq!(r.len(), 2); - } - - #[test] - fn text_skips_wide_spacers() { - let mut r = Row::new(4); - r.cells[0].ch = '漢'; - r.cells[0].flags.insert(CellFlags::WIDE); - r.cells[1].flags.insert(CellFlags::WIDE_SPACER); - r.cells[2].ch = 'x'; - assert_eq!(r.text(), "漢x"); - } -} +#[path = "../../tests/grid/row.rs"] +mod tests; diff --git a/crates/vt/src/grid/scrollback.rs b/crates/vt/src/grid/scrollback.rs index 93842fd..55adf4f 100644 --- a/crates/vt/src/grid/scrollback.rs +++ b/crates/vt/src/grid/scrollback.rs @@ -69,57 +69,5 @@ impl Scrollback { } #[cfg(test)] -mod tests { - use super::*; - - fn marked(ch: char) -> Row { - let mut r = Row::new(2); - r.cells[0].ch = ch; - r - } - - #[test] - fn pushes_in_order() { - let mut sb = Scrollback::new(10); - sb.push(marked('a')); - sb.push(marked('b')); - assert_eq!(sb.len(), 2); - assert_eq!(sb.get(0).unwrap().text(), "a"); - assert_eq!(sb.get(1).unwrap().text(), "b"); - } - - #[test] - fn evicts_oldest_at_limit() { - let mut sb = Scrollback::new(2); - sb.push(marked('a')); - sb.push(marked('b')); - sb.push(marked('c')); - assert_eq!(sb.len(), 2); - assert_eq!(sb.get(0).unwrap().text(), "b"); - assert_eq!(sb.get(1).unwrap().text(), "c"); - } - - #[test] - fn zero_limit_stores_nothing() { - let mut sb = Scrollback::new(0); - sb.push(marked('a')); - assert!(sb.is_empty()); - } - - #[test] - fn clear_empties() { - let mut sb = Scrollback::new(5); - sb.push(marked('a')); - sb.clear(); - assert!(sb.is_empty()); - assert_eq!(sb.limit(), 5); - } - - #[test] - fn resize_rows_changes_width() { - let mut sb = Scrollback::new(5); - sb.push(marked('a')); - sb.resize_rows(7); - assert_eq!(sb.get(0).unwrap().len(), 7); - } -} +#[path = "../../tests/grid/scrollback.rs"] +mod tests; diff --git a/crates/vt/src/hyperlink.rs b/crates/vt/src/hyperlink.rs index 4b7ecbf..7eb429d 100644 --- a/crates/vt/src/hyperlink.rs +++ b/crates/vt/src/hyperlink.rs @@ -59,39 +59,5 @@ impl Hyperlinks { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn intern_dedupes_identical_links() { - let mut h = Hyperlinks::default(); - let a = h.intern(Some("x".into()), "https://a".into()).unwrap(); - let b = h.intern(Some("x".into()), "https://a".into()).unwrap(); - assert_eq!(a, b); - assert_eq!(h.get(a).unwrap().uri, "https://a"); - assert_eq!(h.get(a).unwrap().id.as_deref(), Some("x")); - } - - #[test] - fn distinct_links_get_distinct_ids() { - let mut h = Hyperlinks::default(); - let a = h.intern(None, "https://a".into()).unwrap(); - let b = h.intern(None, "https://b".into()).unwrap(); - // Same uri but different id parameter is also distinct. - let c = h.intern(Some("g".into()), "https://a".into()).unwrap(); - assert_ne!(a, b); - assert_ne!(a, c); - assert_eq!(h.get(b).unwrap().uri, "https://b"); - } - - #[test] - fn clear_empties_the_registry() { - let mut h = Hyperlinks::default(); - let a = h.intern(None, "https://a".into()).unwrap(); - h.clear(); - assert!(h.get(a).is_none()); - // Ids restart after a clear. - let b = h.intern(None, "https://b".into()).unwrap(); - assert_eq!(a, b); - } -} +#[path = "../tests/hyperlink.rs"] +mod tests; diff --git a/crates/vt/src/kitty.rs b/crates/vt/src/kitty.rs index a85af1c..a27f39f 100644 --- a/crates/vt/src/kitty.rs +++ b/crates/vt/src/kitty.rs @@ -67,54 +67,5 @@ impl KittyKeyboard { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_stack_is_legacy() { - let k = KittyKeyboard::default(); - assert_eq!(k.current(), 0); - } - - #[test] - fn push_and_pop() { - let mut k = KittyKeyboard::default(); - k.push(flags::DISAMBIGUATE); - assert_eq!(k.current(), 1); - k.push(flags::DISAMBIGUATE | flags::REPORT_EVENT_TYPES); - assert_eq!(k.current(), 3); - k.pop(1); - assert_eq!(k.current(), 1); - k.pop(9); // clamps - assert_eq!(k.current(), 0); - } - - #[test] - fn set_modes() { - let mut k = KittyKeyboard::default(); - k.set(0b0001, 1); // set - assert_eq!(k.current(), 0b0001); - k.set(0b0010, 2); // add - assert_eq!(k.current(), 0b0011); - k.set(0b0001, 3); // clear - assert_eq!(k.current(), 0b0010); - } - - #[test] - fn flags_are_masked_to_defined_bits() { - let mut k = KittyKeyboard::default(); - k.push(0xff); - assert_eq!(k.current(), flags::ALL); - } - - #[test] - fn depth_is_bounded() { - let mut k = KittyKeyboard::default(); - for _ in 0..(MAX_DEPTH + 5) { - k.push(flags::DISAMBIGUATE); - } - // Still only MAX_DEPTH entries: popping that many empties it. - k.pop(MAX_DEPTH); - assert_eq!(k.current(), 0); - } -} +#[path = "../tests/kitty.rs"] +mod tests; diff --git a/crates/vt/src/mode.rs b/crates/vt/src/mode.rs index 60d4904..7585aa5 100644 --- a/crates/vt/src/mode.rs +++ b/crates/vt/src/mode.rs @@ -75,25 +75,5 @@ impl Default for Modes { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_have_autowrap_and_visible_cursor() { - let m = Modes::default(); - assert!(m.contains(Modes::AUTOWRAP)); - assert!(m.contains(Modes::CURSOR_VISIBLE)); - assert!(!m.contains(Modes::ORIGIN)); - assert!(!m.contains(Modes::ALT_SCREEN)); - assert!(!m.contains(Modes::INSERT)); - } - - #[test] - fn set_and_clear() { - let mut m = Modes::default(); - m.insert(Modes::BRACKETED_PASTE); - assert!(m.contains(Modes::BRACKETED_PASTE)); - m.remove(Modes::BRACKETED_PASTE); - assert!(!m.contains(Modes::BRACKETED_PASTE)); - } -} +#[path = "../tests/mode.rs"] +mod tests; diff --git a/crates/vt/src/screen.rs b/crates/vt/src/screen.rs index 37cf331..b088455 100644 --- a/crates/vt/src/screen.rs +++ b/crates/vt/src/screen.rs @@ -83,50 +83,5 @@ fn default_tabs(cols: usize) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_tab_stops_every_eight() { - let s = Screen::new(20, 4, 0); - assert_eq!(s.next_tab(0), 8); - assert_eq!(s.next_tab(8), 16); - assert_eq!(s.next_tab(16), 19); - assert_eq!(s.prev_tab(19), 16); - assert_eq!(s.prev_tab(8), 0); - assert_eq!(s.prev_tab(3), 0); - } - - #[test] - fn custom_tab_stops() { - let mut s = Screen::new(20, 4, 0); - s.clear_all_tabs(); - s.set_tab(5); - assert_eq!(s.next_tab(0), 5); - assert_eq!(s.next_tab(5), 19); - s.clear_tab(5); - assert_eq!(s.next_tab(0), 19); - } - - #[test] - fn new_screen_region_is_full() { - let s = Screen::new(10, 5, 0); - assert_eq!(s.scroll_top, 0); - assert_eq!(s.scroll_bottom, 4); - } - - #[test] - fn resize_clamps_cursor_and_resets_region() { - let mut s = Screen::new(10, 5, 0); - s.cursor.row = 4; - s.cursor.col = 9; - s.scroll_top = 1; - s.scroll_bottom = 3; - s.resize(4, 2); - assert_eq!(s.cursor.row, 1); - assert_eq!(s.cursor.col, 3); - assert_eq!(s.scroll_top, 0); - assert_eq!(s.scroll_bottom, 1); - assert_eq!(s.tabs.len(), 4); - } -} +#[path = "../tests/screen.rs"] +mod tests; diff --git a/crates/vt/src/search.rs b/crates/vt/src/search.rs index 5e1e936..68b4dd4 100644 --- a/crates/vt/src/search.rs +++ b/crates/vt/src/search.rs @@ -54,40 +54,5 @@ pub fn in_row( } #[cfg(test)] -mod tests { - use super::*; - - fn run(needle: &str, row: &str, fold: bool) -> Vec<(usize, usize)> { - let chars: Vec = row.chars().collect(); - let cols: Vec = (0..chars.len()).collect(); - let needle: Vec = needle.chars().collect(); - in_row(&needle, &chars, &cols, 0, fold, |_| false) - .into_iter() - .map(|m| (m.start_col, m.end_col)) - .collect() - } - - #[test] - fn finds_all_occurrences() { - assert_eq!(run("ab", "abXabYab", false), [(0, 1), (3, 4), (6, 7)]); - } - - #[test] - fn case_insensitive() { - assert_eq!(run("hi", "HI there hi", true), [(0, 1), (9, 10)]); - assert_eq!(run("hi", "HI there hi", false), [(9, 10)]); - } - - #[test] - fn no_match_and_empty() { - assert!(run("zz", "abc", false).is_empty()); - assert!(run("", "abc", false).is_empty()); - assert!(run("abcd", "abc", false).is_empty()); - } - - #[test] - fn non_overlapping() { - // "aa" in "aaaa" yields two matches, not three. - assert_eq!(run("aa", "aaaa", false), [(0, 1), (2, 3)]); - } -} +#[path = "../tests/search.rs"] +mod tests; diff --git a/crates/vt/src/selection.rs b/crates/vt/src/selection.rs index f0d08e6..f19b7fc 100644 --- a/crates/vt/src/selection.rs +++ b/crates/vt/src/selection.rs @@ -237,190 +237,5 @@ fn next_point(grid: &Grid, p: Point) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::cell::{Cell, CellFlags}; - - const EXTRA: &[char] = &['/', '-', '_', '.', '~']; - - fn put(grid: &mut Grid, row: usize, text: &str) { - for (c, ch) in text.chars().enumerate() { - grid.cell_mut(row, c).ch = ch; - } - } - - fn grid_3x10(lines: &[&str]) -> Grid { - let mut g = Grid::new(10, lines.len(), 100); - for (r, l) in lines.iter().enumerate() { - put(&mut g, r, l); - } - g - } - - fn span(s: (isize, usize), e: (isize, usize)) -> (Point, Point) { - (Point::new(s.0, s.1), Point::new(e.0, e.1)) - } - - #[test] - fn point_ordering_is_reading_order() { - assert!(Point::new(-1, 9) < Point::new(0, 0)); - assert!(Point::new(0, 1) < Point::new(0, 2)); - assert!(Point::new(1, 0) > Point::new(0, 9)); - } - - #[test] - fn normalization_orders_start_end() { - // Anchor below/after the extent. - let mut sel = Selection::new(SelectionMode::Cell, span((2, 5), (2, 5))); - sel.update(span((0, 3), (0, 3))); - assert_eq!(sel.start(), Point::new(0, 3)); - assert_eq!(sel.end(), Point::new(2, 5)); - // Extent moves past the anchor the other way. - sel.update(span((3, 1), (3, 1))); - assert_eq!(sel.start(), Point::new(2, 5)); - assert_eq!(sel.end(), Point::new(3, 1)); - } - - #[test] - fn contains_streams_across_rows() { - let mut sel = Selection::new(SelectionMode::Cell, span((0, 4), (0, 4))); - sel.update(span((2, 2), (2, 2))); - assert!(sel.contains(Point::new(0, 4))); - assert!(!sel.contains(Point::new(0, 3))); - assert!(sel.contains(Point::new(1, 0))); // middle row fully selected - assert!(sel.contains(Point::new(1, 9))); - assert!(sel.contains(Point::new(2, 2))); - assert!(!sel.contains(Point::new(2, 3))); - } - - #[test] - fn shift_lines_moves_all_points() { - let mut sel = Selection::new(SelectionMode::Cell, span((1, 0), (2, 9))); - sel.shift_lines(-3); - assert_eq!(sel.start(), Point::new(-2, 0)); - assert_eq!(sel.end(), Point::new(-1, 9)); - } - - #[test] - fn word_expansion_within_row() { - let g = grid_3x10(&["foo bar.gz"]); - let (s, e) = expand_word(&g, Point::new(0, 5), EXTRA); - assert_eq!((s, e), span((0, 4), (0, 9))); // "bar.gz": '.' is a word char - let (s, e) = expand_word(&g, Point::new(0, 1), EXTRA); - assert_eq!((s, e), span((0, 0), (0, 2))); - } - - #[test] - fn word_expansion_on_non_word_is_single_cell() { - let g = grid_3x10(&["foo bar"]); - let (s, e) = expand_word(&g, Point::new(0, 3), EXTRA); - assert_eq!((s, e), span((0, 3), (0, 3))); - } - - #[test] - fn word_expansion_crosses_soft_wrap() { - let mut g = Grid::new(4, 2, 10); - put(&mut g, 0, " abc"); - put(&mut g, 1, "de f"); - g.row_mut(0).wrapped = true; - let (s, e) = expand_word(&g, Point::new(0, 2), EXTRA); - assert_eq!((s, e), span((0, 1), (1, 1))); - // Without the wrap flag the word stops at the row edge. - g.row_mut(0).wrapped = false; - let (s, e) = expand_word(&g, Point::new(0, 2), EXTRA); - assert_eq!((s, e), span((0, 1), (0, 3))); - } - - /// "a漢 b" laid out as a real grid: head at 1, spacer at 2, space at - /// 3, 'b' at 4. - fn wide_grid() -> Grid { - let mut g = Grid::new(10, 1, 0); - g.cell_mut(0, 0).ch = 'a'; - g.cell_mut(0, 1).ch = '漢'; - g.cell_mut(0, 1).flags.insert(CellFlags::WIDE); - g.cell_mut(0, 2).flags.insert(CellFlags::WIDE_SPACER); - g.cell_mut(0, 4).ch = 'b'; - g - } - - #[test] - fn word_expansion_treats_wide_spacer_as_its_head() { - let g = wide_grid(); - let (s, e) = expand_word(&g, Point::new(0, 0), EXTRA); - assert_eq!((s, e), span((0, 0), (0, 2))); // spans head + spacer - // Starting on the spacer itself classifies as the head. - let (s, e) = expand_word(&g, Point::new(0, 2), EXTRA); - assert_eq!((s, e), span((0, 0), (0, 2))); - } - - #[test] - fn line_expansion_follows_wraps_both_ways() { - let mut g = grid_3x10(&["aaaaaaaaaa", "bbbbbbbbbb", "cc"]); - g.row_mut(0).wrapped = true; - g.row_mut(1).wrapped = true; - let (s, e) = expand_line(&g, Point::new(1, 4)); - assert_eq!((s, e), span((0, 0), (2, 9))); - // An unwrapped row is its own logical line. - let g2 = grid_3x10(&["a", "b", "c"]); - let (s, e) = expand_line(&g2, Point::new(1, 4)); - assert_eq!((s, e), span((1, 0), (1, 9))); - } - - #[test] - fn line_expansion_reaches_into_scrollback() { - let mut g = Grid::new(4, 2, 10); - put(&mut g, 0, "wrap"); - g.row_mut(0).wrapped = true; - g.scroll_up(0, 1, 1, true, Cell::default()); // "wrap" -> scrollback - put(&mut g, 0, "tail"); - let (s, e) = expand_line(&g, Point::new(0, 1)); - assert_eq!((s, e), span((-1, 0), (0, 3))); - } - - #[test] - fn text_joins_soft_wraps_without_newline() { - let mut g = grid_3x10(&["aaaaaaaaaa", "bb", "cc"]); - g.row_mut(0).wrapped = true; - let sel = Selection::new(SelectionMode::Cell, span((0, 0), (2, 9))); - assert_eq!(text(&g, &sel), "aaaaaaaaaabb\ncc"); - } - - #[test] - fn text_trims_trailing_whitespace_per_hard_line() { - let g = grid_3x10(&["hi ", "there "]); - let sel = Selection::new(SelectionMode::Cell, span((0, 0), (1, 9))); - assert_eq!(text(&g, &sel), "hi\nthere"); - } - - #[test] - fn text_skips_wide_spacers() { - let g = wide_grid(); - let sel = Selection::new(SelectionMode::Cell, span((0, 0), (0, 4))); - assert_eq!(text(&g, &sel), "a漢 b"); - } - - #[test] - fn text_partial_first_and_last_rows() { - let g = grid_3x10(&["abcdefghij", "klmnopqrst"]); - let sel = Selection::new(SelectionMode::Cell, span((0, 7), (1, 2))); - assert_eq!(text(&g, &sel), "hij\nklm"); - } - - #[test] - fn text_spans_scrollback_and_live() { - let mut g = Grid::new(4, 2, 10); - put(&mut g, 0, "old"); - g.scroll_up(0, 1, 1, true, Cell::default()); - put(&mut g, 0, "new"); - let sel = Selection::new(SelectionMode::Cell, span((-1, 0), (0, 3))); - assert_eq!(text(&g, &sel), "old\nnew"); - } - - #[test] - fn clamp_point_bounds() { - let mut g = Grid::new(4, 2, 10); - g.scroll_up(0, 1, 1, true, Cell::default()); - assert_eq!(clamp_point(&g, Point::new(-9, 99)), Point::new(-1, 3)); - assert_eq!(clamp_point(&g, Point::new(9, 0)), Point::new(1, 0)); - } -} +#[path = "../tests/selection.rs"] +mod tests; diff --git a/crates/vt/src/sgr.rs b/crates/vt/src/sgr.rs index 48e9c42..f80e934 100644 --- a/crates/vt/src/sgr.rs +++ b/crates/vt/src/sgr.rs @@ -146,154 +146,5 @@ fn clamp8(v: u16) -> u8 { } #[cfg(test)] -mod tests { - use super::*; - - fn pen_after(params: &[&[u16]]) -> Cell { - let mut pen = Cell::default(); - apply(&mut pen, params.iter().copied()); - pen - } - - #[test] - fn empty_resets() { - let mut pen = Cell::default(); - pen.flags.insert(CellFlags::BOLD); - pen.fg = Color::Indexed(1); - apply(&mut pen, std::iter::empty()); - assert_eq!(pen, Cell::default()); - } - - #[test] - fn zero_resets() { - let mut pen = Cell::default(); - pen.flags.insert(CellFlags::BOLD | CellFlags::ITALIC); - pen.bg = Color::Rgb(1, 2, 3); - apply(&mut pen, [&[0u16][..]]); - assert_eq!(pen, Cell::default()); - } - - #[test] - fn simple_attributes() { - let pen = pen_after(&[&[1], &[3], &[4], &[7], &[9]]); - assert!(pen.flags.contains(CellFlags::BOLD)); - assert!(pen.flags.contains(CellFlags::ITALIC)); - assert!(pen.flags.contains(CellFlags::UNDERLINE)); - assert!(pen.flags.contains(CellFlags::INVERSE)); - assert!(pen.flags.contains(CellFlags::STRIKETHROUGH)); - } - - #[test] - fn attribute_clears() { - let pen = pen_after(&[&[1], &[2], &[4], &[22], &[24]]); - assert!(!pen.flags.contains(CellFlags::BOLD)); - assert!(!pen.flags.contains(CellFlags::DIM)); - assert!(!pen.flags.contains(CellFlags::UNDERLINE)); - } - - #[test] - fn double_underline_via_21() { - let pen = pen_after(&[&[4], &[21]]); - assert!(pen.flags.contains(CellFlags::DOUBLE_UNDERLINE)); - assert!(!pen.flags.contains(CellFlags::UNDERLINE)); - } - - #[test] - fn underline_styles_colon() { - assert!(pen_after(&[&[4, 2]]) - .flags - .contains(CellFlags::DOUBLE_UNDERLINE)); - assert!(pen_after(&[&[4, 3]]) - .flags - .contains(CellFlags::CURLY_UNDERLINE)); - assert!(pen_after(&[&[4, 4]]) - .flags - .contains(CellFlags::DOTTED_UNDERLINE)); - assert!(pen_after(&[&[4, 5]]) - .flags - .contains(CellFlags::DASHED_UNDERLINE)); - assert!(pen_after(&[&[4, 0]]).flags & CellFlags::ANY_UNDERLINE == CellFlags::empty()); - } - - #[test] - fn named_colors() { - let pen = pen_after(&[&[31], &[44]]); - assert_eq!(pen.fg, Color::Indexed(1)); - assert_eq!(pen.bg, Color::Indexed(4)); - } - - #[test] - fn bright_colors() { - let pen = pen_after(&[&[91], &[104]]); - assert_eq!(pen.fg, Color::Indexed(9)); - assert_eq!(pen.bg, Color::Indexed(12)); - } - - #[test] - fn default_colors() { - let pen = pen_after(&[&[31], &[44], &[39], &[49]]); - assert_eq!(pen.fg, Color::Default); - assert_eq!(pen.bg, Color::Default); - } - - #[test] - fn indexed_semicolon() { - let pen = pen_after(&[&[38], &[5], &[208]]); - assert_eq!(pen.fg, Color::Indexed(208)); - } - - #[test] - fn indexed_colon() { - let pen = pen_after(&[&[38, 5, 123]]); - assert_eq!(pen.fg, Color::Indexed(123)); - } - - #[test] - fn truecolor_semicolon() { - let pen = pen_after(&[&[48], &[2], &[10], &[20], &[30]]); - assert_eq!(pen.bg, Color::Rgb(10, 20, 30)); - } - - #[test] - fn truecolor_colon() { - let pen = pen_after(&[&[38, 2, 1, 2, 3]]); - assert_eq!(pen.fg, Color::Rgb(1, 2, 3)); - } - - #[test] - fn truecolor_colon_with_colorspace() { - let pen = pen_after(&[&[38, 2, 0, 9, 8, 7]]); - assert_eq!(pen.fg, Color::Rgb(9, 8, 7)); - } - - #[test] - fn underline_color() { - let pen = pen_after(&[&[58, 5, 33]]); - assert_eq!(pen.underline_color, Color::Indexed(33)); - let pen = pen_after(&[&[58], &[2], &[7], &[8], &[9]]); - assert_eq!(pen.underline_color, Color::Rgb(7, 8, 9)); - let pen = pen_after(&[&[58, 5, 33], &[59]]); - assert_eq!(pen.underline_color, Color::Default); - } - - #[test] - fn malformed_extended_color_is_ignored() { - let pen = pen_after(&[&[38], &[9]]); - assert_eq!(pen.fg, Color::Default); - let pen = pen_after(&[&[38, 2, 1]]); - assert_eq!(pen.fg, Color::Default); - } - - #[test] - fn values_clamp_to_u8() { - let pen = pen_after(&[&[38, 2, 300, 300, 300]]); - assert_eq!(pen.fg, Color::Rgb(255, 255, 255)); - } - - #[test] - fn parameters_after_color_still_apply() { - let pen = pen_after(&[&[38], &[5], &[10], &[1]]); - assert_eq!(pen.fg, Color::Indexed(10)); - assert!(pen.flags.contains(CellFlags::BOLD)); - } -} +#[path = "../tests/sgr.rs"] +mod tests; diff --git a/crates/vt/src/term/csi.rs b/crates/vt/src/term/csi.rs index 58e64c0..78bde62 100644 --- a/crates/vt/src/term/csi.rs +++ b/crates/vt/src/term/csi.rs @@ -202,126 +202,5 @@ fn set_private_mode(inner: &mut Inner, param: u16, enable: bool) { } #[cfg(test)] -mod tests { - use crate::mode::Modes; - use crate::term::Terminal; - - #[test] - fn cup_moves_cursor() { - let mut t = Terminal::new(10, 5, 0); - t.feed(b"\x1b[3;4H"); - assert_eq!(t.cursor_pos(), (2, 3)); - t.feed(b"\x1b[H"); - assert_eq!(t.cursor_pos(), (0, 0)); - } - - #[test] - fn private_modes_toggle() { - let mut t = Terminal::new(10, 5, 0); - t.feed(b"\x1b[?25l"); - assert!(!t.cursor_visible()); - t.feed(b"\x1b[?25h"); - assert!(t.cursor_visible()); - t.feed(b"\x1b[?2004h"); - assert!(t.modes().contains(Modes::BRACKETED_PASTE)); - t.feed(b"\x1b[?1000h\x1b[?1006h"); - assert!(t.modes().contains(Modes::MOUSE_CLICK)); - assert!(t.modes().contains(Modes::MOUSE_SGR)); - t.feed(b"\x1b[?1000l"); - assert!(!t.modes().contains(Modes::MOUSE_CLICK)); - } - - #[test] - fn insert_mode_shifts_on_print() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"abc\x1b[1G\x1b[4hX"); - assert_eq!(t.row_text(0), "Xabc"); - t.feed(b"\x1b[4l"); - assert!(!t.modes().contains(Modes::INSERT)); - } - - #[test] - fn dsr_five_reports_ok() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[5n"); - assert_eq!(t.take_output(), b"\x1b[0n"); - } - - #[test] - fn da1_reports_vt220() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[c"); - assert_eq!(t.take_output(), b"\x1b[?62;22c"); - } - - #[test] - fn decscusr_stores_style() { - use crate::cursor::CursorStyle; - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[4 q"); - assert_eq!(t.cursor_style(), CursorStyle::SteadyUnderline); - t.feed(b"\x1b[0 q"); - assert_eq!(t.cursor_style(), CursorStyle::BlinkingBlock); - } - - #[test] - fn title_stack_push_pop() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]2;first\x07\x1b[22;0t\x1b]2;second\x07"); - assert_eq!(t.title(), "second"); - t.feed(b"\x1b[23;0t"); - assert_eq!(t.title(), "first"); - } - - #[test] - fn rep_repeats_last_char() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"x\x1b[3b"); - assert_eq!(t.row_text(0), "xxxx"); - } - - #[test] - fn kitty_keyboard_push_pop_set_query() { - let mut t = Terminal::new(10, 3, 0); - assert_eq!(t.kitty_keyboard_flags(), 0); - // Query in legacy mode. - t.feed(b"\x1b[?u"); - assert_eq!(t.take_output(), b"\x1b[?0u"); - // Push disambiguate. - t.feed(b"\x1b[>1u"); - assert_eq!(t.kitty_keyboard_flags(), 1); - t.feed(b"\x1b[?u"); - assert_eq!(t.take_output(), b"\x1b[?1u"); - // Add a bit via set mode 2. - t.feed(b"\x1b[=2;2u"); - assert_eq!(t.kitty_keyboard_flags(), 3); - // Pop back to legacy. - t.feed(b"\x1b[<1u"); - assert_eq!(t.kitty_keyboard_flags(), 0); - } - - #[test] - fn kitty_keyboard_is_per_screen() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[>5u"); // main screen - assert_eq!(t.kitty_keyboard_flags(), 5); - t.feed(b"\x1b[?1049h"); // enter alt: its own (empty) stack - assert_eq!(t.kitty_keyboard_flags(), 0); - t.feed(b"\x1b[?1049l"); // back to main - assert_eq!(t.kitty_keyboard_flags(), 5); - } - - #[test] - fn kitty_keyboard_reset_by_ris() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[>9u\x1bc"); - assert_eq!(t.kitty_keyboard_flags(), 0); - } - - #[test] - fn unknown_csi_is_ignored() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[999z\x1b[?9999hok"); - assert_eq!(t.row_text(0), "ok"); - } -} +#[path = "../../tests/term/csi.rs"] +mod tests; diff --git a/crates/vt/src/term/dcs.rs b/crates/vt/src/term/dcs.rs index 9f479ba..ca1ca0e 100644 --- a/crates/vt/src/term/dcs.rs +++ b/crates/vt/src/term/dcs.rs @@ -73,62 +73,5 @@ fn lookup(name: &[u8]) -> Option> { } #[cfg(test)] -mod tests { - use crate::term::Terminal; - - #[test] - fn xtgettcap_reports_colors() { - let mut t = Terminal::new(10, 3, 0); - // "Co" -> 436f; value "256" -> 323536. - t.feed(b"\x1bP+q436f\x1b\\"); - assert_eq!(t.take_output(), b"\x1bP1+r436f=323536\x1b\\"); - } - - #[test] - fn xtgettcap_reports_terminal_name() { - let mut t = Terminal::new(10, 3, 0); - // "TN" -> 544e. - t.feed(b"\x1bP+q544e\x1b\\"); - let out = t.take_output(); - let want = format!( - "\x1bP1+r544e={}\x1b\\", - super::hex_encode(b"xterm-256color") - ); - assert_eq!(out, want.as_bytes()); - } - - #[test] - fn xtgettcap_boolean_has_no_value() { - let mut t = Terminal::new(10, 3, 0); - // "Tc" -> 5463. - t.feed(b"\x1bP+q5463\x1b\\"); - assert_eq!(t.take_output(), b"\x1bP1+r5463\x1b\\"); - } - - #[test] - fn xtgettcap_unknown_is_zero() { - let mut t = Terminal::new(10, 3, 0); - // "zz" -> 7a7a. - t.feed(b"\x1bP+q7a7a\x1b\\"); - assert_eq!(t.take_output(), b"\x1bP0+r7a7a\x1b\\"); - } - - #[test] - fn xtgettcap_multiple_caps() { - let mut t = Terminal::new(10, 3, 0); - // "Co;RGB" -> 436f;524742. - t.feed(b"\x1bP+q436f;524742\x1b\\"); - assert_eq!( - t.take_output(), - b"\x1bP1+r436f=323536\x1b\\\x1bP1+r524742=382f382f38\x1b\\" - ); - } - - #[test] - fn non_xtgettcap_dcs_ignored() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1bPsomething\x1b\\ok"); - assert!(t.take_output().is_empty()); - assert_eq!(t.row_text(0), "ok"); - } -} +#[path = "../../tests/term/dcs.rs"] +mod tests; diff --git a/crates/vt/src/term/mod.rs b/crates/vt/src/term/mod.rs index b6dfc03..c2e38dd 100644 --- a/crates/vt/src/term/mod.rs +++ b/crates/vt/src/term/mod.rs @@ -485,347 +485,5 @@ impl Terminal { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn construction_defaults() { - let t = Terminal::new(80, 24, 100); - assert_eq!(t.cols(), 80); - assert_eq!(t.rows(), 24); - assert!(!t.is_alt_screen()); - assert!(t.cursor_visible()); - assert_eq!(t.cursor_pos(), (0, 0)); - assert_eq!(t.title(), ""); - assert_eq!(t.cursor_style(), CursorStyle::BlinkingBlock); - } - - #[test] - fn feed_prints_text() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"hi"); - assert_eq!(t.row_text(0), "hi"); - assert_eq!(t.cursor_pos(), (0, 2)); - } - - #[test] - fn visible_rows_with_offset() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc\r\nd"); - // "a" and "b" scrolled into history. - assert_eq!(t.row_text(0), "c"); - t.set_display_offset(2); - assert_eq!(t.row_text(0), "a"); - assert_eq!(t.row_text(1), "b"); - t.set_display_offset(99); - assert_eq!(t.display_offset(), 2); - } - - #[test] - fn display_offset_stays_stable_as_output_arrives() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc\r\nd"); // scrollback: a, b - t.set_display_offset(2); - assert_eq!(t.row_text(0), "a"); - t.feed(b"\r\ne"); // "c" scrolls into history - assert_eq!(t.display_offset(), 3); - assert_eq!(t.row_text(0), "a"); // view did not shift - assert_eq!(t.row_text(1), "b"); - } - - #[test] - fn display_offset_clamps_when_ring_evicts() { - let mut t = Terminal::new(4, 2, 2); - t.feed(b"a\r\nb\r\nc\r\nd"); // ring full: a, b - t.set_display_offset(2); - t.feed(b"\r\ne"); // pushes "c", evicts "a" - assert_eq!(t.display_offset(), 2); // clamped to ring length - assert_eq!(t.row_text(0), "b"); - } - - #[test] - fn display_offset_untouched_at_bottom() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc"); - assert_eq!(t.display_offset(), 0); - t.feed(b"\r\nd"); // more history, still at the live bottom - assert_eq!(t.display_offset(), 0); - } - - #[test] - fn scroll_display_deltas_clamp() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc\r\nd"); // scrollback len 2 - t.scroll_display(99); - assert_eq!(t.display_offset(), 2); - t.scroll_display(-1); - assert_eq!(t.display_offset(), 1); - t.scroll_display(-99); - assert_eq!(t.display_offset(), 0); - } - - #[test] - fn entering_alt_resets_display_offset() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc\r\nd"); - t.set_display_offset(2); - t.feed(b"\x1b[?1049h"); - assert_eq!(t.display_offset(), 0); - // No scrollback on alt: scrolling is a no-op. - t.scroll_display(5); - assert_eq!(t.display_offset(), 0); - // vt does not restore the offset on exit; the app decides. - t.feed(b"\x1b[?1049l"); - assert_eq!(t.display_offset(), 0); - } - - #[test] - fn mouse_mode_tracks_decset() { - use crate::mode::MouseMode; - let mut t = Terminal::new(10, 3, 0); - assert_eq!(t.mouse_mode(), MouseMode::None); - assert!(!t.mouse_sgr()); - t.feed(b"\x1b[?1000h"); - assert_eq!(t.mouse_mode(), MouseMode::Click); - t.feed(b"\x1b[?1002h"); - assert_eq!(t.mouse_mode(), MouseMode::Drag); - t.feed(b"\x1b[?1003h"); - assert_eq!(t.mouse_mode(), MouseMode::Motion); - t.feed(b"\x1b[?1006h"); - assert!(t.mouse_sgr()); - t.feed(b"\x1b[?1003l"); - assert_eq!(t.mouse_mode(), MouseMode::Drag); - t.feed(b"\x1b[?1002l\x1b[?1000l\x1b[?1006l"); - assert_eq!(t.mouse_mode(), MouseMode::None); - assert!(!t.mouse_sgr()); - } - - #[test] - fn alternate_scroll_defaults_off_and_tracks_1007() { - let mut t = Terminal::new(10, 3, 0); - assert!(!t.alternate_scroll()); // xterm default - t.feed(b"\x1b[?1007h"); - assert!(t.alternate_scroll()); - t.feed(b"\x1b[?1007l"); - assert!(!t.alternate_scroll()); - } - - #[test] - fn resize_clamps_display_offset() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc"); - t.set_display_offset(1); - t.resize(6, 4); - assert!(t.display_offset() <= t.grid().scrollback().len()); - assert_eq!(t.cols(), 6); - assert_eq!(t.rows(), 4); - } - - #[test] - fn fresh_terminal_is_fully_damaged() { - let mut t = Terminal::new(10, 3, 0); - assert_eq!(t.take_damage(), Damage::Full); - assert_eq!(t.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn printing_marks_row_dirty_and_take_clears() { - let mut t = Terminal::new(10, 3, 0); - t.take_damage(); - t.feed(b"hi"); - assert_eq!(t.take_damage(), Damage::Rows(vec![0])); - assert_eq!(t.take_damage(), Damage::Rows(vec![])); - t.feed(b"\x1b[3;1Hx"); - assert_eq!(t.take_damage(), Damage::Rows(vec![2])); - } - - #[test] - fn scroll_escalates_to_full_damage() { - let mut t = Terminal::new(4, 2, 10); - t.take_damage(); - t.feed(b"a\r\nb\r\nc"); // last linefeed scrolls - assert_eq!(t.take_damage(), Damage::Full); - } - - #[test] - fn resize_escalates_to_full_damage() { - let mut t = Terminal::new(10, 3, 0); - t.take_damage(); - t.resize(8, 4); - assert_eq!(t.take_damage(), Damage::Full); - } - - #[test] - fn alt_switch_escalates_to_full_damage() { - let mut t = Terminal::new(10, 3, 0); - t.take_damage(); - t.feed(b"\x1b[?1049h"); - assert_eq!(t.take_damage(), Damage::Full); - t.feed(b"\x1b[?1049l"); - assert_eq!(t.take_damage(), Damage::Full); - // Leaving alt while already on primary changes nothing. - t.feed(b"\x1b[?1049l"); - assert_eq!(t.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn ris_and_ed_escalate_to_full_damage() { - let mut t = Terminal::new(10, 3, 0); - t.take_damage(); - t.feed(b"\x1bc"); - assert_eq!(t.take_damage(), Damage::Full); - t.feed(b"\x1b[2J"); - assert_eq!(t.take_damage(), Damage::Full); - t.feed(b"\x1b[3J"); - assert_eq!(t.take_damage(), Damage::Full); - } - - #[test] - fn palette_osc_escalates_to_full_damage() { - let mut t = Terminal::new(10, 3, 0); - t.take_damage(); - t.feed(b"\x1b]4;1;rgb:ff/00/00\x07"); - assert_eq!(t.take_damage(), Damage::Full); - t.feed(b"\x1b]104\x07"); - assert_eq!(t.take_damage(), Damage::Full); - } - - #[test] - fn display_offset_change_escalates_to_full_damage() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"a\r\nb\r\nc"); - t.take_damage(); - t.set_display_offset(1); - assert_eq!(t.take_damage(), Damage::Full); - // Setting the same offset again is not damage. - t.set_display_offset(1); - assert_eq!(t.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn full_damage_clears_stale_row_damage() { - let mut t = Terminal::new(10, 3, 0); - t.take_damage(); - t.feed(b"hi"); - t.resize(8, 4); - assert_eq!(t.take_damage(), Damage::Full); - assert_eq!(t.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn bell_take_and_clear() { - let mut t = Terminal::new(10, 3, 0); - assert!(!t.take_bell()); - t.feed(b"\x07"); - assert!(t.take_bell()); - assert!(!t.take_bell()); - } - - #[test] - fn title_change_signal() { - let mut t = Terminal::new(10, 3, 0); - assert_eq!(t.take_title_changed(), None); - t.feed(b"\x1b]2;hello\x07"); - assert_eq!(t.take_title_changed(), Some("hello".to_string())); - assert_eq!(t.take_title_changed(), None); - assert_eq!(t.title(), "hello"); - t.feed(b"\x1b]0;again\x07"); - assert_eq!(t.take_title_changed(), Some("again".to_string())); - } - - #[test] - fn cursor_keys_app_tracks_decckm() { - let mut t = Terminal::new(10, 3, 0); - assert!(!t.cursor_keys_app()); - t.feed(b"\x1b[?1h"); - assert!(t.cursor_keys_app()); - t.feed(b"\x1b[?1l"); - assert!(!t.cursor_keys_app()); - // RIS clears it. - t.feed(b"\x1b[?1h\x1bc"); - assert!(!t.cursor_keys_app()); - } - - #[test] - fn keypad_app_tracks_deckpam_deckpnm_and_ris() { - let mut t = Terminal::new(10, 3, 0); - assert!(!t.keypad_app()); - t.feed(b"\x1b="); - assert!(t.keypad_app()); - t.feed(b"\x1b>"); - assert!(!t.keypad_app()); - t.feed(b"\x1b=\x1bc"); - assert!(!t.keypad_app()); - } - - #[test] - fn bracketed_paste_accessor() { - let mut t = Terminal::new(10, 3, 0); - assert!(!t.bracketed_paste()); - t.feed(b"\x1b[?2004h"); - assert!(t.bracketed_paste()); - t.feed(b"\x1b[?2004l"); - assert!(!t.bracketed_paste()); - t.feed(b"\x1b[?2004h\x1bc"); - assert!(!t.bracketed_paste()); - } - - #[test] - fn focus_reporting_emits_only_when_enabled() { - let mut t = Terminal::new(10, 3, 0); - // Off by default: report_focus is a no-op. - assert!(!t.focus_reporting()); - t.report_focus(true); - assert!(t.take_output().is_empty()); - // Enable ?1004 and focus in/out emit CSI I / CSI O. - t.feed(b"\x1b[?1004h"); - assert!(t.focus_reporting()); - t.report_focus(true); - assert_eq!(t.take_output(), b"\x1b[I"); - t.report_focus(false); - assert_eq!(t.take_output(), b"\x1b[O"); - t.feed(b"\x1b[?1004l"); - t.report_focus(true); - assert!(t.take_output().is_empty()); - } - - #[test] - fn synchronized_output_tracks_2026() { - let mut t = Terminal::new(10, 3, 0); - assert!(!t.synchronized_output()); - t.feed(b"\x1b[?2026h"); - assert!(t.synchronized_output()); - t.feed(b"\x1b[?2026l"); - assert!(!t.synchronized_output()); - } - - #[test] - fn search_finds_matches_across_scrollback() { - let mut t = Terminal::new(10, 2, 10); - t.feed(b"foo bar\r\nbaz foo\r\nqux"); // "foo" on lines 0 and 1 - let hits = t.search("foo", false); - assert_eq!(hits.len(), 2); - // First match is the oldest (lowest global line), col 0. - assert_eq!((hits[0].start_col, hits[0].end_col), (0, 2)); - // Case-insensitive by default; case-sensitive can differ. - t.feed(b"\r\nFOO"); - assert_eq!(t.search("foo", false).len(), 3); - assert_eq!(t.search("foo", true).len(), 2); - } - - #[test] - fn da2_reports_secondary_attributes() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b[>c"); - assert_eq!(t.take_output(), b"\x1b[>0;276;0c"); - } - - #[test] - fn title_stack_pop_signals_change() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]2;first\x07\x1b[22;0t\x1b]2;second\x07"); - t.take_title_changed(); - t.feed(b"\x1b[23;0t"); - assert_eq!(t.take_title_changed(), Some("first".to_string())); - } -} +#[path = "../../tests/term/mod.rs"] +mod tests; diff --git a/crates/vt/src/term/ops.rs b/crates/vt/src/term/ops.rs index 5735828..618f07d 100644 --- a/crates/vt/src/term/ops.rs +++ b/crates/vt/src/term/ops.rs @@ -573,189 +573,5 @@ fn clear_wide_at(scr: &mut Screen, row: usize, col: usize) { } #[cfg(test)] -mod tests { - use crate::term::Terminal; - - fn term() -> Terminal { - Terminal::new(10, 4, 50) - } - - #[test] - fn write_char_advances() { - let mut t = term(); - t.inner.write_char('a'); - t.inner.write_char('b'); - assert_eq!(t.row_text(0), "ab"); - assert_eq!(t.cursor_pos(), (0, 2)); - } - - #[test] - fn wide_char_takes_two_cells() { - let mut t = term(); - t.inner.write_char('漢'); - assert!(t.cell(0, 0).is_wide()); - assert!(t.cell(0, 1).is_wide_spacer()); - assert_eq!(t.cursor_pos(), (0, 2)); - } - - #[test] - fn overwriting_wide_head_clears_spacer() { - let mut t = term(); - t.inner.write_char('漢'); - t.inner.cursor_to(0, 0); - t.inner.write_char('x'); - assert_eq!(t.cell(0, 0).ch, 'x'); - assert!(!t.cell(0, 1).is_wide_spacer()); - } - - #[test] - fn overwriting_spacer_clears_head() { - let mut t = term(); - t.inner.write_char('漢'); - t.inner.cursor_to(0, 1); - t.inner.write_char('x'); - assert_eq!(t.cell(0, 1).ch, 'x'); - assert!(!t.cell(0, 0).is_wide()); - assert_eq!(t.cell(0, 0).ch, ' '); - } - - #[test] - fn pending_wrap_then_write_wraps() { - let mut t = term(); - for _ in 0..10 { - t.inner.write_char('x'); - } - assert_eq!(t.cursor_pos(), (0, 9)); - assert!(t.screen().cursor.pending_wrap); - t.inner.write_char('y'); - assert_eq!(t.cursor_pos(), (1, 1)); - assert_eq!(t.cell(1, 0).ch, 'y'); - assert!(t.grid().row(0).wrapped); - } - - #[test] - fn cursor_movement_clamps() { - let mut t = term(); - t.inner.cursor_up(5); - assert_eq!(t.cursor_pos(), (0, 0)); - t.inner.cursor_down(99); - assert_eq!(t.cursor_pos(), (3, 0)); - t.inner.cursor_right(99); - assert_eq!(t.cursor_pos(), (3, 9)); - t.inner.cursor_left(99); - assert_eq!(t.cursor_pos(), (3, 0)); - } - - #[test] - fn scroll_region_constrains_linefeed() { - let mut t = term(); - // 1-based margins 2..3 = rows 1..2 zero-based. - t.inner.set_scroll_region(2, 3); - t.inner.cursor_to(1, 0); - t.inner.write_char('a'); - t.inner.linefeed(); - t.inner.carriage_return(); - t.inner.write_char('b'); - t.inner.linefeed(); // at bottom margin: scrolls region, cursor stays - assert_eq!(t.row_text(1), "b"); - assert_eq!(t.row_text(0), ""); // row 0 untouched - assert_eq!(t.row_text(3), ""); // below region untouched - assert_eq!(t.cursor_pos().0, 2); - } - - #[test] - fn delete_chars_shifts_left() { - let mut t = term(); - t.feed(b"abcdef"); - t.inner.cursor_to(0, 1); - t.inner.delete_chars(2); - assert_eq!(t.row_text(0), "adef"); - } - - #[test] - fn insert_blank_shifts_right() { - let mut t = term(); - t.feed(b"abc"); - t.inner.cursor_to(0, 1); - t.inner.insert_blank(2); - assert_eq!(t.row_text(0), "a bc"); - } - - #[test] - fn full_reset_restores_defaults() { - let mut t = term(); - t.feed(b"\x1b[?25l\x1b[31mhello"); - t.inner.full_reset(); - assert!(t.cursor_visible()); - assert_eq!(t.row_text(0), ""); - assert_eq!(t.cursor_pos(), (0, 0)); - } - - #[test] - fn alignment_test_fills_screen() { - let mut t = term(); - t.inner.screen_alignment_test(); - assert_eq!(t.row_text(0), "EEEEEEEEEE"); - assert_eq!(t.row_text(3), "EEEEEEEEEE"); - } - - /// 10-col terminal whose row 0 soft-wrapped into row 1. - fn wrapped() -> Terminal { - let mut t = term(); - t.feed(b"0123456789ab"); - assert!(t.grid().row(0).wrapped); - t - } - - #[test] - fn erase_line_to_end_breaks_wrap() { - let mut t = wrapped(); - t.feed(b"\x1b[1;5H\x1b[K"); // EL 0 from mid-row - assert!(!t.grid().row(0).wrapped); - let mut t = wrapped(); - t.feed(b"\x1b[1;1H\x1b[2K"); // EL 2 - assert!(!t.grid().row(0).wrapped); - // EL 1 leaves the tail (and the continuation) intact. - let mut t = wrapped(); - t.feed(b"\x1b[1;5H\x1b[1K"); - assert!(t.grid().row(0).wrapped); - } - - #[test] - fn erase_display_below_breaks_wrap_on_cursor_row() { - let mut t = wrapped(); - t.feed(b"\x1b[1;5H\x1b[J"); - assert!(!t.grid().row(0).wrapped); - } - - #[test] - fn char_edits_break_wrap() { - let mut t = wrapped(); - t.feed(b"\x1b[1;1H\x1b[P"); // DCH shifts the tail left - assert!(!t.grid().row(0).wrapped); - let mut t = wrapped(); - t.feed(b"\x1b[1;1H\x1b[@"); // ICH pushes content off the right edge - assert!(!t.grid().row(0).wrapped); - let mut t = wrapped(); - t.feed(b"\x1b[1;10H\x1b[X"); // ECH reaching the last column - assert!(!t.grid().row(0).wrapped); - // ECH that stops short keeps the continuation. - let mut t = wrapped(); - t.feed(b"\x1b[1;1H\x1b[X"); - assert!(t.grid().row(0).wrapped); - } - - #[test] - fn overwriting_last_cell_breaks_wrap() { - let mut t = wrapped(); - t.feed(b"\x1b[1;10HZ"); - assert!(!t.grid().row(0).wrapped); - // ...and a fresh continuation re-sets it. - t.feed(b"\x1b[2;1H"); // park the cursor; flag stays cleared - assert!(!t.grid().row(0).wrapped); - let mut t = wrapped(); - t.feed(b"\x1b[1;10HZw"); // overwrite then continue: re-wrapped - assert!(t.grid().row(0).wrapped); - assert_eq!(t.cell(1, 0).ch, 'w'); - } -} +#[path = "../../tests/term/ops.rs"] +mod tests; diff --git a/crates/vt/src/term/osc.rs b/crates/vt/src/term/osc.rs index cab12e8..cfafd5e 100644 --- a/crates/vt/src/term/osc.rs +++ b/crates/vt/src/term/osc.rs @@ -255,216 +255,5 @@ fn component(s: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use crate::term::Terminal; - - #[test] - fn title_via_osc0_and_osc2() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]0;hello\x07"); - assert_eq!(t.title(), "hello"); - t.feed(b"\x1b]2;a;b\x1b\\"); - assert_eq!(t.title(), "a;b"); - } - - #[test] - fn palette_set_and_reset() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]4;1;rgb:ff/00/00\x07"); - assert_eq!(t.palette_override(1), Some((255, 0, 0))); - t.feed(b"\x1b]4;2;#00ff00;3;#0000ff\x07"); - assert_eq!(t.palette_override(2), Some((0, 255, 0))); - assert_eq!(t.palette_override(3), Some((0, 0, 255))); - t.feed(b"\x1b]104;2\x07"); - assert_eq!(t.palette_override(2), None); - assert_eq!(t.palette_override(3), Some((0, 0, 255))); - t.feed(b"\x1b]104\x07"); - assert_eq!(t.palette_override(1), None); - assert_eq!(t.palette_override(3), None); - } - - #[test] - fn cwd_stored() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]7;file://host/Users/me\x07"); - assert_eq!(t.cwd(), Some("file://host/Users/me")); - } - - #[test] - fn cursor_color_set_and_reset() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]12;#102030\x07"); - assert_eq!(t.cursor_color(), Some((16, 32, 48))); - t.feed(b"\x1b]112\x07"); - assert_eq!(t.cursor_color(), None); - } - - #[test] - fn unknown_osc_ignored() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]777;whatever\x07ok"); - assert_eq!(t.row_text(0), "ok"); - } - - fn report_colors() -> crate::term::ReportColors { - crate::term::ReportColors { - foreground: (0xc0, 0xc0, 0xc0), - background: (0x10, 0x10, 0x10), - cursor: (0xff, 0xff, 0x00), - palette: [(1, 2, 3); 256], - } - } - - #[test] - fn osc10_11_queries_report_theme_colors() { - let mut t = Terminal::new(10, 3, 0); - t.set_report_colors(report_colors()); - t.feed(b"\x1b]10;?\x07"); - assert_eq!(t.take_output(), b"\x1b]10;rgb:c0c0/c0c0/c0c0\x07"); - t.feed(b"\x1b]11;?\x1b\\"); - // ST request gets an ST-terminated reply. - assert_eq!(t.take_output(), b"\x1b]11;rgb:1010/1010/1010\x1b\\"); - } - - #[test] - fn osc_color_query_ignored_without_report_colors() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]11;?\x07"); - assert!(t.take_output().is_empty()); - } - - #[test] - fn osc4_query_prefers_override_then_report_palette() { - let mut t = Terminal::new(10, 3, 0); - t.set_report_colors(report_colors()); - // Unoverridden index answers from the report palette. - t.feed(b"\x1b]4;5;?\x07"); - assert_eq!(t.take_output(), b"\x1b]4;5;rgb:0101/0202/0303\x07"); - // An OSC 4 override wins over the report palette. - t.feed(b"\x1b]4;5;rgb:ff/00/00\x07"); - t.feed(b"\x1b]4;5;?\x07"); - assert_eq!(t.take_output(), b"\x1b]4;5;rgb:ffff/0000/0000\x07"); - } - - #[test] - fn osc12_query_uses_override_then_report_cursor() { - let mut t = Terminal::new(10, 3, 0); - t.set_report_colors(report_colors()); - t.feed(b"\x1b]12;?\x07"); - assert_eq!(t.take_output(), b"\x1b]12;rgb:ffff/ffff/0000\x07"); - t.feed(b"\x1b]12;#010203\x07"); - t.feed(b"\x1b]12;?\x07"); - assert_eq!(t.take_output(), b"\x1b]12;rgb:0101/0202/0303\x07"); - } - - #[test] - fn osc52_set_decodes_base64() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]52;c;aGVsbG8=\x07"); - let clip = t.take_clipboard().expect("clipboard write"); - assert_eq!(clip.kind, "c"); - assert_eq!(clip.data, b"hello"); - // Taken once. - assert!(t.take_clipboard().is_none()); - } - - #[test] - fn osc52_empty_kind_defaults_to_clipboard() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]52;;Zm9vYmFy\x07"); - let clip = t.take_clipboard().expect("clipboard write"); - assert_eq!(clip.kind, "c"); - assert_eq!(clip.data, b"foobar"); - } - - #[test] - fn osc52_query_and_garbage_ignored() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b]52;c;?\x07"); - assert!(t.take_clipboard().is_none()); - t.feed(b"\x1b]52;c;@@notbase64@@\x07"); - assert!(t.take_clipboard().is_none()); - } - - #[test] - fn osc8_links_cells_until_closed() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"\x1b]8;;https://example.com\x07ab\x1b]8;;\x07cd"); - // 'a' and 'b' carry the link; 'c' and 'd' do not. - assert_eq!(t.cell_hyperlink(t.cell(0, 0)), Some("https://example.com")); - assert_eq!(t.cell_hyperlink(t.cell(0, 1)), Some("https://example.com")); - assert_eq!(t.cell_hyperlink(t.cell(0, 2)), None); - assert_eq!(t.cell_hyperlink(t.cell(0, 3)), None); - } - - #[test] - fn osc8_uri_with_semicolons_is_preserved() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"\x1b]8;;https://x/a;b;c\x07z\x1b]8;;\x07"); - assert_eq!(t.cell_hyperlink(t.cell(0, 0)), Some("https://x/a;b;c")); - } - - #[test] - fn osc8_id_param_groups_links() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"\x1b]8;id=foo;https://a\x07x\x1b]8;;\x07"); - let id = t.cell(0, 0).hyperlink.expect("linked"); - let link = t.hyperlink(id).expect("resolves"); - assert_eq!(link.id.as_deref(), Some("foo")); - assert_eq!(link.uri, "https://a"); - } - - #[test] - fn osc133_marks_prompt_rows() { - let mut t = Terminal::new(10, 4, 10); - // Prompt on row 0, then output pushes a second prompt down a line. - t.feed(b"\x1b]133;A\x07$ \r\nout\r\n\x1b]133;A\x07$ "); - let prompts = t.prompt_lines(); - // Two prompt rows recorded (no scrollback yet, so global == grid row). - assert_eq!(prompts, vec![0, 2]); - } - - #[test] - fn osc133_prompts_follow_into_scrollback() { - let mut t = Terminal::new(10, 2, 10); - // Mark a prompt, then scroll it into history. - t.feed(b"\x1b]133;A\x07top\r\nb\r\nc\r\nd"); - // The marked row is now the oldest scrollback line: global index 0. - assert_eq!(t.prompt_lines().first(), Some(&0)); - assert_eq!(t.visible_row(0).text(), "c"); - } - - #[test] - fn osc8_links_cleared_by_ris() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"\x1b]8;;https://a\x07x"); - let id = t.cell(0, 0).hyperlink.expect("linked"); - t.feed(b"\x1bc"); - // After RIS the registry is empty and the cell is blank. - assert!(t.hyperlink(id).is_none()); - assert_eq!(t.cell_hyperlink(t.cell(0, 0)), None); - } - - #[test] - fn color_spec_forms() { - assert_eq!(parse_color_spec("rgb:ff/00/80"), Some((255, 0, 128))); - assert_eq!(parse_color_spec("rgb:f/0/8"), Some((255, 0, 136))); - assert_eq!(parse_color_spec("rgb:ffff/0000/8000"), Some((255, 0, 128))); - assert_eq!(parse_color_spec("#ff0080"), Some((255, 0, 128))); - assert_eq!(parse_color_spec("#f08"), Some((255, 0, 136))); - assert_eq!(parse_color_spec("#ffff00008000"), Some((255, 0, 128))); - assert_eq!(parse_color_spec("nonsense"), None); - assert_eq!(parse_color_spec("#12345"), None); - assert_eq!(parse_color_spec("rgb:gg/00/00"), None); - } - - #[test] - fn number_parsing() { - assert_eq!(parse_number(b"0"), Some(0)); - assert_eq!(parse_number(b"104"), Some(104)); - assert_eq!(parse_number(b""), None); - assert_eq!(parse_number(b"12a"), None); - assert_eq!(parse_number(b"999999"), None); - } -} +#[path = "../../tests/term/osc.rs"] +mod tests; diff --git a/crates/vt/src/term/perform.rs b/crates/vt/src/term/perform.rs index e5307a6..d5699a0 100644 --- a/crates/vt/src/term/perform.rs +++ b/crates/vt/src/term/perform.rs @@ -77,77 +77,5 @@ impl vte::Perform for Inner { } #[cfg(test)] -mod tests { - use crate::charset::Charset; - use crate::term::Terminal; - - #[test] - fn bel_sets_flag() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x07"); - assert!(t.take_bell()); - assert!(!t.take_bell()); - } - - #[test] - fn backspace_moves_left() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"ab\x08c"); - assert_eq!(t.row_text(0), "ac"); - } - - #[test] - fn ht_moves_to_tab_stop() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"\tx"); - assert_eq!(t.cell(0, 8).ch, 'x'); - } - - #[test] - fn nel_is_cr_plus_lf() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"ab\x1bEc"); - assert_eq!(t.row_text(1), "c"); - assert_eq!(t.cursor_pos(), (1, 1)); - } - - #[test] - fn hts_sets_tab_stop() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"\x1b[5G\x1bH\r\tx"); - assert_eq!(t.cell(0, 4).ch, 'x'); - } - - #[test] - fn keypad_modes() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b="); - assert!(t.modes().contains(crate::Modes::APP_KEYPAD)); - t.feed(b"\x1b>"); - assert!(!t.modes().contains(crate::Modes::APP_KEYPAD)); - } - - #[test] - fn charset_designation_and_shift() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\x1b(0"); - assert_eq!(t.inner.charsets.g0, Charset::DecSpecial); - t.feed(b"q"); - assert_eq!(t.cell(0, 0).ch, '─'); - t.feed(b"\x1b(B"); - t.feed(b"q"); - assert_eq!(t.cell(0, 1).ch, 'q'); - // SO selects G1. - t.feed(b"\x1b)0\x0eq\x0fq"); - assert_eq!(t.cell(0, 2).ch, '─'); - assert_eq!(t.cell(0, 3).ch, 'q'); - } - - #[test] - fn ris_resets() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"hello\x1b[?25l\x1bc"); - assert_eq!(t.row_text(0), ""); - assert!(t.cursor_visible()); - } -} +#[path = "../../tests/term/perform.rs"] +mod tests; diff --git a/crates/vt/src/term/report.rs b/crates/vt/src/term/report.rs index ee53661..6d5ac51 100644 --- a/crates/vt/src/term/report.rs +++ b/crates/vt/src/term/report.rs @@ -121,52 +121,5 @@ fn base64_value(b: u8) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rgb_widens_by_replication() { - assert_eq!(format_rgb((255, 0, 128)), "rgb:ffff/0000/8080"); - assert_eq!(format_rgb((16, 32, 48)), "rgb:1010/2020/3030"); - } - - #[test] - fn base64_roundtrips() { - for s in [ - "", - "f", - "fo", - "foo", - "foob", - "fooba", - "foobar", - "hello, world", - ] { - let enc = base64_encode(s.as_bytes()); - assert_eq!(base64_decode(enc.as_bytes()).unwrap(), s.as_bytes(), "{s}"); - } - } - - #[test] - fn base64_known_vectors() { - assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); - assert_eq!(base64_decode(b"Zm9vYmFy").unwrap(), b"foobar"); - // Whitespace is tolerated. - assert_eq!(base64_decode(b"Zm9v\nYmFy").unwrap(), b"foobar"); - } - - #[test] - fn base64_rejects_garbage() { - assert_eq!(base64_decode(b"@@@"), None); - assert_eq!(base64_decode(b"Zm9v!"), None); - } - - #[test] - fn hex_roundtrips() { - assert_eq!(hex_encode(b"Co"), "436f"); - assert_eq!(hex_decode(b"436f").unwrap(), b"Co"); - assert_eq!(hex_decode(b"524742").unwrap(), b"RGB"); - assert_eq!(hex_decode(b"abc"), None); // odd length - assert_eq!(hex_decode(b"zz"), None); // non-hex - } -} +#[path = "../../tests/term/report.rs"] +mod tests; diff --git a/crates/vt/src/term/select.rs b/crates/vt/src/term/select.rs index f83fabd..ed203b6 100644 --- a/crates/vt/src/term/select.rs +++ b/crates/vt/src/term/select.rs @@ -127,212 +127,5 @@ impl Inner { } #[cfg(test)] -mod tests { - use crate::grid::damage::Damage; - use crate::selection::{Point, SelectionMode}; - use crate::term::Terminal; - - fn p(line: isize, col: usize) -> Point { - Point::new(line, col) - } - - #[test] - fn cell_selection_and_text() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"hello world"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(0, 4)); - assert_eq!(t.selection_text().as_deref(), Some("hello")); - } - - #[test] - fn selection_normalizes_upward_drag() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"ab\r\ncd"); - t.start_selection(SelectionMode::Cell, p(1, 1)); - t.update_selection(p(0, 0)); - let sel = t.selection().unwrap(); - assert_eq!(sel.start(), p(0, 0)); - assert_eq!(sel.end(), p(1, 1)); - assert_eq!(t.selection_text().as_deref(), Some("ab\ncd")); - } - - #[test] - fn word_selection_honors_word_chars() { - let mut t = Terminal::new(20, 3, 0); - t.feed(b"foo bar.gz baz"); - t.start_selection(SelectionMode::Word, p(0, 5)); - assert_eq!(t.selection_text().as_deref(), Some("bar.gz")); - t.set_word_chars(""); - t.start_selection(SelectionMode::Word, p(0, 5)); - assert_eq!(t.selection_text().as_deref(), Some("bar")); - } - - #[test] - fn word_selection_crosses_soft_wrap() { - let mut t = Terminal::new(4, 3, 10); - t.feed(b"xx abcd"); // wraps: "xx a" + "bcd" - assert!(t.grid().row(0).wrapped); - t.start_selection(SelectionMode::Word, p(0, 3)); - assert_eq!(t.selection_text().as_deref(), Some("abcd")); - } - - #[test] - fn line_selection_spans_logical_line() { - let mut t = Terminal::new(4, 3, 10); - t.feed(b"abcdef\r\nxyz"); - t.start_selection(SelectionMode::Line, p(1, 0)); - let sel = t.selection().unwrap(); - assert_eq!(sel.start(), p(0, 0)); - assert_eq!(sel.end(), p(1, 3)); - assert_eq!(t.selection_text().as_deref(), Some("abcdef")); - } - - #[test] - fn text_soft_wrap_joins_hard_break_newlines() { - let mut t = Terminal::new(4, 3, 10); - t.feed(b"abcdef\r\nxyz"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(2, 3)); - assert_eq!(t.selection_text().as_deref(), Some("abcdef\nxyz")); - } - - #[test] - fn text_handles_wide_chars_and_trailing_spaces() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"\xe6\xbc\xa2\xe5\xad\x97 x \r\nok"); // "漢字 x " - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(1, 9)); - assert_eq!(t.selection_text().as_deref(), Some("漢字 x\nok")); - } - - #[test] - fn update_without_selection_is_noop() { - let mut t = Terminal::new(10, 3, 0); - t.update_selection(p(0, 5)); - assert!(t.selection().is_none()); - assert_eq!(t.selection_text(), None); - } - - #[test] - fn selection_changes_are_full_damage() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"abc"); - t.take_damage(); - t.start_selection(SelectionMode::Cell, p(0, 0)); - assert_eq!(t.take_damage(), Damage::Full); - t.update_selection(p(0, 2)); - assert_eq!(t.take_damage(), Damage::Full); - t.clear_selection(); - assert_eq!(t.take_damage(), Damage::Full); - // Clearing nothing is not damage. - t.clear_selection(); - assert_eq!(t.take_damage(), Damage::Rows(vec![])); - } - - #[test] - fn print_inside_selection_clears_it() { - let mut t = Terminal::new(10, 4, 0); - t.feed(b"abc"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(0, 2)); - // Printing on an unselected row keeps the selection. - t.feed(b"\x1b[3;1Hx"); - assert!(t.selection().is_some()); - // Printing on the selected row clears it. - t.feed(b"\x1b[1;2Hy"); - assert!(t.selection().is_none()); - } - - #[test] - fn selection_stays_anchored_across_scroll_into_history() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"ab\r\ncd"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(0, 1)); - t.feed(b"\r\nef"); // scrolls "ab" into scrollback - let sel = t.selection().unwrap(); - assert_eq!(sel.start(), p(-1, 0)); - assert_eq!(t.selection_text().as_deref(), Some("ab")); - } - - #[test] - fn selection_invalidated_when_scrolled_off_ring() { - let mut t = Terminal::new(4, 2, 1); - t.feed(b"ab\r\ncd"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(0, 1)); - t.feed(b"\r\ne"); // "ab" -> ring (len 1), selection at line -1 - assert!(t.selection().is_some()); - t.feed(b"\r\nf"); // "cd" -> ring, "ab" evicted: selection off-ring - assert!(t.selection().is_none()); - } - - #[test] - fn region_scroll_clears_selection() { - let mut t = Terminal::new(10, 4, 10); - t.feed(b"sel\x1b[2;3r\x1b[3;1H"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.update_selection(p(0, 2)); - t.feed(b"\n"); // scrolls rows 1..=2 only (top != 0): conservative clear - assert!(t.selection().is_none()); - } - - #[test] - fn reverse_scroll_clears_selection() { - let mut t = Terminal::new(10, 3, 10); - t.feed(b"sel\x1b[1;1H"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.feed(b"\x1bM"); // RI at the top: scroll down - assert!(t.selection().is_none()); - } - - #[test] - fn alt_screen_switch_clears_selection() { - let mut t = Terminal::new(10, 3, 10); - t.feed(b"abc"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.feed(b"\x1b[?1049h"); - assert!(t.selection().is_none()); - } - - #[test] - fn erase_display_clears_only_when_intersecting() { - let mut t = Terminal::new(4, 2, 10); - t.feed(b"ab\r\ncd\r\nef"); // "ab" now in scrollback - t.start_selection(SelectionMode::Cell, p(-1, 0)); - t.update_selection(p(-1, 1)); - t.feed(b"\x1b[2J"); // live rows only: scrollback selection survives - assert!(t.selection().is_some()); - t.feed(b"\x1b[3J"); // history erased: selection dies, offset resets - assert!(t.selection().is_none()); - assert_eq!(t.display_offset(), 0); - } - - #[test] - fn resize_and_ris_clear_selection() { - let mut t = Terminal::new(10, 3, 10); - t.feed(b"abc"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.resize(8, 4); - assert!(t.selection().is_none()); - t.feed(b"abc"); - t.start_selection(SelectionMode::Cell, p(0, 0)); - t.feed(b"\x1bc"); - assert!(t.selection().is_none()); - } - - #[test] - fn contains_matches_renderer_view() { - let mut t = Terminal::new(10, 3, 0); - t.feed(b"ab\r\ncd\r\nef"); - t.start_selection(SelectionMode::Cell, p(0, 1)); - t.update_selection(p(2, 0)); - let sel = t.selection().unwrap(); - assert!(!sel.contains(p(0, 0))); - assert!(sel.contains(p(0, 1))); - assert!(sel.contains(p(1, 5))); // full middle row - assert!(sel.contains(p(2, 0))); - assert!(!sel.contains(p(2, 1))); - } -} +#[path = "../../tests/term/select.rs"] +mod tests; diff --git a/crates/vt/src/url.rs b/crates/vt/src/url.rs index 018a6d1..a98f66b 100644 --- a/crates/vt/src/url.rs +++ b/crates/vt/src/url.rs @@ -73,52 +73,5 @@ fn balanced_paren(s: &[char]) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - fn urls(text: &str) -> Vec { - let chars: Vec = text.chars().collect(); - find(&chars) - .into_iter() - .map(|(a, b)| chars[a..b].iter().collect()) - .collect() - } - - #[test] - fn finds_basic_urls() { - assert_eq!(urls("see https://example.com now"), ["https://example.com"]); - assert_eq!(urls("http://a.b/c?d=1&e=2"), ["http://a.b/c?d=1&e=2"]); - assert_eq!(urls("mailto:me@x.io"), ["mailto:me@x.io"]); - } - - #[test] - fn trims_trailing_punctuation() { - assert_eq!(urls("go to https://x.io."), ["https://x.io"]); - assert_eq!(urls("(see https://x.io)"), ["https://x.io"]); - assert_eq!(urls("\"https://x.io\","), ["https://x.io"]); - } - - #[test] - fn keeps_balanced_parens() { - assert_eq!( - urls("https://en.wikipedia.org/wiki/Foo_(bar)"), - ["https://en.wikipedia.org/wiki/Foo_(bar)"] - ); - } - - #[test] - fn multiple_and_none() { - assert_eq!( - urls("a https://1.com b http://2.com"), - ["https://1.com", "http://2.com"] - ); - assert!(urls("no links here").is_empty()); - assert!(urls("https://").is_empty()); // scheme only - assert!(urls("nothttp://x").len() == 1); // still matches the embedded one - } - - #[test] - fn case_insensitive_scheme() { - assert_eq!(urls("HTTPS://X.IO/p"), ["HTTPS://X.IO/p"]); - } -} +#[path = "../tests/url.rs"] +mod tests; diff --git a/crates/vt/tests/cell.rs b/crates/vt/tests/cell.rs new file mode 100644 index 0000000..b4a214f --- /dev/null +++ b/crates/vt/tests/cell.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn default_cell_is_blank() { + let c = Cell::default(); + assert_eq!(c.ch, ' '); + assert_eq!(c.fg, Color::Default); + assert_eq!(c.bg, Color::Default); + assert!(c.flags.is_empty()); +} + +#[test] +fn erased_keeps_pen_background_only() { + let mut pen = Cell::default(); + pen.bg = Color::Indexed(4); + pen.fg = Color::Indexed(1); + pen.flags = CellFlags::BOLD | CellFlags::UNDERLINE; + let e = Cell::erased(pen); + assert_eq!(e.bg, Color::Indexed(4)); + assert_eq!(e.fg, Color::Default); + assert_eq!(e.ch, ' '); + assert!(e.flags.is_empty()); +} + +#[test] +fn any_underline_covers_all_styles() { + assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::UNDERLINE)); + assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::DOUBLE_UNDERLINE)); + assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::CURLY_UNDERLINE)); + assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::DOTTED_UNDERLINE)); + assert!(CellFlags::ANY_UNDERLINE.contains(CellFlags::DASHED_UNDERLINE)); + assert!(!CellFlags::ANY_UNDERLINE.contains(CellFlags::STRIKETHROUGH)); +} + +#[test] +fn cell_is_small() { + assert!(std::mem::size_of::() <= 24); +} diff --git a/crates/vt/tests/charset.rs b/crates/vt/tests/charset.rs new file mode 100644 index 0000000..22acc4c --- /dev/null +++ b/crates/vt/tests/charset.rs @@ -0,0 +1,41 @@ +use super::*; + +#[test] +fn ascii_passes_through() { + let cs = Charsets::default(); + assert_eq!(cs.map('q'), 'q'); + assert_eq!(cs.map('A'), 'A'); +} + +#[test] +fn dec_special_maps_line_drawing() { + let cs = Charsets { + g0: Charset::DecSpecial, + ..Default::default() + }; + assert_eq!(cs.map('q'), '─'); + assert_eq!(cs.map('x'), '│'); + assert_eq!(cs.map('l'), '┌'); + assert_eq!(cs.map('j'), '┘'); + assert_eq!(cs.map('A'), 'A'); +} + +#[test] +fn shift_out_selects_g1() { + let mut cs = Charsets { + g1: Charset::DecSpecial, + ..Default::default() + }; + assert_eq!(cs.map('q'), 'q'); + cs.shifted = true; + assert_eq!(cs.map('q'), '─'); + cs.shifted = false; + assert_eq!(cs.map('q'), 'q'); +} + +#[test] +fn final_byte_designation() { + assert_eq!(from_final(b'0'), Charset::DecSpecial); + assert_eq!(from_final(b'B'), Charset::Ascii); + assert_eq!(from_final(b'A'), Charset::Ascii); +} diff --git a/crates/vt/tests/color.rs b/crates/vt/tests/color.rs new file mode 100644 index 0000000..21a8128 --- /dev/null +++ b/crates/vt/tests/color.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn default_is_default_variant() { + assert_eq!(Color::default(), Color::Default); +} + +#[test] +fn named_maps_to_indexed() { + assert_eq!(Color::from(NamedColor::Black), Color::Indexed(0)); + assert_eq!(Color::from(NamedColor::Red), Color::Indexed(1)); + assert_eq!(Color::from(NamedColor::BrightWhite), Color::Indexed(15)); + assert_eq!(NamedColor::BrightBlack.index(), 8); +} diff --git a/crates/vt/tests/cursor.rs b/crates/vt/tests/cursor.rs new file mode 100644 index 0000000..f6f7078 --- /dev/null +++ b/crates/vt/tests/cursor.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn default_cursor_is_home() { + let c = Cursor::default(); + assert_eq!((c.row, c.col), (0, 0)); + assert!(!c.pending_wrap); +} + +#[test] +fn decscusr_mapping() { + assert_eq!( + CursorStyle::from_decscusr(0), + Some(CursorStyle::BlinkingBlock) + ); + assert_eq!( + CursorStyle::from_decscusr(1), + Some(CursorStyle::BlinkingBlock) + ); + assert_eq!( + CursorStyle::from_decscusr(2), + Some(CursorStyle::SteadyBlock) + ); + assert_eq!( + CursorStyle::from_decscusr(3), + Some(CursorStyle::BlinkingUnderline) + ); + assert_eq!( + CursorStyle::from_decscusr(4), + Some(CursorStyle::SteadyUnderline) + ); + assert_eq!( + CursorStyle::from_decscusr(5), + Some(CursorStyle::BlinkingBar) + ); + assert_eq!(CursorStyle::from_decscusr(6), Some(CursorStyle::SteadyBar)); + assert_eq!(CursorStyle::from_decscusr(7), None); +} + +#[test] +fn saved_cursor_default_is_home() { + let s = SavedCursor::default(); + assert_eq!((s.row, s.col), (0, 0)); + assert!(!s.origin); +} diff --git a/crates/vt/tests/grid/damage.rs b/crates/vt/tests/grid/damage.rs new file mode 100644 index 0000000..8734ec3 --- /dev/null +++ b/crates/vt/tests/grid/damage.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn fresh_tracker_is_full() { + let mut d = DamageTracker::new(); + assert!(d.is_full()); + assert_eq!(d.take(), Damage::Full); + assert_eq!(d.take(), Damage::Rows(vec![])); +} + +#[test] +fn rows_accumulate_sorted_and_deduped() { + let mut d = DamageTracker::new(); + d.take(); + d.mark_row(5); + d.mark_row(1); + d.mark_row(5); + assert_eq!(d.take(), Damage::Rows(vec![1, 5])); + assert_eq!(d.take(), Damage::Rows(vec![])); +} + +#[test] +fn full_overrides_rows() { + let mut d = DamageTracker::new(); + d.take(); + d.mark_row(2); + d.mark_full(); + assert_eq!(d.take(), Damage::Full); + assert_eq!(d.take(), Damage::Rows(vec![])); +} + +#[test] +fn mark_row_while_full_is_noop() { + let mut d = DamageTracker::new(); + d.mark_row(3); + assert_eq!(d.take(), Damage::Full); + assert_eq!(d.take(), Damage::Rows(vec![])); +} + +#[test] +fn is_empty() { + assert!(Damage::Rows(vec![]).is_empty()); + assert!(!Damage::Rows(vec![0]).is_empty()); + assert!(!Damage::Full.is_empty()); +} diff --git a/crates/vt/tests/grid/mod.rs b/crates/vt/tests/grid/mod.rs new file mode 100644 index 0000000..633d56a --- /dev/null +++ b/crates/vt/tests/grid/mod.rs @@ -0,0 +1,122 @@ +use super::*; + +fn grid_with_letters(rows: usize) -> Grid { + let mut g = Grid::new(4, rows, 100); + for r in 0..rows { + g.cell_mut(r, 0).ch = (b'a' + r as u8) as char; + } + g +} + +#[test] +fn new_grid_dimensions() { + let g = Grid::new(80, 24, 10); + assert_eq!(g.cols(), 80); + assert_eq!(g.rows(), 24); + assert_eq!(g.row(0).len(), 80); +} + +#[test] +fn scroll_up_moves_rows_and_blanks_bottom() { + let mut g = grid_with_letters(3); + g.scroll_up(0, 2, 1, false, Cell::default()); + assert_eq!(g.row(0).text(), "b"); + assert_eq!(g.row(1).text(), "c"); + assert_eq!(g.row(2).text(), ""); +} + +#[test] +fn scroll_up_saves_to_scrollback() { + let mut g = grid_with_letters(3); + g.scroll_up(0, 2, 2, true, Cell::default()); + assert_eq!(g.scrollback().len(), 2); + assert_eq!(g.scrollback().get(0).unwrap().text(), "a"); + assert_eq!(g.scrollback().get(1).unwrap().text(), "b"); + assert_eq!(g.row(0).text(), "c"); +} + +#[test] +fn scroll_up_region_only() { + let mut g = grid_with_letters(4); + g.scroll_up(1, 2, 1, false, Cell::default()); + assert_eq!(g.row(0).text(), "a"); + assert_eq!(g.row(1).text(), "c"); + assert_eq!(g.row(2).text(), ""); + assert_eq!(g.row(3).text(), "d"); +} + +#[test] +fn scroll_down_moves_rows_and_blanks_top() { + let mut g = grid_with_letters(3); + g.scroll_down(0, 2, 1, Cell::default()); + assert_eq!(g.row(0).text(), ""); + assert_eq!(g.row(1).text(), "a"); + assert_eq!(g.row(2).text(), "b"); +} + +#[test] +fn scroll_clamps_oversized_count() { + let mut g = grid_with_letters(3); + g.scroll_up(0, 2, 99, false, Cell::default()); + assert_eq!(g.row(0).text(), ""); + assert_eq!(g.row(2).text(), ""); +} + +#[test] +fn resize_grows_and_shrinks() { + let mut g = grid_with_letters(3); + g.resize(8, 5); + assert_eq!(g.cols(), 8); + assert_eq!(g.rows(), 5); + assert_eq!(g.row(0).text(), "a"); + assert_eq!(g.row(4).text(), ""); + g.resize(2, 2); + assert_eq!(g.cols(), 2); + assert_eq!(g.rows(), 2); + assert_eq!(g.row(1).text(), "b"); +} + +#[test] +fn resize_clamps_to_one() { + let mut g = Grid::new(4, 4, 0); + g.resize(0, 0); + assert_eq!(g.cols(), 1); + assert_eq!(g.rows(), 1); +} + +#[test] +fn fresh_grid_is_fully_damaged() { + let mut g = Grid::new(4, 4, 0); + assert_eq!(g.take_damage(), Damage::Full); + assert_eq!(g.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn cell_and_row_mutation_mark_rows() { + let mut g = Grid::new(4, 4, 0); + g.take_damage(); + g.cell_mut(2, 1).ch = 'x'; + g.row_mut(0).fill(Cell::default()); + assert_eq!(g.take_damage(), Damage::Rows(vec![0, 2])); + assert_eq!(g.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn scroll_escalates_to_full() { + let mut g = grid_with_letters(3); + g.take_damage(); + g.scroll_up(0, 2, 1, false, Cell::default()); + assert_eq!(g.take_damage(), Damage::Full); + g.scroll_down(0, 2, 1, Cell::default()); + assert_eq!(g.take_damage(), Damage::Full); +} + +#[test] +fn resize_and_scrollback_clear_escalate_to_full() { + let mut g = grid_with_letters(3); + g.take_damage(); + g.resize(8, 5); + assert_eq!(g.take_damage(), Damage::Full); + g.clear_scrollback(); + assert_eq!(g.take_damage(), Damage::Full); +} diff --git a/crates/vt/tests/grid/row.rs b/crates/vt/tests/grid/row.rs new file mode 100644 index 0000000..909ef8b --- /dev/null +++ b/crates/vt/tests/grid/row.rs @@ -0,0 +1,41 @@ +use super::*; +use crate::cell::CellFlags; +use crate::color::Color; + +#[test] +fn new_row_is_blank() { + let r = Row::new(5); + assert_eq!(r.len(), 5); + assert_eq!(r.text(), ""); + assert!(!r.wrapped); +} + +#[test] +fn fill_resets_wrap() { + let mut r = Row::new(3); + r.wrapped = true; + r.fill(Cell::default()); + assert!(!r.wrapped); +} + +#[test] +fn resize_pads_and_truncates() { + let mut r = Row::new(3); + let mut blank = Cell::default(); + blank.bg = Color::Indexed(2); + r.resize(6, blank); + assert_eq!(r.len(), 6); + assert_eq!(r.cells[5].bg, Color::Indexed(2)); + r.resize(2, blank); + assert_eq!(r.len(), 2); +} + +#[test] +fn text_skips_wide_spacers() { + let mut r = Row::new(4); + r.cells[0].ch = '漢'; + r.cells[0].flags.insert(CellFlags::WIDE); + r.cells[1].flags.insert(CellFlags::WIDE_SPACER); + r.cells[2].ch = 'x'; + assert_eq!(r.text(), "漢x"); +} diff --git a/crates/vt/tests/grid/scrollback.rs b/crates/vt/tests/grid/scrollback.rs new file mode 100644 index 0000000..ec3da7c --- /dev/null +++ b/crates/vt/tests/grid/scrollback.rs @@ -0,0 +1,52 @@ +use super::*; + +fn marked(ch: char) -> Row { + let mut r = Row::new(2); + r.cells[0].ch = ch; + r +} + +#[test] +fn pushes_in_order() { + let mut sb = Scrollback::new(10); + sb.push(marked('a')); + sb.push(marked('b')); + assert_eq!(sb.len(), 2); + assert_eq!(sb.get(0).unwrap().text(), "a"); + assert_eq!(sb.get(1).unwrap().text(), "b"); +} + +#[test] +fn evicts_oldest_at_limit() { + let mut sb = Scrollback::new(2); + sb.push(marked('a')); + sb.push(marked('b')); + sb.push(marked('c')); + assert_eq!(sb.len(), 2); + assert_eq!(sb.get(0).unwrap().text(), "b"); + assert_eq!(sb.get(1).unwrap().text(), "c"); +} + +#[test] +fn zero_limit_stores_nothing() { + let mut sb = Scrollback::new(0); + sb.push(marked('a')); + assert!(sb.is_empty()); +} + +#[test] +fn clear_empties() { + let mut sb = Scrollback::new(5); + sb.push(marked('a')); + sb.clear(); + assert!(sb.is_empty()); + assert_eq!(sb.limit(), 5); +} + +#[test] +fn resize_rows_changes_width() { + let mut sb = Scrollback::new(5); + sb.push(marked('a')); + sb.resize_rows(7); + assert_eq!(sb.get(0).unwrap().len(), 7); +} diff --git a/crates/vt/tests/hyperlink.rs b/crates/vt/tests/hyperlink.rs new file mode 100644 index 0000000..4e53916 --- /dev/null +++ b/crates/vt/tests/hyperlink.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn intern_dedupes_identical_links() { + let mut h = Hyperlinks::default(); + let a = h.intern(Some("x".into()), "https://a".into()).unwrap(); + let b = h.intern(Some("x".into()), "https://a".into()).unwrap(); + assert_eq!(a, b); + assert_eq!(h.get(a).unwrap().uri, "https://a"); + assert_eq!(h.get(a).unwrap().id.as_deref(), Some("x")); +} + +#[test] +fn distinct_links_get_distinct_ids() { + let mut h = Hyperlinks::default(); + let a = h.intern(None, "https://a".into()).unwrap(); + let b = h.intern(None, "https://b".into()).unwrap(); + // Same uri but different id parameter is also distinct. + let c = h.intern(Some("g".into()), "https://a".into()).unwrap(); + assert_ne!(a, b); + assert_ne!(a, c); + assert_eq!(h.get(b).unwrap().uri, "https://b"); +} + +#[test] +fn clear_empties_the_registry() { + let mut h = Hyperlinks::default(); + let a = h.intern(None, "https://a".into()).unwrap(); + h.clear(); + assert!(h.get(a).is_none()); + // Ids restart after a clear. + let b = h.intern(None, "https://b".into()).unwrap(); + assert_eq!(a, b); +} diff --git a/crates/vt/tests/kitty.rs b/crates/vt/tests/kitty.rs new file mode 100644 index 0000000..a59a867 --- /dev/null +++ b/crates/vt/tests/kitty.rs @@ -0,0 +1,49 @@ +use super::*; + +#[test] +fn empty_stack_is_legacy() { + let k = KittyKeyboard::default(); + assert_eq!(k.current(), 0); +} + +#[test] +fn push_and_pop() { + let mut k = KittyKeyboard::default(); + k.push(flags::DISAMBIGUATE); + assert_eq!(k.current(), 1); + k.push(flags::DISAMBIGUATE | flags::REPORT_EVENT_TYPES); + assert_eq!(k.current(), 3); + k.pop(1); + assert_eq!(k.current(), 1); + k.pop(9); // clamps + assert_eq!(k.current(), 0); +} + +#[test] +fn set_modes() { + let mut k = KittyKeyboard::default(); + k.set(0b0001, 1); // set + assert_eq!(k.current(), 0b0001); + k.set(0b0010, 2); // add + assert_eq!(k.current(), 0b0011); + k.set(0b0001, 3); // clear + assert_eq!(k.current(), 0b0010); +} + +#[test] +fn flags_are_masked_to_defined_bits() { + let mut k = KittyKeyboard::default(); + k.push(0xff); + assert_eq!(k.current(), flags::ALL); +} + +#[test] +fn depth_is_bounded() { + let mut k = KittyKeyboard::default(); + for _ in 0..(MAX_DEPTH + 5) { + k.push(flags::DISAMBIGUATE); + } + // Still only MAX_DEPTH entries: popping that many empties it. + k.pop(MAX_DEPTH); + assert_eq!(k.current(), 0); +} diff --git a/crates/vt/tests/mode.rs b/crates/vt/tests/mode.rs new file mode 100644 index 0000000..7c320cc --- /dev/null +++ b/crates/vt/tests/mode.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn defaults_have_autowrap_and_visible_cursor() { + let m = Modes::default(); + assert!(m.contains(Modes::AUTOWRAP)); + assert!(m.contains(Modes::CURSOR_VISIBLE)); + assert!(!m.contains(Modes::ORIGIN)); + assert!(!m.contains(Modes::ALT_SCREEN)); + assert!(!m.contains(Modes::INSERT)); +} + +#[test] +fn set_and_clear() { + let mut m = Modes::default(); + m.insert(Modes::BRACKETED_PASTE); + assert!(m.contains(Modes::BRACKETED_PASTE)); + m.remove(Modes::BRACKETED_PASTE); + assert!(!m.contains(Modes::BRACKETED_PASTE)); +} diff --git a/crates/vt/tests/screen.rs b/crates/vt/tests/screen.rs new file mode 100644 index 0000000..2a71bf6 --- /dev/null +++ b/crates/vt/tests/screen.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn default_tab_stops_every_eight() { + let s = Screen::new(20, 4, 0); + assert_eq!(s.next_tab(0), 8); + assert_eq!(s.next_tab(8), 16); + assert_eq!(s.next_tab(16), 19); + assert_eq!(s.prev_tab(19), 16); + assert_eq!(s.prev_tab(8), 0); + assert_eq!(s.prev_tab(3), 0); +} + +#[test] +fn custom_tab_stops() { + let mut s = Screen::new(20, 4, 0); + s.clear_all_tabs(); + s.set_tab(5); + assert_eq!(s.next_tab(0), 5); + assert_eq!(s.next_tab(5), 19); + s.clear_tab(5); + assert_eq!(s.next_tab(0), 19); +} + +#[test] +fn new_screen_region_is_full() { + let s = Screen::new(10, 5, 0); + assert_eq!(s.scroll_top, 0); + assert_eq!(s.scroll_bottom, 4); +} + +#[test] +fn resize_clamps_cursor_and_resets_region() { + let mut s = Screen::new(10, 5, 0); + s.cursor.row = 4; + s.cursor.col = 9; + s.scroll_top = 1; + s.scroll_bottom = 3; + s.resize(4, 2); + assert_eq!(s.cursor.row, 1); + assert_eq!(s.cursor.col, 3); + assert_eq!(s.scroll_top, 0); + assert_eq!(s.scroll_bottom, 1); + assert_eq!(s.tabs.len(), 4); +} diff --git a/crates/vt/tests/search.rs b/crates/vt/tests/search.rs new file mode 100644 index 0000000..2da46c9 --- /dev/null +++ b/crates/vt/tests/search.rs @@ -0,0 +1,35 @@ +use super::*; + +fn run(needle: &str, row: &str, fold: bool) -> Vec<(usize, usize)> { + let chars: Vec = row.chars().collect(); + let cols: Vec = (0..chars.len()).collect(); + let needle: Vec = needle.chars().collect(); + in_row(&needle, &chars, &cols, 0, fold, |_| false) + .into_iter() + .map(|m| (m.start_col, m.end_col)) + .collect() +} + +#[test] +fn finds_all_occurrences() { + assert_eq!(run("ab", "abXabYab", false), [(0, 1), (3, 4), (6, 7)]); +} + +#[test] +fn case_insensitive() { + assert_eq!(run("hi", "HI there hi", true), [(0, 1), (9, 10)]); + assert_eq!(run("hi", "HI there hi", false), [(9, 10)]); +} + +#[test] +fn no_match_and_empty() { + assert!(run("zz", "abc", false).is_empty()); + assert!(run("", "abc", false).is_empty()); + assert!(run("abcd", "abc", false).is_empty()); +} + +#[test] +fn non_overlapping() { + // "aa" in "aaaa" yields two matches, not three. + assert_eq!(run("aa", "aaaa", false), [(0, 1), (2, 3)]); +} diff --git a/crates/vt/tests/selection.rs b/crates/vt/tests/selection.rs new file mode 100644 index 0000000..d72651a --- /dev/null +++ b/crates/vt/tests/selection.rs @@ -0,0 +1,185 @@ +use super::*; +use crate::cell::{Cell, CellFlags}; + +const EXTRA: &[char] = &['/', '-', '_', '.', '~']; + +fn put(grid: &mut Grid, row: usize, text: &str) { + for (c, ch) in text.chars().enumerate() { + grid.cell_mut(row, c).ch = ch; + } +} + +fn grid_3x10(lines: &[&str]) -> Grid { + let mut g = Grid::new(10, lines.len(), 100); + for (r, l) in lines.iter().enumerate() { + put(&mut g, r, l); + } + g +} + +fn span(s: (isize, usize), e: (isize, usize)) -> (Point, Point) { + (Point::new(s.0, s.1), Point::new(e.0, e.1)) +} + +#[test] +fn point_ordering_is_reading_order() { + assert!(Point::new(-1, 9) < Point::new(0, 0)); + assert!(Point::new(0, 1) < Point::new(0, 2)); + assert!(Point::new(1, 0) > Point::new(0, 9)); +} + +#[test] +fn normalization_orders_start_end() { + // Anchor below/after the extent. + let mut sel = Selection::new(SelectionMode::Cell, span((2, 5), (2, 5))); + sel.update(span((0, 3), (0, 3))); + assert_eq!(sel.start(), Point::new(0, 3)); + assert_eq!(sel.end(), Point::new(2, 5)); + // Extent moves past the anchor the other way. + sel.update(span((3, 1), (3, 1))); + assert_eq!(sel.start(), Point::new(2, 5)); + assert_eq!(sel.end(), Point::new(3, 1)); +} + +#[test] +fn contains_streams_across_rows() { + let mut sel = Selection::new(SelectionMode::Cell, span((0, 4), (0, 4))); + sel.update(span((2, 2), (2, 2))); + assert!(sel.contains(Point::new(0, 4))); + assert!(!sel.contains(Point::new(0, 3))); + assert!(sel.contains(Point::new(1, 0))); // middle row fully selected + assert!(sel.contains(Point::new(1, 9))); + assert!(sel.contains(Point::new(2, 2))); + assert!(!sel.contains(Point::new(2, 3))); +} + +#[test] +fn shift_lines_moves_all_points() { + let mut sel = Selection::new(SelectionMode::Cell, span((1, 0), (2, 9))); + sel.shift_lines(-3); + assert_eq!(sel.start(), Point::new(-2, 0)); + assert_eq!(sel.end(), Point::new(-1, 9)); +} + +#[test] +fn word_expansion_within_row() { + let g = grid_3x10(&["foo bar.gz"]); + let (s, e) = expand_word(&g, Point::new(0, 5), EXTRA); + assert_eq!((s, e), span((0, 4), (0, 9))); // "bar.gz": '.' is a word char + let (s, e) = expand_word(&g, Point::new(0, 1), EXTRA); + assert_eq!((s, e), span((0, 0), (0, 2))); +} + +#[test] +fn word_expansion_on_non_word_is_single_cell() { + let g = grid_3x10(&["foo bar"]); + let (s, e) = expand_word(&g, Point::new(0, 3), EXTRA); + assert_eq!((s, e), span((0, 3), (0, 3))); +} + +#[test] +fn word_expansion_crosses_soft_wrap() { + let mut g = Grid::new(4, 2, 10); + put(&mut g, 0, " abc"); + put(&mut g, 1, "de f"); + g.row_mut(0).wrapped = true; + let (s, e) = expand_word(&g, Point::new(0, 2), EXTRA); + assert_eq!((s, e), span((0, 1), (1, 1))); + // Without the wrap flag the word stops at the row edge. + g.row_mut(0).wrapped = false; + let (s, e) = expand_word(&g, Point::new(0, 2), EXTRA); + assert_eq!((s, e), span((0, 1), (0, 3))); +} + +/// "a漢 b" laid out as a real grid: head at 1, spacer at 2, space at +/// 3, 'b' at 4. +fn wide_grid() -> Grid { + let mut g = Grid::new(10, 1, 0); + g.cell_mut(0, 0).ch = 'a'; + g.cell_mut(0, 1).ch = '漢'; + g.cell_mut(0, 1).flags.insert(CellFlags::WIDE); + g.cell_mut(0, 2).flags.insert(CellFlags::WIDE_SPACER); + g.cell_mut(0, 4).ch = 'b'; + g +} + +#[test] +fn word_expansion_treats_wide_spacer_as_its_head() { + let g = wide_grid(); + let (s, e) = expand_word(&g, Point::new(0, 0), EXTRA); + assert_eq!((s, e), span((0, 0), (0, 2))); // spans head + spacer + // Starting on the spacer itself classifies as the head. + let (s, e) = expand_word(&g, Point::new(0, 2), EXTRA); + assert_eq!((s, e), span((0, 0), (0, 2))); +} + +#[test] +fn line_expansion_follows_wraps_both_ways() { + let mut g = grid_3x10(&["aaaaaaaaaa", "bbbbbbbbbb", "cc"]); + g.row_mut(0).wrapped = true; + g.row_mut(1).wrapped = true; + let (s, e) = expand_line(&g, Point::new(1, 4)); + assert_eq!((s, e), span((0, 0), (2, 9))); + // An unwrapped row is its own logical line. + let g2 = grid_3x10(&["a", "b", "c"]); + let (s, e) = expand_line(&g2, Point::new(1, 4)); + assert_eq!((s, e), span((1, 0), (1, 9))); +} + +#[test] +fn line_expansion_reaches_into_scrollback() { + let mut g = Grid::new(4, 2, 10); + put(&mut g, 0, "wrap"); + g.row_mut(0).wrapped = true; + g.scroll_up(0, 1, 1, true, Cell::default()); // "wrap" -> scrollback + put(&mut g, 0, "tail"); + let (s, e) = expand_line(&g, Point::new(0, 1)); + assert_eq!((s, e), span((-1, 0), (0, 3))); +} + +#[test] +fn text_joins_soft_wraps_without_newline() { + let mut g = grid_3x10(&["aaaaaaaaaa", "bb", "cc"]); + g.row_mut(0).wrapped = true; + let sel = Selection::new(SelectionMode::Cell, span((0, 0), (2, 9))); + assert_eq!(text(&g, &sel), "aaaaaaaaaabb\ncc"); +} + +#[test] +fn text_trims_trailing_whitespace_per_hard_line() { + let g = grid_3x10(&["hi ", "there "]); + let sel = Selection::new(SelectionMode::Cell, span((0, 0), (1, 9))); + assert_eq!(text(&g, &sel), "hi\nthere"); +} + +#[test] +fn text_skips_wide_spacers() { + let g = wide_grid(); + let sel = Selection::new(SelectionMode::Cell, span((0, 0), (0, 4))); + assert_eq!(text(&g, &sel), "a漢 b"); +} + +#[test] +fn text_partial_first_and_last_rows() { + let g = grid_3x10(&["abcdefghij", "klmnopqrst"]); + let sel = Selection::new(SelectionMode::Cell, span((0, 7), (1, 2))); + assert_eq!(text(&g, &sel), "hij\nklm"); +} + +#[test] +fn text_spans_scrollback_and_live() { + let mut g = Grid::new(4, 2, 10); + put(&mut g, 0, "old"); + g.scroll_up(0, 1, 1, true, Cell::default()); + put(&mut g, 0, "new"); + let sel = Selection::new(SelectionMode::Cell, span((-1, 0), (0, 3))); + assert_eq!(text(&g, &sel), "old\nnew"); +} + +#[test] +fn clamp_point_bounds() { + let mut g = Grid::new(4, 2, 10); + g.scroll_up(0, 1, 1, true, Cell::default()); + assert_eq!(clamp_point(&g, Point::new(-9, 99)), Point::new(-1, 3)); + assert_eq!(clamp_point(&g, Point::new(9, 0)), Point::new(1, 0)); +} diff --git a/crates/vt/tests/sgr.rs b/crates/vt/tests/sgr.rs new file mode 100644 index 0000000..d71aee7 --- /dev/null +++ b/crates/vt/tests/sgr.rs @@ -0,0 +1,149 @@ +use super::*; + +fn pen_after(params: &[&[u16]]) -> Cell { + let mut pen = Cell::default(); + apply(&mut pen, params.iter().copied()); + pen +} + +#[test] +fn empty_resets() { + let mut pen = Cell::default(); + pen.flags.insert(CellFlags::BOLD); + pen.fg = Color::Indexed(1); + apply(&mut pen, std::iter::empty()); + assert_eq!(pen, Cell::default()); +} + +#[test] +fn zero_resets() { + let mut pen = Cell::default(); + pen.flags.insert(CellFlags::BOLD | CellFlags::ITALIC); + pen.bg = Color::Rgb(1, 2, 3); + apply(&mut pen, [&[0u16][..]]); + assert_eq!(pen, Cell::default()); +} + +#[test] +fn simple_attributes() { + let pen = pen_after(&[&[1], &[3], &[4], &[7], &[9]]); + assert!(pen.flags.contains(CellFlags::BOLD)); + assert!(pen.flags.contains(CellFlags::ITALIC)); + assert!(pen.flags.contains(CellFlags::UNDERLINE)); + assert!(pen.flags.contains(CellFlags::INVERSE)); + assert!(pen.flags.contains(CellFlags::STRIKETHROUGH)); +} + +#[test] +fn attribute_clears() { + let pen = pen_after(&[&[1], &[2], &[4], &[22], &[24]]); + assert!(!pen.flags.contains(CellFlags::BOLD)); + assert!(!pen.flags.contains(CellFlags::DIM)); + assert!(!pen.flags.contains(CellFlags::UNDERLINE)); +} + +#[test] +fn double_underline_via_21() { + let pen = pen_after(&[&[4], &[21]]); + assert!(pen.flags.contains(CellFlags::DOUBLE_UNDERLINE)); + assert!(!pen.flags.contains(CellFlags::UNDERLINE)); +} + +#[test] +fn underline_styles_colon() { + assert!(pen_after(&[&[4, 2]]) + .flags + .contains(CellFlags::DOUBLE_UNDERLINE)); + assert!(pen_after(&[&[4, 3]]) + .flags + .contains(CellFlags::CURLY_UNDERLINE)); + assert!(pen_after(&[&[4, 4]]) + .flags + .contains(CellFlags::DOTTED_UNDERLINE)); + assert!(pen_after(&[&[4, 5]]) + .flags + .contains(CellFlags::DASHED_UNDERLINE)); + assert!(pen_after(&[&[4, 0]]).flags & CellFlags::ANY_UNDERLINE == CellFlags::empty()); +} + +#[test] +fn named_colors() { + let pen = pen_after(&[&[31], &[44]]); + assert_eq!(pen.fg, Color::Indexed(1)); + assert_eq!(pen.bg, Color::Indexed(4)); +} + +#[test] +fn bright_colors() { + let pen = pen_after(&[&[91], &[104]]); + assert_eq!(pen.fg, Color::Indexed(9)); + assert_eq!(pen.bg, Color::Indexed(12)); +} + +#[test] +fn default_colors() { + let pen = pen_after(&[&[31], &[44], &[39], &[49]]); + assert_eq!(pen.fg, Color::Default); + assert_eq!(pen.bg, Color::Default); +} + +#[test] +fn indexed_semicolon() { + let pen = pen_after(&[&[38], &[5], &[208]]); + assert_eq!(pen.fg, Color::Indexed(208)); +} + +#[test] +fn indexed_colon() { + let pen = pen_after(&[&[38, 5, 123]]); + assert_eq!(pen.fg, Color::Indexed(123)); +} + +#[test] +fn truecolor_semicolon() { + let pen = pen_after(&[&[48], &[2], &[10], &[20], &[30]]); + assert_eq!(pen.bg, Color::Rgb(10, 20, 30)); +} + +#[test] +fn truecolor_colon() { + let pen = pen_after(&[&[38, 2, 1, 2, 3]]); + assert_eq!(pen.fg, Color::Rgb(1, 2, 3)); +} + +#[test] +fn truecolor_colon_with_colorspace() { + let pen = pen_after(&[&[38, 2, 0, 9, 8, 7]]); + assert_eq!(pen.fg, Color::Rgb(9, 8, 7)); +} + +#[test] +fn underline_color() { + let pen = pen_after(&[&[58, 5, 33]]); + assert_eq!(pen.underline_color, Color::Indexed(33)); + let pen = pen_after(&[&[58], &[2], &[7], &[8], &[9]]); + assert_eq!(pen.underline_color, Color::Rgb(7, 8, 9)); + let pen = pen_after(&[&[58, 5, 33], &[59]]); + assert_eq!(pen.underline_color, Color::Default); +} + +#[test] +fn malformed_extended_color_is_ignored() { + let pen = pen_after(&[&[38], &[9]]); + assert_eq!(pen.fg, Color::Default); + let pen = pen_after(&[&[38, 2, 1]]); + assert_eq!(pen.fg, Color::Default); +} + +#[test] +fn values_clamp_to_u8() { + let pen = pen_after(&[&[38, 2, 300, 300, 300]]); + assert_eq!(pen.fg, Color::Rgb(255, 255, 255)); +} + +#[test] +fn parameters_after_color_still_apply() { + let pen = pen_after(&[&[38], &[5], &[10], &[1]]); + assert_eq!(pen.fg, Color::Indexed(10)); + assert!(pen.flags.contains(CellFlags::BOLD)); +} diff --git a/crates/vt/tests/term/csi.rs b/crates/vt/tests/term/csi.rs new file mode 100644 index 0000000..9d091c0 --- /dev/null +++ b/crates/vt/tests/term/csi.rs @@ -0,0 +1,121 @@ +use crate::mode::Modes; +use crate::term::Terminal; + +#[test] +fn cup_moves_cursor() { + let mut t = Terminal::new(10, 5, 0); + t.feed(b"\x1b[3;4H"); + assert_eq!(t.cursor_pos(), (2, 3)); + t.feed(b"\x1b[H"); + assert_eq!(t.cursor_pos(), (0, 0)); +} + +#[test] +fn private_modes_toggle() { + let mut t = Terminal::new(10, 5, 0); + t.feed(b"\x1b[?25l"); + assert!(!t.cursor_visible()); + t.feed(b"\x1b[?25h"); + assert!(t.cursor_visible()); + t.feed(b"\x1b[?2004h"); + assert!(t.modes().contains(Modes::BRACKETED_PASTE)); + t.feed(b"\x1b[?1000h\x1b[?1006h"); + assert!(t.modes().contains(Modes::MOUSE_CLICK)); + assert!(t.modes().contains(Modes::MOUSE_SGR)); + t.feed(b"\x1b[?1000l"); + assert!(!t.modes().contains(Modes::MOUSE_CLICK)); +} + +#[test] +fn insert_mode_shifts_on_print() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"abc\x1b[1G\x1b[4hX"); + assert_eq!(t.row_text(0), "Xabc"); + t.feed(b"\x1b[4l"); + assert!(!t.modes().contains(Modes::INSERT)); +} + +#[test] +fn dsr_five_reports_ok() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[5n"); + assert_eq!(t.take_output(), b"\x1b[0n"); +} + +#[test] +fn da1_reports_vt220() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[c"); + assert_eq!(t.take_output(), b"\x1b[?62;22c"); +} + +#[test] +fn decscusr_stores_style() { + use crate::cursor::CursorStyle; + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[4 q"); + assert_eq!(t.cursor_style(), CursorStyle::SteadyUnderline); + t.feed(b"\x1b[0 q"); + assert_eq!(t.cursor_style(), CursorStyle::BlinkingBlock); +} + +#[test] +fn title_stack_push_pop() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]2;first\x07\x1b[22;0t\x1b]2;second\x07"); + assert_eq!(t.title(), "second"); + t.feed(b"\x1b[23;0t"); + assert_eq!(t.title(), "first"); +} + +#[test] +fn rep_repeats_last_char() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"x\x1b[3b"); + assert_eq!(t.row_text(0), "xxxx"); +} + +#[test] +fn kitty_keyboard_push_pop_set_query() { + let mut t = Terminal::new(10, 3, 0); + assert_eq!(t.kitty_keyboard_flags(), 0); + // Query in legacy mode. + t.feed(b"\x1b[?u"); + assert_eq!(t.take_output(), b"\x1b[?0u"); + // Push disambiguate. + t.feed(b"\x1b[>1u"); + assert_eq!(t.kitty_keyboard_flags(), 1); + t.feed(b"\x1b[?u"); + assert_eq!(t.take_output(), b"\x1b[?1u"); + // Add a bit via set mode 2. + t.feed(b"\x1b[=2;2u"); + assert_eq!(t.kitty_keyboard_flags(), 3); + // Pop back to legacy. + t.feed(b"\x1b[<1u"); + assert_eq!(t.kitty_keyboard_flags(), 0); +} + +#[test] +fn kitty_keyboard_is_per_screen() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[>5u"); // main screen + assert_eq!(t.kitty_keyboard_flags(), 5); + t.feed(b"\x1b[?1049h"); // enter alt: its own (empty) stack + assert_eq!(t.kitty_keyboard_flags(), 0); + t.feed(b"\x1b[?1049l"); // back to main + assert_eq!(t.kitty_keyboard_flags(), 5); +} + +#[test] +fn kitty_keyboard_reset_by_ris() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[>9u\x1bc"); + assert_eq!(t.kitty_keyboard_flags(), 0); +} + +#[test] +fn unknown_csi_is_ignored() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[999z\x1b[?9999hok"); + assert_eq!(t.row_text(0), "ok"); +} diff --git a/crates/vt/tests/term/dcs.rs b/crates/vt/tests/term/dcs.rs new file mode 100644 index 0000000..3126658 --- /dev/null +++ b/crates/vt/tests/term/dcs.rs @@ -0,0 +1,57 @@ +use crate::term::Terminal; + +#[test] +fn xtgettcap_reports_colors() { + let mut t = Terminal::new(10, 3, 0); + // "Co" -> 436f; value "256" -> 323536. + t.feed(b"\x1bP+q436f\x1b\\"); + assert_eq!(t.take_output(), b"\x1bP1+r436f=323536\x1b\\"); +} + +#[test] +fn xtgettcap_reports_terminal_name() { + let mut t = Terminal::new(10, 3, 0); + // "TN" -> 544e. + t.feed(b"\x1bP+q544e\x1b\\"); + let out = t.take_output(); + let want = format!( + "\x1bP1+r544e={}\x1b\\", + super::hex_encode(b"xterm-256color") + ); + assert_eq!(out, want.as_bytes()); +} + +#[test] +fn xtgettcap_boolean_has_no_value() { + let mut t = Terminal::new(10, 3, 0); + // "Tc" -> 5463. + t.feed(b"\x1bP+q5463\x1b\\"); + assert_eq!(t.take_output(), b"\x1bP1+r5463\x1b\\"); +} + +#[test] +fn xtgettcap_unknown_is_zero() { + let mut t = Terminal::new(10, 3, 0); + // "zz" -> 7a7a. + t.feed(b"\x1bP+q7a7a\x1b\\"); + assert_eq!(t.take_output(), b"\x1bP0+r7a7a\x1b\\"); +} + +#[test] +fn xtgettcap_multiple_caps() { + let mut t = Terminal::new(10, 3, 0); + // "Co;RGB" -> 436f;524742. + t.feed(b"\x1bP+q436f;524742\x1b\\"); + assert_eq!( + t.take_output(), + b"\x1bP1+r436f=323536\x1b\\\x1bP1+r524742=382f382f38\x1b\\" + ); +} + +#[test] +fn non_xtgettcap_dcs_ignored() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1bPsomething\x1b\\ok"); + assert!(t.take_output().is_empty()); + assert_eq!(t.row_text(0), "ok"); +} diff --git a/crates/vt/tests/term/mod.rs b/crates/vt/tests/term/mod.rs new file mode 100644 index 0000000..3a1316d --- /dev/null +++ b/crates/vt/tests/term/mod.rs @@ -0,0 +1,342 @@ +use super::*; + +#[test] +fn construction_defaults() { + let t = Terminal::new(80, 24, 100); + assert_eq!(t.cols(), 80); + assert_eq!(t.rows(), 24); + assert!(!t.is_alt_screen()); + assert!(t.cursor_visible()); + assert_eq!(t.cursor_pos(), (0, 0)); + assert_eq!(t.title(), ""); + assert_eq!(t.cursor_style(), CursorStyle::BlinkingBlock); +} + +#[test] +fn feed_prints_text() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"hi"); + assert_eq!(t.row_text(0), "hi"); + assert_eq!(t.cursor_pos(), (0, 2)); +} + +#[test] +fn visible_rows_with_offset() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc\r\nd"); + // "a" and "b" scrolled into history. + assert_eq!(t.row_text(0), "c"); + t.set_display_offset(2); + assert_eq!(t.row_text(0), "a"); + assert_eq!(t.row_text(1), "b"); + t.set_display_offset(99); + assert_eq!(t.display_offset(), 2); +} + +#[test] +fn display_offset_stays_stable_as_output_arrives() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc\r\nd"); // scrollback: a, b + t.set_display_offset(2); + assert_eq!(t.row_text(0), "a"); + t.feed(b"\r\ne"); // "c" scrolls into history + assert_eq!(t.display_offset(), 3); + assert_eq!(t.row_text(0), "a"); // view did not shift + assert_eq!(t.row_text(1), "b"); +} + +#[test] +fn display_offset_clamps_when_ring_evicts() { + let mut t = Terminal::new(4, 2, 2); + t.feed(b"a\r\nb\r\nc\r\nd"); // ring full: a, b + t.set_display_offset(2); + t.feed(b"\r\ne"); // pushes "c", evicts "a" + assert_eq!(t.display_offset(), 2); // clamped to ring length + assert_eq!(t.row_text(0), "b"); +} + +#[test] +fn display_offset_untouched_at_bottom() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc"); + assert_eq!(t.display_offset(), 0); + t.feed(b"\r\nd"); // more history, still at the live bottom + assert_eq!(t.display_offset(), 0); +} + +#[test] +fn scroll_display_deltas_clamp() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc\r\nd"); // scrollback len 2 + t.scroll_display(99); + assert_eq!(t.display_offset(), 2); + t.scroll_display(-1); + assert_eq!(t.display_offset(), 1); + t.scroll_display(-99); + assert_eq!(t.display_offset(), 0); +} + +#[test] +fn entering_alt_resets_display_offset() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc\r\nd"); + t.set_display_offset(2); + t.feed(b"\x1b[?1049h"); + assert_eq!(t.display_offset(), 0); + // No scrollback on alt: scrolling is a no-op. + t.scroll_display(5); + assert_eq!(t.display_offset(), 0); + // vt does not restore the offset on exit; the app decides. + t.feed(b"\x1b[?1049l"); + assert_eq!(t.display_offset(), 0); +} + +#[test] +fn mouse_mode_tracks_decset() { + use crate::mode::MouseMode; + let mut t = Terminal::new(10, 3, 0); + assert_eq!(t.mouse_mode(), MouseMode::None); + assert!(!t.mouse_sgr()); + t.feed(b"\x1b[?1000h"); + assert_eq!(t.mouse_mode(), MouseMode::Click); + t.feed(b"\x1b[?1002h"); + assert_eq!(t.mouse_mode(), MouseMode::Drag); + t.feed(b"\x1b[?1003h"); + assert_eq!(t.mouse_mode(), MouseMode::Motion); + t.feed(b"\x1b[?1006h"); + assert!(t.mouse_sgr()); + t.feed(b"\x1b[?1003l"); + assert_eq!(t.mouse_mode(), MouseMode::Drag); + t.feed(b"\x1b[?1002l\x1b[?1000l\x1b[?1006l"); + assert_eq!(t.mouse_mode(), MouseMode::None); + assert!(!t.mouse_sgr()); +} + +#[test] +fn alternate_scroll_defaults_off_and_tracks_1007() { + let mut t = Terminal::new(10, 3, 0); + assert!(!t.alternate_scroll()); // xterm default + t.feed(b"\x1b[?1007h"); + assert!(t.alternate_scroll()); + t.feed(b"\x1b[?1007l"); + assert!(!t.alternate_scroll()); +} + +#[test] +fn resize_clamps_display_offset() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc"); + t.set_display_offset(1); + t.resize(6, 4); + assert!(t.display_offset() <= t.grid().scrollback().len()); + assert_eq!(t.cols(), 6); + assert_eq!(t.rows(), 4); +} + +#[test] +fn fresh_terminal_is_fully_damaged() { + let mut t = Terminal::new(10, 3, 0); + assert_eq!(t.take_damage(), Damage::Full); + assert_eq!(t.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn printing_marks_row_dirty_and_take_clears() { + let mut t = Terminal::new(10, 3, 0); + t.take_damage(); + t.feed(b"hi"); + assert_eq!(t.take_damage(), Damage::Rows(vec![0])); + assert_eq!(t.take_damage(), Damage::Rows(vec![])); + t.feed(b"\x1b[3;1Hx"); + assert_eq!(t.take_damage(), Damage::Rows(vec![2])); +} + +#[test] +fn scroll_escalates_to_full_damage() { + let mut t = Terminal::new(4, 2, 10); + t.take_damage(); + t.feed(b"a\r\nb\r\nc"); // last linefeed scrolls + assert_eq!(t.take_damage(), Damage::Full); +} + +#[test] +fn resize_escalates_to_full_damage() { + let mut t = Terminal::new(10, 3, 0); + t.take_damage(); + t.resize(8, 4); + assert_eq!(t.take_damage(), Damage::Full); +} + +#[test] +fn alt_switch_escalates_to_full_damage() { + let mut t = Terminal::new(10, 3, 0); + t.take_damage(); + t.feed(b"\x1b[?1049h"); + assert_eq!(t.take_damage(), Damage::Full); + t.feed(b"\x1b[?1049l"); + assert_eq!(t.take_damage(), Damage::Full); + // Leaving alt while already on primary changes nothing. + t.feed(b"\x1b[?1049l"); + assert_eq!(t.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn ris_and_ed_escalate_to_full_damage() { + let mut t = Terminal::new(10, 3, 0); + t.take_damage(); + t.feed(b"\x1bc"); + assert_eq!(t.take_damage(), Damage::Full); + t.feed(b"\x1b[2J"); + assert_eq!(t.take_damage(), Damage::Full); + t.feed(b"\x1b[3J"); + assert_eq!(t.take_damage(), Damage::Full); +} + +#[test] +fn palette_osc_escalates_to_full_damage() { + let mut t = Terminal::new(10, 3, 0); + t.take_damage(); + t.feed(b"\x1b]4;1;rgb:ff/00/00\x07"); + assert_eq!(t.take_damage(), Damage::Full); + t.feed(b"\x1b]104\x07"); + assert_eq!(t.take_damage(), Damage::Full); +} + +#[test] +fn display_offset_change_escalates_to_full_damage() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"a\r\nb\r\nc"); + t.take_damage(); + t.set_display_offset(1); + assert_eq!(t.take_damage(), Damage::Full); + // Setting the same offset again is not damage. + t.set_display_offset(1); + assert_eq!(t.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn full_damage_clears_stale_row_damage() { + let mut t = Terminal::new(10, 3, 0); + t.take_damage(); + t.feed(b"hi"); + t.resize(8, 4); + assert_eq!(t.take_damage(), Damage::Full); + assert_eq!(t.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn bell_take_and_clear() { + let mut t = Terminal::new(10, 3, 0); + assert!(!t.take_bell()); + t.feed(b"\x07"); + assert!(t.take_bell()); + assert!(!t.take_bell()); +} + +#[test] +fn title_change_signal() { + let mut t = Terminal::new(10, 3, 0); + assert_eq!(t.take_title_changed(), None); + t.feed(b"\x1b]2;hello\x07"); + assert_eq!(t.take_title_changed(), Some("hello".to_string())); + assert_eq!(t.take_title_changed(), None); + assert_eq!(t.title(), "hello"); + t.feed(b"\x1b]0;again\x07"); + assert_eq!(t.take_title_changed(), Some("again".to_string())); +} + +#[test] +fn cursor_keys_app_tracks_decckm() { + let mut t = Terminal::new(10, 3, 0); + assert!(!t.cursor_keys_app()); + t.feed(b"\x1b[?1h"); + assert!(t.cursor_keys_app()); + t.feed(b"\x1b[?1l"); + assert!(!t.cursor_keys_app()); + // RIS clears it. + t.feed(b"\x1b[?1h\x1bc"); + assert!(!t.cursor_keys_app()); +} + +#[test] +fn keypad_app_tracks_deckpam_deckpnm_and_ris() { + let mut t = Terminal::new(10, 3, 0); + assert!(!t.keypad_app()); + t.feed(b"\x1b="); + assert!(t.keypad_app()); + t.feed(b"\x1b>"); + assert!(!t.keypad_app()); + t.feed(b"\x1b=\x1bc"); + assert!(!t.keypad_app()); +} + +#[test] +fn bracketed_paste_accessor() { + let mut t = Terminal::new(10, 3, 0); + assert!(!t.bracketed_paste()); + t.feed(b"\x1b[?2004h"); + assert!(t.bracketed_paste()); + t.feed(b"\x1b[?2004l"); + assert!(!t.bracketed_paste()); + t.feed(b"\x1b[?2004h\x1bc"); + assert!(!t.bracketed_paste()); +} + +#[test] +fn focus_reporting_emits_only_when_enabled() { + let mut t = Terminal::new(10, 3, 0); + // Off by default: report_focus is a no-op. + assert!(!t.focus_reporting()); + t.report_focus(true); + assert!(t.take_output().is_empty()); + // Enable ?1004 and focus in/out emit CSI I / CSI O. + t.feed(b"\x1b[?1004h"); + assert!(t.focus_reporting()); + t.report_focus(true); + assert_eq!(t.take_output(), b"\x1b[I"); + t.report_focus(false); + assert_eq!(t.take_output(), b"\x1b[O"); + t.feed(b"\x1b[?1004l"); + t.report_focus(true); + assert!(t.take_output().is_empty()); +} + +#[test] +fn synchronized_output_tracks_2026() { + let mut t = Terminal::new(10, 3, 0); + assert!(!t.synchronized_output()); + t.feed(b"\x1b[?2026h"); + assert!(t.synchronized_output()); + t.feed(b"\x1b[?2026l"); + assert!(!t.synchronized_output()); +} + +#[test] +fn search_finds_matches_across_scrollback() { + let mut t = Terminal::new(10, 2, 10); + t.feed(b"foo bar\r\nbaz foo\r\nqux"); // "foo" on lines 0 and 1 + let hits = t.search("foo", false); + assert_eq!(hits.len(), 2); + // First match is the oldest (lowest global line), col 0. + assert_eq!((hits[0].start_col, hits[0].end_col), (0, 2)); + // Case-insensitive by default; case-sensitive can differ. + t.feed(b"\r\nFOO"); + assert_eq!(t.search("foo", false).len(), 3); + assert_eq!(t.search("foo", true).len(), 2); +} + +#[test] +fn da2_reports_secondary_attributes() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b[>c"); + assert_eq!(t.take_output(), b"\x1b[>0;276;0c"); +} + +#[test] +fn title_stack_pop_signals_change() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]2;first\x07\x1b[22;0t\x1b]2;second\x07"); + t.take_title_changed(); + t.feed(b"\x1b[23;0t"); + assert_eq!(t.take_title_changed(), Some("first".to_string())); +} diff --git a/crates/vt/tests/term/ops.rs b/crates/vt/tests/term/ops.rs new file mode 100644 index 0000000..119b542 --- /dev/null +++ b/crates/vt/tests/term/ops.rs @@ -0,0 +1,184 @@ +use crate::term::Terminal; + +fn term() -> Terminal { + Terminal::new(10, 4, 50) +} + +#[test] +fn write_char_advances() { + let mut t = term(); + t.inner.write_char('a'); + t.inner.write_char('b'); + assert_eq!(t.row_text(0), "ab"); + assert_eq!(t.cursor_pos(), (0, 2)); +} + +#[test] +fn wide_char_takes_two_cells() { + let mut t = term(); + t.inner.write_char('漢'); + assert!(t.cell(0, 0).is_wide()); + assert!(t.cell(0, 1).is_wide_spacer()); + assert_eq!(t.cursor_pos(), (0, 2)); +} + +#[test] +fn overwriting_wide_head_clears_spacer() { + let mut t = term(); + t.inner.write_char('漢'); + t.inner.cursor_to(0, 0); + t.inner.write_char('x'); + assert_eq!(t.cell(0, 0).ch, 'x'); + assert!(!t.cell(0, 1).is_wide_spacer()); +} + +#[test] +fn overwriting_spacer_clears_head() { + let mut t = term(); + t.inner.write_char('漢'); + t.inner.cursor_to(0, 1); + t.inner.write_char('x'); + assert_eq!(t.cell(0, 1).ch, 'x'); + assert!(!t.cell(0, 0).is_wide()); + assert_eq!(t.cell(0, 0).ch, ' '); +} + +#[test] +fn pending_wrap_then_write_wraps() { + let mut t = term(); + for _ in 0..10 { + t.inner.write_char('x'); + } + assert_eq!(t.cursor_pos(), (0, 9)); + assert!(t.screen().cursor.pending_wrap); + t.inner.write_char('y'); + assert_eq!(t.cursor_pos(), (1, 1)); + assert_eq!(t.cell(1, 0).ch, 'y'); + assert!(t.grid().row(0).wrapped); +} + +#[test] +fn cursor_movement_clamps() { + let mut t = term(); + t.inner.cursor_up(5); + assert_eq!(t.cursor_pos(), (0, 0)); + t.inner.cursor_down(99); + assert_eq!(t.cursor_pos(), (3, 0)); + t.inner.cursor_right(99); + assert_eq!(t.cursor_pos(), (3, 9)); + t.inner.cursor_left(99); + assert_eq!(t.cursor_pos(), (3, 0)); +} + +#[test] +fn scroll_region_constrains_linefeed() { + let mut t = term(); + // 1-based margins 2..3 = rows 1..2 zero-based. + t.inner.set_scroll_region(2, 3); + t.inner.cursor_to(1, 0); + t.inner.write_char('a'); + t.inner.linefeed(); + t.inner.carriage_return(); + t.inner.write_char('b'); + t.inner.linefeed(); // at bottom margin: scrolls region, cursor stays + assert_eq!(t.row_text(1), "b"); + assert_eq!(t.row_text(0), ""); // row 0 untouched + assert_eq!(t.row_text(3), ""); // below region untouched + assert_eq!(t.cursor_pos().0, 2); +} + +#[test] +fn delete_chars_shifts_left() { + let mut t = term(); + t.feed(b"abcdef"); + t.inner.cursor_to(0, 1); + t.inner.delete_chars(2); + assert_eq!(t.row_text(0), "adef"); +} + +#[test] +fn insert_blank_shifts_right() { + let mut t = term(); + t.feed(b"abc"); + t.inner.cursor_to(0, 1); + t.inner.insert_blank(2); + assert_eq!(t.row_text(0), "a bc"); +} + +#[test] +fn full_reset_restores_defaults() { + let mut t = term(); + t.feed(b"\x1b[?25l\x1b[31mhello"); + t.inner.full_reset(); + assert!(t.cursor_visible()); + assert_eq!(t.row_text(0), ""); + assert_eq!(t.cursor_pos(), (0, 0)); +} + +#[test] +fn alignment_test_fills_screen() { + let mut t = term(); + t.inner.screen_alignment_test(); + assert_eq!(t.row_text(0), "EEEEEEEEEE"); + assert_eq!(t.row_text(3), "EEEEEEEEEE"); +} + +/// 10-col terminal whose row 0 soft-wrapped into row 1. +fn wrapped() -> Terminal { + let mut t = term(); + t.feed(b"0123456789ab"); + assert!(t.grid().row(0).wrapped); + t +} + +#[test] +fn erase_line_to_end_breaks_wrap() { + let mut t = wrapped(); + t.feed(b"\x1b[1;5H\x1b[K"); // EL 0 from mid-row + assert!(!t.grid().row(0).wrapped); + let mut t = wrapped(); + t.feed(b"\x1b[1;1H\x1b[2K"); // EL 2 + assert!(!t.grid().row(0).wrapped); + // EL 1 leaves the tail (and the continuation) intact. + let mut t = wrapped(); + t.feed(b"\x1b[1;5H\x1b[1K"); + assert!(t.grid().row(0).wrapped); +} + +#[test] +fn erase_display_below_breaks_wrap_on_cursor_row() { + let mut t = wrapped(); + t.feed(b"\x1b[1;5H\x1b[J"); + assert!(!t.grid().row(0).wrapped); +} + +#[test] +fn char_edits_break_wrap() { + let mut t = wrapped(); + t.feed(b"\x1b[1;1H\x1b[P"); // DCH shifts the tail left + assert!(!t.grid().row(0).wrapped); + let mut t = wrapped(); + t.feed(b"\x1b[1;1H\x1b[@"); // ICH pushes content off the right edge + assert!(!t.grid().row(0).wrapped); + let mut t = wrapped(); + t.feed(b"\x1b[1;10H\x1b[X"); // ECH reaching the last column + assert!(!t.grid().row(0).wrapped); + // ECH that stops short keeps the continuation. + let mut t = wrapped(); + t.feed(b"\x1b[1;1H\x1b[X"); + assert!(t.grid().row(0).wrapped); +} + +#[test] +fn overwriting_last_cell_breaks_wrap() { + let mut t = wrapped(); + t.feed(b"\x1b[1;10HZ"); + assert!(!t.grid().row(0).wrapped); + // ...and a fresh continuation re-sets it. + t.feed(b"\x1b[2;1H"); // park the cursor; flag stays cleared + assert!(!t.grid().row(0).wrapped); + let mut t = wrapped(); + t.feed(b"\x1b[1;10HZw"); // overwrite then continue: re-wrapped + assert!(t.grid().row(0).wrapped); + assert_eq!(t.cell(1, 0).ch, 'w'); +} diff --git a/crates/vt/tests/term/osc.rs b/crates/vt/tests/term/osc.rs new file mode 100644 index 0000000..e1f5873 --- /dev/null +++ b/crates/vt/tests/term/osc.rs @@ -0,0 +1,211 @@ +use super::*; +use crate::term::Terminal; + +#[test] +fn title_via_osc0_and_osc2() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]0;hello\x07"); + assert_eq!(t.title(), "hello"); + t.feed(b"\x1b]2;a;b\x1b\\"); + assert_eq!(t.title(), "a;b"); +} + +#[test] +fn palette_set_and_reset() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]4;1;rgb:ff/00/00\x07"); + assert_eq!(t.palette_override(1), Some((255, 0, 0))); + t.feed(b"\x1b]4;2;#00ff00;3;#0000ff\x07"); + assert_eq!(t.palette_override(2), Some((0, 255, 0))); + assert_eq!(t.palette_override(3), Some((0, 0, 255))); + t.feed(b"\x1b]104;2\x07"); + assert_eq!(t.palette_override(2), None); + assert_eq!(t.palette_override(3), Some((0, 0, 255))); + t.feed(b"\x1b]104\x07"); + assert_eq!(t.palette_override(1), None); + assert_eq!(t.palette_override(3), None); +} + +#[test] +fn cwd_stored() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]7;file://host/Users/me\x07"); + assert_eq!(t.cwd(), Some("file://host/Users/me")); +} + +#[test] +fn cursor_color_set_and_reset() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]12;#102030\x07"); + assert_eq!(t.cursor_color(), Some((16, 32, 48))); + t.feed(b"\x1b]112\x07"); + assert_eq!(t.cursor_color(), None); +} + +#[test] +fn unknown_osc_ignored() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]777;whatever\x07ok"); + assert_eq!(t.row_text(0), "ok"); +} + +fn report_colors() -> crate::term::ReportColors { + crate::term::ReportColors { + foreground: (0xc0, 0xc0, 0xc0), + background: (0x10, 0x10, 0x10), + cursor: (0xff, 0xff, 0x00), + palette: [(1, 2, 3); 256], + } +} + +#[test] +fn osc10_11_queries_report_theme_colors() { + let mut t = Terminal::new(10, 3, 0); + t.set_report_colors(report_colors()); + t.feed(b"\x1b]10;?\x07"); + assert_eq!(t.take_output(), b"\x1b]10;rgb:c0c0/c0c0/c0c0\x07"); + t.feed(b"\x1b]11;?\x1b\\"); + // ST request gets an ST-terminated reply. + assert_eq!(t.take_output(), b"\x1b]11;rgb:1010/1010/1010\x1b\\"); +} + +#[test] +fn osc_color_query_ignored_without_report_colors() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]11;?\x07"); + assert!(t.take_output().is_empty()); +} + +#[test] +fn osc4_query_prefers_override_then_report_palette() { + let mut t = Terminal::new(10, 3, 0); + t.set_report_colors(report_colors()); + // Unoverridden index answers from the report palette. + t.feed(b"\x1b]4;5;?\x07"); + assert_eq!(t.take_output(), b"\x1b]4;5;rgb:0101/0202/0303\x07"); + // An OSC 4 override wins over the report palette. + t.feed(b"\x1b]4;5;rgb:ff/00/00\x07"); + t.feed(b"\x1b]4;5;?\x07"); + assert_eq!(t.take_output(), b"\x1b]4;5;rgb:ffff/0000/0000\x07"); +} + +#[test] +fn osc12_query_uses_override_then_report_cursor() { + let mut t = Terminal::new(10, 3, 0); + t.set_report_colors(report_colors()); + t.feed(b"\x1b]12;?\x07"); + assert_eq!(t.take_output(), b"\x1b]12;rgb:ffff/ffff/0000\x07"); + t.feed(b"\x1b]12;#010203\x07"); + t.feed(b"\x1b]12;?\x07"); + assert_eq!(t.take_output(), b"\x1b]12;rgb:0101/0202/0303\x07"); +} + +#[test] +fn osc52_set_decodes_base64() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]52;c;aGVsbG8=\x07"); + let clip = t.take_clipboard().expect("clipboard write"); + assert_eq!(clip.kind, "c"); + assert_eq!(clip.data, b"hello"); + // Taken once. + assert!(t.take_clipboard().is_none()); +} + +#[test] +fn osc52_empty_kind_defaults_to_clipboard() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]52;;Zm9vYmFy\x07"); + let clip = t.take_clipboard().expect("clipboard write"); + assert_eq!(clip.kind, "c"); + assert_eq!(clip.data, b"foobar"); +} + +#[test] +fn osc52_query_and_garbage_ignored() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b]52;c;?\x07"); + assert!(t.take_clipboard().is_none()); + t.feed(b"\x1b]52;c;@@notbase64@@\x07"); + assert!(t.take_clipboard().is_none()); +} + +#[test] +fn osc8_links_cells_until_closed() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"\x1b]8;;https://example.com\x07ab\x1b]8;;\x07cd"); + // 'a' and 'b' carry the link; 'c' and 'd' do not. + assert_eq!(t.cell_hyperlink(t.cell(0, 0)), Some("https://example.com")); + assert_eq!(t.cell_hyperlink(t.cell(0, 1)), Some("https://example.com")); + assert_eq!(t.cell_hyperlink(t.cell(0, 2)), None); + assert_eq!(t.cell_hyperlink(t.cell(0, 3)), None); +} + +#[test] +fn osc8_uri_with_semicolons_is_preserved() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"\x1b]8;;https://x/a;b;c\x07z\x1b]8;;\x07"); + assert_eq!(t.cell_hyperlink(t.cell(0, 0)), Some("https://x/a;b;c")); +} + +#[test] +fn osc8_id_param_groups_links() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"\x1b]8;id=foo;https://a\x07x\x1b]8;;\x07"); + let id = t.cell(0, 0).hyperlink.expect("linked"); + let link = t.hyperlink(id).expect("resolves"); + assert_eq!(link.id.as_deref(), Some("foo")); + assert_eq!(link.uri, "https://a"); +} + +#[test] +fn osc133_marks_prompt_rows() { + let mut t = Terminal::new(10, 4, 10); + // Prompt on row 0, then output pushes a second prompt down a line. + t.feed(b"\x1b]133;A\x07$ \r\nout\r\n\x1b]133;A\x07$ "); + let prompts = t.prompt_lines(); + // Two prompt rows recorded (no scrollback yet, so global == grid row). + assert_eq!(prompts, vec![0, 2]); +} + +#[test] +fn osc133_prompts_follow_into_scrollback() { + let mut t = Terminal::new(10, 2, 10); + // Mark a prompt, then scroll it into history. + t.feed(b"\x1b]133;A\x07top\r\nb\r\nc\r\nd"); + // The marked row is now the oldest scrollback line: global index 0. + assert_eq!(t.prompt_lines().first(), Some(&0)); + assert_eq!(t.visible_row(0).text(), "c"); +} + +#[test] +fn osc8_links_cleared_by_ris() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"\x1b]8;;https://a\x07x"); + let id = t.cell(0, 0).hyperlink.expect("linked"); + t.feed(b"\x1bc"); + // After RIS the registry is empty and the cell is blank. + assert!(t.hyperlink(id).is_none()); + assert_eq!(t.cell_hyperlink(t.cell(0, 0)), None); +} + +#[test] +fn color_spec_forms() { + assert_eq!(parse_color_spec("rgb:ff/00/80"), Some((255, 0, 128))); + assert_eq!(parse_color_spec("rgb:f/0/8"), Some((255, 0, 136))); + assert_eq!(parse_color_spec("rgb:ffff/0000/8000"), Some((255, 0, 128))); + assert_eq!(parse_color_spec("#ff0080"), Some((255, 0, 128))); + assert_eq!(parse_color_spec("#f08"), Some((255, 0, 136))); + assert_eq!(parse_color_spec("#ffff00008000"), Some((255, 0, 128))); + assert_eq!(parse_color_spec("nonsense"), None); + assert_eq!(parse_color_spec("#12345"), None); + assert_eq!(parse_color_spec("rgb:gg/00/00"), None); +} + +#[test] +fn number_parsing() { + assert_eq!(parse_number(b"0"), Some(0)); + assert_eq!(parse_number(b"104"), Some(104)); + assert_eq!(parse_number(b""), None); + assert_eq!(parse_number(b"12a"), None); + assert_eq!(parse_number(b"999999"), None); +} diff --git a/crates/vt/tests/term/perform.rs b/crates/vt/tests/term/perform.rs new file mode 100644 index 0000000..4d38d3b --- /dev/null +++ b/crates/vt/tests/term/perform.rs @@ -0,0 +1,72 @@ +use crate::charset::Charset; +use crate::term::Terminal; + +#[test] +fn bel_sets_flag() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x07"); + assert!(t.take_bell()); + assert!(!t.take_bell()); +} + +#[test] +fn backspace_moves_left() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"ab\x08c"); + assert_eq!(t.row_text(0), "ac"); +} + +#[test] +fn ht_moves_to_tab_stop() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"\tx"); + assert_eq!(t.cell(0, 8).ch, 'x'); +} + +#[test] +fn nel_is_cr_plus_lf() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"ab\x1bEc"); + assert_eq!(t.row_text(1), "c"); + assert_eq!(t.cursor_pos(), (1, 1)); +} + +#[test] +fn hts_sets_tab_stop() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"\x1b[5G\x1bH\r\tx"); + assert_eq!(t.cell(0, 4).ch, 'x'); +} + +#[test] +fn keypad_modes() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b="); + assert!(t.modes().contains(crate::Modes::APP_KEYPAD)); + t.feed(b"\x1b>"); + assert!(!t.modes().contains(crate::Modes::APP_KEYPAD)); +} + +#[test] +fn charset_designation_and_shift() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\x1b(0"); + assert_eq!(t.inner.charsets.g0, Charset::DecSpecial); + t.feed(b"q"); + assert_eq!(t.cell(0, 0).ch, '─'); + t.feed(b"\x1b(B"); + t.feed(b"q"); + assert_eq!(t.cell(0, 1).ch, 'q'); + // SO selects G1. + t.feed(b"\x1b)0\x0eq\x0fq"); + assert_eq!(t.cell(0, 2).ch, '─'); + assert_eq!(t.cell(0, 3).ch, 'q'); +} + +#[test] +fn ris_resets() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"hello\x1b[?25l\x1bc"); + assert_eq!(t.row_text(0), ""); + assert!(t.cursor_visible()); +} diff --git a/crates/vt/tests/term/report.rs b/crates/vt/tests/term/report.rs new file mode 100644 index 0000000..1adb54a --- /dev/null +++ b/crates/vt/tests/term/report.rs @@ -0,0 +1,47 @@ +use super::*; + +#[test] +fn rgb_widens_by_replication() { + assert_eq!(format_rgb((255, 0, 128)), "rgb:ffff/0000/8080"); + assert_eq!(format_rgb((16, 32, 48)), "rgb:1010/2020/3030"); +} + +#[test] +fn base64_roundtrips() { + for s in [ + "", + "f", + "fo", + "foo", + "foob", + "fooba", + "foobar", + "hello, world", + ] { + let enc = base64_encode(s.as_bytes()); + assert_eq!(base64_decode(enc.as_bytes()).unwrap(), s.as_bytes(), "{s}"); + } +} + +#[test] +fn base64_known_vectors() { + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(base64_decode(b"Zm9vYmFy").unwrap(), b"foobar"); + // Whitespace is tolerated. + assert_eq!(base64_decode(b"Zm9v\nYmFy").unwrap(), b"foobar"); +} + +#[test] +fn base64_rejects_garbage() { + assert_eq!(base64_decode(b"@@@"), None); + assert_eq!(base64_decode(b"Zm9v!"), None); +} + +#[test] +fn hex_roundtrips() { + assert_eq!(hex_encode(b"Co"), "436f"); + assert_eq!(hex_decode(b"436f").unwrap(), b"Co"); + assert_eq!(hex_decode(b"524742").unwrap(), b"RGB"); + assert_eq!(hex_decode(b"abc"), None); // odd length + assert_eq!(hex_decode(b"zz"), None); // non-hex +} diff --git a/crates/vt/tests/term/select.rs b/crates/vt/tests/term/select.rs new file mode 100644 index 0000000..81cbd11 --- /dev/null +++ b/crates/vt/tests/term/select.rs @@ -0,0 +1,207 @@ +use crate::grid::damage::Damage; +use crate::selection::{Point, SelectionMode}; +use crate::term::Terminal; + +fn p(line: isize, col: usize) -> Point { + Point::new(line, col) +} + +#[test] +fn cell_selection_and_text() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"hello world"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(0, 4)); + assert_eq!(t.selection_text().as_deref(), Some("hello")); +} + +#[test] +fn selection_normalizes_upward_drag() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"ab\r\ncd"); + t.start_selection(SelectionMode::Cell, p(1, 1)); + t.update_selection(p(0, 0)); + let sel = t.selection().unwrap(); + assert_eq!(sel.start(), p(0, 0)); + assert_eq!(sel.end(), p(1, 1)); + assert_eq!(t.selection_text().as_deref(), Some("ab\ncd")); +} + +#[test] +fn word_selection_honors_word_chars() { + let mut t = Terminal::new(20, 3, 0); + t.feed(b"foo bar.gz baz"); + t.start_selection(SelectionMode::Word, p(0, 5)); + assert_eq!(t.selection_text().as_deref(), Some("bar.gz")); + t.set_word_chars(""); + t.start_selection(SelectionMode::Word, p(0, 5)); + assert_eq!(t.selection_text().as_deref(), Some("bar")); +} + +#[test] +fn word_selection_crosses_soft_wrap() { + let mut t = Terminal::new(4, 3, 10); + t.feed(b"xx abcd"); // wraps: "xx a" + "bcd" + assert!(t.grid().row(0).wrapped); + t.start_selection(SelectionMode::Word, p(0, 3)); + assert_eq!(t.selection_text().as_deref(), Some("abcd")); +} + +#[test] +fn line_selection_spans_logical_line() { + let mut t = Terminal::new(4, 3, 10); + t.feed(b"abcdef\r\nxyz"); + t.start_selection(SelectionMode::Line, p(1, 0)); + let sel = t.selection().unwrap(); + assert_eq!(sel.start(), p(0, 0)); + assert_eq!(sel.end(), p(1, 3)); + assert_eq!(t.selection_text().as_deref(), Some("abcdef")); +} + +#[test] +fn text_soft_wrap_joins_hard_break_newlines() { + let mut t = Terminal::new(4, 3, 10); + t.feed(b"abcdef\r\nxyz"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(2, 3)); + assert_eq!(t.selection_text().as_deref(), Some("abcdef\nxyz")); +} + +#[test] +fn text_handles_wide_chars_and_trailing_spaces() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"\xe6\xbc\xa2\xe5\xad\x97 x \r\nok"); // "漢字 x " + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(1, 9)); + assert_eq!(t.selection_text().as_deref(), Some("漢字 x\nok")); +} + +#[test] +fn update_without_selection_is_noop() { + let mut t = Terminal::new(10, 3, 0); + t.update_selection(p(0, 5)); + assert!(t.selection().is_none()); + assert_eq!(t.selection_text(), None); +} + +#[test] +fn selection_changes_are_full_damage() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"abc"); + t.take_damage(); + t.start_selection(SelectionMode::Cell, p(0, 0)); + assert_eq!(t.take_damage(), Damage::Full); + t.update_selection(p(0, 2)); + assert_eq!(t.take_damage(), Damage::Full); + t.clear_selection(); + assert_eq!(t.take_damage(), Damage::Full); + // Clearing nothing is not damage. + t.clear_selection(); + assert_eq!(t.take_damage(), Damage::Rows(vec![])); +} + +#[test] +fn print_inside_selection_clears_it() { + let mut t = Terminal::new(10, 4, 0); + t.feed(b"abc"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(0, 2)); + // Printing on an unselected row keeps the selection. + t.feed(b"\x1b[3;1Hx"); + assert!(t.selection().is_some()); + // Printing on the selected row clears it. + t.feed(b"\x1b[1;2Hy"); + assert!(t.selection().is_none()); +} + +#[test] +fn selection_stays_anchored_across_scroll_into_history() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"ab\r\ncd"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(0, 1)); + t.feed(b"\r\nef"); // scrolls "ab" into scrollback + let sel = t.selection().unwrap(); + assert_eq!(sel.start(), p(-1, 0)); + assert_eq!(t.selection_text().as_deref(), Some("ab")); +} + +#[test] +fn selection_invalidated_when_scrolled_off_ring() { + let mut t = Terminal::new(4, 2, 1); + t.feed(b"ab\r\ncd"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(0, 1)); + t.feed(b"\r\ne"); // "ab" -> ring (len 1), selection at line -1 + assert!(t.selection().is_some()); + t.feed(b"\r\nf"); // "cd" -> ring, "ab" evicted: selection off-ring + assert!(t.selection().is_none()); +} + +#[test] +fn region_scroll_clears_selection() { + let mut t = Terminal::new(10, 4, 10); + t.feed(b"sel\x1b[2;3r\x1b[3;1H"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.update_selection(p(0, 2)); + t.feed(b"\n"); // scrolls rows 1..=2 only (top != 0): conservative clear + assert!(t.selection().is_none()); +} + +#[test] +fn reverse_scroll_clears_selection() { + let mut t = Terminal::new(10, 3, 10); + t.feed(b"sel\x1b[1;1H"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.feed(b"\x1bM"); // RI at the top: scroll down + assert!(t.selection().is_none()); +} + +#[test] +fn alt_screen_switch_clears_selection() { + let mut t = Terminal::new(10, 3, 10); + t.feed(b"abc"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.feed(b"\x1b[?1049h"); + assert!(t.selection().is_none()); +} + +#[test] +fn erase_display_clears_only_when_intersecting() { + let mut t = Terminal::new(4, 2, 10); + t.feed(b"ab\r\ncd\r\nef"); // "ab" now in scrollback + t.start_selection(SelectionMode::Cell, p(-1, 0)); + t.update_selection(p(-1, 1)); + t.feed(b"\x1b[2J"); // live rows only: scrollback selection survives + assert!(t.selection().is_some()); + t.feed(b"\x1b[3J"); // history erased: selection dies, offset resets + assert!(t.selection().is_none()); + assert_eq!(t.display_offset(), 0); +} + +#[test] +fn resize_and_ris_clear_selection() { + let mut t = Terminal::new(10, 3, 10); + t.feed(b"abc"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.resize(8, 4); + assert!(t.selection().is_none()); + t.feed(b"abc"); + t.start_selection(SelectionMode::Cell, p(0, 0)); + t.feed(b"\x1bc"); + assert!(t.selection().is_none()); +} + +#[test] +fn contains_matches_renderer_view() { + let mut t = Terminal::new(10, 3, 0); + t.feed(b"ab\r\ncd\r\nef"); + t.start_selection(SelectionMode::Cell, p(0, 1)); + t.update_selection(p(2, 0)); + let sel = t.selection().unwrap(); + assert!(!sel.contains(p(0, 0))); + assert!(sel.contains(p(0, 1))); + assert!(sel.contains(p(1, 5))); // full middle row + assert!(sel.contains(p(2, 0))); + assert!(!sel.contains(p(2, 1))); +} diff --git a/crates/vt/tests/url.rs b/crates/vt/tests/url.rs new file mode 100644 index 0000000..14cbbd6 --- /dev/null +++ b/crates/vt/tests/url.rs @@ -0,0 +1,47 @@ +use super::*; + +fn urls(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + find(&chars) + .into_iter() + .map(|(a, b)| chars[a..b].iter().collect()) + .collect() +} + +#[test] +fn finds_basic_urls() { + assert_eq!(urls("see https://example.com now"), ["https://example.com"]); + assert_eq!(urls("http://a.b/c?d=1&e=2"), ["http://a.b/c?d=1&e=2"]); + assert_eq!(urls("mailto:me@x.io"), ["mailto:me@x.io"]); +} + +#[test] +fn trims_trailing_punctuation() { + assert_eq!(urls("go to https://x.io."), ["https://x.io"]); + assert_eq!(urls("(see https://x.io)"), ["https://x.io"]); + assert_eq!(urls("\"https://x.io\","), ["https://x.io"]); +} + +#[test] +fn keeps_balanced_parens() { + assert_eq!( + urls("https://en.wikipedia.org/wiki/Foo_(bar)"), + ["https://en.wikipedia.org/wiki/Foo_(bar)"] + ); +} + +#[test] +fn multiple_and_none() { + assert_eq!( + urls("a https://1.com b http://2.com"), + ["https://1.com", "http://2.com"] + ); + assert!(urls("no links here").is_empty()); + assert!(urls("https://").is_empty()); // scheme only + assert!(urls("nothttp://x").len() == 1); // still matches the embedded one +} + +#[test] +fn case_insensitive_scheme() { + assert_eq!(urls("HTTPS://X.IO/p"), ["HTTPS://X.IO/p"]); +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index e8d3ec0..61425da 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -1,4 +1,5 @@ [package] +autotests = false name = "workspace" version.workspace = true edition.workspace = true diff --git a/crates/workspace/src/id.rs b/crates/workspace/src/id.rs index b5cb3b3..b6ffc75 100644 --- a/crates/workspace/src/id.rs +++ b/crates/workspace/src/id.rs @@ -24,17 +24,5 @@ impl PaneIds { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ids_are_unique_and_monotonic() { - let mut ids = PaneIds::new(); - let a = ids.next(); - let b = ids.next(); - let c = ids.next(); - assert_ne!(a, b); - assert_ne!(b, c); - assert!(a < b && b < c); - } -} +#[path = "../tests/id.rs"] +mod tests; diff --git a/crates/workspace/src/layout.rs b/crates/workspace/src/layout.rs index a7331bc..6206a6e 100644 --- a/crates/workspace/src/layout.rs +++ b/crates/workspace/src/layout.rs @@ -82,147 +82,5 @@ fn walk(node: &Node, rect: Rect, divider: f32, out: &mut Layout) { } #[cfg(test)] -mod tests { - use super::*; - use crate::id::PaneIds; - - fn ids(n: usize) -> Vec { - let mut alloc = PaneIds::new(); - (0..n).map(|_| alloc.next()).collect() - } - - #[test] - fn single_pane_fills_rect() { - let p = ids(1); - let tree = PaneTree::new(p[0]); - let rect = Rect::new(5.0, 7.0, 100.0, 60.0); - let layout = compute_layout(&tree, rect, 4.0); - assert_eq!(layout.panes, vec![(p[0], rect)]); - assert!(layout.dividers.is_empty()); - } - - #[test] - fn horizontal_split_with_divider() { - let p = ids(2); - let mut tree = PaneTree::new(p[0]); - let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 50.0), 4.0); - assert_eq!( - layout.pane_rect(p[0]), - Some(Rect::new(0.0, 0.0, 48.0, 50.0)) - ); - assert_eq!( - layout.pane_rect(p[1]), - Some(Rect::new(52.0, 0.0, 48.0, 50.0)) - ); - assert_eq!( - layout.dividers, - vec![(s, Rect::new(48.0, 0.0, 4.0, 50.0), Axis::Horizontal)] - ); - } - - #[test] - fn vertical_split_with_divider() { - let p = ids(2); - let mut tree = PaneTree::new(p[0]); - let s = tree.split(p[0], Axis::Vertical, p[1], false).unwrap(); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 80.0, 110.0), 10.0); - assert_eq!( - layout.pane_rect(p[0]), - Some(Rect::new(0.0, 0.0, 80.0, 50.0)) - ); - assert_eq!( - layout.pane_rect(p[1]), - Some(Rect::new(0.0, 60.0, 80.0, 50.0)) - ); - assert_eq!( - layout.dividers, - vec![(s, Rect::new(0.0, 50.0, 80.0, 10.0), Axis::Vertical)] - ); - } - - #[test] - fn three_deep_nesting_both_axes() { - // a | (b over (c | d)), all ratios 0.5, no divider thickness. - let p = ids(4); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - tree.split(p[2], Axis::Horizontal, p[3], false).unwrap(); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); - assert_eq!( - layout.pane_rect(p[0]), - Some(Rect::new(0.0, 0.0, 50.0, 100.0)) - ); - assert_eq!( - layout.pane_rect(p[1]), - Some(Rect::new(50.0, 0.0, 50.0, 50.0)) - ); - assert_eq!( - layout.pane_rect(p[2]), - Some(Rect::new(50.0, 50.0, 25.0, 50.0)) - ); - assert_eq!( - layout.pane_rect(p[3]), - Some(Rect::new(75.0, 50.0, 25.0, 50.0)) - ); - assert_eq!(layout.dividers.len(), 3); - } - - #[test] - fn ratio_change_moves_rects() { - let p = ids(2); - let mut tree = PaneTree::new(p[0]); - let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - tree.set_ratio(s, 0.25); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); - assert_eq!( - layout.pane_rect(p[0]), - Some(Rect::new(0.0, 0.0, 25.0, 100.0)) - ); - assert_eq!( - layout.pane_rect(p[1]), - Some(Rect::new(25.0, 0.0, 75.0, 100.0)) - ); - - // Clamped ratio bounds the first pane at 90%. - tree.set_ratio(s, 1.5); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); - assert_eq!( - layout.pane_rect(p[0]), - Some(Rect::new(0.0, 0.0, 90.0, 100.0)) - ); - } - - #[test] - fn nested_divider_rects() { - // a | b, then b over c; divider 4, rect 104x104. - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 104.0, 104.0), 4.0); - assert_eq!( - layout.pane_rect(p[0]), - Some(Rect::new(0.0, 0.0, 50.0, 104.0)) - ); - assert_eq!( - layout.pane_rect(p[1]), - Some(Rect::new(54.0, 0.0, 50.0, 50.0)) - ); - assert_eq!( - layout.pane_rect(p[2]), - Some(Rect::new(54.0, 54.0, 50.0, 50.0)) - ); - assert_eq!( - layout.dividers, - vec![ - (outer, Rect::new(50.0, 0.0, 4.0, 104.0), Axis::Horizontal), - (inner, Rect::new(54.0, 50.0, 50.0, 4.0), Axis::Vertical), - ] - ); - // Divider order matches the tree's listing. - let listed: Vec<_> = layout.dividers.iter().map(|(s, _, a)| (*s, *a)).collect(); - assert_eq!(listed, tree.list_dividers()); - } -} +#[path = "../tests/layout.rs"] +mod tests; diff --git a/crates/workspace/src/nav.rs b/crates/workspace/src/nav.rs index 29618b3..bb62f80 100644 --- a/crates/workspace/src/nav.rs +++ b/crates/workspace/src/nav.rs @@ -77,98 +77,5 @@ fn centerdistance(from: Rect, to: Rect, dir: Direction) -> f32 { } #[cfg(test)] -mod tests { - use super::*; - use crate::id::PaneIds; - use crate::layout::compute_layout; - use crate::tree::Axis; - - fn ids(n: usize) -> Vec { - let mut alloc = PaneIds::new(); - (0..n).map(|_| alloc.next()).collect() - } - - /// a b - /// c d - fn grid(divider: f32) -> (Vec, PaneTree, Layout) { - let p = ids(4); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - tree.split(p[0], Axis::Vertical, p[2], false).unwrap(); - tree.split(p[1], Axis::Vertical, p[3], false).unwrap(); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), divider); - (p, tree, layout) - } - - #[test] - fn grid_navigation_from_every_pane() { - let (p, _, layout) = grid(0.0); - let (a, b, c, d) = (p[0], p[1], p[2], p[3]); - let cases = [ - (a, Direction::Right, Some(b)), - (a, Direction::Down, Some(c)), - (a, Direction::Left, None), - (a, Direction::Up, None), - (b, Direction::Left, Some(a)), - (b, Direction::Down, Some(d)), - (b, Direction::Right, None), - (b, Direction::Up, None), - (c, Direction::Up, Some(a)), - (c, Direction::Right, Some(d)), - (c, Direction::Down, None), - (c, Direction::Left, None), - (d, Direction::Up, Some(b)), - (d, Direction::Left, Some(c)), - (d, Direction::Down, None), - (d, Direction::Right, None), - ]; - for (from, dir, expected) in cases { - assert_eq!(neighbor(&layout, from, dir), expected, "{from:?} {dir:?}"); - } - } - - #[test] - fn grid_navigation_with_divider_thickness() { - let (p, _, layout) = grid(4.0); - assert_eq!(neighbor(&layout, p[0], Direction::Right), Some(p[1])); - assert_eq!(neighbor(&layout, p[3], Direction::Up), Some(p[1])); - assert_eq!(neighbor(&layout, p[3], Direction::Left), Some(p[2])); - } - - #[test] - fn nearest_center_breaks_column_ties() { - // Left pane spans full height; right side is b over c with c larger. - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - tree.set_ratio(inner, 0.2); - let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); - // a's center y = 50; b center y = 10, c center y = 60 -> picks c. - assert_eq!(neighbor(&layout, p[0], Direction::Right), Some(p[2])); - assert_eq!(neighbor(&layout, p[1], Direction::Left), Some(p[0])); - assert_eq!(neighbor(&layout, p[2], Direction::Up), Some(p[1])); - } - - #[test] - fn next_prev_wrap_in_layout_order() { - let (p, tree, _) = grid(0.0); - // Layout order: a, c, b, d. - assert_eq!(next(&tree, p[0]), Some(p[2])); - assert_eq!(next(&tree, p[2]), Some(p[1])); - assert_eq!(next(&tree, p[1]), Some(p[3])); - assert_eq!(next(&tree, p[3]), Some(p[0])); - assert_eq!(prev(&tree, p[0]), Some(p[3])); - assert_eq!(prev(&tree, p[3]), Some(p[1])); - } - - #[test] - fn unknown_pane_yields_none() { - let (p, tree, layout) = grid(0.0); - let stranger = ids(5)[4]; - assert!(!p.contains(&stranger)); - assert_eq!(neighbor(&layout, stranger, Direction::Left), None); - assert_eq!(next(&tree, stranger), None); - assert_eq!(prev(&tree, stranger), None); - } -} +#[path = "../tests/nav.rs"] +mod tests; diff --git a/crates/workspace/src/tabs.rs b/crates/workspace/src/tabs.rs index 4c2d6f7..24ecd70 100644 --- a/crates/workspace/src/tabs.rs +++ b/crates/workspace/src/tabs.rs @@ -148,125 +148,5 @@ impl Tabs { } #[cfg(test)] -mod tests { - use super::*; - use crate::id::PaneIds; - use crate::tree::Axis; - - fn ids(n: usize) -> Vec { - let mut alloc = PaneIds::new(); - (0..n).map(|_| alloc.next()).collect() - } - - #[test] - fn starts_with_one_active_tab() { - let p = ids(1); - let tabs = Tabs::new(p[0]); - assert_eq!(tabs.len(), 1); - assert_eq!(tabs.active_index(), 0); - assert_eq!(tabs.focused(), p[0]); - assert!(!tabs.is_empty()); - } - - #[test] - fn new_tab_appends_and_activates() { - let p = ids(3); - let mut tabs = Tabs::new(p[0]); - assert_eq!(tabs.new_tab(p[1]), 1); - assert_eq!(tabs.new_tab(p[2]), 2); - assert_eq!(tabs.active_index(), 2); - assert_eq!(tabs.focused(), p[2]); - } - - #[test] - fn close_tab_refuses_last_and_out_of_range() { - let p = ids(2); - let mut tabs = Tabs::new(p[0]); - assert!(!tabs.close_tab(0)); - tabs.new_tab(p[1]); - assert!(!tabs.close_tab(5)); - assert!(tabs.close_tab(1)); - assert_eq!(tabs.len(), 1); - assert!(!tabs.close_tab(0)); - } - - #[test] - fn close_tab_adjusts_active_index() { - let p = ids(3); - let mut tabs = Tabs::new(p[0]); - tabs.new_tab(p[1]); - tabs.new_tab(p[2]); - - // Closing before the active tab shifts it left. - tabs.activate(2); - assert!(tabs.close_tab(0)); - assert_eq!(tabs.active_index(), 1); - assert_eq!(tabs.focused(), p[2]); - - // Closing the active last tab clamps to the new end. - assert!(tabs.close_tab(1)); - assert_eq!(tabs.active_index(), 0); - assert_eq!(tabs.focused(), p[1]); - } - - #[test] - fn activate_and_cycling_wrap() { - let p = ids(3); - let mut tabs = Tabs::new(p[0]); - tabs.new_tab(p[1]); - tabs.new_tab(p[2]); - assert!(tabs.activate(0)); - assert!(!tabs.activate(3)); - assert_eq!(tabs.active_index(), 0); - tabs.activate_prev(); - assert_eq!(tabs.active_index(), 2); - tabs.activate_next(); - assert_eq!(tabs.active_index(), 0); - tabs.activate_next(); - assert_eq!(tabs.active_index(), 1); - } - - #[test] - fn move_tab_reorders_and_tracks_active() { - let p = ids(3); - let mut tabs = Tabs::new(p[0]); - tabs.new_tab(p[1]); - tabs.new_tab(p[2]); // order: 0,1,2; active 2. - - // Moving the active tab keeps it active at its new index. - assert!(tabs.move_tab(2, 0)); // order: 2,0,1. - assert_eq!(tabs.active_index(), 0); - assert_eq!(tabs.focused(), p[2]); - - // Moving another tab across the active one shifts the index. - assert!(tabs.move_tab(2, 0)); // order: 1,2,0; active follows p[2] to 1. - assert_eq!(tabs.active_index(), 1); - assert_eq!(tabs.focused(), p[2]); - assert_eq!(tabs.get(0).unwrap().focused, p[1]); - assert_eq!(tabs.get(2).unwrap().focused, p[0]); - - assert!(!tabs.move_tab(0, 9)); - assert!(!tabs.move_tab(9, 0)); - assert!(tabs.move_tab(1, 1)); - } - - #[test] - fn focus_only_panes_in_active_tab() { - let p = ids(3); - let mut tabs = Tabs::new(p[0]); - tabs.active_mut() - .tree - .split(p[0], Axis::Horizontal, p[1], false) - .unwrap(); - assert!(tabs.focus(p[1])); - assert_eq!(tabs.focused(), p[1]); - assert!(!tabs.focus(p[2])); - assert_eq!(tabs.focused(), p[1]); - - tabs.new_tab(p[2]); - assert_eq!(tabs.focused(), p[2]); - assert!(!tabs.focus(p[0])); // p[0] lives in the other tab. - tabs.activate(0); - assert_eq!(tabs.focused(), p[1]); // per-tab focus is remembered. - } -} +#[path = "../tests/tabs.rs"] +mod tests; diff --git a/crates/workspace/src/tree.rs b/crates/workspace/src/tree.rs index ac3a7e1..bdda770 100644 --- a/crates/workspace/src/tree.rs +++ b/crates/workspace/src/tree.rs @@ -250,127 +250,5 @@ fn containsnode(node: &Node, pane: PaneId) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use crate::id::PaneIds; - - fn ids(n: usize) -> Vec { - let mut alloc = PaneIds::new(); - (0..n).map(|_| alloc.next()).collect() - } - - #[test] - fn new_tree_holds_root() { - let p = ids(1); - let tree = PaneTree::new(p[0]); - assert_eq!(tree.panes(), vec![p[0]]); - assert!(tree.contains(p[0])); - assert!(tree.list_dividers().is_empty()); - } - - #[test] - fn split_orders_panes() { - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - assert_eq!(tree.panes(), vec![p[0], p[1]]); - // new_first puts the new pane before the target. - tree.split(p[0], Axis::Vertical, p[2], true).unwrap(); - assert_eq!(tree.panes(), vec![p[2], p[0], p[1]]); - } - - #[test] - fn split_rejects_missing_target_and_duplicate_pane() { - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - assert!(tree.split(p[1], Axis::Horizontal, p[2], false).is_none()); - assert!(tree.split(p[0], Axis::Horizontal, p[0], false).is_none()); - assert_eq!(tree.panes(), vec![p[0]]); - } - - #[test] - fn remove_collapses_chain() { - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - assert_eq!(tree.panes(), vec![p[0], p[1], p[2]]); - - assert!(tree.remove(p[1])); - assert_eq!(tree.panes(), vec![p[0], p[2]]); - assert_eq!(tree.list_dividers().len(), 1); - - assert!(tree.remove(p[2])); - assert_eq!(tree.panes(), vec![p[0]]); - assert_eq!(tree.root(), &Node::Leaf(p[0])); - } - - #[test] - fn remove_inner_split_sibling_subtree_survives() { - let p = ids(4); - let mut tree = PaneTree::new(p[0]); - tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - tree.split(p[0], Axis::Vertical, p[2], false).unwrap(); - tree.split(p[2], Axis::Vertical, p[3], false).unwrap(); - assert_eq!(tree.panes(), vec![p[0], p[2], p[3], p[1]]); - - assert!(tree.remove(p[0])); - assert_eq!(tree.panes(), vec![p[2], p[3], p[1]]); - assert!(tree.remove(p[3])); - assert!(tree.remove(p[1])); - assert_eq!(tree.panes(), vec![p[2]]); - } - - #[test] - fn remove_refuses_last_pane_and_missing() { - let p = ids(2); - let mut tree = PaneTree::new(p[0]); - assert!(!tree.remove(p[0])); - assert!(!tree.remove(p[1])); - assert!(tree.contains(p[0])); - } - - #[test] - fn ratio_clamps() { - let p = ids(2); - let mut tree = PaneTree::new(p[0]); - let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - assert_eq!(tree.ratio(s), Some(0.5)); - assert!(tree.set_ratio(s, 0.05)); - assert_eq!(tree.ratio(s), Some(MIN_RATIO)); - assert!(tree.set_ratio(s, 0.95)); - assert_eq!(tree.ratio(s), Some(MAX_RATIO)); - assert!(tree.set_ratio(s, 0.3)); - assert_eq!(tree.ratio(s), Some(0.3)); - assert!(!tree.set_ratio(SplitId(999), 0.5)); - } - - #[test] - fn nearest_split_picks_closest_matching_axis() { - // A | (B / C): root horizontal split, right child vertical split. - let p = ids(4); - let mut tree = PaneTree::new(p[0]); - let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - // B's nearest vertical divider is the inner split; horizontal is outer. - assert_eq!(tree.nearest_split(p[1], Axis::Vertical), Some(inner)); - assert_eq!(tree.nearest_split(p[1], Axis::Horizontal), Some(outer)); - // A only sits under the outer horizontal split. - assert_eq!(tree.nearest_split(p[0], Axis::Horizontal), Some(outer)); - assert_eq!(tree.nearest_split(p[0], Axis::Vertical), None); - // Missing pane has no split. - assert_eq!(tree.nearest_split(p[3], Axis::Horizontal), None); - } - - #[test] - fn dividers_listed_parent_first() { - let p = ids(3); - let mut tree = PaneTree::new(p[0]); - let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); - let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); - assert_eq!( - tree.list_dividers(), - vec![(outer, Axis::Horizontal), (inner, Axis::Vertical)] - ); - } -} +#[path = "../tests/tree.rs"] +mod tests; diff --git a/crates/workspace/tests/id.rs b/crates/workspace/tests/id.rs new file mode 100644 index 0000000..1a80e82 --- /dev/null +++ b/crates/workspace/tests/id.rs @@ -0,0 +1,12 @@ +use super::*; + +#[test] +fn ids_are_unique_and_monotonic() { + let mut ids = PaneIds::new(); + let a = ids.next(); + let b = ids.next(); + let c = ids.next(); + assert_ne!(a, b); + assert_ne!(b, c); + assert!(a < b && b < c); +} diff --git a/crates/workspace/tests/layout.rs b/crates/workspace/tests/layout.rs new file mode 100644 index 0000000..0b203d7 --- /dev/null +++ b/crates/workspace/tests/layout.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::id::PaneIds; + +fn ids(n: usize) -> Vec { + let mut alloc = PaneIds::new(); + (0..n).map(|_| alloc.next()).collect() +} + +#[test] +fn single_pane_fills_rect() { + let p = ids(1); + let tree = PaneTree::new(p[0]); + let rect = Rect::new(5.0, 7.0, 100.0, 60.0); + let layout = compute_layout(&tree, rect, 4.0); + assert_eq!(layout.panes, vec![(p[0], rect)]); + assert!(layout.dividers.is_empty()); +} + +#[test] +fn horizontal_split_with_divider() { + let p = ids(2); + let mut tree = PaneTree::new(p[0]); + let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 50.0), 4.0); + assert_eq!( + layout.pane_rect(p[0]), + Some(Rect::new(0.0, 0.0, 48.0, 50.0)) + ); + assert_eq!( + layout.pane_rect(p[1]), + Some(Rect::new(52.0, 0.0, 48.0, 50.0)) + ); + assert_eq!( + layout.dividers, + vec![(s, Rect::new(48.0, 0.0, 4.0, 50.0), Axis::Horizontal)] + ); +} + +#[test] +fn vertical_split_with_divider() { + let p = ids(2); + let mut tree = PaneTree::new(p[0]); + let s = tree.split(p[0], Axis::Vertical, p[1], false).unwrap(); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 80.0, 110.0), 10.0); + assert_eq!( + layout.pane_rect(p[0]), + Some(Rect::new(0.0, 0.0, 80.0, 50.0)) + ); + assert_eq!( + layout.pane_rect(p[1]), + Some(Rect::new(0.0, 60.0, 80.0, 50.0)) + ); + assert_eq!( + layout.dividers, + vec![(s, Rect::new(0.0, 50.0, 80.0, 10.0), Axis::Vertical)] + ); +} + +#[test] +fn three_deep_nesting_both_axes() { + // a | (b over (c | d)), all ratios 0.5, no divider thickness. + let p = ids(4); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + tree.split(p[2], Axis::Horizontal, p[3], false).unwrap(); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + assert_eq!( + layout.pane_rect(p[0]), + Some(Rect::new(0.0, 0.0, 50.0, 100.0)) + ); + assert_eq!( + layout.pane_rect(p[1]), + Some(Rect::new(50.0, 0.0, 50.0, 50.0)) + ); + assert_eq!( + layout.pane_rect(p[2]), + Some(Rect::new(50.0, 50.0, 25.0, 50.0)) + ); + assert_eq!( + layout.pane_rect(p[3]), + Some(Rect::new(75.0, 50.0, 25.0, 50.0)) + ); + assert_eq!(layout.dividers.len(), 3); +} + +#[test] +fn ratio_change_moves_rects() { + let p = ids(2); + let mut tree = PaneTree::new(p[0]); + let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + tree.set_ratio(s, 0.25); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + assert_eq!( + layout.pane_rect(p[0]), + Some(Rect::new(0.0, 0.0, 25.0, 100.0)) + ); + assert_eq!( + layout.pane_rect(p[1]), + Some(Rect::new(25.0, 0.0, 75.0, 100.0)) + ); + + // Clamped ratio bounds the first pane at 90%. + tree.set_ratio(s, 1.5); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + assert_eq!( + layout.pane_rect(p[0]), + Some(Rect::new(0.0, 0.0, 90.0, 100.0)) + ); +} + +#[test] +fn nested_divider_rects() { + // a | b, then b over c; divider 4, rect 104x104. + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 104.0, 104.0), 4.0); + assert_eq!( + layout.pane_rect(p[0]), + Some(Rect::new(0.0, 0.0, 50.0, 104.0)) + ); + assert_eq!( + layout.pane_rect(p[1]), + Some(Rect::new(54.0, 0.0, 50.0, 50.0)) + ); + assert_eq!( + layout.pane_rect(p[2]), + Some(Rect::new(54.0, 54.0, 50.0, 50.0)) + ); + assert_eq!( + layout.dividers, + vec![ + (outer, Rect::new(50.0, 0.0, 4.0, 104.0), Axis::Horizontal), + (inner, Rect::new(54.0, 50.0, 50.0, 4.0), Axis::Vertical), + ] + ); + // Divider order matches the tree's listing. + let listed: Vec<_> = layout.dividers.iter().map(|(s, _, a)| (*s, *a)).collect(); + assert_eq!(listed, tree.list_dividers()); +} diff --git a/crates/workspace/tests/nav.rs b/crates/workspace/tests/nav.rs new file mode 100644 index 0000000..34817d6 --- /dev/null +++ b/crates/workspace/tests/nav.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::id::PaneIds; +use crate::layout::compute_layout; +use crate::tree::Axis; + +fn ids(n: usize) -> Vec { + let mut alloc = PaneIds::new(); + (0..n).map(|_| alloc.next()).collect() +} + +/// a b +/// c d +fn grid(divider: f32) -> (Vec, PaneTree, Layout) { + let p = ids(4); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + tree.split(p[0], Axis::Vertical, p[2], false).unwrap(); + tree.split(p[1], Axis::Vertical, p[3], false).unwrap(); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), divider); + (p, tree, layout) +} + +#[test] +fn grid_navigation_from_every_pane() { + let (p, _, layout) = grid(0.0); + let (a, b, c, d) = (p[0], p[1], p[2], p[3]); + let cases = [ + (a, Direction::Right, Some(b)), + (a, Direction::Down, Some(c)), + (a, Direction::Left, None), + (a, Direction::Up, None), + (b, Direction::Left, Some(a)), + (b, Direction::Down, Some(d)), + (b, Direction::Right, None), + (b, Direction::Up, None), + (c, Direction::Up, Some(a)), + (c, Direction::Right, Some(d)), + (c, Direction::Down, None), + (c, Direction::Left, None), + (d, Direction::Up, Some(b)), + (d, Direction::Left, Some(c)), + (d, Direction::Down, None), + (d, Direction::Right, None), + ]; + for (from, dir, expected) in cases { + assert_eq!(neighbor(&layout, from, dir), expected, "{from:?} {dir:?}"); + } +} + +#[test] +fn grid_navigation_with_divider_thickness() { + let (p, _, layout) = grid(4.0); + assert_eq!(neighbor(&layout, p[0], Direction::Right), Some(p[1])); + assert_eq!(neighbor(&layout, p[3], Direction::Up), Some(p[1])); + assert_eq!(neighbor(&layout, p[3], Direction::Left), Some(p[2])); +} + +#[test] +fn nearest_center_breaks_column_ties() { + // Left pane spans full height; right side is b over c with c larger. + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + tree.set_ratio(inner, 0.2); + let layout = compute_layout(&tree, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + // a's center y = 50; b center y = 10, c center y = 60 -> picks c. + assert_eq!(neighbor(&layout, p[0], Direction::Right), Some(p[2])); + assert_eq!(neighbor(&layout, p[1], Direction::Left), Some(p[0])); + assert_eq!(neighbor(&layout, p[2], Direction::Up), Some(p[1])); +} + +#[test] +fn next_prev_wrap_in_layout_order() { + let (p, tree, _) = grid(0.0); + // Layout order: a, c, b, d. + assert_eq!(next(&tree, p[0]), Some(p[2])); + assert_eq!(next(&tree, p[2]), Some(p[1])); + assert_eq!(next(&tree, p[1]), Some(p[3])); + assert_eq!(next(&tree, p[3]), Some(p[0])); + assert_eq!(prev(&tree, p[0]), Some(p[3])); + assert_eq!(prev(&tree, p[3]), Some(p[1])); +} + +#[test] +fn unknown_pane_yields_none() { + let (p, tree, layout) = grid(0.0); + let stranger = ids(5)[4]; + assert!(!p.contains(&stranger)); + assert_eq!(neighbor(&layout, stranger, Direction::Left), None); + assert_eq!(next(&tree, stranger), None); + assert_eq!(prev(&tree, stranger), None); +} diff --git a/crates/workspace/tests/tabs.rs b/crates/workspace/tests/tabs.rs new file mode 100644 index 0000000..93c6613 --- /dev/null +++ b/crates/workspace/tests/tabs.rs @@ -0,0 +1,120 @@ +use super::*; +use crate::id::PaneIds; +use crate::tree::Axis; + +fn ids(n: usize) -> Vec { + let mut alloc = PaneIds::new(); + (0..n).map(|_| alloc.next()).collect() +} + +#[test] +fn starts_with_one_active_tab() { + let p = ids(1); + let tabs = Tabs::new(p[0]); + assert_eq!(tabs.len(), 1); + assert_eq!(tabs.active_index(), 0); + assert_eq!(tabs.focused(), p[0]); + assert!(!tabs.is_empty()); +} + +#[test] +fn new_tab_appends_and_activates() { + let p = ids(3); + let mut tabs = Tabs::new(p[0]); + assert_eq!(tabs.new_tab(p[1]), 1); + assert_eq!(tabs.new_tab(p[2]), 2); + assert_eq!(tabs.active_index(), 2); + assert_eq!(tabs.focused(), p[2]); +} + +#[test] +fn close_tab_refuses_last_and_out_of_range() { + let p = ids(2); + let mut tabs = Tabs::new(p[0]); + assert!(!tabs.close_tab(0)); + tabs.new_tab(p[1]); + assert!(!tabs.close_tab(5)); + assert!(tabs.close_tab(1)); + assert_eq!(tabs.len(), 1); + assert!(!tabs.close_tab(0)); +} + +#[test] +fn close_tab_adjusts_active_index() { + let p = ids(3); + let mut tabs = Tabs::new(p[0]); + tabs.new_tab(p[1]); + tabs.new_tab(p[2]); + + // Closing before the active tab shifts it left. + tabs.activate(2); + assert!(tabs.close_tab(0)); + assert_eq!(tabs.active_index(), 1); + assert_eq!(tabs.focused(), p[2]); + + // Closing the active last tab clamps to the new end. + assert!(tabs.close_tab(1)); + assert_eq!(tabs.active_index(), 0); + assert_eq!(tabs.focused(), p[1]); +} + +#[test] +fn activate_and_cycling_wrap() { + let p = ids(3); + let mut tabs = Tabs::new(p[0]); + tabs.new_tab(p[1]); + tabs.new_tab(p[2]); + assert!(tabs.activate(0)); + assert!(!tabs.activate(3)); + assert_eq!(tabs.active_index(), 0); + tabs.activate_prev(); + assert_eq!(tabs.active_index(), 2); + tabs.activate_next(); + assert_eq!(tabs.active_index(), 0); + tabs.activate_next(); + assert_eq!(tabs.active_index(), 1); +} + +#[test] +fn move_tab_reorders_and_tracks_active() { + let p = ids(3); + let mut tabs = Tabs::new(p[0]); + tabs.new_tab(p[1]); + tabs.new_tab(p[2]); // order: 0,1,2; active 2. + + // Moving the active tab keeps it active at its new index. + assert!(tabs.move_tab(2, 0)); // order: 2,0,1. + assert_eq!(tabs.active_index(), 0); + assert_eq!(tabs.focused(), p[2]); + + // Moving another tab across the active one shifts the index. + assert!(tabs.move_tab(2, 0)); // order: 1,2,0; active follows p[2] to 1. + assert_eq!(tabs.active_index(), 1); + assert_eq!(tabs.focused(), p[2]); + assert_eq!(tabs.get(0).unwrap().focused, p[1]); + assert_eq!(tabs.get(2).unwrap().focused, p[0]); + + assert!(!tabs.move_tab(0, 9)); + assert!(!tabs.move_tab(9, 0)); + assert!(tabs.move_tab(1, 1)); +} + +#[test] +fn focus_only_panes_in_active_tab() { + let p = ids(3); + let mut tabs = Tabs::new(p[0]); + tabs.active_mut() + .tree + .split(p[0], Axis::Horizontal, p[1], false) + .unwrap(); + assert!(tabs.focus(p[1])); + assert_eq!(tabs.focused(), p[1]); + assert!(!tabs.focus(p[2])); + assert_eq!(tabs.focused(), p[1]); + + tabs.new_tab(p[2]); + assert_eq!(tabs.focused(), p[2]); + assert!(!tabs.focus(p[0])); // p[0] lives in the other tab. + tabs.activate(0); + assert_eq!(tabs.focused(), p[1]); // per-tab focus is remembered. +} diff --git a/crates/workspace/tests/tree.rs b/crates/workspace/tests/tree.rs new file mode 100644 index 0000000..754be30 --- /dev/null +++ b/crates/workspace/tests/tree.rs @@ -0,0 +1,122 @@ +use super::*; +use crate::id::PaneIds; + +fn ids(n: usize) -> Vec { + let mut alloc = PaneIds::new(); + (0..n).map(|_| alloc.next()).collect() +} + +#[test] +fn new_tree_holds_root() { + let p = ids(1); + let tree = PaneTree::new(p[0]); + assert_eq!(tree.panes(), vec![p[0]]); + assert!(tree.contains(p[0])); + assert!(tree.list_dividers().is_empty()); +} + +#[test] +fn split_orders_panes() { + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + assert_eq!(tree.panes(), vec![p[0], p[1]]); + // new_first puts the new pane before the target. + tree.split(p[0], Axis::Vertical, p[2], true).unwrap(); + assert_eq!(tree.panes(), vec![p[2], p[0], p[1]]); +} + +#[test] +fn split_rejects_missing_target_and_duplicate_pane() { + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + assert!(tree.split(p[1], Axis::Horizontal, p[2], false).is_none()); + assert!(tree.split(p[0], Axis::Horizontal, p[0], false).is_none()); + assert_eq!(tree.panes(), vec![p[0]]); +} + +#[test] +fn remove_collapses_chain() { + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + assert_eq!(tree.panes(), vec![p[0], p[1], p[2]]); + + assert!(tree.remove(p[1])); + assert_eq!(tree.panes(), vec![p[0], p[2]]); + assert_eq!(tree.list_dividers().len(), 1); + + assert!(tree.remove(p[2])); + assert_eq!(tree.panes(), vec![p[0]]); + assert_eq!(tree.root(), &Node::Leaf(p[0])); +} + +#[test] +fn remove_inner_split_sibling_subtree_survives() { + let p = ids(4); + let mut tree = PaneTree::new(p[0]); + tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + tree.split(p[0], Axis::Vertical, p[2], false).unwrap(); + tree.split(p[2], Axis::Vertical, p[3], false).unwrap(); + assert_eq!(tree.panes(), vec![p[0], p[2], p[3], p[1]]); + + assert!(tree.remove(p[0])); + assert_eq!(tree.panes(), vec![p[2], p[3], p[1]]); + assert!(tree.remove(p[3])); + assert!(tree.remove(p[1])); + assert_eq!(tree.panes(), vec![p[2]]); +} + +#[test] +fn remove_refuses_last_pane_and_missing() { + let p = ids(2); + let mut tree = PaneTree::new(p[0]); + assert!(!tree.remove(p[0])); + assert!(!tree.remove(p[1])); + assert!(tree.contains(p[0])); +} + +#[test] +fn ratio_clamps() { + let p = ids(2); + let mut tree = PaneTree::new(p[0]); + let s = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + assert_eq!(tree.ratio(s), Some(0.5)); + assert!(tree.set_ratio(s, 0.05)); + assert_eq!(tree.ratio(s), Some(MIN_RATIO)); + assert!(tree.set_ratio(s, 0.95)); + assert_eq!(tree.ratio(s), Some(MAX_RATIO)); + assert!(tree.set_ratio(s, 0.3)); + assert_eq!(tree.ratio(s), Some(0.3)); + assert!(!tree.set_ratio(SplitId(999), 0.5)); +} + +#[test] +fn nearest_split_picks_closest_matching_axis() { + // A | (B / C): root horizontal split, right child vertical split. + let p = ids(4); + let mut tree = PaneTree::new(p[0]); + let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + // B's nearest vertical divider is the inner split; horizontal is outer. + assert_eq!(tree.nearest_split(p[1], Axis::Vertical), Some(inner)); + assert_eq!(tree.nearest_split(p[1], Axis::Horizontal), Some(outer)); + // A only sits under the outer horizontal split. + assert_eq!(tree.nearest_split(p[0], Axis::Horizontal), Some(outer)); + assert_eq!(tree.nearest_split(p[0], Axis::Vertical), None); + // Missing pane has no split. + assert_eq!(tree.nearest_split(p[3], Axis::Horizontal), None); +} + +#[test] +fn dividers_listed_parent_first() { + let p = ids(3); + let mut tree = PaneTree::new(p[0]); + let outer = tree.split(p[0], Axis::Horizontal, p[1], false).unwrap(); + let inner = tree.split(p[1], Axis::Vertical, p[2], false).unwrap(); + assert_eq!( + tree.list_dividers(), + vec![(outer, Axis::Horizontal), (inner, Axis::Vertical)] + ); +} diff --git a/scripts/linux.sh b/scripts/linux.sh new file mode 100755 index 0000000..d38e9ff --- /dev/null +++ b/scripts/linux.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Build Prompt (release) for Linux and produce a .tar.gz, a .deb, and an +# AppImage under dist/linux. The binary is the `prompt` bin from crates/app; +# the version is read from the workspace Cargo.toml. Builds natively for the +# host architecture (no cross-compiling) — pass x86_64 or aarch64 only to label +# the artifacts and pick the right helper downloads. +# +# Requirements (install beforehand): a Rust toolchain, the gpui system deps +# (clang, libasound2-dev, libfontconfig-dev, libssl-dev, libvulkan1, +# libwayland-dev, libx11-xcb-dev, libxkbcommon-x11-dev), curl, and file. +# cargo-deb is installed on demand if missing. +# +# Usage: scripts/linux.sh [x86_64|aarch64] +set -euo pipefail + +arch="${1:-$(uname -m)}" +case "$arch" in + x86_64 | amd64) arch="x86_64"; triple="x86_64-unknown-linux-gnu"; debarch="amd64" ;; + aarch64 | arm64) arch="aarch64"; triple="aarch64-unknown-linux-gnu"; debarch="arm64" ;; + *) echo "error: unsupported arch '$arch' (want x86_64 or aarch64)" >&2; exit 1 ;; +esac + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +version="$(sed -n 's/^version = "\([0-9][^"]*\)".*/\1/p' Cargo.toml | head -1)" +[ -n "$version" ] || { echo "error: could not read version from Cargo.toml" >&2; exit 1; } +echo "[linux] Prompt $version for $triple" + +out="$root/dist/linux" +rm -rf "$out" +mkdir -p "$out" + +# --- build ---------------------------------------------------------------- +rustup target add "$triple" >/dev/null 2>&1 || true +cargo build --release -p app --target "$triple" +bin="target/$triple/release/prompt" +strip "$bin" 2>/dev/null || true + +# --- staging tree (shared by tar.gz and the AppImage AppDir) --------------- +appdir="$out/AppDir" +mkdir -p "$appdir/usr/bin" "$appdir/usr/share/applications" "$appdir/usr/share/pixmaps" +cp "$bin" "$appdir/usr/bin/prompt" +cp assets/prompt.desktop "$appdir/usr/share/applications/prompt.desktop" +# 512px icon: linuxdeploy only accepts standard icon sizes (<=512), not the +# 1024px master. +cp assets/icon512.png "$appdir/usr/share/pixmaps/prompt.png" + +# --- .tar.gz --------------------------------------------------------------- +stem="prompt-$version-linux-$arch" +stage="$out/$stem" +mkdir -p "$stage" +cp -r "$appdir/usr" "$stage/usr" +cp LICENSE README.md "$stage/" 2>/dev/null || true +tar -C "$out" -czf "$out/$stem.tar.gz" "$stem" +rm -rf "$stage" +echo "[linux] -> $stem.tar.gz" + +# --- .deb (cargo-deb) ------------------------------------------------------ +command -v cargo-deb >/dev/null 2>&1 || cargo install cargo-deb --locked +cargo deb -p app --no-build --target "$triple" --output "$out/prompt_${version}_${debarch}.deb" +echo "[linux] -> prompt_${version}_${debarch}.deb" + +# --- AppImage (linuxdeploy + appimagetool) --------------------------------- +# Runners often lack FUSE, so extract-and-run the helper AppImages. +export APPIMAGE_EXTRACT_AND_RUN=1 +tools="$out/tools" +mkdir -p "$tools" +ld="$tools/linuxdeploy-$arch.AppImage" +ait="$tools/appimagetool-$arch.AppImage" +curl -fsSL -o "$ld" "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$arch.AppImage" +curl -fsSL -o "$ait" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$arch.AppImage" +chmod +x "$ld" "$ait" +"$ld" --appdir "$appdir" \ + --executable "$appdir/usr/bin/prompt" \ + --desktop-file "$appdir/usr/share/applications/prompt.desktop" \ + --icon-file "$appdir/usr/share/pixmaps/prompt.png" +ARCH="$arch" "$ait" "$appdir" "$out/Prompt-$version-$arch.AppImage" +echo "[linux] -> Prompt-$version-$arch.AppImage" + +# --- cleanup intermediates, leave only shippable artifacts ----------------- +rm -rf "$appdir" "$tools" +echo "[linux] artifacts in dist/linux:" +ls -1 "$out"