Files
improvise/tests/bug_hunt_reproductions.rs

447 lines
15 KiB
Rust

//! Bug-hunt reproductions. An `#[ignore]`d one is still open; un-ignoring it is
//! part of the fix. Open ones: `cargo test --test bug_hunt_reproductions -- --ignored`.
use improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;
use improvise_io::{
formula::AggFunc,
persistence::{format_md, parse_md},
workbook::Workbook,
};
#[test]
fn grouped_item_order_survives_save() {
let mut w = Workbook::new("test");
w.add_category("C").unwrap();
w.add_item_in_group("C", "first", "G").unwrap();
w.active_view_mut().set_expand_depth("C", 1);
w.add_item("C", "second").unwrap();
let r = parse_md(&format_md(&w)).unwrap();
assert_eq!(
r.model.category("C").unwrap().ordered_item_names(),
w.model.category("C").unwrap().ordered_item_names()
);
}
#[test]
fn aggregation_survives_save() {
let mut w = Workbook::new("test");
w.add_item("_Measure", "Price").unwrap();
w.model.measure_agg.insert("Price".into(), AggFunc::Avg);
let r = parse_md(&format_md(&w)).unwrap();
assert!(
matches!(r.model.measure_agg.get("Price"), Some(AggFunc::Avg)),
"loaded aggregation: {:?}",
r.model.measure_agg
);
}
#[test]
fn deleted_default_stays_deleted() {
let mut w = Workbook::new("test");
w.create_view("Custom");
w.delete_view("Default").unwrap();
let r = parse_md(&format_md(&w)).unwrap();
assert_eq!(
r.views.keys().collect::<Vec<_>>(),
w.views.keys().collect::<Vec<_>>()
);
}
#[test]
fn csv_nonfinite_is_not_zero() {
for token in ["NaN", "inf", "-inf", "1e999"] {
let (r, _) = parse_csv_bytes_with_warnings(format!("Value\n{token}\n").as_bytes()).unwrap();
assert_ne!(r[0]["Value"].as_f64(), Some(0.0), "{token} became zero");
assert_eq!(
r[0]["Value"].as_str(),
Some(token),
"{token} must survive as its own text, not as some other number"
);
}
}
#[test]
fn duplicate_csv_headers_not_silent() {
let (records, warnings) = parse_csv_bytes_with_warnings(b"A,A\n1,2\n").unwrap();
let fields = records[0].as_object().unwrap();
assert_eq!(
fields.len(),
2,
"both columns must survive as separate fields: {fields:?}"
);
assert_eq!(fields["A"], "1", "{fields:?}");
assert_eq!(fields["A_2"], "2", "{fields:?}");
assert!(
warnings
.iter()
.any(|warning| warning.to_lowercase().contains("duplicate") && warning.contains("A")),
"the rename must be reported: {warnings:?}"
);
}
#[test]
fn blank_measure_still_infers_numeric() {
use improvise_io::import::{analyzer::FieldKind, wizard::ImportPipeline};
let (records, _) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEast,10\nWest,\n").unwrap();
let p = ImportPipeline::from_csv(records);
let proposal = p.proposals.iter().find(|p| p.field == "Revenue").unwrap();
assert_eq!(proposal.kind, FieldKind::Measure);
}
#[test]
fn repeated_import_coordinates_not_silently_overwritten() {
use improvise_io::import::wizard::ImportPipeline;
let (records, _) =
parse_csv_bytes_with_warnings(b"Region,Revenue\nEast,10\nEast,20\n").unwrap();
let mut pipeline = ImportPipeline::from_csv(records);
pipeline.on_duplicate = improvise_io::import::wizard::DuplicatePolicy::Sum;
let (wb, warnings) = pipeline.build_model_with_warnings().unwrap();
let values: Vec<f64> = wb
.model
.data
.iter_cells()
.filter_map(|(_, v)| v.as_f64())
.collect();
assert_eq!(
values,
[30.0],
"the two records are one cell and neither may be dropped"
);
let warning = warnings
.iter()
.find(|w| w.to_lowercase().contains("duplicate"))
.unwrap_or_else(|| panic!("summing is a decision and is reported: {warnings:?}"));
assert!(warning.contains("Region=East"), "{warning}");
assert!(warning.contains("_Measure=Revenue"), "{warning}");
}
#[test]
#[ignore = "improvise-55v2: known bug reproduction"]
fn nonfinite_cell_value_survives_wire() {
use improvise::protocol::{CellValue, Command};
let cmd = Command::SetYanked(Some(CellValue::Number(f64::INFINITY)));
let json = serde_json::to_string(&cmd).unwrap();
assert!(
serde_json::from_str::<Command>(&json).is_ok(),
"unreadable wire: {json}"
);
}
#[test]
#[ignore = "improvise-8l0c: known bug reproduction"]
fn formula_panel_unicode_does_not_panic() {
use improvise::{
protocol::FormulaCache,
ui::{app::AppMode, formula_panel::FormulaContent, panel::Panel},
};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
let cache = FormulaCache {
formulas: vec!["|é| = 1".into()],
};
let area = Rect::new(0, 0, 7, 5);
let mut buffer = Buffer::empty(area);
Panel::new(
FormulaContent::new(&cache, &AppMode::Normal),
&AppMode::Normal,
0,
)
.render(area, &mut buffer);
}
#[test]
#[ignore = "improvise-vw47: known bug reproduction"]
fn new_view_after_delete_does_not_overwrite_survivor() {
use improvise::{
command::{
cmd::{Cmd, panel::CreateAndSwitchView},
key::{KeyCode, KeyModifiers},
},
ui::app::App,
};
let mut w = Workbook::new("test");
w.create_view("View 2");
w.create_view("View 3").number_format = ".4".into();
w.delete_view("View 2").unwrap();
let mut app = App::new(w, None);
let effects =
CreateAndSwitchView.execute(&app.cmd_context(KeyCode::Char('n'), KeyModifiers::NONE));
app.apply_effects(effects);
assert_eq!(app.model_state.workbook.views["View 3"].number_format, ".4");
assert_eq!(app.model_state.workbook.views.len(), 3);
}
#[test]
#[ignore = "improvise-cj4u: known bug reproduction"]
fn label_stays_off_axes_in_new_view() {
use improvise_io::view::Axis;
let mut w = Workbook::new("test");
w.add_label_category("Description").unwrap();
assert_eq!(w.active_view().axis_of("Description"), Axis::None);
assert_eq!(w.create_view("Other").axis_of("Description"), Axis::None);
}
#[test]
fn long_csv_row_is_not_silently_dropped() {
let (records, warnings) = parse_csv_bytes_with_warnings(b"A,B\n1,2,3\n4,5\n1,2,3,4\n").unwrap();
assert_eq!(records.len(), 3, "no row is discarded outright");
let warning = warnings
.iter()
.find(|w| w.contains("more fields"))
.unwrap_or_else(|| panic!("extra values dropped silently: {warnings:?}"));
assert!(
warning.contains("2 row(s)") && warning.contains("2 header"),
"the warning counts the affected rows and the header width: {warning}"
);
assert!(
warning.contains("widest row: 4"),
"the warning says how much was dropped: {warning}"
);
let (_, clean) = parse_csv_bytes_with_warnings(b"A,B\n1,2\n").unwrap();
assert!(
clean.is_empty(),
"control: a full row is not long: {clean:?}"
);
}
// Passing control: embedded carriage returns already survive persistence.
#[test]
fn literal_cr_name_survives_roundtrip() {
let mut w = Workbook::new("test");
w.add_category("C").unwrap();
w.add_item("C", "x\ry").unwrap();
let text = format_md(&w);
let r = parse_md(&text).unwrap();
assert_eq!(
r.model.category("C").unwrap().ordered_item_names(),
vec!["x\ry"]
);
}
#[test]
fn scalar_cell_survives_roundtrip() {
use improvise_io::model::cell::{CellKey, CellValue};
let mut w = Workbook::new("test");
w.model
.set_cell(CellKey::new(vec![]), CellValue::Number(42.0));
let text = format_md(&w);
assert!(
parse_md(&text).is_ok(),
"writer emitted unreadable document: {text}"
);
}
fn revenue_book() -> Workbook {
use improvise_io::model::cell::{CellKey, CellValue};
let mut w = Workbook::new("T");
w.add_category("Region").unwrap();
w.add_item("Region", "East").unwrap();
w.add_item("_Measure", "Revenue").unwrap();
w.model.set_cell(
CellKey::new(vec![
("Region".into(), "East".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(10.0),
);
w
}
#[test]
fn sum_of_derived_measure_agrees_with_identity_expression() {
use improvise_io::{formula::parse_formula, model::cell::CellKey};
let mut w = revenue_book();
for raw in [
"Double = Revenue * 2",
"A = SUM(Double)",
"B = SUM(Double + 0)",
] {
w.model.add_formula(parse_formula(raw, "_Measure").unwrap());
}
let key = CellKey::new(vec![("Region".into(), "East".into())]);
let a = w.model.evaluate(&key.clone().with("_Measure", "A"));
let b = w.model.evaluate(&key.with("_Measure", "B"));
assert_eq!(a, b);
}
#[test]
fn unknown_aggregate_reference_is_not_all_measures() {
use improvise_io::{
formula::parse_formula,
model::cell::{CellKey, CellValue},
};
let mut w = revenue_book();
w.model
.add_formula(parse_formula("Total = SUM(Typo)", "_Measure").unwrap());
let key = CellKey::new(vec![
("Region".into(), "East".into()),
("_Measure".into(), "Total".into()),
]);
assert!(
matches!(w.model.evaluate(&key), Some(CellValue::Error(_))),
"actual {:?}",
w.model.evaluate(&key)
);
}
#[test]
#[ignore = "improvise-3b2b: known bug reproduction"]
fn category_panel_cannot_delete_required_virtual() {
use improvise::{
command::{
cmd::{Cmd, panel::DeleteCategoryAtCursor},
key::{KeyCode, KeyModifiers},
},
ui::app::App,
};
let mut app = App::new(revenue_book(), None);
app.view_state.cat_panel_cursor = 0;
let fx =
DeleteCategoryAtCursor.execute(&app.cmd_context(KeyCode::Char('d'), KeyModifiers::NONE));
app.apply_effects(fx);
let fx = improvise::command::cmd::grid::ToggleRecordsMode
.execute(&app.cmd_context(KeyCode::Char('r'), KeyModifiers::NONE));
app.apply_effects(fx);
assert!(
app.layout.is_records_mode(),
"deleting _Index prevents records mode; status {}",
app.view_state.status_msg
);
}
#[test]
#[ignore = "improvise-8l0c: known bug reproduction"]
fn formula_panel_handles_zero_inner_width() {
use improvise::{
protocol::FormulaCache,
ui::{app::AppMode, formula_panel::FormulaContent, panel::Panel},
};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
let cache = FormulaCache {
formulas: vec!["A = 1".into()],
};
let area = Rect::new(0, 0, 2, 5);
let mut buffer = Buffer::empty(area);
Panel::new(
FormulaContent::new(&cache, &AppMode::Normal),
&AppMode::Normal,
0,
)
.render(area, &mut buffer);
}
#[test]
fn save_with_unfinished_record_is_readable() {
use improvise::{
command::{
cmd::{
Cmd,
grid::{AddRecordRow, ToggleRecordsMode},
},
key::{KeyCode, KeyModifiers},
},
ui::app::App,
};
let mut app = App::new(revenue_book(), None);
let fx = ToggleRecordsMode.execute(&app.cmd_context(KeyCode::Char('r'), KeyModifiers::NONE));
app.apply_effects(fx);
assert!(app.layout.is_records_mode());
let fx = AddRecordRow.execute(&app.cmd_context(KeyCode::Char('o'), KeyModifiers::NONE));
app.apply_effects(fx);
let text = format_md(&app.model_state.workbook);
assert!(parse_md(&text).is_ok(), "saved unparseable file: {text}");
}
#[test]
fn circular_formula_not_a_plausible_cached_number() {
use improvise_io::{
formula::parse_formula,
model::{
Aggregation,
cell::{CellKey, CellValue},
},
};
let mut w = revenue_book();
w.model
.add_formula(parse_formula("Revenue = Revenue + 1", "_Measure").unwrap());
let key = CellKey::new(vec![
("Region".into(), "East".into()),
("_Measure".into(), "Revenue".into()),
]);
assert!(matches!(w.model.evaluate(&key), Some(CellValue::Error(_))));
w.model
.recompute_formulas(&["_Index".into(), "_Dim".into()]);
let actual = w.model.evaluate_aggregated(&key, Aggregation::OverHidden);
assert!(
matches!(actual, Some(CellValue::Error(_))),
"circular formula became {actual:?}"
);
}
#[test]
fn wide_cell_clamps_without_overflow() {
use improvise::{
model::cell::{CellKey, CellValue},
ui::app::App,
};
// 65535 overflows the `+ 1` gap; 65536 truncates the `as u16` cast to 0.
// 20 is the ordinary case the cap must leave alone, gap included.
for (len, expect) in [
(20usize, 21u16),
(65535, improvise::ui::viewport::MAX_COL_WIDTH),
(65536, improvise::ui::viewport::MAX_COL_WIDTH),
] {
let mut w = revenue_book();
w.model.set_cell(
CellKey::new(vec![
("Region".into(), "East".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Text("x".repeat(len)),
);
let app = App::new(w, None);
let widths = improvise::ui::viewport::compute_col_widths(
&app.model_state.workbook.model,
&app.layout,
&improvise::format::FormatPlan::uniform(""),
);
assert_eq!(
widths,
vec![expect],
"a {len}-column value must yield exactly one column, of width {expect}"
);
}
}
#[test]
fn ordinary_controls() {
use improvise_io::import::{analyzer::FieldKind, wizard::ImportPipeline};
let (records, warnings) =
parse_csv_bytes_with_warnings(b"Region,Revenue\nEast,10\nWest,20\n").unwrap();
assert!(warnings.is_empty());
let p = ImportPipeline::from_csv(records);
assert_eq!(
p.proposals
.iter()
.find(|p| p.field == "Revenue")
.unwrap()
.kind,
FieldKind::Measure
);
let (w, warnings) = p.build_model_with_warnings().unwrap();
assert!(warnings.is_empty());
assert_eq!(
w.model
.data
.iter_cells()
.filter_map(|(_, v)| v.as_f64())
.sum::<f64>(),
30.0
);
assert!(parse_md(&format_md(&w)).is_ok());
}
// Passing control (improvise-nvq4): quote_name already pipe-quotes a name
// containing `/`, so hidden/collapsed round-trip. The report did not reproduce.
#[test]
fn slash_in_hidden_name_survives_roundtrip() {
let mut w = Workbook::new("test");
w.add_category("C").unwrap();
w.add_item("C", "A/B").unwrap();
w.add_item_in_group("C", "x", "G/H").unwrap();
w.active_view_mut().set_expand_depth("C", 1);
w.active_view_mut().hide_item("C", "A/B");
w.active_view_mut().toggle_group_collapse("C", &["G/H"]);
let text = format_md(&w);
let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}"));
assert!(
r.active_view().is_hidden("C", "A/B"),
"hidden lost:\n{text}"
);
assert!(
r.active_view().is_group_collapsed("C", &["G/H"]),
"collapsed lost:\n{text}"
);
}