From 0c730261ab7421ab20cb69fc6677c2863d881909 Mon Sep 17 00:00:00 2001 From: Wess Cope Date: Tue, 23 Jun 2026 13:28:08 -0400 Subject: [PATCH 1/4] Zed-style titlebar, cwd fixes, Linux packaging, relocate tests; release 0.3.0 - Custom transparent titlebar with the tabs folded in, window dragging, and Linux window controls + resize edges - Shell opens in $HOME by default; new tabs and windows inherit the focused pane's working directory - Linux release artifacts (.tar.gz, .deb, AppImage) for x86_64 and aarch64 via scripts/linux.sh, plus release + validation CI workflows - Move all unit tests into per-crate tests/ directories, compiled in-crate via #[path] with autotests=false; declare existing integration tests --- .github/workflows/linux.yml | 64 +++++ .github/workflows/release.yml | 50 ++++ Cargo.lock | 24 +- Cargo.toml | 2 +- assets/prompt.desktop | 11 + crates/app/Cargo.toml | 17 ++ crates/app/src/boxdraw.rs | 93 +------- crates/app/src/bridge.rs | 31 +-- crates/app/src/colors.rs | 141 +----------- crates/app/src/element.rs | 187 +-------------- crates/app/src/font.rs | 46 +--- crates/app/src/keys.rs | 92 +------- crates/app/src/main.rs | 3 + crates/app/src/metrics.rs | 102 +------- crates/app/src/mouse.rs | 81 +------ crates/app/src/session.rs | 90 +------- crates/app/src/splits.rs | 96 +------- crates/app/src/tabbar.rs | 25 +- crates/app/src/textedit.rs | 55 +---- crates/app/src/view.rs | 88 +------ crates/app/tests/boxdraw.rs | 89 +++++++ crates/app/tests/bridge.rs | 27 +++ crates/app/tests/colors.rs | 137 +++++++++++ crates/app/tests/element.rs | 183 +++++++++++++++ crates/app/tests/font.rs | 42 ++++ crates/app/tests/keys.rs | 88 +++++++ crates/app/tests/metrics.rs | 98 ++++++++ crates/app/tests/mouse.rs | 77 +++++++ crates/app/tests/session.rs | 86 +++++++ crates/app/tests/splits.rs | 92 ++++++++ crates/app/tests/tabbar.rs | 21 ++ crates/app/tests/textedit.rs | 51 ++++ crates/app/tests/view.rs | 84 +++++++ crates/assist/Cargo.toml | 1 + crates/assist/src/context.rs | 53 +---- crates/assist/src/model.rs | 24 +- crates/assist/src/safety.rs | 15 +- crates/assist/tests/context.rs | 49 ++++ crates/assist/tests/model.rs | 20 ++ crates/assist/tests/safety.rs | 11 + crates/config/Cargo.toml | 1 + crates/config/src/action.rs | 223 +----------------- crates/config/src/apply.rs | 153 +----------- crates/config/src/edit.rs | 72 +----- crates/config/src/keybind.rs | 331 +------------------------- crates/config/src/lib.rs | 33 +-- crates/config/src/options.rs | 96 +------- crates/config/src/parse.rs | 178 +------------- crates/config/src/value.rs | 97 +------- crates/config/src/watch.rs | 91 +------- crates/config/tests/action.rs | 219 ++++++++++++++++++ crates/config/tests/apply.rs | 149 ++++++++++++ crates/config/tests/edit.rs | 68 ++++++ crates/config/tests/keybind.rs | 327 ++++++++++++++++++++++++++ crates/config/tests/lib.rs | 29 +++ crates/config/tests/options.rs | 92 ++++++++ crates/config/tests/parse.rs | 174 ++++++++++++++ crates/config/tests/value.rs | 93 ++++++++ crates/config/tests/watch.rs | 87 +++++++ crates/input/Cargo.toml | 1 + crates/input/src/csi.rs | 51 +--- crates/input/src/encode.rs | 346 +--------------------------- crates/input/src/kitty.rs | 79 +------ crates/input/src/mouse.rs | 166 +------------ crates/input/src/paste.rs | 44 +--- crates/input/tests/csi.rs | 47 ++++ crates/input/tests/encode.rs | 342 +++++++++++++++++++++++++++ crates/input/tests/kitty.rs | 75 ++++++ crates/input/tests/mouse.rs | 162 +++++++++++++ crates/input/tests/paste.rs | 40 ++++ crates/macros/Cargo.toml | 1 + crates/macros/src/lib.rs | 25 +- crates/macros/src/record.rs | 74 +----- crates/macros/src/store.rs | 55 +---- crates/macros/tests/lib.rs | 21 ++ crates/macros/tests/record.rs | 70 ++++++ crates/macros/tests/store.rs | 51 ++++ crates/mcp/Cargo.toml | 1 + crates/mcp/src/serve.rs | 76 +----- crates/mcp/tests/serve.rs | 72 ++++++ crates/plugin/Cargo.toml | 1 + crates/plugin/src/lib.rs | 29 +-- crates/plugin/src/load.rs | 34 +-- crates/plugin/src/manifest.rs | 85 +------ crates/plugin/tests/lib.rs | 25 ++ crates/plugin/tests/load.rs | 30 +++ crates/plugin/tests/manifest.rs | 81 +++++++ crates/pty/Cargo.toml | 1 + crates/pty/src/session.rs | 118 +--------- crates/pty/src/spawn.rs | 55 +---- crates/pty/src/unix.rs | 33 +-- crates/pty/src/winsize.rs | 37 +-- crates/pty/tests/session.rs | 114 +++++++++ crates/pty/tests/spawn.rs | 51 ++++ crates/pty/tests/unix.rs | 29 +++ crates/pty/tests/winsize.rs | 33 +++ crates/terminal/Cargo.toml | 7 + crates/terminal/src/options.rs | 22 +- crates/terminal/src/session.rs | 122 +--------- crates/terminal/tests/e2e.rs | 88 +++++++ crates/terminal/tests/options.rs | 18 ++ crates/terminal/tests/session.rs | 166 +++++++------ crates/theme/Cargo.toml | 1 + crates/theme/src/builtin/dark.rs | 20 +- crates/theme/src/builtin/light.rs | 20 +- crates/theme/src/builtin/mod.rs | 174 +------------- crates/theme/src/overrides.rs | 102 +------- crates/theme/src/palette.rs | 86 +------ crates/theme/src/rgb.rs | 70 +----- crates/theme/src/scheme.rs | 34 +-- crates/theme/tests/builtin/dark.rs | 16 ++ crates/theme/tests/builtin/light.rs | 16 ++ crates/theme/tests/builtin/mod.rs | 170 ++++++++++++++ crates/theme/tests/overrides.rs | 98 ++++++++ crates/theme/tests/palette.rs | 82 +++++++ crates/theme/tests/rgb.rs | 66 ++++++ crates/theme/tests/scheme.rs | 30 +++ crates/vt/Cargo.toml | 11 + crates/vt/src/cell.rs | 42 +--- crates/vt/src/charset.rs | 45 +--- crates/vt/src/color.rs | 18 +- crates/vt/src/cursor.rs | 49 +--- crates/vt/src/grid/damage.rs | 49 +--- crates/vt/src/grid/mod.rs | 126 +--------- crates/vt/src/grid/row.rs | 45 +--- crates/vt/src/grid/scrollback.rs | 56 +---- crates/vt/src/hyperlink.rs | 38 +-- crates/vt/src/kitty.rs | 53 +---- crates/vt/src/mode.rs | 24 +- crates/vt/src/screen.rs | 49 +--- crates/vt/src/search.rs | 39 +--- crates/vt/src/selection.rs | 189 +-------------- crates/vt/src/sgr.rs | 153 +----------- crates/vt/src/term/csi.rs | 125 +--------- crates/vt/src/term/dcs.rs | 61 +---- crates/vt/src/term/mod.rs | 346 +--------------------------- crates/vt/src/term/ops.rs | 188 +-------------- crates/vt/src/term/osc.rs | 215 +---------------- crates/vt/src/term/perform.rs | 76 +----- crates/vt/src/term/report.rs | 51 +--- crates/vt/src/term/select.rs | 211 +---------------- crates/vt/src/url.rs | 51 +--- crates/vt/tests/cell.rs | 38 +++ crates/vt/tests/charset.rs | 41 ++++ crates/vt/tests/color.rs | 14 ++ crates/vt/tests/cursor.rs | 45 ++++ crates/vt/tests/grid/damage.rs | 45 ++++ crates/vt/tests/grid/mod.rs | 122 ++++++++++ crates/vt/tests/grid/row.rs | 41 ++++ crates/vt/tests/grid/scrollback.rs | 52 +++++ crates/vt/tests/hyperlink.rs | 34 +++ crates/vt/tests/kitty.rs | 49 ++++ crates/vt/tests/mode.rs | 20 ++ crates/vt/tests/screen.rs | 45 ++++ crates/vt/tests/search.rs | 35 +++ crates/vt/tests/selection.rs | 185 +++++++++++++++ crates/vt/tests/sgr.rs | 149 ++++++++++++ crates/vt/tests/term/csi.rs | 121 ++++++++++ crates/vt/tests/term/dcs.rs | 57 +++++ crates/vt/tests/term/mod.rs | 342 +++++++++++++++++++++++++++ crates/vt/tests/term/ops.rs | 184 +++++++++++++++ crates/vt/tests/term/osc.rs | 211 +++++++++++++++++ crates/vt/tests/term/perform.rs | 72 ++++++ crates/vt/tests/term/report.rs | 47 ++++ crates/vt/tests/term/select.rs | 207 +++++++++++++++++ crates/vt/tests/url.rs | 47 ++++ crates/workspace/Cargo.toml | 1 + crates/workspace/src/id.rs | 16 +- crates/workspace/src/layout.rs | 146 +----------- crates/workspace/src/nav.rs | 97 +------- crates/workspace/src/tabs.rs | 124 +--------- crates/workspace/src/tree.rs | 126 +--------- crates/workspace/tests/id.rs | 12 + crates/workspace/tests/layout.rs | 142 ++++++++++++ crates/workspace/tests/nav.rs | 93 ++++++++ crates/workspace/tests/tabs.rs | 120 ++++++++++ crates/workspace/tests/tree.rs | 122 ++++++++++ scripts/linux.sh | 82 +++++++ 178 files changed, 7435 insertions(+), 7181 deletions(-) create mode 100644 .github/workflows/linux.yml create mode 100644 assets/prompt.desktop create mode 100644 crates/app/tests/boxdraw.rs create mode 100644 crates/app/tests/bridge.rs create mode 100644 crates/app/tests/colors.rs create mode 100644 crates/app/tests/element.rs create mode 100644 crates/app/tests/font.rs create mode 100644 crates/app/tests/keys.rs create mode 100644 crates/app/tests/metrics.rs create mode 100644 crates/app/tests/mouse.rs create mode 100644 crates/app/tests/session.rs create mode 100644 crates/app/tests/splits.rs create mode 100644 crates/app/tests/tabbar.rs create mode 100644 crates/app/tests/textedit.rs create mode 100644 crates/app/tests/view.rs create mode 100644 crates/assist/tests/context.rs create mode 100644 crates/assist/tests/model.rs create mode 100644 crates/assist/tests/safety.rs create mode 100644 crates/config/tests/action.rs create mode 100644 crates/config/tests/apply.rs create mode 100644 crates/config/tests/edit.rs create mode 100644 crates/config/tests/keybind.rs create mode 100644 crates/config/tests/lib.rs create mode 100644 crates/config/tests/options.rs create mode 100644 crates/config/tests/parse.rs create mode 100644 crates/config/tests/value.rs create mode 100644 crates/config/tests/watch.rs create mode 100644 crates/input/tests/csi.rs create mode 100644 crates/input/tests/encode.rs create mode 100644 crates/input/tests/kitty.rs create mode 100644 crates/input/tests/mouse.rs create mode 100644 crates/input/tests/paste.rs create mode 100644 crates/macros/tests/lib.rs create mode 100644 crates/macros/tests/record.rs create mode 100644 crates/macros/tests/store.rs create mode 100644 crates/mcp/tests/serve.rs create mode 100644 crates/plugin/tests/lib.rs create mode 100644 crates/plugin/tests/load.rs create mode 100644 crates/plugin/tests/manifest.rs create mode 100644 crates/pty/tests/session.rs create mode 100644 crates/pty/tests/spawn.rs create mode 100644 crates/pty/tests/unix.rs create mode 100644 crates/pty/tests/winsize.rs create mode 100644 crates/terminal/tests/e2e.rs create mode 100644 crates/terminal/tests/options.rs create mode 100644 crates/theme/tests/builtin/dark.rs create mode 100644 crates/theme/tests/builtin/light.rs create mode 100644 crates/theme/tests/builtin/mod.rs create mode 100644 crates/theme/tests/overrides.rs create mode 100644 crates/theme/tests/palette.rs create mode 100644 crates/theme/tests/rgb.rs create mode 100644 crates/theme/tests/scheme.rs create mode 100644 crates/vt/tests/cell.rs create mode 100644 crates/vt/tests/charset.rs create mode 100644 crates/vt/tests/color.rs create mode 100644 crates/vt/tests/cursor.rs create mode 100644 crates/vt/tests/grid/damage.rs create mode 100644 crates/vt/tests/grid/mod.rs create mode 100644 crates/vt/tests/grid/row.rs create mode 100644 crates/vt/tests/grid/scrollback.rs create mode 100644 crates/vt/tests/hyperlink.rs create mode 100644 crates/vt/tests/kitty.rs create mode 100644 crates/vt/tests/mode.rs create mode 100644 crates/vt/tests/screen.rs create mode 100644 crates/vt/tests/search.rs create mode 100644 crates/vt/tests/selection.rs create mode 100644 crates/vt/tests/sgr.rs create mode 100644 crates/vt/tests/term/csi.rs create mode 100644 crates/vt/tests/term/dcs.rs create mode 100644 crates/vt/tests/term/mod.rs create mode 100644 crates/vt/tests/term/ops.rs create mode 100644 crates/vt/tests/term/osc.rs create mode 100644 crates/vt/tests/term/perform.rs create mode 100644 crates/vt/tests/term/report.rs create mode 100644 crates/vt/tests/term/select.rs create mode 100644 crates/vt/tests/url.rs create mode 100644 crates/workspace/tests/id.rs create mode 100644 crates/workspace/tests/layout.rs create mode 100644 crates/workspace/tests/nav.rs create mode 100644 crates/workspace/tests/tabs.rs create mode 100644 crates/workspace/tests/tree.rs create mode 100755 scripts/linux.sh 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/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..1fdcc34 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,13 +1,30 @@ [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." +assets = [ + ["../../assets/prompt.desktop", "usr/share/applications/prompt.desktop", "644"], + ["../../assets/icon.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/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..ceb5b44 --- /dev/null +++ b/scripts/linux.sh @@ -0,0 +1,82 @@ +#!/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" +cp assets/icon.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" From bdaeeefee8be988ca2a36934df2a4367b5bd14fc Mon Sep 17 00:00:00 2001 From: Wess Cope Date: Tue, 23 Jun 2026 13:37:40 -0400 Subject: [PATCH 2/4] Fix Linux quick-terminal build: layer_shell import path and PropMode::REPLACE --- crates/app/src/linux.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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(()) From 643448e6d2be673ccec7a423ac6b10d6041db2cf Mon Sep 17 00:00:00 2001 From: Wess Cope Date: Tue, 23 Jun 2026 13:47:31 -0400 Subject: [PATCH 3/4] Use 512px icon for Linux packaging (linuxdeploy rejects 1024px) --- assets/icon512.png | Bin 0 -> 145229 bytes crates/app/Cargo.toml | 2 +- scripts/linux.sh | 4 +++- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 assets/icon512.png diff --git a/assets/icon512.png b/assets/icon512.png new file mode 100644 index 0000000000000000000000000000000000000000..f73a9860c9f01506b53c8aad93075038ec5d4779 GIT binary patch literal 145229 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

37;tQlmjwmop=iD<-8!Xgu$88e2 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

XyP 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 bq+IZhIvowK*S Date: Tue, 23 Jun 2026 13:57:31 -0400 Subject: [PATCH 4/4] Include prompt binary in .deb (explicit assets disabled auto-detection) --- crates/app/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3b6cd70..dbff858 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,7 +20,11 @@ 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"], ]