9f082b52b7
Replaces the three shell scripts' logic with one binary. tmux stays the pane engine; src/session/ is the only module that knows that. The grid model: coordinates are sparse ordinals, so only their sort order matters and a socktop group is one cell however many hosts it holds. Horizontal movement walks a cell's sub-sequence (tiled overview, then each host zoomed) and leaves only after the last one; entry direction decides whether you land on the first or last sub-screen. Vertical movement returns to where you were in that row, and snaps to the nearest column only on the first visit. Notable details found while building: * Unquoted "at: 0x0" is hexadecimal 0 to YAML, and "1x0" is not valid hex, so only the row-0 entries would break. Deserialization catches the integer case and names the fix. * Panes are addressed by tmux id, never index, and each command is wrapped so the pane outlives it. v1's remain-on-exit cannot do this: it is a per-window option that new windows do not inherit, so a monitor that exits during construction destroys its window and the next split fails with "no current target". Now a dead monitor stays on screen with its status. * Contact-count averaging, not summing: a panel reporting one finger as three contacts must not look like three times the travel. * Movement subcommands wait for the move to happen and report where they landed, so they are scriptable rather than fire-and-forget. 36 tests: the grid model, the gesture classifier and an end-to-end pass against a real tmux server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
5.8 KiB
Rust
183 lines
5.8 KiB
Rust
//! End-to-end against a real tmux server.
|
|
//!
|
|
//! Covers what the unit tests deliberately cannot: that the grid's idea of
|
|
//! "sub-screen 3 of the cell at 0x0" lands on the pane a person would expect,
|
|
//! zoomed. Skipped when tmux is not installed.
|
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
use socktop_swipe::config::Config;
|
|
use socktop_swipe::grid::{Grid, Move};
|
|
use socktop_swipe::monitor;
|
|
use socktop_swipe::session::tmux::Tmux;
|
|
use socktop_swipe::session::Multiplexer;
|
|
|
|
const SESSION: &str = "socktop-swipe-selftest";
|
|
|
|
fn have_tmux() -> bool {
|
|
Command::new("tmux")
|
|
.arg("-V")
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn tmux(args: &[&str]) -> String {
|
|
let out = Command::new("tmux").args(args).output().expect("tmux");
|
|
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
|
}
|
|
|
|
fn config() -> Config {
|
|
// `true` exits at once, which also exercises remain-on-exit keeping the
|
|
// pane addressable afterwards.
|
|
let yaml = format!(
|
|
r#"
|
|
session: {SESSION}
|
|
binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }}
|
|
touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }}
|
|
screens:
|
|
- at: "-1x0"
|
|
type: unifly
|
|
- at: "0x0"
|
|
type: socktop
|
|
socktop_group: [alpha, bravo, charlie, delta]
|
|
layout: tiled
|
|
- at: "0x5"
|
|
type: socktop
|
|
socktop_group: "echo1, foxtrot"
|
|
layout: even-vertical
|
|
- at: "1x0"
|
|
type: uptime-kuma-status
|
|
url: https://example.invalid/status
|
|
"#
|
|
);
|
|
serde_yaml::from_str(&yaml).expect("test config should parse")
|
|
}
|
|
|
|
/// (active window name, active pane title, is the window zoomed)
|
|
fn visible() -> (String, String, bool) {
|
|
let s = tmux(&[
|
|
"display-message",
|
|
"-p",
|
|
"-t",
|
|
SESSION,
|
|
"#{window_name}\t#{pane_title}\t#{window_zoomed_flag}",
|
|
]);
|
|
let f: Vec<&str> = s.split('\t').collect();
|
|
(f[0].into(), f[1].into(), f[2] == "1")
|
|
}
|
|
|
|
struct Cleanup;
|
|
impl Drop for Cleanup {
|
|
fn drop(&mut self) {
|
|
let _ = Command::new("tmux")
|
|
.args(["kill-session", "-t", SESSION])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn session_layout_and_navigation_match_the_grid() {
|
|
if !have_tmux() {
|
|
eprintln!("skipping: tmux is not installed");
|
|
return;
|
|
}
|
|
let _cleanup = Cleanup;
|
|
|
|
let cfg = config();
|
|
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
|
let mux = Tmux::new(&cfg.session, false);
|
|
mux.build(&grid).expect("session should build");
|
|
|
|
// One window per cell, in grid order, named by coordinate.
|
|
let windows = tmux(&["list-windows", "-t", SESSION, "-F", "#{window_name} #{window_panes}"]);
|
|
assert_eq!(
|
|
windows.lines().collect::<Vec<_>>(),
|
|
vec!["rm1c0 1", "r0c0 4", "r0c5 2", "r1c0 1"],
|
|
"windows should be one per cell, in grid order, with one pane per host"
|
|
);
|
|
|
|
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
|
|
let (win, _, zoomed) = visible();
|
|
assert_eq!(win, "r0c0");
|
|
assert!(!zoomed, "the overview must not be zoomed");
|
|
|
|
// Forward walks the sub-sequence, zooming each host in declaration order.
|
|
for expected in ["alpha", "bravo", "charlie", "delta"] {
|
|
let pos = grid.apply(Move::Forward);
|
|
mux.show(&grid, &pos).unwrap();
|
|
let (win, title, zoomed) = visible();
|
|
assert_eq!(win, "r0c0");
|
|
assert_eq!(title, expected, "wrong host zoomed");
|
|
assert!(zoomed, "{expected} should be zoomed full-screen");
|
|
}
|
|
|
|
// Past the last host, on to the next cell's overview.
|
|
let pos = grid.apply(Move::Forward);
|
|
mux.show(&grid, &pos).unwrap();
|
|
let (win, _, zoomed) = visible();
|
|
assert_eq!(win, "r0c5", "0x5 follows 0x0 despite the gap in column numbers");
|
|
assert!(!zoomed);
|
|
|
|
// Back must land on 0x0's LAST host, not its overview.
|
|
let pos = grid.apply(Move::Back);
|
|
mux.show(&grid, &pos).unwrap();
|
|
let (win, title, zoomed) = visible();
|
|
assert_eq!((win.as_str(), title.as_str(), zoomed), ("r0c0", "delta", true));
|
|
|
|
// Up to unifly: a single-pane cell, so nothing to zoom.
|
|
let pos = grid.apply(Move::Up);
|
|
mux.show(&grid, &pos).unwrap();
|
|
let (win, _, zoomed) = visible();
|
|
assert_eq!(win, "rm1c0");
|
|
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
|
|
|
|
// And back down to exactly the host we left.
|
|
let pos = grid.apply(Move::Down);
|
|
mux.show(&grid, &pos).unwrap();
|
|
let (win, title, zoomed) = visible();
|
|
assert_eq!(
|
|
(win.as_str(), title.as_str(), zoomed),
|
|
("r0c0", "delta", true),
|
|
"returning must restore the host AND its zoom"
|
|
);
|
|
|
|
// Down twice: through row 0 to kuma.
|
|
let pos = grid.apply(Move::Down);
|
|
mux.show(&grid, &pos).unwrap();
|
|
assert_eq!(visible().0, "r1c0");
|
|
}
|
|
|
|
#[test]
|
|
fn pane_commands_are_quoted_not_interpreted() {
|
|
if !have_tmux() {
|
|
return;
|
|
}
|
|
// A profile name containing shell metacharacters must reach the program as
|
|
// one argument, not be re-split or evaluated by the sh tmux runs it under.
|
|
let cfg: Config = serde_yaml::from_str(
|
|
r#"
|
|
session: socktop-swipe-quoting-test
|
|
binaries: { socktop: /bin/echo }
|
|
touch: { device: /dev/null, width: 1, height: 1, grab: false }
|
|
screens:
|
|
- at: "0x0"
|
|
type: socktop
|
|
socktop_group: ["a b; touch /tmp/socktop-swipe-pwned"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let cells = monitor::build_cells(&cfg).unwrap();
|
|
let argv = &cells[0].panes[0].command;
|
|
assert_eq!(argv, &["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]);
|
|
|
|
let quoted = shell_words::join(argv.iter().map(String::as_str));
|
|
let reparsed = shell_words::split("ed).unwrap();
|
|
assert_eq!(&reparsed, argv, "quoting must survive the round trip through sh");
|
|
}
|