d00cea2477
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.
Two halves, per the user's ruling:
* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
noise on whole numbers is the cheaper error — a currency column with two
redundant zeroes is legible, a ratio with none is wrong — and a format cannot
be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
keeps the view's. `formula.pest` gains
`format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
reason (a predicate is invisible to the grammar-walking generator) and
`FORMAT` added to `quote::KEYWORDS`.
`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.
Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.
Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.
The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
445 lines
15 KiB
Rust
445 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.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"].as_f64(), Some(1.0), "{fields:?}");
|
|
assert_eq!(fields["A_2"].as_f64(), Some(2.0), "{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::new(serde_json::Value::Array(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 (wb, warnings) = ImportPipeline::new(serde_json::Value::Array(records))
|
|
.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::new(serde_json::Value::Array(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().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}"
|
|
);
|
|
}
|