use std::path::{Path, PathBuf};
use tuitab::data::doc::{Format, Seg};
use tuitab::data::io::{doc_io::Shape, load_file_as, load_file_with_doc, save_file_as};
use tuitab::data::view::ViewMode;
fn fixture(name: &str) -> PathBuf {
Path::new("test_data").join(name)
}
fn out(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join("tuitab-doc-tests");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn toml_opens_as_key_value_rows_with_typed_nodes() {
let (df, doc) = load_file_with_doc(&fixture("app.toml"), None).unwrap();
let doc = doc.expect("toml must carry a document tree");
assert_eq!(doc.view.mode, ViewMode::KeyValue);
let names: Vec<&str> = df.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["key", "value", "type"]);
assert_eq!(df.visible_row_count(), 5, "5 top-level keys");
let types: Vec<String> = (0..5).map(|r| df.get_physical(r, 2)).collect();
assert!(types.contains(&"datetime".to_string()), "{:?}", types);
assert!(types.contains(&"dict".to_string()), "{:?}", types);
assert!(types.contains(&"list".to_string()), "{:?}", types);
}
#[test]
fn editing_a_toml_value_and_saving_preserves_structure_and_datetime() {
let (mut df, doc) = load_file_with_doc(&fixture("app.toml"), None).unwrap();
let mut doc = doc.unwrap();
let row = (0..df.visible_row_count())
.find(|r| df.get_physical(*r, 0) == "port")
.unwrap();
let shown = doc.set_cell(row, 1, "9090").unwrap();
df.set_cell(row, 1, shown).unwrap();
let path = out("app-edited.toml");
save_file_as(&df, Some(&doc), &path, Shape::Records, "app").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("port = 9090"), "{}", text);
assert!(
text.contains("1979-05-27T07:32:00Z"),
"datetime must not degrade to a quoted string: {}",
text
);
assert!(text.contains("[db]"), "nested table survives: {}", text);
assert!(
text.contains("[[servers]]"),
"array of tables survives: {}",
text
);
}
#[test]
fn toml_converts_to_yaml_and_json_by_changing_the_extension() {
let (df, doc) = load_file_with_doc(&fixture("app.toml"), None).unwrap();
let doc = doc.unwrap();
let yaml_path = out("app.yaml");
save_file_as(&df, Some(&doc), &yaml_path, Shape::Records, "app").unwrap();
let yaml = std::fs::read_to_string(&yaml_path).unwrap();
assert!(yaml.contains("host: localhost"), "{}", yaml);
let json_path = out("app.json");
save_file_as(&df, Some(&doc), &json_path, Shape::Records, "app").unwrap();
let json = std::fs::read_to_string(&json_path).unwrap();
assert!(json.contains("\"pool\": 10"), "{}", json);
let (_, reopened) = load_file_with_doc(&yaml_path, None).unwrap();
let reopened = reopened.unwrap();
let host = reopened
.doc
.read()
.unwrap()
.root
.get(&[Seg::Key("db".into()), Seg::Key("host".into())])
.cloned();
assert_eq!(host, Some(tuitab::data::doc::Node::Str("localhost".into())));
}
#[test]
fn multi_document_yaml_becomes_rows_and_stays_multi_document() {
let (df, doc) = load_file_with_doc(&fixture("k8s.yaml"), None).unwrap();
let doc = doc.unwrap();
assert_eq!(doc.view.mode, ViewMode::Records);
assert_eq!(df.visible_row_count(), 2, "one row per document");
let path = out("k8s-out.yaml");
save_file_as(&df, Some(&doc), &path, Shape::Records, "k8s").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(
text.matches("---").count(),
2,
"document separators must survive: {}",
text
);
}
#[test]
fn jsonl_round_trips_and_can_be_saved_as_json() {
let (df, doc) = load_file_with_doc(&fixture("rows.jsonl"), None).unwrap();
let doc = doc.unwrap();
assert_eq!(df.visible_row_count(), 2);
assert_eq!(df.col_count(), 2);
let path = out("rows.json");
save_file_as(&df, Some(&doc), &path, Shape::Records, "rows").unwrap();
let json = std::fs::read_to_string(&path).unwrap();
assert!(json.trim_start().starts_with('['), "{}", json);
}
#[test]
fn nested_json_dives_into_subtrees_and_edits_are_visible_from_the_root() {
let (_df, doc) = load_file_with_doc(&fixture("nested.json"), None).unwrap();
let root = doc.unwrap();
let (tags_df, mut tags) = root
.dive(vec![Seg::Idx(0), Seg::Key("tags".into())])
.unwrap();
assert_eq!(tags.view.mode, ViewMode::Scalars);
assert_eq!(tags_df.visible_row_count(), 2);
tags.set_cell(0, 0, "z").unwrap();
let value = root
.doc
.read()
.unwrap()
.root
.get(&[Seg::Idx(0), Seg::Key("tags".into()), Seg::Idx(0)])
.cloned();
assert_eq!(value, Some(tuitab::data::doc::Node::Str("z".into())));
}
#[test]
fn a_plain_csv_saves_as_json_records() {
let (df, doc) = load_file_with_doc(&fixture("prices.csv"), None).unwrap();
assert!(doc.is_none(), "csv has no document tree");
let path = out("prices.json");
save_file_as(&df, None, &path, Shape::Records, "prices").unwrap();
let json = std::fs::read_to_string(&path).unwrap();
assert!(json.trim_start().starts_with('['), "{}", json);
let toml_path = out("prices.toml");
save_file_as(&df, None, &toml_path, Shape::Records, "prices").unwrap();
let toml = std::fs::read_to_string(&toml_path).unwrap();
assert!(toml.contains("[[prices]]"), "{}", toml);
}
#[test]
fn forcing_a_format_overrides_the_extension() {
let forced = load_file_as(&fixture("prices.csv"), None, Some(Format::Toml));
assert!(forced.is_err(), "csv is not valid toml");
let (_, doc) = load_file_as(&fixture("app.toml"), None, Some(Format::Toml)).unwrap();
assert!(doc.is_some());
}
#[test]
fn a_toml_file_renders_as_key_value_rows_in_the_real_ui() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = tuitab::app::App::new(&fixture("app.toml"), None).unwrap();
let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
terminal.draw(|f| tuitab::ui::render(f, &mut app)).unwrap();
let screen: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
for expected in [
"key",
"value",
"type",
"port",
"8080",
"datetime",
"{2} host=localhost",
] {
assert!(
screen.contains(expected),
"expected `{}` on screen:\n{}",
expected,
screen
);
}
}
#[test]
fn enter_dives_into_a_nested_node_and_esc_pops_back() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("app.toml"), None).unwrap();
let root_title = app.stack.active().title.clone();
while app
.stack
.active()
.dataframe
.get_physical(app.stack.active().table_state.selected().unwrap_or(0), 0)
!= "servers"
{
app.handle_action(Action::MoveDown);
}
app.handle_action(Action::OpenRow);
assert_eq!(
app.stack.active().title,
"app.toml › servers",
"breadcrumbs name the anchored node"
);
assert_eq!(app.stack.active().dataframe.visible_row_count(), 2);
let cols: Vec<String> = app
.stack
.active()
.dataframe
.columns
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(cols, vec!["host", "weight"], "array of tables → records");
app.handle_action(Action::CycleViewMode);
assert_ne!(
app.stack.active().dataframe.columns[0].name,
"host",
"the projection actually changed"
);
app.handle_action(Action::PopSheet);
assert_eq!(app.stack.active().title, root_title);
}
#[test]
fn drilling_in_from_a_directory_keeps_the_document() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(Path::new("test_data"), None).unwrap();
assert!(app.stack.active().is_dir_sheet);
let df = &app.stack.active().dataframe;
let row = (0..df.visible_row_count())
.find(|r| df.get_physical(*r, 0).contains("app.toml"))
.expect("app.toml must be listed");
app.stack.active_mut().table_state.select(Some(row));
app.handle_action(Action::OpenRow);
let sheet = app.stack.active();
assert!(
sheet.doc.is_some(),
"a TOML opened from a directory must carry its tree"
);
let path = out("drill-save.toml");
save_file_as(
&sheet.dataframe,
sheet.doc.as_ref(),
&path,
Shape::Records,
&sheet.title,
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[db]"), "structure must survive: {}", text);
assert!(text.contains("[[servers]]"), "{}", text);
assert!(
!text.contains("[[test_data"),
"must not be rewritten as a flat table: {}",
text
);
}
#[test]
fn popping_back_from_a_dive_shows_the_edit() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let meta_col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "meta")
.unwrap();
app.stack.active_mut().cursor_col = meta_col;
assert_eq!(
app.stack.active().dataframe.get_physical(0, meta_col),
"{1} ok=true"
);
app.handle_action(Action::OpenCell);
assert!(app.stack.can_pop(), "dived into the meta object");
{
let s = app.stack.active_mut();
s.edit_row = 0;
s.edit_col = 1;
s.edit_input = tuitab::ui::text_input::TextInput::with_value("false".into());
}
app.handle_action(Action::ApplyEdit);
app.handle_action(Action::PopSheet);
assert_eq!(
app.stack.active().dataframe.get_physical(0, meta_col),
"{1} ok=false",
"the parent must re-render the edited subtree"
);
}
#[test]
fn a_key_named_value_is_not_mistaken_for_the_bare_column() {
use tuitab::data::doc::{Doc, Node};
use tuitab::data::io::doc_io::DocState;
let doc = Doc::from_str(r#"[{"value":"real"}, 7]"#, Format::Json).unwrap();
let (df, mut state) = DocState::from_doc(doc).unwrap();
let names: Vec<&str> = df.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec!["value_1", "value"],
"the synthetic column is renamed"
);
state.set_cell(0, 1, "changed").unwrap();
let root = state.doc.read().unwrap().root.clone();
assert_eq!(
root.get(&[Seg::Idx(0)]).map(|n| n.type_name()),
Some("dict"),
"row 0 must still be an object"
);
assert_eq!(
root.get(&[Seg::Idx(0), Seg::Key("value".into())]),
Some(&Node::Str("changed".into()))
);
}
#[test]
fn expand_and_contract_a_nested_column_from_the_app() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let meta_col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "meta")
.unwrap();
app.stack.active_mut().cursor_col = meta_col;
app.handle_action(Action::ExpandColumn);
let names: Vec<String> = app
.stack
.active()
.dataframe
.columns
.iter()
.map(|c| c.name.clone())
.collect();
assert!(names.contains(&"meta.ok".to_string()), "{:?}", names);
assert!(names.contains(&"meta.note".to_string()), "{:?}", names);
assert!(
!names.contains(&"meta".to_string()),
"parent is replaced: {:?}",
names
);
let ok_col = names.iter().position(|n| n == "meta.ok").unwrap();
{
let s = app.stack.active_mut();
s.edit_row = 0;
s.edit_col = ok_col;
s.edit_input = tuitab::ui::text_input::TextInput::with_value("false".into());
}
app.handle_action(Action::ApplyEdit);
let doc = app.stack.active().doc.as_ref().unwrap();
assert_eq!(
doc.doc.read().unwrap().root.get(&[
Seg::Idx(0),
Seg::Key("meta".into()),
Seg::Key("ok".into())
]),
Some(&tuitab::data::doc::Node::Bool(false)),
"editing an expanded column must write into the nested node"
);
app.stack.active_mut().cursor_col = ok_col;
app.handle_action(Action::ContractColumn);
let names: Vec<String> = app
.stack
.active()
.dataframe
.columns
.iter()
.map(|c| c.name.clone())
.collect();
assert!(
names.contains(&"meta".to_string()),
"folded back: {:?}",
names
);
}
#[test]
fn expansion_survives_switching_back_from_a_dive() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let meta_col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "meta")
.unwrap();
app.stack.active_mut().cursor_col = meta_col;
app.handle_action(Action::ExpandColumn);
app.stack.active_mut().cursor_col = 0;
app.handle_action(Action::OpenRow);
app.handle_action(Action::PopSheet);
let names: Vec<String> = app
.stack
.active()
.dataframe
.columns
.iter()
.map(|c| c.name.clone())
.collect();
assert!(names.contains(&"meta.ok".to_string()), "{:?}", names);
}
#[test]
fn expanding_columns_does_not_leak_into_the_saved_file() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let meta_col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "meta")
.unwrap();
app.stack.active_mut().cursor_col = meta_col;
app.handle_action(Action::ExpandColumn);
let sheet = app.stack.active();
let path = out("nested-expanded.json");
save_file_as(
&sheet.dataframe,
sheet.doc.as_ref(),
&path,
Shape::Records,
&sheet.title,
)
.unwrap();
let json = std::fs::read_to_string(&path).unwrap();
assert!(json.contains("\"meta\""), "nesting is preserved: {}", json);
assert!(
!json.contains("meta.ok"),
"must not flatten on save: {}",
json
);
}
#[test]
fn undo_after_expanding_keeps_the_table_and_the_node_mapping_in_step() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
{
let s = app.stack.active_mut();
s.edit_row = 0;
s.edit_col = s
.dataframe
.columns
.iter()
.position(|c| c.name == "name")
.unwrap();
s.edit_input = tuitab::ui::text_input::TextInput::with_value("ALPHA".into());
}
app.handle_action(Action::ApplyEdit);
let meta_col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "meta")
.unwrap();
app.stack.active_mut().cursor_col = meta_col;
app.handle_action(Action::ExpandColumn);
app.handle_action(Action::Undo);
let s = app.stack.active();
let doc = s.doc.as_ref().unwrap();
assert_eq!(
doc.col_roles.len(),
s.dataframe.columns.len(),
"column roles must describe the table that is actually shown"
);
assert_eq!(doc.row_paths.len(), s.dataframe.df.height());
let name_col = s
.dataframe
.columns
.iter()
.position(|c| c.name == "name")
.unwrap();
assert_eq!(s.dataframe.get_physical(0, name_col), "alpha");
}
#[test]
fn column_ops_are_refused_on_a_doc_sheet() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let before: Vec<String> = app
.stack
.active()
.dataframe
.columns
.iter()
.map(|c| c.name.clone())
.collect();
for action in [
Action::DeleteColumn,
Action::StartInsertColumn,
Action::MoveColumnRight,
Action::StartColReplace,
Action::StartColSplit,
] {
app.stack.active_mut().cursor_col = 0;
app.handle_action(action);
let s = app.stack.active();
let now: Vec<String> = s.dataframe.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(now, before, "the table must be left alone");
assert!(s.doc_mapping_ok(), "the node mapping must stay valid");
}
{
let s = app.stack.active_mut();
s.edit_row = 0;
s.edit_col = before.iter().position(|n| n == "name").unwrap();
s.edit_input = tuitab::ui::text_input::TextInput::with_value("ALPHA".into());
}
app.handle_action(Action::ApplyEdit);
let s = app.stack.active();
assert_eq!(
s.doc
.as_ref()
.unwrap()
.doc
.read()
.unwrap()
.root
.get(&[Seg::Idx(0), Seg::Key("name".into())]),
Some(&tuitab::data::doc::Node::Str("ALPHA".into()))
);
}
#[test]
fn a_large_json_loaded_in_the_background_keeps_its_document() {
use tuitab::data::async_loader::{load_in_background, LoadEvent};
let path = out("big.json");
let mut text = String::from("[\n");
let row = r#"{"id":0,"meta":{"ok":true},"pad":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}"#;
let n = (11 * 1024 * 1024) / row.len();
for i in 0..n {
if i > 0 {
text.push_str(",\n");
}
text.push_str(row);
}
text.push_str("\n]\n");
std::fs::write(&path, &text).unwrap();
assert!(std::fs::metadata(&path).unwrap().len() > 10 * 1024 * 1024);
let rx = load_in_background(path.clone(), None);
let LoadEvent::Complete(result) = rx.recv().unwrap();
let (df, doc) = result.unwrap();
assert_eq!(df.visible_row_count(), n);
let doc = doc.expect("the background loader must carry the document tree");
let out_path = out("big-out.json");
save_file_as(&df, Some(&doc), &out_path, Shape::Records, "big").unwrap();
let head: String = std::fs::read_to_string(&out_path)
.unwrap()
.chars()
.take(200)
.collect();
assert!(head.contains("\"meta\""), "nesting must survive: {}", head);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn an_unknown_extension_is_sniffed_from_the_contents() {
let (df, doc) = load_file_with_doc(&fixture("deploy.conf"), None).unwrap();
let doc = doc.expect("deploy.conf is TOML and must open as a document");
assert_eq!(doc.format(), Format::Toml);
assert_eq!(df.visible_row_count(), 2, "listen and tls");
let prose = out("notes.rubbish");
std::fs::write(&prose, "1. Definitions.\n\nSome prose, not a document.\n").unwrap();
assert!(
load_file_with_doc(&prose, None).is_err(),
"prose is neither tabular nor a document"
);
}
#[test]
fn an_extensionless_file_only_sniffs_json() {
let json = out("noext-json");
std::fs::write(&json, "[{\"a\":1}]\n").unwrap();
let (_, doc) = load_file_with_doc(&json, None).unwrap();
assert_eq!(doc.map(|d| d.format()), Some(Format::Json));
let csv = out("noext-csv");
std::fs::write(&csv, "a,b\n1,2\n").unwrap();
let (df, doc) = load_file_with_doc(&csv, None).unwrap();
assert!(doc.is_none(), "still read as CSV");
assert_eq!(df.col_count(), 2);
let yaml_ish = out("noext-yaml");
std::fs::write(&yaml_ish, "a: 1\nb: 2\n").unwrap();
let (_, doc) = load_file_with_doc(&yaml_ish, None).unwrap();
assert!(doc.is_none(), "no extension means no YAML guess");
}
#[test]
fn an_array_rooted_document_can_still_be_saved_as_toml() {
let (df, doc) = load_file_with_doc(&fixture("rows.jsonl"), None).unwrap();
let doc = doc.unwrap();
let path = out("rows.toml");
save_file_as(&df, Some(&doc), &path, Shape::Records, "rows").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.matches("[[rows]]").count(), 2, "{}", text);
}
#[test]
fn saving_a_plain_table_asks_for_a_shape() {
use tuitab::types::{Action, AppMode};
let mut app = tuitab::app::App::new(&fixture("prices.csv"), None).unwrap();
let target = out("shape-choice.json");
let _ = std::fs::remove_file(&target);
app.handle_action(Action::SaveFile);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(target.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert_eq!(app.mode, AppMode::SaveShapeSelect, "must ask first");
assert!(
!target.exists(),
"nothing written until the shape is chosen"
);
app.handle_action(Action::ChoiceDown);
app.handle_action(Action::ApplySaveShape);
assert_eq!(app.mode, AppMode::Normal, "{}", app.status_message);
let json = std::fs::read_to_string(&target).unwrap();
assert!(json.trim_start().starts_with('{'), "column shape: {}", json);
assert!(json.contains("["), "each column is an array: {}", json);
let second = out("shape-choice-2.json");
app.handle_action(Action::SaveFile);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(second.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert_eq!(app.mode, AppMode::SaveShapeSelect);
app.handle_action(Action::ApplySaveShape);
let again = std::fs::read_to_string(&second).unwrap();
assert!(again.trim_start().starts_with('{'), "same shape: {}", again);
}
#[test]
fn the_remembered_shape_survives_a_shorter_option_list() {
use tuitab::data::io::doc_io::Shape;
use tuitab::types::{Action, AppMode};
let two_col = out("pairs.csv");
std::fs::write(&two_col, "k,v\na,1\nb,2\n").unwrap();
let mut app = tuitab::app::App::new(&two_col, None).unwrap();
let target = out("kv-shape.json");
let _ = std::fs::remove_file(&target);
app.handle_action(Action::SaveFile);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(target.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert_eq!(app.save.shapes.len(), 3, "records, columns, key/value");
app.handle_action(Action::ChoiceDown);
app.handle_action(Action::ChoiceDown);
app.handle_action(Action::ApplySaveShape);
assert_eq!(app.save.shape, Shape::KeyValue);
app.handle_action(Action::PopSheet);
let mut app = tuitab::app::App::new(&fixture("sample.csv"), None).unwrap();
app.save.shape = Shape::KeyValue;
app.handle_action(Action::SaveFile);
let wide = out("wide-shape.json");
app.save.input =
tuitab::ui::text_input::TextInput::with_value(wide.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert_eq!(app.mode, AppMode::SaveShapeSelect);
assert_eq!(
app.save.shapes.len(),
2,
"key/value needs exactly 2 columns"
);
assert_eq!(app.save.shape_index, 0, "falls back to the first option");
app.handle_action(Action::ApplySaveShape);
assert_eq!(app.save.shape, Shape::Records);
let json = std::fs::read_to_string(&wide).unwrap();
assert!(json.trim_start().starts_with('['), "records: {}", json);
}
#[test]
fn saving_a_document_sheet_does_not_ask_for_a_shape() {
use tuitab::types::{Action, AppMode};
let mut app = tuitab::app::App::new(&fixture("app.toml"), None).unwrap();
let target = out("no-shape-question.yaml");
app.handle_action(Action::SaveFile);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(target.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert_eq!(app.mode, AppMode::Normal, "{}", app.status_message);
assert!(std::fs::read_to_string(&target)
.unwrap()
.contains("host: localhost"));
}
#[test]
fn open_as_forces_a_format_from_the_directory_listing() {
use tuitab::types::{Action, AppMode};
std::fs::write(fixture("mislabelled.txt"), "alpha: 1\nbeta: 2\n").unwrap();
let mut app = tuitab::app::App::new(Path::new("test_data"), None).unwrap();
let df = &app.stack.active().dataframe;
let row = (0..df.visible_row_count())
.find(|r| df.get_physical(*r, 0).contains("mislabelled.txt"))
.expect("listed");
app.stack.active_mut().table_state.select(Some(row));
app.handle_action(Action::OpenAs);
assert_eq!(app.mode, AppMode::OpenAsSelect);
app.handle_action(Action::ChoiceDown);
app.handle_action(Action::ChoiceDown);
app.handle_action(Action::ApplyOpenAs);
let s = app.stack.active();
let doc = s.doc.as_ref().expect("opened as a document");
assert_eq!(doc.format(), Format::Yaml);
assert_eq!(s.dataframe.visible_row_count(), 2, "alpha and beta");
let _ = std::fs::remove_file(fixture("mislabelled.txt"));
}
#[test]
fn saving_an_edited_toml_config_over_itself_keeps_its_comments() {
use tuitab::types::Action;
let path = out("commented.toml");
std::fs::write(
&path,
"# tuitab demo config\nname = \"demo\" # display name\nport = 8080\n\n# database\n[db]\nhost = \"localhost\"\n",
)
.unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
let row = (0..app.stack.active().dataframe.visible_row_count())
.find(|r| app.stack.active().dataframe.get_physical(*r, 0) == "port")
.unwrap();
{
let s = app.stack.active_mut();
s.edit_row = row;
s.edit_col = 1;
s.edit_input = tuitab::ui::text_input::TextInput::with_value("9090".into());
}
app.handle_action(Action::ApplyEdit);
app.handle_action(Action::SaveFile);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(path.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("port = 9090"), "the edit landed: {}", text);
assert!(text.contains("# tuitab demo config"), "{}", text);
assert!(text.contains("# display name"), "{}", text);
assert!(text.contains("# database"), "{}", text);
}
#[test]
fn converting_a_commented_toml_to_yaml_drops_the_comments() {
let path = out("commented-src.toml");
std::fs::write(&path, "# a comment\nname = \"demo\"\n").unwrap();
let (df, doc) = load_file_with_doc(&path, None).unwrap();
let target = out("commented-out.yaml");
save_file_as(&df, doc.as_ref(), &target, Shape::Records, "x").unwrap();
let text = std::fs::read_to_string(&target).unwrap();
assert!(!text.contains("# a comment"), "{}", text);
assert!(text.contains("name: demo"), "{}", text);
}
#[test]
fn deleting_rows_removes_them_from_the_document() {
use tuitab::types::Action;
let path = out("delete-rows.json");
std::fs::write(&path, r#"[{"id":1},{"id":2},{"id":3}]"#).unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.stack.active_mut().dataframe.selected_rows.insert(0);
app.stack.active_mut().dataframe.selected_rows.insert(2);
app.handle_action(Action::DeleteSelectedRows);
let s = app.stack.active();
assert_eq!(s.dataframe.visible_row_count(), 1, "{}", app.status_message);
assert_eq!(s.dataframe.get_physical(0, 0), "2");
assert!(s.doc_mapping_ok(), "the mapping is rebuilt with the table");
app.handle_action(Action::SaveFile);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(path.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
let text = std::fs::read_to_string(&path).unwrap();
assert!(
!text.contains("\"id\": 1"),
"deleted rows stay deleted: {}",
text
);
assert!(text.contains("\"id\": 2"), "{}", text);
app.handle_action(Action::Undo);
assert_eq!(app.stack.active().dataframe.visible_row_count(), 3);
}
#[test]
fn deleting_a_row_in_key_value_mode_removes_the_key() {
use tuitab::types::Action;
let path = out("delete-key.toml");
std::fs::write(&path, "a = 1\nb = 2\nc = 3\n").unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
let row = (0..3)
.find(|r| app.stack.active().dataframe.get_physical(*r, 0) == "b")
.unwrap();
app.stack.active_mut().dataframe.selected_rows.insert(row);
app.handle_action(Action::DeleteSelectedRows);
let sheet = app.stack.active();
let target = out("delete-key-out.toml");
save_file_as(
&sheet.dataframe,
sheet.doc.as_ref(),
&target,
Shape::Records,
"x",
)
.unwrap();
let text = std::fs::read_to_string(&target).unwrap();
assert!(!text.contains("b ="), "{}", text);
assert!(text.contains("a = 1") && text.contains("c = 3"), "{}", text);
}
#[test]
fn table_reshaping_operations_are_refused_on_a_doc_sheet() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let before = app.stack.active().dataframe.columns.len();
for action in [
Action::StartExpression,
Action::CreatePctColumn,
Action::PasteRows,
] {
app.handle_action(action.clone());
assert_eq!(
app.stack.active().dataframe.columns.len(),
before,
"{:?} must not reshape the table",
action
);
assert!(app.stack.active().doc_mapping_ok());
}
}
#[test]
fn a_lossy_conversion_is_named_in_the_status_line() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("k8s.yaml"), None).unwrap();
app.handle_action(Action::SaveFile);
let target = out("k8s-lossy.json");
app.save.input =
tuitab::ui::text_input::TextInput::with_value(target.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert!(
app.status_message.contains("document separators"),
"{}",
app.status_message
);
let src = out("lossy-src.toml");
std::fs::write(&src, "# a note\nname = \"x\"\n").unwrap();
let mut app = tuitab::app::App::new(&src, None).unwrap();
app.handle_action(Action::SaveFile);
let yaml = out("lossy-out.yaml");
app.save.input =
tuitab::ui::text_input::TextInput::with_value(yaml.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert!(
app.status_message.contains("comments"),
"{}",
app.status_message
);
app.handle_action(Action::SaveFile);
let toml = out("lossy-out.toml");
app.save.input =
tuitab::ui::text_input::TextInput::with_value(toml.to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert!(
!app.status_message.contains("note:"),
"{}",
app.status_message
);
}
#[test]
fn the_node_path_is_shown_in_the_status_line_when_idle() {
use ratatui::{backend::TestBackend, Terminal};
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let host_col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "meta")
.unwrap();
app.stack.active_mut().cursor_col = host_col;
app.stack.active_mut().table_state.select(Some(1));
app.status_message.clear();
let mut terminal = Terminal::new(TestBackend::new(140, 12)).unwrap();
terminal.draw(|f| tuitab::ui::render(f, &mut app)).unwrap();
let screen: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(screen.contains("[1].meta"), "path on screen:\n{}", screen);
app.handle_action(Action::CopyNodePath);
assert!(
app.status_message.contains("[1].meta") || app.status_message.contains("Copy failed"),
"{}",
app.status_message
);
app.handle_action(Action::CycleViewMode);
assert!(!app.status_message.is_empty());
}
#[test]
fn deleting_rows_from_a_sorted_view_leaves_no_stale_sort() {
use tuitab::types::Action;
let path = out("sorted-delete.json");
std::fs::write(&path, r#"[{"n":3},{"n":1},{"n":2}]"#).unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.stack.active_mut().cursor_col = 0;
app.handle_action(Action::SortAscending);
assert_eq!(
app.stack
.active()
.dataframe
.get_physical(app.stack.active().dataframe.row_order[0], 0),
"1"
);
app.stack.active_mut().dataframe.selected_rows.insert(1);
app.handle_action(Action::DeleteSelectedRows);
let s = app.stack.active();
assert_eq!(s.dataframe.visible_row_count(), 2);
let shown: Vec<String> = (0..2)
.map(|d| s.dataframe.get_physical(s.dataframe.row_order[d], 0))
.collect();
assert!(
s.sort_keys.is_empty(),
"a sort marker that no longer describes the rows ({:?}) is worse than none",
shown
);
}
#[test]
fn deleting_from_a_scalar_view_removes_the_element() {
use tuitab::types::Action;
let path = out("scalar-delete.json");
std::fs::write(&path, "[10, 20, 30]").unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
assert_eq!(app.stack.active().dataframe.visible_row_count(), 3);
app.stack.active_mut().dataframe.selected_rows.insert(1);
app.handle_action(Action::DeleteSelectedRows);
let sheet = app.stack.active();
assert_eq!(
sheet.dataframe.visible_row_count(),
2,
"{}",
app.status_message
);
let target = out("scalar-delete-out.json");
save_file_as(
&sheet.dataframe,
sheet.doc.as_ref(),
&target,
Shape::Records,
"x",
)
.unwrap();
let text = std::fs::read_to_string(&target).unwrap();
assert!(!text.contains("20"), "{}", text);
assert!(text.contains("10") && text.contains("30"), "{}", text);
}
#[test]
fn document_search_finds_nodes_that_are_not_on_screen() {
use tuitab::types::{Action, AppMode};
let path = out("deep-search.json");
std::fs::write(
&path,
r#"[{"id":1,"meta":{"owner":"alice","tags":["x"]}},
{"id":2,"meta":{"owner":"bob","tags":["alice-backup"]}}]"#,
)
.unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartDocSearch);
assert_eq!(app.mode, AppMode::DocSearching);
for c in "alice".chars() {
app.handle_action(Action::SearchInput(c));
}
app.handle_action(Action::ApplyDocSearch);
assert_eq!(app.mode, AppMode::Normal);
let s = app.stack.active();
assert!(s.doc_hits.is_some(), "a results sheet was pushed");
let names: Vec<&str> = s
.dataframe
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, vec!["path", "value", "type", "matched"]);
assert_eq!(s.dataframe.visible_row_count(), 2, "the owner and the tag");
let paths: Vec<String> = (0..2).map(|r| s.dataframe.get_physical(r, 0)).collect();
assert!(paths.contains(&"[0].meta.owner".to_string()), "{:?}", paths);
assert!(
paths.contains(&"[1].meta.tags[0]".to_string()),
"{:?}",
paths
);
app.stack.active_mut().table_state.select(Some(
paths.iter().position(|p| p == "[0].meta.owner").unwrap(),
));
app.handle_action(Action::OpenRow);
let s = app.stack.active();
assert!(s.doc.is_some(), "landed on a document sheet");
assert!(
s.title.ends_with("[0] › meta"),
"opened the containing node: {}",
s.title
);
assert_eq!(
s.doc.as_ref().unwrap().view.anchor,
vec![Seg::Idx(0), Seg::Key("meta".into())]
);
let cursor_row = s.table_state.selected().unwrap();
assert_eq!(
s.dataframe
.get_physical(s.dataframe.row_order[cursor_row], 0),
"owner"
);
}
#[test]
fn document_search_matches_key_names_too() {
use tuitab::types::Action;
let path = out("key-search.toml");
std::fs::write(&path, "[db]\nhostname = \"h\"\n[cache]\nhost = \"c\"\n").unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartDocSearch);
for c in "^host".chars() {
app.handle_action(Action::SearchInput(c));
}
app.handle_action(Action::ApplyDocSearch);
let s = app.stack.active();
assert_eq!(s.dataframe.visible_row_count(), 2, "hostname and host");
let matched: Vec<String> = (0..2).map(|r| s.dataframe.get_physical(r, 3)).collect();
assert!(matched.iter().all(|m| m == "key"), "{:?}", matched);
}
#[test]
fn document_search_with_no_match_pushes_nothing() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let depth = app.stack.depth();
app.handle_action(Action::StartDocSearch);
for c in "zzzznope".chars() {
app.handle_action(Action::SearchInput(c));
}
app.handle_action(Action::ApplyDocSearch);
assert_eq!(app.stack.depth(), depth, "no sheet for no results");
assert!(
app.status_message.contains("No match"),
"{}",
app.status_message
);
}
#[test]
fn a_hit_list_does_not_navigate_after_the_document_changed() {
use tuitab::types::Action;
let path = out("stale-hits.json");
std::fs::write(&path, r#"[{"tag":"keep"},{"tag":"keep"},{"tag":"needle"}]"#).unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartDocSearch);
for c in "needle".chars() {
app.handle_action(Action::SearchInput(c));
}
app.handle_action(Action::ApplyDocSearch);
assert_eq!(app.stack.active().dataframe.visible_row_count(), 1);
assert_eq!(app.stack.active().dataframe.get_physical(0, 0), "[2].tag");
app.handle_action(Action::PopSheet);
app.stack.active_mut().dataframe.selected_rows.insert(0);
app.handle_action(Action::DeleteSelectedRows);
app.handle_action(Action::StartDocSearch);
for c in "needle".chars() {
app.handle_action(Action::SearchInput(c));
}
app.handle_action(Action::ApplyDocSearch);
assert_eq!(app.stack.active().dataframe.get_physical(0, 0), "[1].tag");
}
#[test]
fn a_stale_hit_refuses_rather_than_opening_the_wrong_node() {
use tuitab::types::Action;
let path = out("stale-hits-2.json");
std::fs::write(
&path,
r#"[{"tag":"a"},{"tag":"b"},{"tag":"needle"},{"tag":"d"}]"#,
)
.unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartDocSearch);
for c in "needle".chars() {
app.handle_action(Action::SearchInput(c));
}
app.handle_action(Action::ApplyDocSearch);
assert_eq!(app.stack.active().dataframe.get_physical(0, 0), "[2].tag");
{
let hits = app.stack.active().doc_hits.as_ref().unwrap();
let mut guard = hits.doc.write().unwrap();
guard.root.remove(&[Seg::Idx(0)]).unwrap();
guard.bump();
}
app.handle_action(Action::OpenRow);
assert!(
app.status_message.contains("changed"),
"a stale hit must refuse: {}",
app.status_message
);
assert!(
app.stack.active().doc_hits.is_some(),
"and stay on the hit list rather than navigating"
);
}
#[test]
fn goto_path_jumps_to_the_node_and_reports_a_wrong_path_usefully() {
use tuitab::types::{Action, AppMode};
let path = out("goto.json");
std::fs::write(
&path,
r#"{"servers":[{"host":"a"},{"host":"b"}],"awkward.key":{"x":1}}"#,
)
.unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartPathGoto);
assert_eq!(app.mode, AppMode::PathInput);
app.stack.active_mut().path_input =
tuitab::ui::text_input::TextInput::with_value("servers[1].host".into());
app.handle_action(Action::ApplyPathGoto);
let s = app.stack.active();
assert!(s.title.ends_with("servers › [1]"), "{}", s.title);
assert_eq!(
s.doc.as_ref().unwrap().view.anchor,
vec![Seg::Key("servers".into()), Seg::Idx(1)]
);
let row = s.table_state.selected().unwrap();
assert_eq!(
s.dataframe.get_physical(s.dataframe.row_order[row], 0),
"host"
);
app.handle_action(Action::PopSheet);
app.handle_action(Action::StartPathGoto);
app.stack.active_mut().path_input =
tuitab::ui::text_input::TextInput::with_value("[\"awkward.key\"]".into());
app.handle_action(Action::ApplyPathGoto);
assert_eq!(
app.stack.active().doc.as_ref().unwrap().view.anchor,
vec![Seg::Key("awkward.key".into())],
"{}",
app.status_message
);
app.handle_action(Action::PopSheet);
let depth = app.stack.depth();
app.handle_action(Action::StartPathGoto);
app.stack.active_mut().path_input =
tuitab::ui::text_input::TextInput::with_value("servers[1].nope".into());
app.handle_action(Action::ApplyPathGoto);
assert_eq!(app.stack.depth(), depth);
assert!(
app.status_message.contains("servers[1]") && app.status_message.contains("exists"),
"{}",
app.status_message
);
app.handle_action(Action::StartPathGoto);
app.stack.active_mut().path_input =
tuitab::ui::text_input::TextInput::with_value("servers[0].host".into());
app.handle_action(Action::ApplyPathGoto);
let depth_before = app.stack.depth();
let anchor = app.stack.active().doc.as_ref().unwrap().view.anchor.clone();
app.handle_action(Action::StartPathGoto);
app.handle_action(Action::ApplyPathGoto); assert_eq!(app.stack.depth(), depth_before, "no duplicate sheet");
assert_eq!(app.stack.active().doc.as_ref().unwrap().view.anchor, anchor);
app.handle_action(Action::PopSheet);
app.handle_action(Action::StartPathGoto);
app.stack.active_mut().path_input =
tuitab::ui::text_input::TextInput::with_value("servers[1".into());
app.handle_action(Action::ApplyPathGoto);
assert!(
app.status_message.contains("Bad path"),
"{}",
app.status_message
);
}
#[test]
fn a_jq_query_opens_its_result_as_a_sheet() {
use tuitab::types::{Action, AppMode};
let path = out("query.json");
std::fs::write(
&path,
r#"[{"n":1,"ok":true},{"n":2,"ok":false},{"n":3,"ok":true}]"#,
)
.unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartQuery);
assert_eq!(app.mode, AppMode::QueryInput);
for c in ".[] | select(.ok)".chars() {
app.handle_action(Action::QueryInputChar(c));
}
app.handle_action(Action::ApplyQuery);
assert_eq!(app.mode, AppMode::Normal, "{}", app.status_message);
let s = app.stack.active();
assert_eq!(s.dataframe.visible_row_count(), 2, "{}", app.status_message);
assert_eq!(s.dataframe.get_physical(1, 0), "3");
assert!(s.doc.is_some(), "the result is a document sheet");
assert!(
s.source_path.is_none(),
"a query result is not the source file"
);
let target = out("query-result.json");
save_file_as(
&s.dataframe,
s.doc.as_ref(),
&target,
Shape::Records,
&s.title,
)
.unwrap();
let text = std::fs::read_to_string(&target).unwrap();
assert!(
text.contains("\"n\": 1") && text.contains("\"n\": 3"),
"{}",
text
);
assert!(!text.contains("\"n\": 2"), "{}", text);
}
#[test]
fn a_jq_query_can_reshape_a_document_into_a_table() {
use tuitab::types::Action;
let path = out("reshape.yaml");
std::fs::write(
&path,
"users:\n alice:\n age: 30\n bob:\n age: 40\n",
)
.unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartQuery);
for c in ".users | to_entries | map({name: .key, age: .value.age})".chars() {
app.handle_action(Action::QueryInputChar(c));
}
app.handle_action(Action::ApplyQuery);
let s = app.stack.active();
let names: Vec<&str> = s
.dataframe
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, vec!["name", "age"], "{}", app.status_message);
assert_eq!(s.dataframe.visible_row_count(), 2);
assert_eq!(
s.doc.as_ref().unwrap().format(),
Format::Yaml,
"keeps the source format"
);
}
#[test]
fn a_query_matching_nothing_opens_an_empty_sheet() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let depth = app.stack.depth();
app.handle_action(Action::StartQuery);
app.stack.active_mut().query_input =
tuitab::ui::text_input::TextInput::with_value(".[] | select(.id == 999)".into());
app.handle_action(Action::ApplyQuery);
assert_eq!(app.stack.depth(), depth + 1, "{}", app.status_message);
assert_eq!(app.stack.active().dataframe.visible_row_count(), 0);
assert!(
app.status_message.contains("no matches"),
"{}",
app.status_message
);
}
#[test]
fn a_failing_query_reports_and_pushes_nothing() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("nested.json"), None).unwrap();
let depth = app.stack.depth();
for program in [".[ | broken", ".a | .[0]"] {
app.handle_action(Action::StartQuery);
app.stack.active_mut().query_input =
tuitab::ui::text_input::TextInput::with_value(program.into());
app.handle_action(Action::ApplyQuery);
assert_eq!(app.stack.depth(), depth, "no sheet for `{}`", program);
assert!(
app.status_message.contains("Query failed"),
"`{}`: {}",
program,
app.status_message
);
}
}
#[test]
fn a_query_result_offers_a_usable_save_name() {
use tuitab::types::Action;
let path = out("savename.json");
std::fs::write(&path, r#"[{"n":1,"ok":true},{"n":2,"ok":false}]"#).unwrap();
let mut app = tuitab::app::App::new(&path, None).unwrap();
app.handle_action(Action::StartQuery);
for c in ".[] | select(.ok)".chars() {
app.handle_action(Action::QueryInputChar(c));
}
app.handle_action(Action::ApplyQuery);
app.handle_action(Action::SaveFile);
let prefill = app.save.input.as_str().to_string();
assert!(
!prefill.contains('›') && !prefill.contains('|'),
"the prefill must be a path, not a query: {}",
prefill
);
assert!(prefill.ends_with(".json"), "{}", prefill);
app.save.input =
tuitab::ui::text_input::TextInput::with_value(out(&prefill).to_string_lossy().into_owned());
app.handle_action(Action::ApplySave);
assert!(
app.status_message.contains("Saved"),
"{}",
app.status_message
);
}
#[test]
fn a_deeply_nested_document_survives_every_conversion() {
use tuitab::data::doc::{Doc, Format as F, SaveOpts};
let original = std::fs::read_to_string(fixture("deep.json")).unwrap();
let doc = Doc::from_str(&original, F::Json).unwrap();
assert_eq!(
doc.to_string_as(F::Json, &SaveOpts::default()).unwrap(),
original
);
for fmt in [F::Yaml, F::Toml] {
let text = doc.to_string_as(fmt, &SaveOpts::default()).unwrap();
let back = Doc::from_str(&text, fmt).unwrap();
assert_eq!(back.root, doc.root, "{:?} round trip", fmt);
}
}
#[test]
fn jsonl_turns_a_document_into_a_one_record_stream_and_warns() {
use tuitab::data::doc::{Doc, Format as F, Node, SaveOpts};
let doc = Doc::from_str(r#"{"a":1,"b":[2,3]}"#, F::Json).unwrap();
let text = doc.to_string_as(F::Jsonl, &SaveOpts::default()).unwrap();
let back = Doc::from_str(&text, F::Jsonl).unwrap();
assert_eq!(text.lines().count(), 1);
let Node::Arr(items) = &back.root else {
panic!("jsonl always reads as a stream, got {:?}", back.root)
};
assert_eq!(items.len(), 1);
assert_eq!(items[0], doc.root, "the data itself is intact");
let (_, state) = tuitab::data::io::doc_io::DocState::from_doc(doc).unwrap();
let note = state.conversion_loss(F::Jsonl).expect("must warn");
assert!(note.contains("one-record stream"), "{}", note);
let list = Doc::from_str(r#"[{"a":1},{"a":2}]"#, F::Json).unwrap();
let (_, state) = tuitab::data::io::doc_io::DocState::from_doc(list).unwrap();
assert_eq!(state.conversion_loss(F::Jsonl), None);
}
#[test]
fn multi_line_values_survive_the_table_and_the_editor() {
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("deep.json"), None).unwrap();
let row = (0..app.stack.active().dataframe.visible_row_count())
.find(|r| app.stack.active().dataframe.get_physical(*r, 0) == "panels")
.unwrap();
app.stack.active_mut().table_state.select(Some(row));
app.handle_action(Action::OpenRow);
let sql = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name == "sql")
.unwrap();
let before = app.stack.active().dataframe.get_physical(0, sql);
assert!(
before.contains('\n'),
"the cell holds the real value: {:?}",
before
);
{
let s = app.stack.active_mut();
s.edit_row = 0;
s.edit_col = sql;
s.edit_input = tuitab::ui::text_input::TextInput::with_value(before.clone());
}
app.handle_action(Action::ApplyEdit);
let after = app
.stack
.active()
.doc
.as_ref()
.unwrap()
.doc
.read()
.unwrap()
.root
.get(&tuitab::data::doc::parse_path("panels[0].sql").unwrap())
.cloned();
assert_eq!(
after,
Some(tuitab::data::doc::Node::Str(before)),
"a single-line editor must not flatten a multi-line value"
);
}
#[test]
fn expanding_deep_nesting_keeps_cells_addressable() {
use tuitab::data::doc::{parse_path, Node};
use tuitab::types::Action;
let mut app = tuitab::app::App::new(&fixture("deep.json"), None).unwrap();
let row = (0..app.stack.active().dataframe.visible_row_count())
.find(|r| app.stack.active().dataframe.get_physical(*r, 0) == "panels")
.unwrap();
app.stack.active_mut().table_state.select(Some(row));
app.handle_action(Action::OpenRow);
for _ in 0..2 {
let col = app
.stack
.active()
.dataframe
.columns
.iter()
.position(|c| c.name.starts_with("config"))
.unwrap();
app.stack.active_mut().cursor_col = col;
app.handle_action(Action::ExpandColumn);
}
let s = app.stack.active();
let names: Vec<String> = s.dataframe.columns.iter().map(|c| c.name.clone()).collect();
assert!(
names.iter().any(|n| n == "config.defaults.custom"),
"{:?}",
names
);
let col = names
.iter()
.position(|n| n == "config.defaults.custom")
.unwrap();
assert_eq!(
s.doc.as_ref().unwrap().path_of(0, col),
Some(parse_path("panels[0].config.defaults.custom").unwrap())
);
let d = names.iter().position(|n| n == "description").unwrap();
assert_eq!(s.dataframe.get_physical(0, d), "");
assert!(matches!(
s.doc.as_ref().unwrap().node_at(1, d),
Some(Node::Str(_))
));
}
#[test]
fn an_xlsx_written_by_tuitab_reads_back() {
let (df, _) = load_file_with_doc(&fixture("prices.csv"), None).unwrap();
let path = out("roundtrip.xlsx");
let _ = std::fs::remove_file(&path);
save_file_as(&df, None, &path, Shape::Records, "prices").unwrap();
let (back, doc) = load_file_with_doc(&path, None).unwrap();
assert!(doc.is_none(), "xlsx is tabular, not a document");
assert_eq!(back.col_count(), df.col_count());
assert_eq!(back.visible_row_count(), df.visible_row_count());
assert_eq!(back.get_physical(0, 1), df.get_physical(0, 1));
}