From abde25a05954fd5ffc3065d14cf710ace81ffb4f Mon Sep 17 00:00:00 2001 From: Antoine Stevan <44101798+amtoine@users.noreply.github.com> Date: Sun, 20 Aug 2023 11:37:01 +0200 Subject: [PATCH] adds tests (#3) ## non-test commits - fbcb3ab: *allow the Config to be cloned* - 9be5ceb: *give a &Key to transition_state* - ce401b7: *implement Default for Mode and State* - 0770cca: *refactor the state initialization in `from_value`* - ae3ed68: *fix overflow panic when moving in the data* - df49a86: *make `State::from_value` public to super* - 4c26453: *derive PartialEq and Debug for the whole config* ## TODO - [x] `navigation.rs`: make sure the navigation in the data is ok - [x] `app.rs`: make sure the application state machine works - [x] `tui.rs`: make sure the rendering works as intended - [x] `config/`: make sure the parsing of the config works --- README.md | 10 +- src/app.rs | 447 ++++++++++++++++++++++++++++++++++++++---- src/config/mod.rs | 86 +++++++- src/config/parsing.rs | 295 ++++++++++++++++++++++++++++ src/navigation.rs | 152 +++++++++++++- src/tui.rs | 130 ++++++++++++ 6 files changed, 1072 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index dbbe6aa..537de18 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,11 @@ cargo doc --document-private-items --no-deps --open - [ ] add check for the config to make sure it's valid - [ ] when going into a file or URL, open it - [ ] give different colors to names and type -- [ ] add tests... - - [ ] to `navigation.rs` to make sure the navigation in the data is ok - - [ ] to `app.rs` to make sure the application state machine works - - [ ] to `parsing.rs` to make sure the parsing of the config works - - [ ] to `tui.rs` to make sure the rendering works as intended +- [x] add tests... + - [x] to `navigation.rs` to make sure the navigation in the data is ok + - [x] to `app.rs` to make sure the application state machine works + - [x] to `parsing.rs` to make sure the parsing of the config works + - [x] to `tui.rs` to make sure the rendering works as intended - [ ] restrict the visibility of objects when possible - [ ] show true tables as such diff --git a/src/app.rs b/src/app.rs index 93d5258..f45a713 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,7 +18,7 @@ use super::navigation::Direction; use super::{config::Config, navigation, tui}; /// the mode in which the application is -#[derive(PartialEq)] +#[derive(Clone, PartialEq)] pub(super) enum Mode { /// the NORMAL mode is the *navigation* mode, where the user can move around in the data Normal, @@ -28,18 +28,18 @@ pub(super) enum Mode { Peeking, } -impl Mode { - fn default() -> Mode { - Mode::Normal +impl Default for Mode { + fn default() -> Self { + Self::Normal } } impl std::fmt::Display for Mode { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { let repr = match self { - Mode::Normal => "NORMAL", - Mode::Insert => "INSERT", - Mode::Peeking => "PEEKING", + Self::Normal => "NORMAL", + Self::Insert => "INSERT", + Self::Peeking => "PEEKING", }; write!(f, "{}", repr) } @@ -56,9 +56,9 @@ pub(super) struct State { pub mode: Mode, } -impl State { - fn default() -> State { - State { +impl Default for State { + fn default() -> Self { + Self { cell_path: CellPath { members: vec![] }, bottom: false, mode: Mode::default(), @@ -66,7 +66,29 @@ impl State { } } +impl State { + pub(super) fn from_value(value: &Value) -> Self { + let mut state = Self::default(); + match value { + Value::List { vals, .. } => state.cell_path.members.push(PathMember::Int { + val: 0, + span: Span::unknown(), + optional: vals.is_empty(), + }), + Value::Record { cols, .. } => state.cell_path.members.push(PathMember::String { + val: cols.get(0).unwrap_or(&"".to_string()).into(), + span: Span::unknown(), + optional: cols.is_empty(), + }), + _ => {} + } + + state + } +} + /// the result of a state transition +#[derive(Debug, PartialEq)] struct TransitionResult { /// whether or not to exit the application exit: bool, @@ -88,26 +110,13 @@ pub(super) fn run( input: &Value, config: &Config, ) -> Result { - let mut state = State::default(); - match input { - Value::List { vals, .. } => state.cell_path.members.push(PathMember::Int { - val: 0, - span: Span::unknown(), - optional: vals.is_empty(), - }), - Value::Record { cols, .. } => state.cell_path.members.push(PathMember::String { - val: cols.get(0).unwrap_or(&"".to_string()).into(), - span: Span::unknown(), - optional: cols.is_empty(), - }), - _ => {} - }; + let mut state = State::from_value(input); loop { terminal.draw(|frame| tui::render_ui(frame, input, &state, config))?; let key = console::Term::stderr().read_key()?; - match transition_state(key, config, &mut state, input)? { + match transition_state(&key, config, &mut state, input)? { TransitionResult { exit: true, result } => match result { None => break, Some(value) => return Ok(value), @@ -121,55 +130,55 @@ pub(super) fn run( /// perform the state transition based on the key pressed and the previous state #[allow(clippy::collapsible_if)] fn transition_state( - key: Key, + key: &Key, config: &Config, state: &mut State, value: &Value, ) -> Result { - if key == config.keybindings.quit { + if key == &config.keybindings.quit { return Ok(TransitionResult { exit: true, result: None, }); - } else if key == config.keybindings.insert { + } else if key == &config.keybindings.insert { if state.mode == Mode::Normal { state.mode = Mode::Insert; } - } else if key == config.keybindings.normal { + } else if key == &config.keybindings.normal { if state.mode == Mode::Insert { state.mode = Mode::Normal; } - } else if key == config.keybindings.navigation.down { + } else if key == &config.keybindings.navigation.down { if state.mode == Mode::Normal { navigation::go_up_or_down_in_data(state, value, Direction::Down); } - } else if key == config.keybindings.navigation.up { + } else if key == &config.keybindings.navigation.up { if state.mode == Mode::Normal { navigation::go_up_or_down_in_data(state, value, Direction::Up); } - } else if key == config.keybindings.navigation.right { + } else if key == &config.keybindings.navigation.right { if state.mode == Mode::Normal { navigation::go_deeper_in_data(state, value); } - } else if key == config.keybindings.navigation.left { + } else if key == &config.keybindings.navigation.left { if state.mode == Mode::Normal { navigation::go_back_in_data(state); } - } else if key == config.keybindings.peek { + } else if key == &config.keybindings.peek { if state.mode == Mode::Normal { state.mode = Mode::Peeking; } } if state.mode == Mode::Peeking { - if key == config.keybindings.peeking.quit { + if key == &config.keybindings.peeking.quit { state.mode = Mode::Normal; - } else if key == config.keybindings.peeking.all { + } else if key == &config.keybindings.peeking.all { return Ok(TransitionResult { exit: true, result: Some(value.clone()), }); - } else if key == config.keybindings.peeking.current { + } else if key == &config.keybindings.peeking.current { state.cell_path.members.pop(); return Ok(TransitionResult { exit: true, @@ -179,7 +188,7 @@ fn transition_state( .follow_cell_path(&state.cell_path.members, false)?, ), }); - } else if key == config.keybindings.peeking.under { + } else if key == &config.keybindings.peeking.under { return Ok(TransitionResult { exit: true, result: Some( @@ -196,3 +205,365 @@ fn transition_state( result: None, }) } + +#[cfg(test)] +mod tests { + use console::Key; + use nu_protocol::{ast::PathMember, Span, Value}; + + use super::{transition_state, State}; + use crate::{ + app::Mode, + config::{repr_keycode, Config}, + }; + + /// { + /// l: ["my", "list", "elements"], + /// r: {a: 1, b: 2}, + /// s: "some string", + /// i: 123, + /// } + fn test_value() -> Value { + Value::test_record( + vec!["l", "r", "s", "i"], + vec![ + Value::test_list(vec![ + Value::test_string("my"), + Value::test_string("list"), + Value::test_string("elements"), + ]), + Value::test_record(vec!["a", "b"], vec![Value::test_int(1), Value::test_int(2)]), + Value::test_string("some string"), + Value::test_int(123), + ], + ) + } + + #[test] + fn switch_modes() { + let config = Config::default(); + let keybindings = config.clone().keybindings; + + let mut state = State::default(); + let value = test_value(); + + assert!(state.mode == Mode::Normal); + + // INSERT -> PEEKING: not allowed + // PEEKING -> INSERT: not allowed + let transitions = vec![ + (&keybindings.normal, Mode::Normal), + (&keybindings.insert, Mode::Insert), + (&keybindings.insert, Mode::Insert), + (&keybindings.normal, Mode::Normal), + (&keybindings.peek, Mode::Peeking), + (&keybindings.peek, Mode::Peeking), + (&keybindings.normal, Mode::Normal), + ]; + + for (key, expected_mode) in transitions { + let mode = state.mode.clone(); + + let result = transition_state(&key, &config, &mut state, &value).unwrap(); + + assert!( + !result.exit, + "unexpected exit after pressing {} in {}", + repr_keycode(key), + mode, + ); + assert!( + state.mode == expected_mode, + "expected to be in {} after pressing {} in {}, found {}", + expected_mode, + repr_keycode(key), + mode, + state.mode + ); + } + } + + #[test] + fn quit() { + let config = Config::default(); + let keybindings = config.clone().keybindings; + + let mut state = State::default(); + let value = test_value(); + + let transitions = vec![ + (&keybindings.insert, false), + (&keybindings.quit, true), + (&keybindings.normal, false), + (&keybindings.quit, true), + (&keybindings.peek, false), + (&keybindings.quit, true), + ]; + + for (key, exit) in transitions { + let mode = state.mode.clone(); + + let result = transition_state(key, &config, &mut state, &value).unwrap(); + + if exit { + assert!( + result.exit, + "expected to quit after pressing {} in {} mode", + repr_keycode(key), + mode + ); + } else { + assert!( + !result.exit, + "expected NOT to quit after pressing {} in {} mode", + repr_keycode(key), + mode + ); + } + } + } + + /// a simplified [`PathMember`] that can be put in a single vector, without being too long + enum PM<'a> { + // the [`PathMember::String`] variant + S(&'a str), + // the [`PathMember::Int`] variant + I(usize), + } + + fn to_path_member_vec(cell_path: Vec) -> Vec { + cell_path + .iter() + .map(|x| match *x { + PM::S(val) => PathMember::String { + val: val.into(), + span: Span::test_data(), + optional: false, + }, + PM::I(val) => PathMember::Int { + val, + span: Span::test_data(), + optional: false, + }, + }) + .collect::>() + } + + fn repr_path_member_vec(members: &[PathMember]) -> String { + format!( + "$.{}", + members + .iter() + .map(|m| { + match m { + PathMember::Int { val, .. } => val.to_string(), + PathMember::String { val, .. } => val.to_string(), + } + }) + .collect::>() + .join(".") + ) + } + + #[test] + fn navigate_the_data() { + let config = Config::default(); + let nav = config.clone().keybindings.navigation; + + let value = test_value(); + let mut state = State::from_value(&value); + + assert_eq!(state.bottom, false); + assert_eq!( + state.cell_path.members, + to_path_member_vec(vec![PM::S("l")]) + ); + + let transitions = vec![ + (&nav.up, vec![PM::S("i")], false), + (&nav.up, vec![PM::S("s")], false), + (&nav.up, vec![PM::S("r")], false), + (&nav.up, vec![PM::S("l")], false), + (&nav.down, vec![PM::S("r")], false), + (&nav.left, vec![PM::S("r")], false), + (&nav.right, vec![PM::S("r"), PM::S("a")], false), + (&nav.right, vec![PM::S("r"), PM::S("a")], true), + (&nav.up, vec![PM::S("r"), PM::S("a")], true), + (&nav.down, vec![PM::S("r"), PM::S("a")], true), + (&nav.left, vec![PM::S("r"), PM::S("a")], false), + (&nav.down, vec![PM::S("r"), PM::S("b")], false), + (&nav.right, vec![PM::S("r"), PM::S("b")], true), + (&nav.up, vec![PM::S("r"), PM::S("b")], true), + (&nav.down, vec![PM::S("r"), PM::S("b")], true), + (&nav.left, vec![PM::S("r"), PM::S("b")], false), + (&nav.up, vec![PM::S("r"), PM::S("a")], false), + (&nav.up, vec![PM::S("r"), PM::S("b")], false), + (&nav.left, vec![PM::S("r")], false), + (&nav.down, vec![PM::S("s")], false), + (&nav.left, vec![PM::S("s")], false), + (&nav.right, vec![PM::S("s")], true), + (&nav.up, vec![PM::S("s")], true), + (&nav.down, vec![PM::S("s")], true), + (&nav.left, vec![PM::S("s")], false), + (&nav.down, vec![PM::S("i")], false), + (&nav.left, vec![PM::S("i")], false), + (&nav.right, vec![PM::S("i")], true), + (&nav.up, vec![PM::S("i")], true), + (&nav.down, vec![PM::S("i")], true), + (&nav.left, vec![PM::S("i")], false), + (&nav.down, vec![PM::S("l")], false), + (&nav.left, vec![PM::S("l")], false), + (&nav.right, vec![PM::S("l"), PM::I(0)], false), + (&nav.right, vec![PM::S("l"), PM::I(0)], true), + (&nav.up, vec![PM::S("l"), PM::I(0)], true), + (&nav.down, vec![PM::S("l"), PM::I(0)], true), + (&nav.left, vec![PM::S("l"), PM::I(0)], false), + (&nav.down, vec![PM::S("l"), PM::I(1)], false), + (&nav.right, vec![PM::S("l"), PM::I(1)], true), + (&nav.up, vec![PM::S("l"), PM::I(1)], true), + (&nav.down, vec![PM::S("l"), PM::I(1)], true), + (&nav.left, vec![PM::S("l"), PM::I(1)], false), + (&nav.down, vec![PM::S("l"), PM::I(2)], false), + (&nav.right, vec![PM::S("l"), PM::I(2)], true), + (&nav.up, vec![PM::S("l"), PM::I(2)], true), + (&nav.down, vec![PM::S("l"), PM::I(2)], true), + (&nav.left, vec![PM::S("l"), PM::I(2)], false), + (&nav.up, vec![PM::S("l"), PM::I(1)], false), + (&nav.up, vec![PM::S("l"), PM::I(0)], false), + (&nav.up, vec![PM::S("l"), PM::I(2)], false), + (&nav.left, vec![PM::S("l")], false), + ]; + + for (key, cell_path, bottom) in transitions { + let expected = to_path_member_vec(cell_path); + transition_state(key, &config, &mut state, &value).unwrap(); + + if bottom { + assert!( + state.bottom, + "expected to be at the bottom after pressing {}", + repr_keycode(key) + ); + } else { + assert!( + !state.bottom, + "expected NOT to be at the bottom after pressing {}", + repr_keycode(key) + ); + } + assert_eq!( + state.cell_path.members, + expected, + "expected to be at {:?}, found {:?}", + repr_path_member_vec(&expected), + repr_path_member_vec(&state.cell_path.members) + ); + } + } + + fn run_peeking_scenario( + transitions: Vec<(&Key, bool, Option)>, + config: &Config, + value: &Value, + ) { + let mut state = State::from_value(&value); + + for (key, exit, expected) in transitions { + let mode = state.mode.clone(); + + let result = transition_state(key, &config, &mut state, &value).unwrap(); + + if exit { + assert!( + result.exit, + "expected to peek some data after pressing {} in {} mode", + repr_keycode(key), + mode + ); + } else { + assert!( + !result.exit, + "expected NOT to peek some data after pressing {} in {} mode", + repr_keycode(key), + mode + ); + } + + match expected { + Some(value) => match result.result { + Some(v) => assert_eq!( + value, + v, + "unexpected data after pressing {} in {} mode", + repr_keycode(key), + mode + ), + None => panic!( + "did expect output data after pressing {} in {} mode", + repr_keycode(key), + mode + ), + }, + None => match result.result { + Some(_) => panic!( + "did NOT expect output data after pressing {} in {} mode", + repr_keycode(key), + mode + ), + None => {} + }, + } + } + } + + #[test] + fn peek_data() { + let config = Config::default(); + let keybindings = config.clone().keybindings; + + let value = test_value(); + + let transitions = vec![ + (&keybindings.peek, false, None), + (&keybindings.peeking.all, true, Some(value.clone())), + ]; + run_peeking_scenario(transitions, &config, &value); + + let transitions = vec![ + (&keybindings.peek, false, None), + (&keybindings.peeking.current, true, Some(value.clone())), + ]; + run_peeking_scenario(transitions, &config, &value); + + let transitions = vec![ + (&keybindings.navigation.down, false, None), + (&keybindings.navigation.right, false, None), // on {r: {a: 1, b: 2}} + (&keybindings.peek, false, None), + (&keybindings.peeking.all, true, Some(value.clone())), + ( + &keybindings.peeking.current, + true, + Some(Value::test_record( + vec!["a", "b"], + vec![Value::test_int(1), Value::test_int(2)], + )), + ), + ]; + run_peeking_scenario(transitions, &config, &value); + + let transitions = vec![ + (&keybindings.navigation.down, false, None), + (&keybindings.navigation.right, false, None), // on {r: {a: 1, b: 2}} + (&keybindings.peek, false, None), + (&keybindings.peeking.all, true, Some(value.clone())), + (&keybindings.peeking.under, true, Some(Value::test_int(1))), + ]; + run_peeking_scenario(transitions, &config, &value); + } + + #[ignore = "data edition is not implemented for now"] + #[test] + fn edit_cells() { + /**/ + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 31e9034..5d3f387 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,6 +17,7 @@ use parsing::{ }; /// the configuration for the status bar colors in all [`crate::app::Mode`]s +#[derive(Clone, PartialEq, Debug)] pub(super) struct StatusBarColorConfig { pub normal: BgFgColorConfig, pub insert: BgFgColorConfig, @@ -24,6 +25,7 @@ pub(super) struct StatusBarColorConfig { } /// the colors of the application +#[derive(Clone, PartialEq, Debug)] pub(super) struct ColorConfig { /// the color when a row is NOT selected pub normal: BgFgColorConfig, @@ -37,13 +39,14 @@ pub(super) struct ColorConfig { } /// a pair of background / foreground colors -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq)] pub(super) struct BgFgColorConfig { pub background: Color, pub foreground: Color, } /// the bindings in NORMAL mode (see [crate::app::Mode::Normal]) +#[derive(Clone, PartialEq, Debug)] pub(super) struct NavigationBindingsMap { /// go one row up in the data pub up: Key, @@ -56,6 +59,7 @@ pub(super) struct NavigationBindingsMap { } /// the bindings in PEEKING mode (see [crate::app::Mode::Peeking]) +#[derive(Clone, PartialEq, Debug)] pub(super) struct PeekingBindingsMap { /// peek the whole data structure pub all: Key, @@ -67,6 +71,7 @@ pub(super) struct PeekingBindingsMap { } /// the keybindings mapping +#[derive(Clone, PartialEq, Debug)] pub(super) struct KeyBindingsMap { pub quit: Key, /// go into INSERT mode (see [crate::app::Mode::Insert]) @@ -80,6 +85,7 @@ pub(super) struct KeyBindingsMap { } /// the layout of the application +#[derive(Clone, PartialEq, Debug)] pub(super) enum Layout { /// show each row in a `[name, data, type]` column Table, @@ -88,6 +94,7 @@ pub(super) enum Layout { } /// the configuration of the whole application +#[derive(Clone, PartialEq, Debug)] pub(super) struct Config { pub colors: ColorConfig, pub keybindings: KeyBindingsMap, @@ -427,3 +434,80 @@ pub(super) fn repr_keycode(keycode: &Key) -> String { _ => "??".into(), } } + +// TODO: add proper assert error messages +#[cfg(test)] +mod tests { + use console::Key; + use nu_protocol::Value; + + use super::{repr_keycode, Config}; + + #[test] + fn keycode_representation() { + assert_eq!(repr_keycode(&Key::Char('x')), "x".to_string()); + assert_eq!(repr_keycode(&Key::ArrowLeft), "←".to_string()); + assert_eq!(repr_keycode(&Key::Escape), "".to_string()); + assert_eq!(repr_keycode(&Key::Enter), "??".to_string()); + } + + #[test] + fn parse_invalid_config() { + assert_eq!( + Config::from_value(Value::test_string("x")), + Ok(Config::default()) + ); + } + + #[test] + fn parse_empty_config() { + let cols: Vec<&str> = vec![]; + assert_eq!( + Config::from_value(Value::test_record(cols, vec![])), + Ok(Config::default()) + ); + } + + #[test] + fn parse_config_with_invalid_field() { + let value = Value::test_record(vec!["x"], vec![Value::test_nothing()]); + let result = Config::from_value(value); + assert!(result.is_err()); + let error = result.err().unwrap(); + assert!(error.msg.contains("not a valid config field")); + + let value = Value::test_record( + vec!["colors"], + vec![Value::test_record(vec!["foo"], vec![Value::test_nothing()])], + ); + let result = Config::from_value(value); + assert!(result.is_err()); + let error = result.err().unwrap(); + assert!(error.msg.contains("not a valid config field")); + } + + #[test] + fn parse_config() { + let value = Value::test_record(vec!["show_cell_path"], vec![Value::test_bool(true)]); + assert_eq!(Config::from_value(value), Ok(Config::default())); + + let value = Value::test_record(vec!["show_cell_path"], vec![Value::test_bool(false)]); + let mut expected = Config::default(); + expected.show_cell_path = false; + assert_eq!(Config::from_value(value), Ok(expected)); + + let value = Value::test_record( + vec!["keybindings"], + vec![Value::test_record( + vec!["navigation"], + vec![Value::test_record( + vec!["up"], + vec![Value::test_string("x")], + )], + )], + ); + let mut expected = Config::default(); + expected.keybindings.navigation.up = Key::Char('x'); + assert_eq!(Config::from_value(value), Ok(expected)); + } +} diff --git a/src/config/parsing.rs b/src/config/parsing.rs index 7463092..905318b 100644 --- a/src/config/parsing.rs +++ b/src/config/parsing.rs @@ -266,3 +266,298 @@ pub(super) fn follow_cell_path(value: &Value, cell_path: &[&str]) -> Option( + result: Result, LabeledError>, + cell_path: &str, + expected: &str, + ) { + assert!(result.is_err()); + let err = result.err().unwrap(); + assert_eq!(err.label, "invalid config"); + assert_eq!(err.msg, format!("`$.{}` {}", cell_path, expected)); + } + + #[test] + fn trying_bool() { + test_tried_error( + try_bool(&Value::test_string("not a bool"), &[]), + "", + "should be a bool, found string", + ); + test_tried_error( + try_bool(&Value::test_int(123), &[]), + "", + "should be a bool, found int", + ); + + assert_eq!(try_bool(&Value::test_bool(true), &[]), Ok(Some(true))); + assert_eq!(try_bool(&Value::test_bool(false), &[]), Ok(Some(false))); + assert_eq!(try_bool(&Value::test_nothing(), &["x"]), Ok(None)); + } + + #[test] + fn trying_string() { + test_tried_error( + try_string(&Value::test_bool(true), &[]), + "", + "should be a string, found bool", + ); + test_tried_error( + try_string(&Value::test_int(123), &[]), + "", + "should be a string, found int", + ); + + assert_eq!( + try_string(&Value::test_string("my string"), &[]), + Ok(Some("my string".to_string())) + ); + assert_eq!( + try_string(&Value::test_string("my string"), &["x"]), + Ok(None) + ); + } + + #[test] + fn trying_key() { + test_tried_error( + try_key(&Value::test_bool(true), &[]), + "", + "should be a string, found bool", + ); + test_tried_error( + try_key(&Value::test_int(123), &[]), + "", + "should be a string, found int", + ); + test_tried_error( + try_key(&Value::test_string("enter"), &[]), + "", + "should be a character or one of [up, down, left, right, escape] , found enter", + ); + + let cases = vec![ + ("up", Key::ArrowUp), + ("down", Key::ArrowDown), + ("left", Key::ArrowLeft), + ("right", Key::ArrowRight), + ("escape", Key::Escape), + ("a", Key::Char('a')), + ("b", Key::Char('b')), + ("x", Key::Char('x')), + ]; + + for (input, expected) in cases { + assert_eq!(try_key(&Value::test_string(input), &[]), Ok(Some(expected))); + } + } + + #[test] + fn trying_layout() { + test_tried_error( + try_layout(&Value::test_bool(true), &[]), + "", + "should be a string, found bool", + ); + test_tried_error( + try_layout(&Value::test_int(123), &[]), + "", + "should be a string, found int", + ); + test_tried_error( + try_layout(&Value::test_string("collapsed"), &[]), + "", + "should be one of [table, compact] , found collapsed", + ); + + let cases = vec![("table", Layout::Table), ("compact", Layout::Compact)]; + + for (input, expected) in cases { + assert_eq!( + try_layout(&Value::test_string(input), &[]), + Ok(Some(expected)) + ); + } + } + + #[test] + fn trying_modifier() { + test_tried_error( + try_modifier(&Value::test_bool(true), &[]), + "", + "should be a string or null, found bool", + ); + test_tried_error( + try_modifier(&Value::test_int(123), &[]), + "", + "should be a string or null, found int", + ); + test_tried_error( + try_modifier(&Value::test_string("x"), &[]), + "", + "should be the empty string, one of [italic, bold, underline, blink] or null, found x", + ); + + assert_eq!( + try_modifier(&Value::test_nothing(), &[]), + Ok(Some(Modifier::empty())) + ); + + let cases = vec![ + ("", Modifier::empty()), + ("italic", Modifier::ITALIC), + ("bold", Modifier::BOLD), + ("underline", Modifier::UNDERLINED), + ("blink", Modifier::SLOW_BLINK), + ]; + + for (input, expected) in cases { + assert_eq!( + try_modifier(&Value::test_string(input), &[]), + Ok(Some(expected)) + ); + } + } + + #[test] + fn trying_color() { + test_tried_error( + try_color(&Value::test_bool(true), &[]), + "", + "should be a string, found bool", + ); + test_tried_error( + try_color(&Value::test_int(123), &[]), + "", + "should be a string, found int", + ); + test_tried_error( + try_color(&Value::test_string("x"), &[]), + "", + "should be one of [black, red, green, yellow, blue, magenta, cyan, gray, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan, white] , found x", + ); + + let cases = vec![ + ("black", Color::Black), + ("red", Color::Red), + ("green", Color::Green), + ("blue", Color::Blue), + ]; + + for (input, expected) in cases { + assert_eq!( + try_color(&Value::test_string(input), &[]), + Ok(Some(expected)) + ); + } + } + + #[test] + fn trying_fg_bg_colors() { + let default_color = BgFgColorConfig { + background: Color::Reset, + foreground: Color::Reset, + }; + + test_tried_error( + try_fg_bg_colors(&Value::test_bool(true), &[], &default_color), + "", + "should be a record, found bool", + ); + test_tried_error( + try_fg_bg_colors(&Value::test_int(123), &[], &default_color), + "", + "should be a record, found int", + ); + test_tried_error( + try_fg_bg_colors(&Value::test_string("x"), &[], &default_color), + "", + "should be a record, found string", + ); + test_tried_error( + try_fg_bg_colors( + &Value::test_record(vec!["x"], vec![Value::test_nothing()]), + &[], + &default_color, + ), + "x", + "is not a valid config field", + ); + + let cases = vec![ + (vec![], vec![], default_color.clone()), + ( + vec!["foreground"], + vec![Value::test_string("green")], + BgFgColorConfig { + foreground: Color::Green, + background: Color::Reset, + }, + ), + ( + vec!["background"], + vec![Value::test_string("blue")], + BgFgColorConfig { + foreground: Color::Reset, + background: Color::Blue, + }, + ), + ( + vec!["foreground", "background"], + vec![Value::test_string("green"), Value::test_string("blue")], + BgFgColorConfig { + foreground: Color::Green, + background: Color::Blue, + }, + ), + ]; + + for (cols, vals, expected) in cases { + assert_eq!( + try_fg_bg_colors(&Value::test_record(cols, vals), &[], &default_color), + Ok(Some(expected)) + ); + } + } +} diff --git a/src/navigation.rs b/src/navigation.rs index 9128793..fbc89ef 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -29,7 +29,7 @@ pub(super) fn go_up_or_down_in_data(state: &mut State, input: &Value, direction: } let direction = match direction { - Direction::Up => usize::MAX, + Direction::Up => -1, Direction::Down => 1, }; @@ -49,7 +49,10 @@ pub(super) fn go_up_or_down_in_data(state: &mut State, input: &Value, direction: val: if vals.is_empty() { val } else { - (val + direction + vals.len()) % vals.len() + let len = vals.len() as i32; + let new_index = (val as i32 + direction + len) % len; + + new_index as usize }, span, optional, @@ -69,8 +72,11 @@ pub(super) fn go_up_or_down_in_data(state: &mut State, input: &Value, direction: val: if cols.is_empty() { "".into() } else { - let index = cols.iter().position(|x| x == &val).unwrap(); - cols[(index + direction + cols.len()) % cols.len()].clone() + let index = cols.iter().position(|x| x == &val).unwrap() as i32; + let len = cols.len() as i32; + let new_index = (index + direction + len) % len; + + cols[new_index as usize].clone() }, span, optional, @@ -122,3 +128,141 @@ pub(super) fn go_back_in_data(state: &mut State) { } state.bottom = false; } + +// TODO: add proper assert error messages +#[cfg(test)] +mod tests { + use nu_protocol::{ast::PathMember, Span, Value}; + + use super::{go_back_in_data, go_deeper_in_data, go_up_or_down_in_data, Direction}; + use crate::app::State; + + fn test_string_pathmember(val: impl Into) -> PathMember { + PathMember::String { + val: val.into(), + span: Span::test_data(), + optional: false, + } + } + + fn test_int_pathmember(val: usize) -> PathMember { + PathMember::Int { + val, + span: Span::test_data(), + optional: false, + } + } + + #[test] + fn go_up_and_down_in_list() { + let value = Value::test_list(vec![ + Value::test_nothing(), + Value::test_nothing(), + Value::test_nothing(), + ]); + let mut state = State::from_value(&value); + + let sequence = vec![ + (Direction::Down, 1), + (Direction::Down, 2), + (Direction::Down, 0), + (Direction::Up, 2), + (Direction::Up, 1), + (Direction::Up, 0), + ]; + for (direction, id) in sequence { + go_up_or_down_in_data(&mut state, &value, direction); + let expected = vec![test_int_pathmember(id)]; + assert_eq!(state.cell_path.members, expected); + } + } + + #[test] + fn go_up_and_down_in_record() { + let value = Value::test_record( + vec!["a", "b", "c"], + vec![ + Value::test_nothing(), + Value::test_nothing(), + Value::test_nothing(), + ], + ); + let mut state = State::from_value(&value); + + let sequence = vec![ + (Direction::Down, "b"), + (Direction::Down, "c"), + (Direction::Down, "a"), + (Direction::Up, "c"), + (Direction::Up, "b"), + (Direction::Up, "a"), + ]; + for (direction, id) in sequence { + go_up_or_down_in_data(&mut state, &value, direction); + let expected = vec![test_string_pathmember(id)]; + assert_eq!(state.cell_path.members, expected); + } + } + + #[test] + fn go_deeper() { + let value = Value::test_list(vec![Value::test_record( + vec!["a"], + vec![Value::test_list(vec![Value::test_nothing()])], + )]); + let mut state = State::from_value(&value); + + let mut expected = vec![test_int_pathmember(0)]; + assert_eq!(state.cell_path.members, expected); + + go_deeper_in_data(&mut state, &value); + expected.push(test_string_pathmember("a")); + assert_eq!(state.cell_path.members, expected); + + go_deeper_in_data(&mut state, &value); + expected.push(test_int_pathmember(0)); + assert_eq!(state.cell_path.members, expected); + } + + #[test] + fn hit_bottom() { + let value = Value::test_nothing(); + let mut state = State::from_value(&value); + + assert!(!state.bottom); + + go_deeper_in_data(&mut state, &value); + assert!(state.bottom); + } + + #[test] + fn go_back() { + let value = Value::test_list(vec![Value::test_record( + vec!["a"], + vec![Value::test_list(vec![Value::test_nothing()])], + )]); + let mut state = State::from_value(&value); + state.cell_path.members = vec![ + test_int_pathmember(0), + test_string_pathmember("a"), + test_int_pathmember(0), + ]; + state.bottom = true; + + let mut expected = state.cell_path.members.clone(); + + go_back_in_data(&mut state); + assert_eq!(state.cell_path.members, expected); + + go_back_in_data(&mut state); + expected.pop(); + assert_eq!(state.cell_path.members, expected); + + go_back_in_data(&mut state); + expected.pop(); + assert_eq!(state.cell_path.members, expected); + + go_back_in_data(&mut state); + assert_eq!(state.cell_path.members, expected); + } +} diff --git a/src/tui.rs b/src/tui.rs index 235679a..8eec4e6 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -385,3 +385,133 @@ fn render_status_bar( bottom_bar_rect, ); } + +// TODO: add proper assert error messages +#[cfg(test)] +mod tests { + use nu_protocol::Value; + + use crate::config::{Config, Layout}; + + use super::{repr_data, repr_list, repr_record, repr_simple_value}; + + #[test] + fn simple_value() { + let mut config = Config::default(); + + #[rustfmt::skip] + let cases = vec![ + (Layout::Table, Value::test_string("foo"), vec!["foo", "string"]), + (Layout::Compact, Value::test_string("foo"), vec!["(string) foo"]), + (Layout::Table, Value::test_int(1), vec!["1", "int"]), + (Layout::Compact, Value::test_int(1), vec!["(int) 1"]), + (Layout::Table, Value::test_bool(true), vec!["true", "bool"]), + (Layout::Compact, Value::test_bool(true), vec!["(bool) true"]), + (Layout::Table, Value::test_nothing(), vec!["", "nothing"]), + (Layout::Compact, Value::test_nothing(), vec!["(nothing) "]), + ]; + + for (layout, value, expected) in cases { + config.layout = layout; + let result = repr_simple_value(&value, &config); + let expected: Vec = expected.iter().map(|x| x.to_string()).collect(); + assert_eq!(result, expected); + } + } + + #[test] + fn list() { + let mut config = Config::default(); + + let list = vec![ + Value::test_string("a"), + Value::test_int(1), + Value::test_bool(false), + ]; + + #[rustfmt::skip] + let cases = vec![ + (Layout::Table, list.clone(), vec!["[3 items]", "list"]), + (Layout::Compact, list.clone(), vec!["[list 3 items]"]), + (Layout::Table, vec![], vec!["[0 item]", "list"]), + (Layout::Compact, vec![], vec!["[list 0 item]"]), + (Layout::Table, vec![Value::test_nothing()], vec!["[1 item]", "list"]), + (Layout::Compact, vec![Value::test_nothing()], vec!["[list 1 item]"]), + ]; + + for (layout, list, expected) in cases { + config.layout = layout; + let result = repr_list(&list, &config); + let expected: Vec = expected.iter().map(|x| x.to_string()).collect(); + assert_eq!(result, expected); + } + } + + #[test] + fn record() { + let mut config = Config::default(); + + #[rustfmt::skip] + let cases = vec![ + (Layout::Table, vec!["a", "b", "c"], vec!["{3 fields}", "record"]), + (Layout::Compact, vec!["a", "b", "c"], vec!["{record 3 fields}"]), + (Layout::Table, vec![], vec!["{0 field}", "record"]), + (Layout::Compact, vec![], vec!["{record 0 field}"]), + (Layout::Table, vec!["a"], vec!["{1 field}", "record"]), + (Layout::Compact, vec!["a"], vec!["{record 1 field}"]), + ]; + + for (layout, record, expected) in cases { + config.layout = layout; + let result = repr_record( + &record.iter().map(|x| x.to_string()).collect::>(), + &config, + ); + let expected: Vec = expected.iter().map(|x| x.to_string()).collect(); + assert_eq!(result, expected); + } + } + + #[ignore = "repr_value is just a direct wrapper around repr_list, repr_record and repr_simple_value"] + #[test] + fn value() {} + + #[test] + fn data() { + let mut config = Config::default(); + + let data = Value::test_record( + vec!["l", "r", "s", "i"], + vec![ + Value::test_list(vec![ + Value::test_string("my"), + Value::test_string("list"), + Value::test_string("elements"), + ]), + Value::test_record(vec!["a", "b"], vec![Value::test_int(1), Value::test_int(2)]), + Value::test_string("some string"), + Value::test_int(123), + ], + ); + + config.layout = Layout::Table; + let result = repr_data(&data, &[], &config); + let expected: Vec> = vec![ + vec!["l".into(), "[3 items]".into(), "list".into()], + vec!["r".into(), "{2 fields}".into(), "record".into()], + vec!["s".into(), "some string".into(), "string".into()], + vec!["i".into(), "123".into(), "int".into()], + ]; + assert_eq!(result, expected); + + config.layout = Layout::Compact; + let result = repr_data(&data, &[], &config); + let expected: Vec> = vec![vec![ + "l: [list 3 items]".into(), + "r: {record 2 fields}".into(), + "s: (string) some string".into(), + "i: (int) 123".into(), + ]]; + assert_eq!(result, expected); + } +}