mod keys;
use crossterm::event::KeyCode;
use keys::{columns, key, open, press};
use tuitab::types::AppMode;
const SAMPLE: &str = "test_data/sample.csv";
#[test]
fn adding_a_sort_key_returns_to_normal_mode() {
let mut app = open(SAMPLE, "department");
press(&mut app, "z[");
assert_eq!(
app.mode,
AppMode::Normal,
"a completed z-command must close the prefix"
);
}
#[test]
fn a_key_after_the_sort_chord_is_not_read_as_a_z_command() {
let mut app = open(SAMPLE, "department");
let before = columns(&app);
press(&mut app, "z[");
press(&mut app, "d");
assert_eq!(
columns(&app),
before,
"`d` after `z[` must not delete a column"
);
}
#[test]
fn two_chords_build_a_compound_sort() {
let mut app = open(SAMPLE, "department");
press(&mut app, "z[");
let age = app.stack.active().dataframe.column_index("age").unwrap();
app.stack.active_mut().cursor_col = age;
press(&mut app, "z]");
assert_eq!(
app.stack.active().sort_keys,
vec![("department".to_string(), false), ("age".to_string(), true)],
"both keys must survive"
);
}
#[test]
fn deleting_a_sorted_column_does_not_leave_a_key_that_panics() {
let mut app = open(SAMPLE, "department");
press(&mut app, "]");
assert_eq!(app.stack.active().sort_keys.len(), 1);
press(&mut app, "zd");
let age = app.stack.active().dataframe.column_index("age").unwrap();
app.stack.active_mut().cursor_col = age;
press(&mut app, "z[");
let resolved = app.stack.active().resolved_sort_keys();
for (col, _) in &resolved {
assert!(
*col < app.stack.active().dataframe.columns.len(),
"a resolved key must point at a live column"
);
}
}
#[test]
fn moving_a_column_carries_its_sort_key() {
let mut app = open(SAMPLE, "name");
press(&mut app, "]");
assert_eq!(
app.stack.active().sort_keys,
vec![("name".to_string(), true)]
);
key(&mut app, KeyCode::Char('z'));
key(&mut app, KeyCode::Left);
let moved = app.stack.active().dataframe.column_index("name").unwrap();
assert_eq!(moved, 0, "the column moved");
assert_eq!(
app.stack.active().resolved_sort_keys(),
vec![(moved, true)],
"the sort key must follow the column it names"
);
}
#[test]
fn escaping_the_partition_picker_disarms_the_window_function() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zw");
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Esc);
assert!(
app.pending_window_fn.is_none(),
"cancelling must not leave a function armed"
);
press(&mut app, "zF");
key(&mut app, KeyCode::Enter);
assert!(
columns(&app).iter().any(|c| c.ends_with("pct_of_total")),
"zF must produce a share: {:?}",
columns(&app)
);
}
#[test]
fn the_window_picker_refuses_a_document_sheet() {
let mut app = open("test_data/nested.json", "id");
let before = columns(&app);
press(&mut app, "zw");
assert_ne!(
app.mode,
AppMode::WindowFnSelect,
"the picker must not open on a document sheet"
);
assert_eq!(columns(&app), before, "no column may be added");
assert!(
app.stack.active().doc_mapping_ok(),
"the table must still match the document"
);
let mut plain = open(SAMPLE, "salary");
press(&mut plain, "zw");
assert_eq!(plain.mode, AppMode::WindowFnSelect);
}
#[test]
fn adding_a_window_column_keeps_the_original_row_order() {
let mut app = open(SAMPLE, "age");
let first_before = app
.stack
.active()
.dataframe
.get_physical(app.stack.active().dataframe.original_order[0], 1);
press(&mut app, "]"); press(&mut app, "zf"); press(&mut app, "r");
let sheet = app.stack.active();
assert_eq!(
sheet
.dataframe
.get_physical(sheet.dataframe.row_order[0], 1),
first_before,
"`r` must restore the file's order, not the order at the time the column was added"
);
}
#[test]
fn adding_a_window_column_keeps_the_selection() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "]");
press(&mut app, "s"); press(&mut app, "j");
press(&mut app, "s");
assert_eq!(app.stack.active().dataframe.selected_rows.len(), 2);
let marked: Vec<String> = {
let df = &app.stack.active().dataframe;
let mut names: Vec<String> = df
.selected_rows
.iter()
.map(|p| df.get_physical(*p, 1))
.collect();
names.sort();
names
};
press(&mut app, "zf");
let df = &app.stack.active().dataframe;
let mut still_marked: Vec<String> = df
.selected_rows
.iter()
.map(|p| df.get_physical(*p, 1))
.collect();
still_marked.sort();
assert_eq!(
still_marked, marked,
"the same rows must still be selected, not merely the same number"
);
}
#[test]
fn the_window_picker_accepts_a_text_column_for_functions_that_do_not_need_numbers() {
let mut app = open(SAMPLE, "name");
press(&mut app, "zw");
key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter);
assert!(
columns(&app).iter().any(|c| c.contains("row_number")),
"row_number does not read the column at all: {:?}",
columns(&app)
);
}
#[test]
fn a_numeric_window_on_a_text_column_names_the_function_that_refused() {
let mut app = open(SAMPLE, "name");
let before = columns(&app);
press(&mut app, "zw");
for _ in 0..3 {
key(&mut app, KeyCode::Down); }
key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter);
assert_eq!(columns(&app), before, "nothing may be added");
assert!(
app.status_message.contains("cum_sum"),
"the message must name what was refused: {}",
app.status_message
);
}
#[test]
fn a_window_column_lands_beside_its_source() {
let mut app = open(SAMPLE, "age");
press(&mut app, "zf");
let names = columns(&app);
let age = names.iter().position(|c| c == "age").unwrap();
assert_eq!(
names[age + 1],
"age_pct_of_total",
"the new column should follow its source: {:?}",
names
);
}
#[test]
fn the_operations_that_reshape_a_table_refuse_a_document_sheet() {
for keys in ["zF", "T"] {
let mut app = open("test_data/nested.json", "id");
let before = columns(&app);
press(&mut app, keys);
if app.mode == AppMode::PartitionSelect {
key(&mut app, KeyCode::Enter);
}
assert_eq!(
columns(&app),
before,
"`{}` reshaped a document sheet",
keys
);
assert!(
app.stack.active().doc_mapping_ok(),
"`{}` left the table disagreeing with its document",
keys
);
}
}
#[test]
fn they_still_work_on_a_plain_sheet() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zF");
assert_eq!(app.mode, AppMode::PartitionSelect);
let mut app = open(SAMPLE, "salary");
press(&mut app, "T");
assert_eq!(
app.stack.active().dataframe.visible_row_count(),
5,
"five columns become five rows"
);
}
#[test]
fn sorting_works_on_a_cyrillic_layout() {
let mut app = open(SAMPLE, "age");
press(&mut app, "ъ");
assert_eq!(
app.stack.active().sort_keys,
vec![("age".to_string(), true)],
"`ъ` is `]` on a Cyrillic layout"
);
}
#[test]
fn chords_work_on_a_cyrillic_layout() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "яц"); assert_eq!(
app.mode,
AppMode::WindowFnSelect,
"`яц` is `zw` on a Cyrillic layout"
);
let mut app = open(SAMPLE, "department");
press(&mut app, "ях"); assert_eq!(app.stack.active().sort_keys.len(), 1);
}
fn cells(app: &tuitab::app::App, column: &str) -> Vec<String> {
let df = &app.stack.active().dataframe;
let col = df.column_index(column).unwrap();
(0..df.visible_row_count())
.map(|r| tuitab::data::dataframe::DataFrame::anyvalue_to_string_fmt(&df.get_val(r, col)))
.collect()
}
#[test]
fn a_descending_rank_puts_the_largest_value_first() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zw");
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
assert_eq!(
app.mode,
AppMode::WindowDirSelect,
"a rank must ask which end is first"
);
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter);
let salaries: Vec<f64> = cells(&app, "salary")
.iter()
.map(|v| v.parse().unwrap())
.collect();
let ranks = cells(&app, "salary_rank_desc");
let top = salaries.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let row = salaries.iter().position(|s| *s == top).unwrap();
assert_eq!(
ranks[row], "1",
"the largest salary ranks first: {:?}",
ranks
);
}
#[test]
fn the_default_direction_is_ascending_on_both_surfaces() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zw");
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter);
let salaries: Vec<f64> = cells(&app, "salary")
.iter()
.map(|v| v.parse().unwrap())
.collect();
let ranks = cells(&app, "salary_rank");
let bottom = salaries.iter().cloned().fold(f64::INFINITY, f64::min);
let row = salaries.iter().position(|s| *s == bottom).unwrap();
assert_eq!(
ranks[row], "1",
"the smallest salary ranks first: {:?}",
ranks
);
}
#[test]
fn an_aggregate_window_is_asked_about_neither_order_nor_direction() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zw");
for _ in 0..6 {
key(&mut app, KeyCode::Down); }
key(&mut app, KeyCode::Enter);
assert_eq!(
app.mode,
AppMode::PartitionSelect,
"a sum has no order and no direction to ask about"
);
}
#[test]
fn escaping_the_direction_picker_disarms_the_window_function() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zw");
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Esc);
assert!(
app.pending_window_fn.is_none(),
"cancelling must not leave a function armed"
);
press(&mut app, "zF");
key(&mut app, KeyCode::Enter);
assert!(
columns(&app).iter().any(|c| c.ends_with("pct_of_total")),
"zF must still mean a share: {:?}",
columns(&app)
);
}
#[test]
fn a_direction_does_not_carry_over_to_the_next_window() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "zw");
key(&mut app, KeyCode::Down);
key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter);
press(&mut app, "zw");
key(&mut app, KeyCode::Down);
key(&mut app, KeyCode::Enter);
assert!(
!app.window_fn.desc,
"the picker must open on ascending, not on the last choice"
);
}
#[test]
fn repeating_the_move_chord_keeps_moving_the_same_column() {
let mut app = open(SAMPLE, "salary");
let start = app.stack.active().cursor_col;
assert!(
start >= 3,
"the fixture must have room to move left three times"
);
for _ in 0..3 {
key(&mut app, KeyCode::Char('z'));
key(&mut app, KeyCode::Left);
}
assert_eq!(
columns(&app).iter().position(|c| c == "salary"),
Some(start - 3),
"three chords must move the column three places: {:?}",
columns(&app)
);
assert_eq!(
app.stack.active().cursor_col,
start - 3,
"the cursor must travel with the column it is moving"
);
}
#[test]
fn a_sort_chord_after_a_move_still_means_the_moved_column() {
let mut app = open(SAMPLE, "salary");
press(&mut app, "z]"); assert_eq!(
app.stack.active().sort_keys,
vec![("salary".to_string(), true)]
);
for _ in 0..3 {
key(&mut app, KeyCode::Char('z'));
key(&mut app, KeyCode::Left);
}
press(&mut app, "z[");
assert_eq!(
app.stack.active().sort_keys,
vec![("salary".to_string(), false)],
"the sort must stay on the column under the cursor, not wander"
);
}
#[test]
fn a_second_z_reopens_the_prefix_instead_of_leaving_column_move() {
let mut app = open(SAMPLE, "salary");
key(&mut app, KeyCode::Char('z'));
key(&mut app, KeyCode::Left);
assert_eq!(app.mode, AppMode::ColumnMove);
key(&mut app, KeyCode::Char('z'));
assert_eq!(app.mode, AppMode::ZPrefix);
key(&mut app, KeyCode::Esc);
assert_eq!(app.mode, AppMode::Normal, "Esc must leave the prefix");
}
#[test]
fn the_move_chord_repeats_on_a_cyrillic_layout() {
let mut app = open(SAMPLE, "salary");
let start = app.stack.active().cursor_col;
for _ in 0..2 {
key(&mut app, KeyCode::Char('я'));
key(&mut app, KeyCode::Left);
}
assert_eq!(
columns(&app).iter().position(|c| c == "salary"),
Some(start - 2),
"`я` must reach the prefix from ColumnMove too: {:?}",
columns(&app)
);
}
const GROWTH: &str = "test_data/growth.csv";
#[test]
fn a_running_total_can_be_ordered_by_a_column_from_the_picker() {
let mut app = open(GROWTH, "amount");
let dates_before = cells(&app, "date");
press(&mut app, "zw");
for _ in 0..3 {
key(&mut app, KeyCode::Down); }
key(&mut app, KeyCode::Enter);
assert_eq!(
app.mode,
AppMode::WindowOrderSelect,
"a running total must ask what 'before' means"
);
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter); key(&mut app, KeyCode::Enter);
let name = columns(&app)
.into_iter()
.find(|c| c.contains("cum_sum"))
.expect("a column must have been added");
assert!(
name.contains("date"),
"the order belongs in the name: {}",
name
);
assert_eq!(
cells(&app, &name),
vec!["75", "17", "10", "84", "37", "45"],
"totals must follow the dates, not the file"
);
assert_eq!(
cells(&app, "date"),
dates_before,
"and the table's own order must be untouched"
);
}
#[test]
fn keeping_the_tables_order_goes_straight_to_the_partitions() {
let mut app = open(GROWTH, "amount");
press(&mut app, "zw");
for _ in 0..3 {
key(&mut app, KeyCode::Down); }
key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter);
assert_eq!(app.mode, AppMode::PartitionSelect);
}
#[test]
fn escaping_the_order_picker_disarms_the_window_function() {
let mut app = open(GROWTH, "amount");
press(&mut app, "zw");
for _ in 0..3 {
key(&mut app, KeyCode::Down);
}
key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Esc);
assert!(app.pending_window_fn.is_none());
press(&mut app, "zF");
key(&mut app, KeyCode::Enter);
assert!(
columns(&app).iter().any(|c| c.ends_with("pct_of_total")),
"zF must still mean a share: {:?}",
columns(&app)
);
}
#[test]
fn an_order_does_not_carry_over_to_the_next_window() {
let mut app = open(GROWTH, "amount");
press(&mut app, "zw");
for _ in 0..3 {
key(&mut app, KeyCode::Down);
}
key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Down);
key(&mut app, KeyCode::Down); key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter);
key(&mut app, KeyCode::Enter);
press(&mut app, "zw");
assert!(app.window_fn.order_by.is_none(), "the order must reset");
assert_eq!(app.window_fn.order_index, 0, "and so must the highlight");
}