use crate::edit_action::{
AddComment, AuthorInput, CellInput, CommentMention, CreateBlock, CreateChart,
CreateChartSeries, CreateDiyCell, DeleteCellImage, DeleteChart, DeleteComment, EditComment,
EditPayload, LineStyleUpdate, ModifyPolicy, MoveChart, PayloadsAction, RemoveDiyCell,
ResolveComment, SchemaFieldSpec, SetCellImage, SheetRename, StyleUpdateType, UpdateChart,
WorkbookUpdateType,
};
#[test]
fn cross_sheet_range_unparses_after_calc() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 25,
content: "=VLOOKUP(O8,Sheet2!A5:B20,2,FALSE)".to_string(),
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let f = ws.get_formula(0, 25).unwrap();
assert!(
f.contains("Sheet2!A5:B20"),
"cross-sheet range not preserved: {}",
f
);
assert!(!f.contains("error"), "unparse produced 'error': {}", f);
}
#[test]
fn update_chart_changes_type_and_title() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let chart_id = {
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = ws.get_charts();
assert_eq!(c[0].chart_type, "col");
c[0].chart_id.clone()
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
chart_type: Some("line".to_string()),
title: Some("My Title".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
let c = &charts[0];
assert_eq!(c.chart_type, "line", "type changed");
assert_eq!(c.title.as_deref(), Some("My Title"), "title set");
assert_eq!(c.series.len(), 3);
assert_eq!(
c.series[0].values,
vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)],
"series data preserved"
);
}
#[test]
fn create_chart_from_scratch() {
let mut wb = Workbook::default();
let inputs = [
(0usize, 1usize, "10"),
(0, 2, "20"),
(1, 1, "30"),
(1, 2, "40"),
];
let payloads: Vec<EditPayload> = inputs
.iter()
.map(|(r, c, v)| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: *r,
col: *c,
content: v.to_string(),
})
})
.collect();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "chartNew".to_string(),
chart_type: "col".to_string(),
from_row: 4,
from_col: 1,
from_col_off: 0,
from_row_off: 0,
to_row: 18,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: Some("My Chart".to_string()),
categories_ref: None,
series: vec![
CreateChartSeries {
name: Some("Row1".to_string()),
value_ref: "Sheet1!$B$1:$C$1".to_string(),
color: None,
size_ref: None,
series_type: None,
},
CreateChartSeries {
name: Some("Row2".to_string()),
value_ref: "Sheet1!$B$2:$C$2".to_string(),
color: None,
size_ref: None,
series_type: None,
},
],
block_source: None,
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
assert_eq!(charts.len(), 1, "chart created");
let c = &charts[0];
assert_eq!(c.chart_type, "col");
assert_eq!(c.title.as_deref(), Some("My Chart"));
assert_eq!(c.series.len(), 2);
assert_eq!(c.series[0].values, vec![Some(10.0), Some(20.0)]);
assert_eq!(c.series[1].values, vec![Some(30.0), Some(40.0)]);
assert_eq!((c.from_row, c.from_col), (4, 1));
drop(ws);
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws2 = wb2.get_sheet_by_idx(0).unwrap();
let charts2 = ws2.get_charts();
assert_eq!(charts2.len(), 1, "created chart persists");
assert_eq!(charts2[0].series[0].values, vec![Some(10.0), Some(20.0)]);
}
#[test]
fn delete_chart_removes_it() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let chart_id = {
let ws = wb.get_sheet_by_idx(0).unwrap();
ws.get_charts()
.first()
.expect("chart present")
.chart_id
.clone()
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteChart(DeleteChart {
sheet_idx: 0,
chart_id,
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(ws.get_charts().is_empty(), "chart should be deleted");
drop(ws);
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
assert!(
wb2.get_sheet_by_idx(0).unwrap().get_charts().is_empty(),
"deletion should persist"
);
}
use super::{EditAction, Workbook};
#[test]
fn chart_reflects_live_data() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
assert_eq!(
charts[0].series[0].values,
vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)],
"initial live values match the source range"
);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 1,
content: "100".to_string(),
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
assert_eq!(
charts[0].series[0].values[0],
Some(100.0),
"chart should reflect the edited cell"
);
assert_eq!(
charts[0].series[0].values[1],
Some(13.0),
"others unchanged"
);
let color = charts[0].series[0].color.clone();
assert!(
color.as_ref().is_some_and(|c| c.len() == 6 || c.len() == 8),
"series color should resolve to a theme hex, got {:?}",
color
);
}
#[test]
fn move_chart_updates_anchor() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let sheet_idx = 0usize;
let chart_id = {
let ws = wb.get_sheet_by_idx(sheet_idx).unwrap();
ws.get_charts()
.first()
.expect("a chart should be present on sheet 0")
.chart_id
.clone()
};
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::MoveChart(MoveChart {
sheet_idx,
chart_id: chart_id.clone(),
from_row: 2,
from_col: 1,
from_col_off: 0,
from_row_off: 0,
to_row: 12,
to_col: 7,
to_col_off: 0,
to_row_off: 0,
})],
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let ws = wb.get_sheet_by_idx(sheet_idx).unwrap();
let charts = ws.get_charts();
let c = charts.iter().find(|c| c.chart_id == chart_id).unwrap();
assert_eq!((c.from_row, c.from_col), (2, 1), "anchor should have moved");
assert_eq!((c.to_row, c.to_col), (12, 7));
drop(ws);
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws2 = wb2.get_sheet_by_idx(sheet_idx).unwrap();
let charts2 = ws2.get_charts();
assert_eq!(charts2.len(), 1, "chart should survive save/reload");
let c2 = &charts2[0];
assert_eq!(c2.chart_type, "col", "chart data preserved");
assert_eq!((c2.from_row, c2.from_col), (2, 1), "moved anchor persisted");
assert_eq!((c2.to_row, c2.to_col), (12, 7));
assert_eq!(c2.series.len(), 3);
}
#[test]
fn data_validation_round_trip() {
use logisheets_workbook::prelude::{
CtDataValidation, CtDataValidations, PlainTextString, StDataValidationErrorStyle,
StDataValidationImeMode, StDataValidationOperator, StDataValidationType, Wb, write,
};
let base = Workbook::default().save().unwrap();
let mut raw = Wb::from_file(&base).unwrap();
let dv = CtDataValidations {
data_validations: vec![CtDataValidation {
formula1: Some(PlainTextString {
value: "\"Apple,Banana,Cherry\"".to_string(),
space: None,
}),
formula2: None,
ty: StDataValidationType::List,
error_style: StDataValidationErrorStyle::Stop,
ime_mode: StDataValidationImeMode::NoControl,
operator: StDataValidationOperator::Between,
blank: true,
show_drop_down: false,
show_input_message: false,
show_error_message: true,
prompt_title: None,
prompt: None,
sqref: "A1:A10".to_string(),
}],
disable_prompts: false,
x_window: None,
y_window: None,
count: 1,
};
raw.xl
.worksheets
.values_mut()
.next()
.unwrap()
.worksheet_part
.data_validations = Some(dv);
let input = write(raw).unwrap();
let wb = Workbook::from_file(&input, "dv".to_string()).unwrap();
let out = wb.save().unwrap();
let reloaded = Wb::from_file(&out).unwrap();
let ws = reloaded.xl.worksheets.values().next().unwrap();
let dv2 = ws
.worksheet_part
.data_validations
.as_ref()
.expect("data validation should survive the controller round trip");
assert_eq!(dv2.data_validations.len(), 1);
assert_eq!(dv2.data_validations[0].sqref, "A1:A10");
assert!(matches!(
dv2.data_validations[0].ty,
StDataValidationType::List
));
assert_eq!(
dv2.data_validations[0].formula1.as_ref().unwrap().value,
"\"Apple,Banana,Cherry\""
);
}
#[test]
fn data_validation_flags_invalid_cell() {
use crate::controller::display::Value;
use crate::edit_action::CellInput;
use crate::sid_assigner::ShadowKind;
use logisheets_base::CellId;
use logisheets_workbook::prelude::{
CtDataValidation, CtDataValidations, PlainTextString, StDataValidationErrorStyle,
StDataValidationImeMode, StDataValidationOperator, StDataValidationType, Wb, write,
};
let mut authored = Workbook::default();
authored.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "Apple".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "Zebra".to_string(),
}),
],
undoable: false,
init: false,
}));
let base = authored.save().unwrap();
let mut raw = Wb::from_file(&base).unwrap();
let dv = CtDataValidations {
data_validations: vec![CtDataValidation {
formula1: Some(PlainTextString {
value: "\"Apple,Banana\"".to_string(),
space: None,
}),
formula2: None,
ty: StDataValidationType::List,
error_style: StDataValidationErrorStyle::Stop,
ime_mode: StDataValidationImeMode::NoControl,
operator: StDataValidationOperator::Between,
blank: true,
show_drop_down: false,
show_input_message: false,
show_error_message: true,
prompt_title: None,
prompt: None,
sqref: "A1:A10".to_string(),
}],
disable_prompts: false,
x_window: None,
y_window: None,
count: 1,
};
raw.xl
.worksheets
.values_mut()
.next()
.unwrap()
.worksheet_part
.data_validations = Some(dv);
let input = write(raw).unwrap();
let mut wb = Workbook::from_file(&input, "dv".to_string()).unwrap();
let mut read = |row: usize| -> Value {
let scid = wb
.get_shadow_cell_id(0, row, 0, ShadowKind::Validation)
.unwrap();
let id = match scid.cell_id {
CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
};
assert!(
matches!(read(0), Value::Bool(true)),
"Apple should be valid"
);
assert!(
matches!(read(1), Value::Bool(false)),
"Zebra should be invalid"
);
}
#[test]
fn get_cell_list_validation_reads_list_type() {
use crate::data_validation_manager::ListValidation;
use logisheets_workbook::prelude::{
CtDataValidation, CtDataValidations, PlainTextString, StDataValidationErrorStyle,
StDataValidationImeMode, StDataValidationOperator, StDataValidationType, Wb, write,
};
let mk = |ty: StDataValidationType, f1: &str, sqref: &str| CtDataValidation {
formula1: Some(PlainTextString {
value: f1.to_string(),
space: None,
}),
formula2: None,
ty,
error_style: StDataValidationErrorStyle::Stop,
ime_mode: StDataValidationImeMode::NoControl,
operator: StDataValidationOperator::Between,
blank: true,
show_drop_down: true,
show_input_message: false,
show_error_message: false,
prompt_title: None,
prompt: None,
sqref: sqref.to_string(),
};
let base = Workbook::default().save().unwrap();
let mut raw = Wb::from_file(&base).unwrap();
let dv = CtDataValidations {
data_validations: vec![
mk(
StDataValidationType::List,
"\"East,West,North,South\"",
"A1:A10",
),
mk(StDataValidationType::List, "$G$1:$G$4", "C1"),
mk(StDataValidationType::Whole, "1", "B1"),
],
disable_prompts: false,
x_window: None,
y_window: None,
count: 3,
};
raw.xl
.worksheets
.values_mut()
.next()
.unwrap()
.worksheet_part
.data_validations = Some(dv);
let input = write(raw).unwrap();
let wb = Workbook::from_file(&input, "dv".to_string()).unwrap();
assert_eq!(
wb.get_cell_list_validation(0, 0, 0),
Some(ListValidation::Inline(vec![
"East".into(),
"West".into(),
"North".into(),
"South".into(),
]))
);
assert!(matches!(
wb.get_cell_list_validation(0, 9, 0),
Some(ListValidation::Inline(_))
));
assert_eq!(
wb.get_cell_list_validation(0, 0, 2),
Some(ListValidation::Reference("$G$1:$G$4".to_string()))
);
assert_eq!(wb.get_cell_list_validation(0, 0, 1), None);
assert_eq!(wb.get_cell_list_validation(0, 5, 5), None);
}
#[test]
fn cell_image_round_trip() {
use crate::image_manager::base64;
let png: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03, 0xFD, 0xFE, 0xFF,
];
let data_b64 = base64::encode(png);
let mut wb = Workbook::default();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::SetCellImage(SetCellImage {
sheet_idx: 0,
row: 2,
col: 3,
image_id: "img-a".to_string(),
format: "png".to_string(),
data: data_b64.clone(),
})],
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let ws = wb.get_sheet_by_idx(0).unwrap();
let imgs = ws.get_cell_images();
assert_eq!(imgs.len(), 1);
assert_eq!((imgs[0].row, imgs[0].col), (2, 3));
assert_eq!(imgs[0].format, "png");
assert_eq!(base64::decode(&imgs[0].data).unwrap(), png);
drop(ws);
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws2 = wb2.get_sheet_by_idx(0).unwrap();
let imgs2 = ws2.get_cell_images();
assert_eq!(imgs2.len(), 1, "image should survive save/load");
assert_eq!((imgs2[0].row, imgs2[0].col), (2, 3));
assert_eq!(imgs2[0].format, "png");
assert_eq!(base64::decode(&imgs2[0].data).unwrap(), png);
wb.handle_action(EditAction::Undo);
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(
ws.get_cell_images().len(),
0,
"undo should remove the image"
);
drop(ws);
wb.handle_action(EditAction::Redo);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteCellImage(DeleteCellImage {
sheet_idx: 0,
row: 2,
col: 3,
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(
ws.get_cell_images().len(),
0,
"delete should remove the image"
);
}
#[test]
fn new_workbook() {
let mut wb = Workbook::default();
let ws = wb.get_sheet_by_idx(0).unwrap();
ws.get_cell_position(100, 100).unwrap();
let empty_display_window = ws.get_display_window(0, 0, 100, 100).unwrap();
let row_cnt = empty_display_window.rows.len();
let col_cnt = empty_display_window.cols.len();
let cell_cnt = empty_display_window.cells.len();
assert!(row_cnt >= 100);
assert!(col_cnt >= 100);
assert_eq!(row_cnt * col_cnt, cell_cnt);
assert_eq!(empty_display_window.rows[0].idx, 0);
assert_eq!(empty_display_window.cols[0].idx, 0);
let empty_display_resp = ws.get_display_window_response(0., 0., 100., 100.).unwrap();
assert!(empty_display_resp.window.cells.len() > 0);
assert!(empty_display_resp.window.rows.len() > 0);
assert!(empty_display_resp.window.cols.len() > 0);
assert_eq!(empty_display_resp.window.rows.get(0).unwrap().idx, 0);
assert_eq!(empty_display_resp.window.cols.get(0).unwrap().idx, 0);
let v = empty_display_resp
.window
.cols
.into_iter()
.fold(0., |p, c| return p + c.width);
assert!(v > 100.);
let result = wb.handle_action(crate::EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::SheetRename(SheetRename {
old_name: Some("Sheet1".to_string()),
new_name: "abcd".to_string(),
idx: None,
})],
undoable: true,
init: false,
}));
match result.status {
crate::edit_action::StatusCode::Ok(workbook_update_type) => {
println!("{:?}", workbook_update_type);
assert!(matches!(workbook_update_type, WorkbookUpdateType::Sheet));
}
crate::edit_action::StatusCode::Err(e) => panic!("{:?}", e),
}
}
#[test]
fn create_block() {
let mut wb = Workbook::default();
let id = wb.get_available_block_id(0).unwrap();
let payload_action = PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 1,
master_col: 1,
row_cnt: 3,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: false,
init: false,
};
let _ = wb.handle_action(EditAction::Payloads(payload_action));
let ws = wb.get_sheet_by_idx(0).unwrap();
let _ = ws.get_display_window(0, 0, 10, 10).unwrap();
ws.get_cell_position(1, 1).unwrap();
ws.get_cell_position(3, 3).unwrap();
let resp = ws.get_display_window_response(0., 0., 100., 100.).unwrap();
assert_eq!(resp.window.blocks.len(), 1);
}
#[test]
fn worksheet_page_setup_survives_save_load() {
use logisheets_workbook::prelude::Wb;
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let saved = wb.save().unwrap();
let doc = Wb::from_file(&saved).unwrap();
assert!(
doc.xl
.worksheets
.values()
.any(|ws| ws.worksheet_part.page_setup.is_some()),
"pageSetup should survive open→save"
);
assert!(
doc.xl
.worksheets
.values()
.any(|ws| ws.worksheet_part.page_margins.is_some()),
"pageMargins should survive open→save"
);
assert!(
doc.xl
.worksheets
.values()
.any(|ws| ws.worksheet_part.header_footer.is_some()),
"headerFooter should survive open→save"
);
}
#[test]
fn worksheet_protection_survives_save_load() {
use logisheets_workbook::prelude::Wb;
let buf = std::fs::read("../../tests/6.xlsx").unwrap();
let wb = Workbook::from_file(&buf, "6".to_string()).unwrap();
let saved = wb.save().unwrap();
let doc = Wb::from_file(&saved).unwrap();
assert!(
doc.xl
.worksheets
.values()
.any(|ws| ws.worksheet_part.sheet_protection.is_some()),
"sheetProtection should survive open→save"
);
}
#[test]
fn remove_diy_cell_round_trips_without_panicking() {
let mut wb = Workbook::default();
let id = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 1,
master_col: 1,
row_cnt: 3,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: false,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateDiyCell(CreateDiyCell {
sheet_idx: 0,
row: 1,
col: 1,
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
ws.get_diy_cell_id(1, 1).is_ok(),
"DIY cell should exist after CreateDiyCell"
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::RemoveDiyCell(RemoveDiyCell {
sheet_idx: 0,
row: 1,
col: 1,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"RemoveDiyCell should succeed, got {:?}",
effect.status
);
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
ws.get_diy_cell_id(1, 1).is_err(),
"DIY cell should be gone after RemoveDiyCell"
);
}
#[test]
fn get_col_style() {
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::LineStyleUpdate(LineStyleUpdate {
sheet_idx: 0,
from: 0,
to: 1,
row: true,
ty: StyleUpdateType {
set_num_fmt: Some("0.00".to_string()),
..Default::default()
},
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let style = ws.get_style(0, 0).unwrap();
assert_eq!(style.formatter, "0.00");
}
#[test]
fn overwrite_formula_with_plain_value() {
use crate::controller::display::Value;
use crate::edit_action::CellInput;
let mut wb = Workbook::default();
let r = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "=1+1".to_string(),
})],
undoable: true,
init: false,
}));
assert!(matches!(r.status, crate::edit_action::StatusCode::Ok(_)));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(ws.get_formula(0, 0).unwrap(), "1 + 1");
assert!(matches!(ws.get_value(0, 0).unwrap(), Value::Number(n) if (n - 2.0).abs() < 1e-9));
let r = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "5".to_string(),
})],
undoable: true,
init: false,
}));
assert!(matches!(r.status, crate::edit_action::StatusCode::Ok(_)));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(ws.get_formula(0, 0).unwrap(), "");
match ws.get_value(0, 0).unwrap() {
Value::Number(n) => assert!((n - 5.0).abs() < 1e-9, "got {}", n),
v => panic!("expected Number(5), got {:?}", v),
}
}
#[test]
fn table_converts_to_block_on_load() {
use crate::controller::display::Value;
use crate::edit_action::CellInput;
use logisheets_workbook::prelude::{CtTableColumn, CtTableColumns, Table, Wb, write};
use logisheets_workbook::workbook::TablePart;
fn make_col(id: u32, name: &str) -> CtTableColumn {
CtTableColumn {
calculated_column_formula: None,
totals_row_formula: None,
xml_column_pr: None,
ext_lst: None,
id,
unique_name: None,
name: name.to_string(),
totals_row_function: None,
totals_row_label: None,
query_table_field_id: None,
header_row_dxf_id: None,
data_dxf_id: None,
totals_row_dxf_id: None,
header_row_cell_style: None,
data_cell_style: None,
totals_row_cell_style: None,
}
}
fn make_table(reference: &str, cols: &[&str]) -> Table {
Table {
auto_filter: None,
sort_state: None,
table_columns: CtTableColumns {
table_column: cols
.iter()
.enumerate()
.map(|(i, n)| make_col(i as u32 + 1, n))
.collect(),
count: cols.len() as u32,
},
table_style_info: None,
ext_lst: None,
id: 1,
name: None,
display_name: "Table1".to_string(),
comment: None,
reference: reference.to_string(),
table_type: None,
header_row_count: 1,
insert_row: false,
insert_row_shift: false,
totals_row_count: 0,
totals_row_shown: true,
published: false,
header_row_dxf_id: None,
data_dxf_id: None,
totals_row_dxf_id: None,
header_row_border_dxf_id: None,
table_border_dxf_id: None,
totals_row_border_dxf_id: None,
header_row_cell_style: None,
data_cell_style: None,
totals_row_cell_style: None,
connection_id: None,
}
}
let grid = [
["Region", "Q1", "Q2"],
["East", "10", "20"],
["West", "30", "40"],
["North", "50", "60"],
];
let mut authored = Workbook::default();
let mut payloads = vec![];
for (r, row) in grid.iter().enumerate() {
for (c, val) in row.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: val.to_string(),
}));
}
}
authored.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: false,
}));
let base = authored.save().unwrap();
let mut raw = Wb::from_file(&base).unwrap();
let ws = raw.xl.worksheets.values_mut().next().unwrap();
ws.tables.push(TablePart {
rel_id: "rId777".to_string(),
table: make_table("A1:C4", &["Region", "Q1", "Q2"]),
});
let input = write(raw).unwrap();
let wb = Workbook::from_file(&input, "tbl".to_string()).unwrap();
let ws0 = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws0.get_all_blocks();
assert_eq!(blocks.len(), 1, "the table should have become one block");
let b = &blocks[0];
assert_eq!(
(b.row_start, b.col_start),
(1, 0),
"block starts below header"
);
assert_eq!(
(b.row_cnt, b.col_cnt),
(3, 3),
"block covers the 3 data rows"
);
let schema = b.schema.as_ref().expect("converted block has a schema");
assert_eq!(
schema.name, "Table1",
"the block should adopt the table's own displayName"
);
let field_names: Vec<&str> = schema.fields.iter().map(|f| f.field.as_str()).collect();
assert_eq!(field_names, vec!["Region", "Q1", "Q2"]);
assert!(matches!(ws0.get_value(0, 0).unwrap(), Value::Str(s) if s == "Region"));
assert!(matches!(ws0.get_value(1, 0).unwrap(), Value::Str(s) if s == "East"));
assert!(matches!(ws0.get_value(3, 2).unwrap(), Value::Number(n) if (n - 60.0).abs() < 1e-9));
let resaved = wb.save().unwrap();
let raw2 = Wb::from_file(&resaved).unwrap();
let tables: Vec<_> = raw2
.xl
.worksheets
.values()
.flat_map(|w| w.tables.iter())
.collect();
assert_eq!(
tables.len(),
1,
"the block should be written back out as one table"
);
let wb2 = Workbook::from_file(&resaved, "tbl2".to_string()).unwrap();
let ws2 = wb2.get_sheet_by_idx(0).unwrap();
let blocks2 = ws2.get_all_blocks();
assert_eq!(blocks2.len(), 1, "block survives save/reload");
assert_eq!(
blocks2[0].schema.as_ref().expect("still has a schema").name,
schema.name,
"the ref name should not change across a save"
);
assert!(matches!(ws2.get_value(1, 0).unwrap(), Value::Str(s) if s == "East"));
}
#[test]
fn range_link_redirects_to_block_and_tracks_growth() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput, InsertRowsInBlock};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("v", "r0")],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 3,
content: "10".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "20".to_string(),
}),
],
undoable: false,
init: false,
}));
wb.test_add_range_link(0, (0, 0, 1, 0), bid, 0, 1, 0);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 4,
content: "=SUM(A1:A2)".to_string(),
})],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 4).unwrap(), Value::Number(n) if (n - 30.0).abs() < 1e-9),
"linked SUM(A1:A2) should read the block (30), got {:?}",
ws.get_value(0, 4).unwrap()
);
assert!(matches!(ws.get_value(0, 0).unwrap(), Value::Empty));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 3,
content: "100".to_string(),
})],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 4).unwrap(), Value::Number(n) if (n - 120.0).abs() < 1e-9),
"SUM should recompute after the block changed (120), got {:?}",
ws.get_value(0, 4).unwrap()
);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 1,
cnt: 1,
})],
undoable: false,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "5".to_string(),
})],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 4).unwrap(), Value::Number(n) if (n - 125.0).abs() < 1e-9),
"SUM should include the grown row (100+5+20=125), got {:?}",
ws.get_value(0, 4).unwrap()
);
}
}
#[test]
fn clearing_field_rule_purges_stale_shadow_value() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput, UpsertFieldFormulas};
use crate::sid_assigner::ShadowKind;
use logisheets_base::CellId;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("v", "r0")
.with_validation_formula(Some("#PLACEHOLDER>100".into()))
.with_editability_formula(Some("#PLACEHOLDER>100".into())),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 3,
content: "10".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "20".to_string(),
}),
],
undoable: false,
init: false,
}));
let read = |wb: &mut Workbook, row: usize, kind: ShadowKind| -> Value {
let scid = wb.get_shadow_cell_id(0, row, 3, kind).unwrap();
let id = match scid.cell_id {
CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
};
assert!(
matches!(read(&mut wb, 0, ShadowKind::Validation), Value::Bool(false)),
"validation should fail (10 > 100 is false) while the rule is set"
);
assert!(
matches!(
read(&mut wb, 0, ShadowKind::UserEditable),
Value::Bool(false)
),
"editability should be false while the rule is set"
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpsertFieldFormulas(UpsertFieldFormulas {
sheet_idx: 0,
block_id: bid,
field_formulas: vec![],
validation_formulas: vec![None],
editability_formulas: vec![None],
})],
undoable: false,
init: false,
}));
for row in [0usize, 1usize] {
assert!(
matches!(read(&mut wb, row, ShadowKind::Validation), Value::Empty),
"validation shadow at row {row} should be empty after the rule is cleared, got {:?}",
read(&mut wb, row, ShadowKind::Validation)
);
assert!(
matches!(read(&mut wb, row, ShadowKind::UserEditable), Value::Empty),
"editability shadow at row {row} should be empty after the rule is cleared, got {:?}",
read(&mut wb, row, ShadowKind::UserEditable)
);
}
}
#[test]
fn a_templated_field_refuses_every_user_write() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellClear, CellInput};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("qty", "r0"),
SchemaFieldSpec::new("total", "r1")
.with_value_formula(Some("=#FIELD(\"qty\")*2".into())),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "10".to_string(),
}),
],
undoable: false,
init: false,
}));
let total =
|wb: &Workbook| -> Value { wb.get_sheet_by_idx(0).unwrap().get_value(0, 1).unwrap() };
let formula =
|wb: &Workbook| -> String { wb.get_sheet_by_idx(0).unwrap().get_formula(0, 1).unwrap() };
assert!(
matches!(total(&wb), Value::Number(n) if n == 20.0),
"the template should have computed 10*2, got {:?}",
total(&wb)
);
let materialized = formula(&wb);
assert!(
!materialized.is_empty(),
"the templated cell should carry a real formula"
);
for (label, payload) in [
(
"a plain value",
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 1,
content: "999".to_string(),
}),
),
(
"a literal formula",
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 1,
content: "=42".to_string(),
}),
),
(
"a clear",
EditPayload::CellClear(CellClear {
sheet_idx: 0,
row: 0,
col: 1,
}),
),
] {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![payload],
undoable: false,
init: false,
}));
assert!(
matches!(total(&wb), Value::Number(n) if n == 20.0),
"{label} must not change a templated cell's value, got {:?}",
total(&wb)
);
assert_eq!(
formula(&wb),
materialized,
"{label} must leave the field's formula on the cell"
);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "7".to_string(),
})],
undoable: false,
init: false,
}));
assert!(
matches!(total(&wb), Value::Number(n) if n == 14.0),
"the field formula should still recompute from its input, got {:?}",
total(&wb)
);
}
#[test]
fn a_proposed_value_is_judged_without_touching_the_workbook() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0"),
SchemaFieldSpec::new("qty", "r1")
.with_validation_formula(Some("#PLACEHOLDER>0".into())),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 1,
content: "5".to_string(),
}),
],
undoable: false,
init: false,
}));
let read =
|wb: &Workbook| -> Value { wb.get_sheet_by_idx(0).unwrap().get_value(0, 1).unwrap() };
assert!(
matches!(read(&wb), Value::Number(n) if n == 5.0),
"sanity: the cell should hold the value that was written"
);
let ok = wb.check_field_validation(0, 0, 1, "7".to_string()).unwrap();
assert!(ok.has_rule, "the field carries a validation rule");
assert!(!ok.violates, "7 > 0 passes the rule");
assert_eq!(ok.rule, "#PLACEHOLDER>0");
let bad = wb
.check_field_validation(0, 0, 1, "-3".to_string())
.unwrap();
assert!(bad.violates, "-3 fails `#PLACEHOLDER>0`");
assert!(
matches!(read(&wb), Value::Number(n) if n == 5.0),
"checking a proposed value must leave the cell holding its own value, got {:?}",
read(&wb)
);
let keyless = wb
.check_field_validation(0, 0, 0, "anything".to_string())
.unwrap();
assert!(!keyless.has_rule, "the key field declares no validation");
let outside = wb
.check_field_validation(0, 9, 9, "anything".to_string())
.unwrap();
assert!(!outside.has_rule, "a cell outside every block has no rule");
}
#[test]
fn create_link_payload_redirects_existing_formula() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput, CreateLink, InsertRowsInBlock};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "1".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "2".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 4,
content: "=SUM(A1:A2)".to_string(),
}),
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("v", "r0")],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 3,
content: "10".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "20".to_string(),
}),
],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 4).unwrap(), Value::Number(n) if (n - 3.0).abs() < 1e-9),
"before link, SUM(A1:A2) reads the literal cells (3), got {:?}",
ws.get_value(0, 4).unwrap()
);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 1,
block_id: bid,
block_sheet_idx: None,
})],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 4).unwrap(), Value::Number(n) if (n - 30.0).abs() < 1e-9),
"after link, the existing SUM redirects to the block (30), got {:?}",
ws.get_value(0, 4).unwrap()
);
assert!(matches!(ws.get_value(0, 0).unwrap(), Value::Number(n) if (n - 1.0).abs() < 1e-9));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 1,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "5".to_string(),
}),
],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 4).unwrap(), Value::Number(n) if (n - 35.0).abs() < 1e-9),
"linked SUM tracks the grown block (10+5+20=35), got {:?}",
ws.get_value(0, 4).unwrap()
);
}
}
#[test]
fn linked_range_size_mismatch_reads_block_both_orders() {
use crate::controller::display::Value;
use crate::edit_action::{CellInput, CreateLink};
let sum_of_block = 1.0 + 3.0 + 4.0 + 5.0 + 6.0 + 7.0;
let build = |formula_first: bool| -> Workbook {
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 6,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (i, v) in [1, 3, 4, 5, 6, 7].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 3,
content: v.to_string(),
}));
}
for (i, v) in [1, 2, 3, 4].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 0,
content: v.to_string(),
}));
}
let formula = EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 6,
content: "=SUM(A1:A4)".to_string(),
});
let link = EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 4,
col_cnt: 1,
block_id: bid,
block_sheet_idx: None,
});
if formula_first {
payloads.push(formula);
payloads.push(link);
} else {
payloads.push(link);
payloads.push(formula);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: false,
}));
wb
};
for formula_first in [true, false] {
let wb = build(formula_first);
let ws = wb.get_sheet_by_idx(0).unwrap();
let got = ws.get_value(0, 6).unwrap();
assert!(
matches!(got, Value::Number(n) if (n - sum_of_block).abs() < 1e-9),
"formula_first={formula_first}: SUM(A1:A4) linked to the 6-row block \
should read the block ({sum_of_block}), got {:?}",
got
);
}
}
#[test]
fn linked_multicol_subcolumn_reference_reads_block_column() {
use crate::controller::display::Value;
use crate::edit_action::{CellInput, CreateLink};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 6,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (i, v) in [10, 20, 30, 40, 50, 60].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 3,
content: v.to_string(),
}));
}
for (i, v) in [1, 3, 4, 5, 6, 7].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 4,
content: v.to_string(),
}));
}
for (i, v) in [1, 2, 3, 4].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 0,
content: (v * 100).to_string(),
}));
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 1,
content: v.to_string(),
}));
}
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 6,
content: "=SUM(B1:B4)".to_string(),
}));
payloads.push(EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 4,
col_cnt: 2,
block_id: bid,
block_sheet_idx: None,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let got = ws.get_value(0, 6).unwrap();
assert!(
matches!(got, Value::Number(n) if (n - 26.0).abs() < 1e-9),
"SUM(B1:B4), a sub-column of linked A1:B4, should read block col E (26), got {:?}",
got
);
}
#[test]
fn cross_sheet_linked_column_tracks_block_and_survives_save_load() {
use crate::controller::display::Value;
use crate::edit_action::{
BindFormSchema, CellInput, CreateLink, CreateSheet, InsertRowsInBlock,
};
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateSheet(CreateSheet {
idx: 1,
new_name: "io".into(),
})],
undoable: false,
init: false,
}));
let bid = wb.get_available_block_id(1).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 1,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 1,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("v", "r0")],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 1,
row: 0,
col: 3,
content: "10".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 1,
row: 1,
col: 3,
content: "20".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 6,
content: "=SUM(A1:A2)".into(),
}),
EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 1,
block_id: bid,
block_sheet_idx: Some(1),
}),
],
undoable: false,
init: false,
}));
let val = |wb: &Workbook| wb.get_sheet_by_idx(0).unwrap().get_value(0, 6).unwrap();
assert!(
matches!(val(&wb), Value::Number(n) if (n - 30.0).abs() < 1e-9),
"cross-sheet SUM reads the block on sheet 1 (30), got {:?}",
val(&wb)
);
assert!(matches!(
wb.get_sheet_by_idx(0).unwrap().get_value(0, 0).unwrap(),
Value::Empty
));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 1,
block_id: bid,
start: 2,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 1,
row: 2,
col: 3,
content: "7".into(),
}),
],
undoable: false,
init: false,
}));
assert!(
matches!(val(&wb), Value::Number(n) if (n - 37.0).abs() < 1e-9),
"cross-sheet SUM tracks the appended block row (37), got {:?}",
val(&wb)
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 1,
row: 2,
col: 3,
content: "100".into(),
})],
undoable: false,
init: false,
}));
assert!(
matches!(val(&wb), Value::Number(n) if (n - 130.0).abs() < 1e-9),
"cross-sheet later edit recomputes (130), got {:?}",
val(&wb)
);
let bytes = wb.save().expect("save");
let wb2 = Workbook::from_file(&bytes, "reloaded".into()).expect("load");
let ws0 = wb2.get_sheet_by_idx(0).unwrap();
assert_eq!(
ws0.get_links().len(),
1,
"cross-sheet link restored on load"
);
assert_eq!(ws0.get_formula(0, 6).unwrap(), "SUM(A1:A2)");
assert!(
matches!(ws0.get_value(0, 6).unwrap(), Value::Number(n) if (n - 130.0).abs() < 1e-9),
"cross-sheet value survives save/load (130)"
);
}
#[test]
fn link_survives_save_load() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput, CreateLink, InsertRowsInBlock};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("v", "r0")],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 3,
content: "10".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "20".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 5,
content: "=SUM(A1:A2)".into(),
}),
EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 1,
block_id: bid,
block_sheet_idx: None,
}),
],
undoable: false,
init: false,
}));
assert!(
matches!(wb.get_sheet_by_idx(0).unwrap().get_value(0, 5).unwrap(),
Value::Number(n) if (n - 30.0).abs() < 1e-9)
);
let bytes = wb.save().expect("save");
let mut wb2 = Workbook::from_file(&bytes, "reloaded".into()).expect("load");
let ws2 = wb2.get_sheet_by_idx(0).unwrap();
assert_eq!(
ws2.get_links().len(),
1,
"the link should be restored on load"
);
assert_eq!(ws2.get_formula(0, 5).unwrap(), "SUM(A1:A2)");
assert!(matches!(ws2.get_value(0, 5).unwrap(), Value::Number(n) if (n - 30.0).abs() < 1e-9));
assert!(matches!(ws2.get_value(0, 0).unwrap(), Value::Empty));
wb2.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 2,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 3,
content: "7".into(),
}),
],
undoable: false,
init: false,
}));
assert!(
matches!(wb2.get_sheet_by_idx(0).unwrap().get_value(0, 5).unwrap(),
Value::Number(n) if (n - 37.0).abs() < 1e-9)
);
}
#[test]
fn linked_column_tracks_tail_append() {
use crate::controller::display::Value;
use crate::edit_action::{CellInput, CreateLink, InsertRowsInBlock};
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("v", "r0")],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 3,
content: "10".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 3,
content: "20".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 5,
content: "=SUM(A1:A2)".into(),
}),
EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 1,
block_id: bid,
block_sheet_idx: None,
}),
],
undoable: false,
init: false,
}));
let val = |wb: &Workbook| wb.get_sheet_by_idx(0).unwrap().get_value(0, 5).unwrap();
assert!(
matches!(val(&wb), Value::Number(n) if (n - 30.0).abs() < 1e-9),
"baseline SUM = 30, got {:?}",
val(&wb)
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 2,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 3,
content: "7".into(),
}),
],
undoable: false,
init: false,
}));
assert!(
matches!(val(&wb), Value::Number(n) if (n - 37.0).abs() < 1e-9),
"SUM must follow the tail-appended block row (10+20+7=37), got {:?}",
val(&wb)
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 3,
content: "100".into(),
})],
undoable: false,
init: false,
}));
assert!(
matches!(val(&wb), Value::Number(n) if (n - 130.0).abs() < 1e-9),
"editing the appended cell later must recompute SUM (10+20+100=130), got {:?}",
val(&wb)
);
}
#[test]
fn linked_record_rejects_non_column_references() {
use crate::controller::display::Value;
use crate::edit_action::{CellInput, CreateLink};
let build = |formula: &str, formula_first: bool| -> Workbook {
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 6,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (i, v) in [1, 3, 4, 5, 6, 7].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 4,
content: v.to_string(),
}));
}
for (i, v) in [1, 2, 3, 4].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 0,
content: (v * 10).to_string(),
}));
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 1,
content: v.to_string(),
}));
}
let f = EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 6,
content: formula.to_string(),
});
let link = EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 4,
col_cnt: 2,
block_id: bid,
block_sheet_idx: None,
});
let payloads = if formula_first {
payloads.push(f);
payloads.push(link);
payloads
} else {
payloads.push(link);
payloads.push(f);
payloads
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: false,
}));
wb
};
let is_value_err = |wb: &Workbook| -> bool {
matches!(wb.get_sheet_by_idx(0).unwrap().get_value(0, 6).unwrap(),
Value::Error(s) if s == "#VALUE!")
};
let num = |wb: &Workbook| -> f64 {
match wb.get_sheet_by_idx(0).unwrap().get_value(0, 6).unwrap() {
Value::Number(n) => n,
other => panic!("expected number, got {other:?}"),
}
};
for first in [true, false] {
assert!((num(&build("=SUM(B1:B4)", first)) - 26.0).abs() < 1e-9);
assert!(
is_value_err(&build("=SUM(A1:B4)", first)),
"whole region (first={first})"
);
assert!(
is_value_err(&build("=SUM(A1:A2)", first)),
"partial column (first={first})"
);
assert!(
is_value_err(&build("=A1+0", first)),
"single cell (first={first})"
);
assert!((num(&build("=SUM(Z1:Z9)", first))).abs() < 1e-9);
}
}
#[test]
fn get_links_reports_linked_source_range_coords() {
use crate::edit_action::{CellInput, CreateLink};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "1".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "2".to_string(),
}),
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 3,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::CreateLink(CreateLink {
sheet_idx: 0,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 1,
block_id: bid,
block_sheet_idx: None,
}),
],
undoable: false,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let links = ws.get_links();
assert_eq!(links.len(), 1, "exactly one link, got {:?}", links);
let l = &links[0];
assert_eq!(l.block_id, bid);
assert_eq!((l.start_row, l.start_col), (0, 0));
assert_eq!((l.end_row, l.end_col), (1, 0));
assert_eq!(l.sheet_idx, 0);
}
#[test]
fn test_check_formula() {
let wb = Workbook::new();
let r = wb.check_formula("=1+1".to_string());
assert!(r);
let r = wb.check_formula("=SUM(1)+".to_string());
assert!(!r);
}
#[test]
fn create_block_with_owner_and_policy_roundtrip() {
let mut wb = Workbook::default();
let id = wb.get_available_block_id(0).unwrap();
let payload_action = PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 1,
master_col: 1,
row_cnt: 2,
col_cnt: 2,
owner: Some("what-if-calculator".to_string()),
modify_policy: Some(ModifyPolicy::OwnerAndUser),
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: false,
init: false,
};
let _ = wb.handle_action(EditAction::Payloads(payload_action));
let bytes = wb.save().expect("save");
let reloaded = Workbook::from_file(&bytes, "roundtrip.xlsx".to_string()).expect("reload");
let info = reloaded
.get_block_modify_info(0, id)
.expect("block missing after reload");
assert_eq!(info.owner, "what-if-calculator");
assert!(matches!(info.modify_policy, ModifyPolicy::OwnerAndUser));
}
fn author(name: &str) -> AuthorInput {
AuthorInput {
display_name: name.to_string(),
user_id: None,
provider_id: None,
}
}
fn enterprise_author(name: &str, user_id: &str) -> AuthorInput {
AuthorInput {
display_name: name.to_string(),
user_id: Some(user_id.to_string()),
provider_id: Some("AD".to_string()),
}
}
#[test]
fn comment_thread_add_reply_mention_edit_delete() {
let mut wb = Workbook::default();
let root_id = "{root-0000-0000-0000-000000000001}".to_string();
let bob = enterprise_author("Bob", "bob@corp.com");
let r = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::AddComment(AddComment {
sheet_idx: 0,
row: 2,
col: 3,
comment_id: root_id.clone(),
parent_id: None,
author: enterprise_author("Alice", "alice@corp.com"),
dt: "2026-07-03T10:00:00Z".to_string(),
content: "Please review @Bob".to_string(),
mentions: vec![CommentMention {
start: 15,
len: 4,
author: bob.clone(),
mention_id: None,
}],
})],
undoable: true,
init: false,
}));
assert!(matches!(r.status, crate::edit_action::StatusCode::Ok(_)));
let reply_id = "{reply-0000-0000-0000-000000000002}".to_string();
let _ = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::AddComment(AddComment {
sheet_idx: 0,
row: 2,
col: 3,
comment_id: reply_id.clone(),
parent_id: Some(root_id.clone()),
author: bob.clone(),
dt: "2026-07-03T10:05:00Z".to_string(),
content: "Done".to_string(),
mentions: vec![],
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let comment = ws.get_comment(2, 3).expect("comment thread missing");
assert_eq!(comment.notes.len(), 2);
let root = &comment.notes[0];
assert_eq!(root.author.display_name, "Alice");
assert_eq!(root.author.user_id.as_deref(), Some("alice@corp.com"));
assert_eq!(root.mentions.len(), 1);
assert_eq!(root.mentions[0].person.display_name, "Bob");
assert!(root.parent_id.is_none());
let reply = &comment.notes[1];
assert_eq!(reply.author.display_name, "Bob");
assert_eq!(reply.parent_id.as_deref(), Some(root_id.as_str()));
assert_eq!(ws.get_comments().len(), 1);
}
let _ = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::EditComment(EditComment {
sheet_idx: 0,
comment_id: root_id.clone(),
content: "Reviewed, thanks".to_string(),
mentions: vec![],
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let comment = ws.get_comment(2, 3).unwrap();
assert_eq!(comment.notes[0].content, "Reviewed, thanks");
assert_eq!(comment.notes[0].mentions.len(), 0);
}
let _ = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::ResolveComment(ResolveComment {
sheet_idx: 0,
comment_id: root_id.clone(),
resolved: true,
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(ws.get_comment(2, 3).unwrap().notes[0].resolved);
}
let _ = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteComment(DeleteComment {
sheet_idx: 0,
comment_id: reply_id.clone(),
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(ws.get_comment(2, 3).unwrap().notes.len(), 1);
}
let _ = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteComment(DeleteComment {
sheet_idx: 0,
comment_id: root_id.clone(),
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(ws.get_comment(2, 3).is_none());
}
}
#[test]
fn comment_roundtrip_persists_thread_and_persons() {
let mut wb = Workbook::default();
let root_id = "{root-0000-0000-0000-0000000000aa}".to_string();
let reply_id = "{reply-0000-0000-0000-0000000000bb}".to_string();
let bob = enterprise_author("Bob", "bob@corp.com");
let _ = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::AddComment(AddComment {
sheet_idx: 0,
row: 5,
col: 1,
comment_id: root_id.clone(),
parent_id: None,
author: author("Guest"),
dt: "2026-07-03T12:00:00Z".to_string(),
content: "cc @Bob".to_string(),
mentions: vec![CommentMention {
start: 3,
len: 4,
author: bob.clone(),
mention_id: None,
}],
}),
EditPayload::AddComment(AddComment {
sheet_idx: 0,
row: 5,
col: 1,
comment_id: reply_id.clone(),
parent_id: Some(root_id.clone()),
author: bob.clone(),
dt: "2026-07-03T12:01:00Z".to_string(),
content: "ack".to_string(),
mentions: vec![],
}),
],
undoable: false,
init: false,
}));
let bytes = wb.save().expect("save");
let reloaded = Workbook::from_file(&bytes, "comments.xlsx".to_string()).expect("reload");
let ws = reloaded.get_sheet_by_idx(0).unwrap();
let comment = ws.get_comment(5, 1).expect("thread lost on reload");
assert_eq!(comment.notes.len(), 2);
assert_eq!(comment.notes[0].content, "cc @Bob");
assert_eq!(comment.notes[0].mentions.len(), 1);
assert_eq!(
comment.notes[0].mentions[0].person.user_id.as_deref(),
Some("bob@corp.com")
);
assert_eq!(
comment.notes[1].parent_id.as_deref(),
Some(root_id.as_str())
);
assert_eq!(comment.notes[1].author.display_name, "Bob");
}
#[test]
fn dependency_tracking_precedents_and_dependents() {
use super::CellRefRange;
use crate::edit_action::CellInput;
let mut wb = Workbook::default();
let cell = |row, col, content: &str| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: content.to_string(),
})
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
cell(0, 0, "1"),
cell(1, 0, "2"),
cell(0, 2, "=SUM(A1:A2)"),
cell(1, 2, "=A1*2"),
],
undoable: false,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let is_single = |r: &CellRefRange| {
!r.all_rows && !r.all_cols && r.start_row == r.end_row && r.start_col == r.end_col
};
let deps = ws.get_dependents(0, 0, 1, 0).unwrap();
let c1 = deps
.iter()
.find(|d| d.row == 0 && d.col == 2)
.expect("C1 must depend on A1:A2");
assert!(
!is_single(&c1.via)
&& c1.via.start_row == 0
&& c1.via.end_row == 1
&& c1.via.start_col == 0
&& c1.via.end_col == 0,
"C1's reference is the multi-cell range A1:A2, got {:?}",
c1.via
);
let c2 = deps
.iter()
.find(|d| d.row == 1 && d.col == 2)
.expect("C2 must depend on A1");
assert!(is_single(&c2.via), "C2's reference is single-cell A1");
let deps_a2 = ws.get_dependents(1, 0, 1, 0).unwrap();
assert!(
deps_a2.iter().any(|d| d.row == 0 && d.col == 2),
"C1 depends on A2 via the range"
);
assert!(
!deps_a2.iter().any(|d| d.row == 1 && d.col == 2),
"C2 does NOT depend on A2"
);
let prec = ws.get_precedents(0, 2).unwrap();
assert!(
prec.iter()
.any(|r| r.start_row == 0 && r.end_row == 1 && r.start_col == 0 && r.end_col == 0),
"C1's precedent is A1:A2, got {:?}",
prec
);
}
#[test]
fn a_declared_header_line_is_not_a_record() {
use crate::edit_action::{BindFormSchema, CellInput};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let rows = [
("name", "age"),
("Charlie", "30"),
("Alice", "10"),
("Bob", "20"),
];
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (r, (name, age)) in rows.iter().enumerate() {
for (c, v) in [name, age].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".to_string(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "r0"),
SchemaFieldSpec::new("age", "r1"),
],
row: true,
header_idx: Some(0),
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let ws = wb.get_sheet_by_idx(0).unwrap();
let block = ws
.get_all_blocks()
.into_iter()
.find(|b| b.block_id == bid)
.unwrap();
let keys: Vec<String> = block
.schema
.as_ref()
.unwrap()
.keys
.iter()
.map(|k| k.key.clone())
.collect();
assert_eq!(
keys,
vec!["Charlie", "Alice", "Bob"],
"\"name\" is the header, not a key"
);
let count = wb
.get_sheet_by_idx(0)
.unwrap()
.get_all_blocks()
.into_iter()
.find(|b| b.block_id == bid)
.unwrap()
.row_cnt;
assert_eq!(count, 4, "the block still OWNS four lines");
}
#[test]
fn a_repeated_combination_is_flagged_where_neither_column_alone_is() {
use crate::edit_action::{BindFormSchema, CellInput, UniqueTogetherGroup};
use crate::sid_assigner::ShadowKind;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let rows = [
("f0", "East", "Q1"),
("f1", "South", "Q1"),
("f2", "South", "Q1"),
("f3", "South", "Q2"),
];
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (r, (id, region, quarter)) in rows.iter().enumerate() {
for (c, v) in [id, region, quarter].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "facts".to_string(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "u0"),
SchemaFieldSpec::new("region", "u1"),
SchemaFieldSpec::new("quarter", "u2"),
],
row: true,
header_idx: None,
unique_together: Some(vec![UniqueTogetherGroup {
fields: vec!["region".into(), "quarter".into()],
}]),
}));
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let valid = |wb: &mut Workbook, row: usize, col: usize| -> bool {
let scid = wb
.get_shadow_cell_id(0, row, col, ShadowKind::Validation)
.unwrap();
let id = match scid.cell_id {
crate::CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
!matches!(
wb.get_shadow_info_by_id(id).unwrap().value,
crate::controller::display::Value::Bool(false)
)
};
assert!(!valid(&mut wb, 1, 1), "row 1 repeats (South, Q1)");
assert!(!valid(&mut wb, 1, 2), "and its quarter cell says so too");
assert!(!valid(&mut wb, 2, 1), "row 2 is the other half of the pair");
assert!(valid(&mut wb, 0, 1), "(East, Q1) occurs once");
assert!(valid(&mut wb, 3, 1), "(South, Q2) occurs once");
assert!(valid(&mut wb, 1, 0), "`id` is not part of the group");
}
#[test]
fn a_header_cell_is_not_editable() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput};
use crate::sid_assigner::ShadowKind;
use logisheets_base::CellId;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let rows = [("name", "age"), ("Alice", "10")];
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (r, (name, age)) in rows.iter().enumerate() {
for (c, v) in [name, age].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".to_string(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "h0"),
SchemaFieldSpec::new("age", "h1"),
],
row: true,
header_idx: Some(0),
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let editable = |wb: &mut Workbook, row: usize, col: usize| -> Value {
let scid = wb
.get_shadow_cell_id(0, row, col, ShadowKind::UserEditable)
.unwrap();
let id = match scid.cell_id {
CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
};
assert!(
matches!(editable(&mut wb, 0, 0), Value::Bool(false)),
"the header's key column is not editable, got {:?}",
editable(&mut wb, 0, 0)
);
assert!(
matches!(editable(&mut wb, 0, 1), Value::Bool(false)),
"nor any other column of it, got {:?}",
editable(&mut wb, 0, 1)
);
assert!(
matches!(editable(&mut wb, 1, 0), Value::Empty),
"a record keeps whatever editability it had, got {:?}",
editable(&mut wb, 1, 0)
);
}
#[test]
fn a_header_cell_raises_no_validation_marker() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput};
use crate::sid_assigner::ShadowKind;
use logisheets_base::CellId;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let rows = [("name", "age"), ("Alice", "10"), ("Bob", "11")];
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (r, (name, age)) in rows.iter().enumerate() {
for (c, v) in [name, age].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".to_string(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "h0")
.with_required(true)
.with_unique(true),
SchemaFieldSpec::new("age", "h1").with_unique(true),
],
row: true,
header_idx: Some(0),
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let validation = |wb: &mut Workbook, row: usize, col: usize| -> Value {
match wb.get_shadow_cell_id(0, row, col, ShadowKind::Validation) {
Ok(scid) => {
let id = match scid.cell_id {
CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
}
Err(_) => Value::Empty,
}
};
assert!(
!matches!(validation(&mut wb, 0, 0), Value::Bool(false)),
"the header naming the unique key field is not a violation, got {:?}",
validation(&mut wb, 0, 0)
);
assert!(
!matches!(validation(&mut wb, 0, 1), Value::Bool(false)),
"nor the header of the other unique field, got {:?}",
validation(&mut wb, 0, 1)
);
let effect = apply(
&mut wb,
vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 1,
content: "10".to_string(),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert!(
matches!(validation(&mut wb, 2, 1), Value::Bool(false)),
"a repeated value in a unique field IS a violation, got {:?}",
validation(&mut wb, 2, 1)
);
}
#[test]
fn sorting_a_block_leaves_its_header_on_top() {
use crate::edit_action::{BindFormSchema, CellInput, ReorderBlockLines};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let rows = [
("name", "age"),
("Charlie", "30"),
("Alice", "10"),
("Bob", "20"),
];
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (r, (name, age)) in rows.iter().enumerate() {
for (c, v) in [name, age].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".to_string(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "r0"),
SchemaFieldSpec::new("age", "r1"),
],
row: true,
header_idx: Some(0),
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let order = wb.get_block_sort_order(0, bid, "age", true).unwrap();
assert_eq!(order.new_order.len(), 4, "a full permutation of the lines");
assert_eq!(order.new_order[0], 0, "the header stays first");
let effect = apply(
&mut wb,
vec![EditPayload::ReorderBlockLines(ReorderBlockLines {
sheet_idx: 0,
block_id: bid,
is_row: order.is_row,
new_order: order.new_order.clone(),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let cell = |r: usize, c: usize| -> String {
match wb.get_sheet_by_idx(0).unwrap().get_value(r, c).unwrap() {
crate::controller::display::Value::Str(s) => s,
crate::controller::display::Value::Number(n) => n.to_string(),
other => format!("{other:?}"),
}
};
assert_eq!(cell(0, 0), "name", "the header did not move");
assert_eq!(cell(1, 0), "Alice", "10");
assert_eq!(cell(2, 0), "Bob", "20");
assert_eq!(cell(3, 0), "Charlie", "30");
}
#[test]
fn sort_block_by_field_end_to_end() {
use crate::api::BlockSortOrder;
use crate::controller::display::Value;
use crate::edit_action::{
BindFormSchema, BindRandomSchema, CellInput, RandomSchemaUnit, ReorderBlockLines,
};
fn build() -> (Workbook, usize) {
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let records = [
("Charlie", "30"),
("Alice", "10"),
("Bob", "20"),
("Dave", "5"),
];
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: records.len(),
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "r_name"),
SchemaFieldSpec::new("age", "r_age"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (r, (name, age)) in records.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: 0,
content: name.to_string(),
}));
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: 1,
content: age.to_string(),
}));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: true,
}));
(wb, bid)
}
fn names(wb: &Workbook) -> Vec<String> {
let ws = wb.get_sheet_by_idx(0).unwrap();
(0..4)
.map(|r| match ws.get_value(r, 0).unwrap() {
Value::Str(s) => s,
other => panic!("name at row {r} not text: {other:?}"),
})
.collect()
}
fn ages(wb: &Workbook) -> Vec<f64> {
let ws = wb.get_sheet_by_idx(0).unwrap();
(0..4)
.map(|r| match ws.get_value(r, 1).unwrap() {
Value::Number(n) => n,
other => panic!("age at row {r} not a number: {other:?}"),
})
.collect()
}
fn apply(wb: &mut Workbook, bid: usize, order: BlockSortOrder) {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::ReorderBlockLines(ReorderBlockLines {
sheet_idx: 0,
block_id: bid,
is_row: order.is_row,
new_order: order.new_order,
})],
undoable: false,
init: false,
}));
}
{
let (mut wb, bid) = build();
let order = wb.get_block_sort_order(0, bid, "age", true).unwrap();
assert!(order.is_row, "row-schema block sorts by reordering rows");
assert_eq!(order.new_order, vec![3, 1, 2, 0]);
apply(&mut wb, bid, order);
assert_eq!(ages(&wb), vec![5.0, 10.0, 20.0, 30.0]);
assert_eq!(names(&wb), vec!["Dave", "Alice", "Bob", "Charlie"]);
}
{
let (mut wb, bid) = build();
let order = wb.get_block_sort_order(0, bid, "age", false).unwrap();
assert_eq!(order.new_order, vec![0, 2, 1, 3]);
apply(&mut wb, bid, order);
assert_eq!(ages(&wb), vec![30.0, 20.0, 10.0, 5.0]);
assert_eq!(names(&wb), vec!["Charlie", "Bob", "Alice", "Dave"]);
}
{
let (mut wb, bid) = build();
let order = wb.get_block_sort_order(0, bid, "name", true).unwrap();
assert_eq!(order.new_order, vec![1, 2, 0, 3]);
apply(&mut wb, bid, order);
assert_eq!(names(&wb), vec!["Alice", "Bob", "Charlie", "Dave"]);
assert_eq!(ages(&wb), vec![10.0, 20.0, 30.0, 5.0]);
}
{
let (wb, bid) = build();
assert!(
wb.get_block_sort_order(0, bid, "nope", true).is_err(),
"unknown field name must error"
);
}
{
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindRandomSchema(BindRandomSchema {
ref_name: "rnd".into(),
sheet_idx: 0,
block_id: bid,
units: vec![RandomSchemaUnit {
key: "k0".into(),
render_id: "r0".into(),
row: 0,
col: 0,
}],
}),
],
undoable: false,
init: false,
}));
assert!(
wb.get_block_sort_order(0, bid, "k0", true).is_err(),
"random-schema blocks have no sortable fields"
);
}
{
let (mut wb, bid) = build();
let order = wb.get_block_sort_order(0, bid, "age", true).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::ReorderBlockLines(ReorderBlockLines {
sheet_idx: 0,
block_id: bid,
is_row: order.is_row,
new_order: order.new_order,
})],
undoable: true,
init: false,
}));
assert_eq!(ages(&wb), vec![5.0, 10.0, 20.0, 30.0], "sorted after apply");
wb.handle_action(EditAction::Undo);
assert_eq!(
ages(&wb),
vec![30.0, 10.0, 20.0, 5.0],
"undo restores the original record order"
);
wb.handle_action(EditAction::Redo);
assert_eq!(
ages(&wb),
vec![5.0, 10.0, 20.0, 30.0],
"redo re-applies the sort"
);
}
}
#[test]
fn sort_block_grown_by_insert_rows() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput, InsertRowsInBlock, ReorderBlockLines};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 1,
col_cnt: 1,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("Customer Status", "r0")],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 1,
cnt: 2,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "Charlie".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "Alice".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 0,
content: "Bob".into(),
}),
],
undoable: false,
init: false,
}));
let names = |wb: &Workbook| -> Vec<String> {
let ws = wb.get_sheet_by_idx(0).unwrap();
(0..3)
.map(|r| match ws.get_value(r, 0).unwrap() {
Value::Str(s) => s,
Value::Empty => String::new(),
other => panic!("row {r} not text: {other:?}"),
})
.collect()
};
assert_eq!(
names(&wb),
vec!["Charlie", "Alice", "Bob"],
"records populated as typed"
);
let order = wb
.get_block_sort_order(0, bid, "Customer Status", true)
.unwrap();
assert_eq!(
order.new_order,
vec![1, 2, 0],
"ascending order must reflect the typed values, not blanks (identity)"
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::ReorderBlockLines(ReorderBlockLines {
sheet_idx: 0,
block_id: bid,
is_row: order.is_row,
new_order: order.new_order,
})],
undoable: false,
init: false,
}));
assert_eq!(names(&wb), vec!["Alice", "Bob", "Charlie"]);
}
#[test]
fn sort_block_reference_follows_moved_cell() {
use crate::controller::display::Value;
use crate::edit_action::{BindFormSchema, CellInput, ReorderBlockLines};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let cell = |row, col, content: &str| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: content.to_string(),
})
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 4,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "r_name"),
SchemaFieldSpec::new("age", "r_age"),
],
row: true,
header_idx: None,
unique_together: None,
}),
cell(0, 0, "Charlie"),
cell(0, 1, "30"),
cell(1, 0, "Alice"),
cell(1, 1, "10"),
cell(2, 0, "Bob"),
cell(2, 1, "20"),
cell(3, 0, "Dave"),
cell(3, 1, "5"),
cell(0, 3, "=B1"),
],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 3).unwrap(), Value::Number(n) if (n - 30.).abs() < 1e-9),
"D1 should read B1 = 30 before the sort, got {:?}",
ws.get_value(0, 3).unwrap()
);
assert_eq!(ws.get_formula(0, 3).unwrap(), "B1");
}
let order = wb.get_block_sort_order(0, bid, "age", true).unwrap();
assert_eq!(order.new_order, vec![3, 1, 2, 0]);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::ReorderBlockLines(ReorderBlockLines {
sheet_idx: 0,
block_id: bid,
is_row: order.is_row,
new_order: order.new_order,
})],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(0, 3).unwrap(), Value::Number(n) if (n - 30.).abs() < 1e-9),
"D1 must still read Charlie's 30 (the cell it referenced), got {:?}",
ws.get_value(0, 3).unwrap()
);
assert_eq!(
ws.get_formula(0, 3).unwrap(),
"B4",
"the reference must re-anchor to the moved cell's new coordinate"
);
assert!(
matches!(ws.get_value(0, 1).unwrap(), Value::Number(n) if (n - 5.).abs() < 1e-9),
"B1 should now hold Dave's 5 after the sort"
);
}
}
#[test]
fn formulatext_returns_source_formula_and_na() {
use crate::controller::display::Value;
use crate::edit_action::CellInput;
let mut wb = Workbook::default();
let input = |row: usize, col: usize, content: &str| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: content.to_string(),
})
};
let r = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
input(0, 0, "=1+1"), input(1, 0, "42"), input(0, 1, "=FORMULATEXT(A1)"), input(1, 1, "=FORMULATEXT(A2)"), input(0, 2, "=ISFORMULA(A1)"), input(1, 2, "=ISFORMULA(A2)"), ],
undoable: true,
init: false,
}));
assert!(matches!(r.status, crate::edit_action::StatusCode::Ok(_)));
let ws = wb.get_sheet_by_idx(0).unwrap();
match ws.get_value(0, 1).unwrap() {
Value::Str(s) => assert_eq!(s, "=1 + 1"),
v => panic!("B1 expected the formula text, got {:?}", v),
}
match ws.get_value(1, 1).unwrap() {
Value::Error(e) => assert_eq!(e, "#N/A"),
v => panic!("B2 expected #N/A, got {:?}", v),
}
assert!(
matches!(ws.get_value(0, 2).unwrap(), Value::Bool(true)),
"C1 expected ISFORMULA(A1) == TRUE"
);
assert!(
matches!(ws.get_value(1, 2).unwrap(), Value::Bool(false)),
"C2 expected ISFORMULA(A2) == FALSE"
);
}
#[test]
fn dxfs_round_trip_keeps_cf_references_resolvable() {
use logisheets_workbook::prelude::{
CtCfRule, CtColor, CtConditionalFormatting, CtDxf, CtDxfs, CtFill, CtPatternFill, StCfType,
StConditionalFormattingOperator, StPatternType, Wb, write,
};
fn solid_dxf(rgb: &str) -> CtDxf {
CtDxf {
font: None,
num_fmt: None,
fill: Some(CtFill::PatternFill(CtPatternFill {
fg_color: Some(CtColor {
auto: None,
indexed: None,
rgb: Some(rgb.to_string()),
theme: None,
tint: 0.0,
}),
bg_color: None,
pattern_type: Some(StPatternType::Solid),
})),
alignment: None,
border: None,
protection: None,
}
}
let base = Workbook::default().save().unwrap();
let mut raw = Wb::from_file(&base).unwrap();
raw.xl.styles.1.dxfs = Some(CtDxfs {
count: 2,
dxfs: vec![solid_dxf("FFFF0000"), solid_dxf("FFFFFF00")],
});
raw.xl
.worksheets
.values_mut()
.next()
.unwrap()
.worksheet_part
.conditional_formatting = vec![CtConditionalFormatting {
cf_rules: vec![CtCfRule {
formulas: vec![],
color_scale: None,
data_bar: None,
icon_set: None,
ty: StCfType::CellIs,
dxf_id: Some(1),
priority: 1,
stop_if_true: false,
above_average: true,
percent: false,
bottom: false,
operator: Some(StConditionalFormattingOperator::GreaterThan),
text: None,
time_period: None,
rank: None,
std_dev: None,
equal_average: false,
}],
pviot: false,
sqref: "A1:A10".to_string(),
}];
let input = write(raw).unwrap();
let wb = Workbook::from_file(&input, "dxf".to_string()).unwrap();
let out = wb.save().unwrap();
let reloaded = Wb::from_file(&out).unwrap();
let dxfs = reloaded
.xl
.styles
.1
.dxfs
.as_ref()
.expect("dxfs should survive the controller round trip");
assert_eq!(dxfs.count, 2);
assert_eq!(dxfs.dxfs.len(), 2);
let ws = reloaded.xl.worksheets.values().next().unwrap();
let rule = &ws.worksheet_part.conditional_formatting[0].cf_rules[0];
assert_eq!(rule.dxf_id, Some(1));
let referenced = dxfs
.dxfs
.get(rule.dxf_id.unwrap() as usize)
.expect("dxfId must be in range");
let fg = match referenced.fill.as_ref().unwrap() {
CtFill::PatternFill(p) => p.fg_color.as_ref().unwrap().rgb.as_deref(),
CtFill::GradientFill(_) => None,
};
assert_eq!(fg, Some("FFFFFF00"));
}
#[test]
fn no_dxfs_stays_absent() {
use logisheets_workbook::prelude::Wb;
let out = Workbook::default().save().unwrap();
let reloaded = Wb::from_file(&out).unwrap();
assert!(reloaded.xl.styles.1.dxfs.is_none());
}
#[cfg(test)]
fn with_conditional_formatting(
base: &[u8],
elements: Vec<(&str, Vec<logisheets_workbook::prelude::CtCfRule>)>,
) -> Vec<u8> {
use logisheets_workbook::prelude::{CtConditionalFormatting, Wb, write};
let mut raw = Wb::from_file(base).unwrap();
raw.xl
.worksheets
.values_mut()
.next()
.unwrap()
.worksheet_part
.conditional_formatting = elements
.into_iter()
.map(|(sqref, cf_rules)| CtConditionalFormatting {
cf_rules,
pviot: false,
sqref: sqref.to_string(),
})
.collect();
write(raw).unwrap()
}
#[cfg(test)]
fn cell_is_rule(priority: i32) -> logisheets_workbook::prelude::CtCfRule {
use logisheets_workbook::prelude::{CtCfRule, StCfType, StConditionalFormattingOperator};
CtCfRule {
formulas: vec![],
color_scale: None,
data_bar: None,
icon_set: None,
ty: StCfType::CellIs,
dxf_id: Some(0),
priority,
stop_if_true: false,
above_average: true,
percent: false,
bottom: false,
operator: Some(StConditionalFormattingOperator::GreaterThan),
text: None,
time_period: None,
rank: None,
std_dev: None,
equal_average: false,
}
}
#[cfg(test)]
fn saved_cf_sqrefs(bytes: &[u8]) -> Vec<String> {
use logisheets_workbook::prelude::Wb;
let reloaded = Wb::from_file(bytes).unwrap();
reloaded
.xl
.worksheets
.values()
.next()
.unwrap()
.worksheet_part
.conditional_formatting
.iter()
.map(|cf| cf.sqref.clone())
.collect()
}
#[test]
fn conditional_formatting_sqref_round_trips() {
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(
&base,
vec![
("A1:B10", vec![cell_is_rule(1)]),
("D3", vec![cell_is_rule(2)]),
("F:G", vec![cell_is_rule(3)]),
("2:4", vec![cell_is_rule(4)]),
("A20:A21 C20 E20:F21", vec![cell_is_rule(5)]),
],
);
let wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
let out = wb.save().unwrap();
assert_eq!(
saved_cf_sqrefs(&out),
vec!["A1:B10", "D3", "F:G", "2:4", "A20:A21 C20 E20:F21"]
);
}
#[test]
fn conditional_formatting_range_shifts_when_rows_are_inserted() {
use crate::edit_action::InsertRows;
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(
&base,
vec![
("A2:A10", vec![cell_is_rule(1)]),
("C:C", vec![cell_is_rule(2)]),
],
);
let mut wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 0,
count: 2,
})],
undoable: false,
init: false,
}));
let out = wb.save().unwrap();
assert_eq!(saved_cf_sqrefs(&out), vec!["A4:A12", "C:C"]);
}
#[test]
fn conditional_formatting_range_shifts_when_cols_are_inserted() {
use crate::edit_action::InsertCols;
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(
&base,
vec![
("B2:C10", vec![cell_is_rule(1)]),
("D:D", vec![cell_is_rule(2)]),
],
);
let mut wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertCols(InsertCols {
sheet_idx: 0,
start: 0,
count: 1,
})],
undoable: false,
init: false,
}));
let out = wb.save().unwrap();
assert_eq!(saved_cf_sqrefs(&out), vec!["C2:D10", "E:E"]);
}
#[test]
fn conditional_formatting_anchors_on_block_cells() {
use crate::conditional_formatting_manager::CfRange;
use crate::edit_action::InsertRowsInBlock;
use logisheets_base::CellId;
let mut authored = Workbook::default();
let block_id = authored.get_available_block_id(0).unwrap();
authored.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: block_id,
master_row: 1,
master_col: 1,
row_cnt: 3,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: false,
init: false,
}));
let base = authored.save().unwrap();
let input = with_conditional_formatting(&base, vec![("B2:D4", vec![cell_is_rule(1)])]);
let mut wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
assert_eq!(
wb.get_sheet_by_idx(0).unwrap().get_all_blocks().len(),
1,
"the block must be present at load time for this test to mean anything"
);
{
let sheet_id = wb.status().sheet_info_manager.pos[0];
let blocks = wb
.status()
.conditional_formatting_manager
.get_sheet(sheet_id)
.expect("the rule should be modeled");
assert_eq!(blocks.len(), 1);
match blocks[0].ranges[0] {
CfRange::Rect(start, end) => {
assert!(
matches!(start, CellId::BlockCell(_)),
"top-left should anchor on a block cell, got {start:?}"
);
assert!(
matches!(end, CellId::BlockCell(_)),
"bottom-right should anchor on a block cell, got {end:?}"
);
}
other => panic!("expected a rectangle, got {other:?}"),
}
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id,
start: 1,
cnt: 1,
})],
undoable: false,
init: false,
}));
let out = wb.save().unwrap();
assert_eq!(saved_cf_sqrefs(&out), vec!["B2:D5"]);
}
#[test]
fn unmodelable_conditional_formatting_is_preserved_verbatim() {
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(
&base,
vec![
("B2:B", vec![cell_is_rule(1)]),
("A1:A5", vec![cell_is_rule(2)]),
],
);
let wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
let sheet_id = wb.status().sheet_info_manager.pos[0];
assert_eq!(
wb.status()
.conditional_formatting_manager
.get_sheet(sheet_id)
.map(|b| b.len()),
Some(1),
"only the modelable element should be in the manager"
);
let out = wb.save().unwrap();
let mut sqrefs = saved_cf_sqrefs(&out);
sqrefs.sort();
assert_eq!(sqrefs, vec!["A1:A5", "B2:B"]);
}
#[test]
fn conditional_formatting_rule_evaluates_on_load() {
use crate::controller::display::Value;
use crate::edit_action::CellInput;
use crate::sid_assigner::ShadowKind;
use logisheets_workbook::prelude::{CtCfRule, PlainTextString};
fn greater_than(v: &str) -> CtCfRule {
let mut r = cell_is_rule(1);
r.formulas = vec![PlainTextString {
value: v.to_string(),
space: None,
}];
r
}
let mut authored = Workbook::default();
authored.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "150".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "50".to_string(),
}),
],
undoable: false,
init: false,
}));
let base = authored.save().unwrap();
let input = with_conditional_formatting(&base, vec![("A1:A10", vec![greater_than("100")])]);
let mut wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
let mut mask = |row: usize| -> Value {
let scid = wb
.get_shadow_cell_id(0, row, 0, ShadowKind::ConditionalFormat)
.unwrap();
let id = match scid.cell_id {
logisheets_base::CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
};
let a1 = mask(0);
assert!(
matches!(a1, Value::Number(n) if n == 1.0),
"A1=150 should match the >100 rule, got {a1:?}"
);
let a2 = mask(1);
assert!(
matches!(a2, Value::Number(n) if n == 0.0),
"A2=50 should not match, got {a2:?}"
);
let a3 = mask(2);
assert!(
!matches!(a3, Value::Number(n) if n != 0.0),
"a blank cell must not report a match, got {a3:?}"
);
}
#[test]
fn conditional_formatting_resyncs_after_edits() {
use crate::controller::display::Value;
use crate::edit_action::{CellClear, CellInput, InsertRows};
use crate::sid_assigner::ShadowKind;
use logisheets_workbook::prelude::{CtCfRule, PlainTextString};
fn greater_than(v: &str) -> CtCfRule {
let mut r = cell_is_rule(1);
r.formulas = vec![PlainTextString {
value: v.to_string(),
space: None,
}];
r
}
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(&base, vec![("A1:A10", vec![greater_than("100")])]);
let mut wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
let input_at = |wb: &mut Workbook, row: usize, content: &str| {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: content.to_string(),
})],
undoable: true,
init: false,
}));
};
let mask = |wb: &mut Workbook, row: usize| -> Value {
let scid = wb
.get_shadow_cell_id(0, row, 0, ShadowKind::ConditionalFormat)
.unwrap();
let id = match scid.cell_id {
logisheets_base::CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
};
input_at(&mut wb, 4, "500");
let v = mask(&mut wb, 4);
assert!(
matches!(v, Value::Number(n) if n == 1.0),
"A5=500 should match after the edit, got {v:?}"
);
input_at(&mut wb, 4, "5");
let v = mask(&mut wb, 4);
assert!(
matches!(v, Value::Number(n) if n == 0.0),
"A5=5 should stop matching, got {v:?}"
);
input_at(&mut wb, 4, "500");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellClear(CellClear {
sheet_idx: 0,
row: 4,
col: 0,
})],
undoable: true,
init: false,
}));
let v = mask(&mut wb, 4);
assert!(
!matches!(v, Value::Number(n) if n != 0.0),
"a cleared cell must not report a match, got {v:?}"
);
input_at(&mut wb, 2, "700");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 0,
count: 1,
})],
undoable: true,
init: false,
}));
let v = mask(&mut wb, 3);
assert!(
matches!(v, Value::Number(n) if n == 1.0),
"the value moved to A4 and should still match, got {v:?}"
);
}
#[test]
fn conditional_format_reaches_cell_info_merged() {
use crate::edit_action::CellInput;
use logisheets_workbook::prelude::{
CtColor, CtDxf, CtDxfs, CtFill, CtFont, CtPatternFill, PlainTextString, StPatternType, Wb,
write,
};
fn red(rgb: &str) -> CtColor {
CtColor {
auto: None,
indexed: None,
rgb: Some(rgb.to_string()),
theme: None,
tint: 0.0,
}
}
let mut authored = Workbook::default();
authored.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "150".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "50".to_string(),
}),
EditPayload::CellStyleUpdate(crate::edit_action::CellStyleUpdate {
sheet_idx: 0,
row: 0,
col: 0,
ty: StyleUpdateType {
set_font_bold: Some(true),
..Default::default()
},
}),
],
undoable: false,
init: false,
}));
let base = authored.save().unwrap();
let mut raw = Wb::from_file(&base).unwrap();
raw.xl.styles.1.dxfs = Some(CtDxfs {
count: 1,
dxfs: vec![CtDxf {
font: Some(CtFont {
bold: false,
italic: false,
underline: None,
color: Some(red("FFFF0000")),
sz: None,
name: None,
charset: None,
family: None,
strike: false,
outline: false,
shadow: false,
condense: false,
extend: false,
vert_align: None,
scheme: None,
}),
num_fmt: None,
fill: Some(CtFill::PatternFill(CtPatternFill {
fg_color: Some(red("FFFFFF00")),
bg_color: None,
pattern_type: Some(StPatternType::Solid),
})),
alignment: None,
border: None,
protection: None,
}],
});
let base = write(raw).unwrap();
let mut rule = cell_is_rule(1);
rule.formulas = vec![PlainTextString {
value: "100".to_string(),
space: None,
}];
let input = with_conditional_formatting(&base, vec![("A1:A10", vec![rule])]);
let wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
let ws = wb.get_sheet_by_idx(0).unwrap();
let a1 = ws.get_cell_info(0, 0).unwrap();
let cf = a1
.conditional_format
.as_ref()
.expect("A1=150 matches, so it must carry a conditional format");
assert_eq!(
cf.style.font.color.as_ref().and_then(|c| c.red),
Some(255.0),
"the dxf font colour should be applied"
);
assert!(
cf.style.font.bold,
"the cell's own bold must not be clobbered by a dxf that doesn't set it"
);
assert!(
a1.style.font.color.as_ref().and_then(|c| c.red) != Some(255.0),
"CellInfo::style should remain the cell's own style"
);
let a2 = ws.get_cell_info(1, 0).unwrap();
assert!(
a2.conditional_format.is_none(),
"A2=50 does not match; no conditional format expected"
);
}
#[test]
fn conditional_formatting_follows_undo_and_redo() {
use crate::controller::display::Value;
use crate::edit_action::CellInput;
use crate::sid_assigner::ShadowKind;
use logisheets_workbook::prelude::{CtCfRule, PlainTextString};
fn greater_than(v: &str) -> CtCfRule {
let mut r = cell_is_rule(1);
r.formulas = vec![PlainTextString {
value: v.to_string(),
space: None,
}];
r
}
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(&base, vec![("A1:A10", vec![greater_than("100")])]);
let mut wb = Workbook::from_file(&input, "cf".to_string()).unwrap();
let mask = |wb: &mut Workbook| -> Value {
let scid = wb
.get_shadow_cell_id(0, 0, 0, ShadowKind::ConditionalFormat)
.unwrap();
let id = match scid.cell_id {
logisheets_base::CellId::EphemeralCell(i) => i,
_ => panic!("expected an ephemeral shadow cell"),
};
wb.get_shadow_info_by_id(id).unwrap().value
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "500".to_string(),
})],
undoable: true,
init: false,
}));
let v = mask(&mut wb);
assert!(
matches!(v, Value::Number(n) if n == 1.0),
"A1=500 should match, got {v:?}"
);
wb.handle_action(EditAction::Undo);
let v = mask(&mut wb);
assert!(
!matches!(v, Value::Number(n) if n != 0.0),
"after undo A1 is blank again, so it must not report a match, got {v:?}"
);
wb.handle_action(EditAction::Redo);
let v = mask(&mut wb);
assert!(
matches!(v, Value::Number(n) if n == 1.0),
"after redo A1=500 should match again, got {v:?}"
);
}
#[test]
fn conditional_formatting_spanning_the_full_grid_loads() {
let base = Workbook::default().save().unwrap();
let input = with_conditional_formatting(
&base,
vec![
("A1:A1048576", vec![cell_is_rule(1)]),
("B1:XFD1", vec![cell_is_rule(2)]),
],
);
let wb = Workbook::from_file(&input, "grid".to_string()).expect("must load");
let total: usize = wb
.status()
.conditional_formatting_manager
.data
.iter()
.flat_map(|(_, b)| b.iter())
.map(|b| b.rules.len())
.sum();
assert_eq!(total, 2, "both full-extent rules should survive the load");
let out = wb.save().unwrap();
let reloaded = logisheets_workbook::prelude::Wb::from_file(&out).unwrap();
let ws = reloaded.xl.worksheets.values().next().unwrap();
let sqrefs: Vec<&str> = ws
.worksheet_part
.conditional_formatting
.iter()
.map(|c| c.sqref.as_str())
.collect();
assert!(
sqrefs.contains(&"A1:A1048576"),
"expected the full-column range back, got {sqrefs:?}"
);
assert!(
sqrefs.contains(&"B1:XFD1"),
"expected the full-row range back, got {sqrefs:?}"
);
}
#[test]
fn create_conditional_formatting_rule_end_to_end() {
use crate::edit_action::{CellInput, CreateConditionalFormattingRule};
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "50".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "500".to_string(),
}),
],
undoable: false,
init: false,
}));
let spec = greater_than_spec("100", "FFFFC7CE");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateConditionalFormattingRule(
CreateConditionalFormattingRule {
sheet_idx: 0,
start_row: 0,
start_col: 0,
end_row: 9,
end_col: 0,
rule: spec,
},
)],
undoable: true,
init: false,
}));
let rules = wb
.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].range, "A1:A10");
assert_eq!(rules[0].spec.ty, "cellIs");
assert_eq!(rules[0].spec.operator.as_deref(), Some("greaterThan"));
assert_eq!(rules[0].spec.operands, vec!["100".to_string()]);
assert_eq!(
rules[0]
.spec
.format
.as_ref()
.and_then(|f| f.fill_color.as_deref()),
Some("FFFFC7CE")
);
assert!(rules[0].preview.is_some(), "a preview style should resolve");
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
ws.get_cell_info(1, 0).unwrap().conditional_format.is_some(),
"A2=500 should be formatted"
);
assert!(
ws.get_cell_info(0, 0).unwrap().conditional_format.is_none(),
"A1=50 must not match a >100 rule"
);
let bytes = wb.save().unwrap();
let reloaded = Workbook::from_file(&bytes, "cf".to_string()).unwrap();
let rules = reloaded
.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules();
assert_eq!(rules.len(), 1, "the authored rule must survive a save");
assert_eq!(rules[0].range, "A1:A10");
assert_eq!(
rules[0]
.spec
.format
.as_ref()
.and_then(|f| f.fill_color.as_deref()),
Some("FFFFC7CE"),
"the authored dxf must survive too"
);
}
#[test]
fn update_and_delete_conditional_formatting_rule() {
use crate::edit_action::{DeleteConditionalFormattingRule, UpdateConditionalFormattingRule};
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateConditionalFormattingRule(
crate::edit_action::CreateConditionalFormattingRule {
sheet_idx: 0,
start_row: 0,
start_col: 0,
end_row: 9,
end_col: 0,
rule: greater_than_spec("100", "FFFFC7CE"),
},
)],
undoable: true,
init: false,
}));
let rules = wb
.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules();
let id = rules[0].rule_id;
let dxfs_after_create = wb.status().style_manager.dxf_manager.len();
assert_eq!(dxfs_after_create, 1);
for color in ["FF00FF00", "FF0000FF", "FFFFFF00"] {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateConditionalFormattingRule(
UpdateConditionalFormattingRule {
sheet_idx: 0,
rule_id: id,
rule: greater_than_spec("200", color),
},
)],
undoable: true,
init: false,
}));
}
let rules = wb
.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].rule_id, id, "an update must keep the rule's id");
assert_eq!(rules[0].spec.operands, vec!["200".to_string()]);
assert_eq!(
rules[0]
.spec
.format
.as_ref()
.and_then(|f| f.fill_color.as_deref()),
Some("FFFFFF00")
);
assert_eq!(
wb.status().style_manager.dxf_manager.len(),
dxfs_after_create,
"repeated edits must reuse the rule's dxf slot"
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteConditionalFormattingRule(
DeleteConditionalFormattingRule {
sheet_idx: 0,
rule_id: id,
},
)],
undoable: true,
init: false,
}));
assert!(
wb.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules()
.is_empty(),
"the rule should be gone"
);
assert!(
wb.status().conditional_formatting_manager.is_empty(),
"an element left with no rules should be dropped"
);
}
#[test]
fn invalid_conditional_formatting_specs_are_rejected() {
use crate::conditional_formatting_manager::spec::CfRuleSpec;
use crate::edit_action::CreateConditionalFormattingRule;
let cases: Vec<(&str, CfRuleSpec)> = vec![
(
"unknown type",
CfRuleSpec {
ty: "notARule".to_string(),
..Default::default()
},
),
(
"cellIs with no operator",
CfRuleSpec {
ty: "cellIs".to_string(),
operands: vec!["1".to_string()],
..Default::default()
},
),
(
"between with one operand",
CfRuleSpec {
ty: "cellIs".to_string(),
operator: Some("between".to_string()),
operands: vec!["1".to_string()],
..Default::default()
},
),
(
"containsText with no text",
CfRuleSpec {
ty: "containsText".to_string(),
..Default::default()
},
),
(
"colorScale with one colour",
CfRuleSpec {
ty: "colorScale".to_string(),
colors: vec!["FFFF0000".to_string()],
..Default::default()
},
),
];
for (name, spec) in cases {
let mut wb = Workbook::default();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateConditionalFormattingRule(
CreateConditionalFormattingRule {
sheet_idx: 0,
start_row: 0,
start_col: 0,
end_row: 0,
end_col: 0,
rule: spec,
},
)],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"{name} should be rejected with an error, got {:?}",
effect.status
);
assert!(
wb.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules()
.is_empty(),
"{name} must not leave a rule behind"
);
}
}
#[test]
fn authored_rule_range_shifts_on_insert() {
use crate::edit_action::{CreateConditionalFormattingRule, InsertRows};
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateConditionalFormattingRule(
CreateConditionalFormattingRule {
sheet_idx: 0,
start_row: 4,
start_col: 0,
end_row: 9,
end_col: 0,
rule: greater_than_spec("100", "FFFFC7CE"),
},
)],
undoable: true,
init: false,
}));
assert_eq!(
wb.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules()[0]
.range,
"A5:A10"
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 0,
count: 2,
})],
undoable: true,
init: false,
}));
assert_eq!(
wb.get_sheet_by_idx(0)
.unwrap()
.get_conditional_formatting_rules()[0]
.range,
"A7:A12",
"the range must follow the rows it was anchored to"
);
}
fn greater_than_spec(
operand: &str,
fill: &str,
) -> crate::conditional_formatting_manager::spec::CfRuleSpec {
use crate::conditional_formatting_manager::spec::{CfFormatSpec, CfRuleSpec};
CfRuleSpec {
ty: "cellIs".to_string(),
operator: Some("greaterThan".to_string()),
operands: vec![operand.to_string()],
format: Some(CfFormatSpec {
fill_color: Some(fill.to_string()),
..Default::default()
}),
..Default::default()
}
}
#[test]
fn empty_app_data_round_trips() {
use logisheets_workbook::logisheets::AppData;
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "=1+1".to_string(),
})],
undoable: true,
init: false,
}));
wb.set_app_data(vec![AppData {
name: "logisheets".to_string(),
data: String::new(),
}]);
let bytes = wb.save().unwrap();
let reloaded = Workbook::from_file(&bytes, "saved".to_string())
.expect("a workbook we just saved must be loadable again");
let apps = reloaded.get_app_data();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].name, "logisheets");
assert_eq!(apps[0].data, "");
assert_eq!(
reloaded
.get_sheet_by_idx(0)
.unwrap()
.get_formula(0, 0)
.unwrap(),
"1 + 1"
);
}
#[test]
fn non_empty_app_data_still_round_trips() {
use logisheets_workbook::logisheets::AppData;
let mut wb = Workbook::default();
wb.set_app_data(vec![AppData {
name: "logisheets".to_string(),
data: r#"{"craft":"what-if","n":3}"#.to_string(),
}]);
let bytes = wb.save().unwrap();
let reloaded = Workbook::from_file(&bytes, "saved".to_string()).unwrap();
assert_eq!(
reloaded.get_app_data()[0].data,
r#"{"craft":"what-if","n":3}"#
);
}
#[test]
fn block_schema_key_entries_report_record_row() {
use crate::edit_action::{BindFormSchema, CellInput, InsertRowsInBlock};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("year", "r0"),
SchemaFieldSpec::new("amount", "r1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "2024".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "2025".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 0,
content: "2026".to_string(),
}),
],
undoable: false,
init: false,
}));
let keys_of = |wb: &Workbook| -> Vec<(String, usize)> {
let ws = wb.get_sheet_by_idx(0).unwrap();
let info = ws.get_block_info(bid).unwrap();
let schema = info.schema.expect("block should have a schema");
schema.keys.iter().map(|k| (k.key.clone(), k.idx)).collect()
};
assert_eq!(
keys_of(&wb),
vec![
("2024".to_string(), 0),
("2025".to_string(), 1),
("2026".to_string(), 2),
],
"each key must carry its own block-relative row"
);
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let schema = ws.get_block_info(bid).unwrap().schema.unwrap();
let fields: Vec<(String, usize)> = schema
.fields
.iter()
.map(|f| (f.field.clone(), f.idx))
.collect();
assert!(fields.contains(&("year".to_string(), 0)));
assert!(fields.contains(&("amount".to_string(), 1)));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 1,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "2024H2".to_string(),
}),
],
undoable: false,
init: false,
}));
assert_eq!(
keys_of(&wb),
vec![
("2024".to_string(), 0),
("2024H2".to_string(), 1),
("2025".to_string(), 2),
("2026".to_string(), 3),
],
"after an interior insert every key must still point at its own row"
);
}
#[test]
fn range_straddling_a_block_boundary_does_not_panic() {
use crate::edit_action::{BindFormSchema, CellInput};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "r0"),
SchemaFieldSpec::new("qty", "r1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 1,
content: "4".to_string(),
}),
],
undoable: false,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 4,
content: "=SUM(B1:B10)*2".to_string(),
})],
undoable: false,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 5,
col: 0,
content: "=1+1".to_string(),
})],
undoable: false,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(ws.get_value(5, 0).unwrap(), crate::controller::display::Value::Number(n) if (n - 2.0).abs() < 1e-9),
"the engine must stay usable after rejecting the reference"
);
let bytes = wb.save().unwrap();
let reloaded = Workbook::from_file(&bytes, "saved".to_string())
.expect("reloading must not panic on the rejected reference");
assert_eq!(reloaded.get_sheet_count(), 1);
}
#[test]
fn update_chart_changes_every_setting() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let (chart_id, original_color) = {
let ws = wb.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
(
charts[0].chart_id.clone(),
charts[0].series[0].color.clone(),
)
};
assert!(original_color.is_some(), "fixture series has a theme color");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
chart_type: Some("bar".to_string()),
stacked: Some(true),
legend_pos: Some("right".to_string()),
cat_axis_title: Some("Quarter".to_string()),
val_axis_title: Some("Amount".to_string()),
show_data_labels: Some(true),
data_label_position: Some("ctr".to_string()),
num_fmt: Some("#,##0.00".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
let check = |c: &crate::controller::display::ChartInfo| {
assert_eq!(c.chart_type, "bar");
assert!(c.stacked, "stacked");
assert_eq!(c.legend_pos.as_deref(), Some("right"));
assert_eq!(c.cat_axis_title.as_deref(), Some("Quarter"));
assert_eq!(c.val_axis_title.as_deref(), Some("Amount"));
assert!(c.data_labels.show_value, "data labels on");
assert_eq!(c.data_labels.position.as_deref(), Some("ctr"));
assert_eq!(c.data_labels.num_fmt.as_deref(), Some("#,##0.00"));
assert_eq!(c.val_axis_num_fmt.as_deref(), Some("#,##0.00"));
assert_eq!(c.series.len(), 3);
assert!(c.series[0].color.is_some(), "series color preserved");
};
{
let ws = wb.get_sheet_by_idx(0).unwrap();
check(&ws.get_charts()[0]);
}
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws2 = wb2.get_sheet_by_idx(0).unwrap();
check(&ws2.get_charts()[0]);
assert_eq!(
ws2.get_charts()[0].series[0].color,
original_color,
"the theme color is the same one it was loaded with"
);
}
#[test]
fn update_chart_repoints_series_and_keeps_colors() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let (chart_id, color0) = {
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = ws.get_charts();
(c[0].chart_id.clone(), c[0].series[0].color.clone())
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id,
series: Some(vec![
CreateChartSeries {
name: Some("First".to_string()),
value_ref: "Sheet1!$B$2:$E$2".to_string(),
color: None,
size_ref: None,
series_type: None,
},
CreateChartSeries {
name: Some("Second".to_string()),
value_ref: "Sheet1!$B$3:$E$3".to_string(),
color: Some("FF0000".to_string()),
size_ref: None,
series_type: None,
},
]),
categories_ref: Some("Sheet1!$B$1:$E$1".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.series.len(), 2);
assert_eq!(c.series[0].name.as_deref(), Some("First"));
assert_eq!(c.series[0].color, color0, "kept its position's color");
assert_eq!(c.series[1].color.as_deref(), Some("FF0000"));
assert_eq!(c.series[0].val_ref.as_deref(), Some("Sheet1!$B$2:$E$2"));
assert_eq!(c.cat_ref.as_deref(), Some("Sheet1!$B$1:$E$1"));
assert_eq!(
c.series[0].values,
vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)]
);
}
#[test]
fn chart_categories_and_formats_are_live() {
let mut wb = Workbook::default();
let mut payloads: Vec<EditPayload> = vec![
("A1", 0usize, 0usize, "Jan"),
("A2", 1, 0, "Feb"),
("B1", 0, 1, "1234.5"),
("B2", 1, 1, "6789"),
]
.into_iter()
.map(|(_, r, c, v)| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
})
})
.collect();
payloads.push(EditPayload::CellStyleUpdate(
crate::edit_action::CellStyleUpdate {
sheet_idx: 0,
row: 0,
col: 1,
ty: StyleUpdateType {
set_num_fmt: Some("#,##0.00".to_string()),
..Default::default()
},
},
));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "chartLive".to_string(),
chart_type: "col".to_string(),
from_row: 4,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 18,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: Some("Sheet1!$A$1:$A$2".to_string()),
series: vec![CreateChartSeries {
name: Some("Values".to_string()),
value_ref: "Sheet1!$B$1:$B$2".to_string(),
color: None,
size_ref: None,
series_type: None,
}],
block_source: None,
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.categories, vec!["Jan".to_string(), "Feb".to_string()]);
assert_eq!(
c.series[0].num_fmt.as_deref(),
Some("#,##0.00"),
"series format comes from the source cells"
);
assert_eq!(
c.series[0].formatted_values,
vec![Some("1,234.50".to_string()), Some("6,789.00".to_string())],
"values are pre-formatted for data labels"
);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "March".to_string(),
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(
ws.get_charts()[0].categories,
vec!["Jan".to_string(), "March".to_string()],
"category labels are live"
);
}
#[test]
fn editing_a_chart_keeps_its_styling_and_satellite_parts() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let chart_id = {
let ws = wb.get_sheet_by_idx(0).unwrap();
ws.get_charts()[0].chart_id.clone()
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
chart_type: Some("line".to_string()),
title: Some("Edited".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
let bytes = wb.save().unwrap();
let parts = chart_parts(&bytes);
let chart_xml = parts
.iter()
.find(|(p, _)| p.ends_with("chart1.xml"))
.map(|(_, d)| String::from_utf8_lossy(d).to_string())
.expect("chart part written");
assert!(
chart_xml.contains(r#"<a:defRPr lang="zh-CN" sz="1400""#),
"title font survived the edit"
);
assert!(
chart_xml.contains("<c:majorGridlines><c:spPr>"),
"styled gridlines survived the edit"
);
assert!(
chart_xml.contains(r#"<c:spPr><a:solidFill><a:schemeClr val="bg1"/></a:solidFill>"#),
"chart-area fill survived the edit"
);
assert!(chart_xml.contains("<c:lineChart>"), "the edit applied");
assert!(
parts.iter().any(|(p, _)| p.ends_with("style1.xml")),
"style part kept, got {:?}",
parts.iter().map(|(p, _)| p).collect::<Vec<_>>()
);
assert!(
parts.iter().any(|(p, _)| p.ends_with("colors1.xml")),
"colors part kept"
);
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws2 = wb2.get_sheet_by_idx(0).unwrap();
let c = &ws2.get_charts()[0];
assert_eq!(c.chart_type, "line");
assert_eq!(c.title.as_deref(), Some("Edited"));
assert!(c.series[0].color.is_some(), "series colors still resolve");
}
fn chart_parts(xlsx: &[u8]) -> Vec<(String, Vec<u8>)> {
let wb = logisheets_workbook::workbook::Wb::from_file(xlsx).unwrap();
wb.xl
.worksheets
.values()
.filter_map(|w| w.drawing.as_ref())
.flat_map(|d| d.chart_parts.iter())
.map(|p| (p.path.clone(), p.data.clone()))
.collect()
}
#[test]
fn update_chart_sets_the_axis_scale() {
use crate::edit_action::AxisScaleUpdate;
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let chart_id = {
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(
ws.get_charts()[0].val_axis_scale.min,
None,
"starts automatic"
);
ws.get_charts()[0].chart_id.clone()
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
val_axis_scale: Some(AxisScaleUpdate {
min: Some(0.0),
max: Some(80.0),
major_unit: Some(20.0),
log_base: Some(1.0),
..Default::default()
}),
..Default::default()
})],
undoable: true,
init: false,
}));
let check = |c: &crate::controller::display::ChartInfo| {
assert_eq!(c.val_axis_scale.min, Some(0.0));
assert_eq!(c.val_axis_scale.max, Some(80.0));
assert_eq!(c.val_axis_scale.major_unit, Some(20.0));
assert_eq!(c.val_axis_scale.log_base, None, "invalid log base dropped");
assert!(!c.val_axis_scale.reversed);
};
{
let ws = wb.get_sheet_by_idx(0).unwrap();
check(&ws.get_charts()[0]);
}
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
check(&wb2.get_sheet_by_idx(0).unwrap().get_charts()[0]);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id,
val_axis_scale: Some(AxisScaleUpdate::default()),
..Default::default()
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(ws.get_charts()[0].val_axis_scale.max, None, "back to auto");
}
#[test]
fn chart_edits_are_undoable() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let chart_id = {
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = ws.get_charts();
assert_eq!(c[0].chart_type, "col");
assert_eq!(c[0].title, None);
c[0].chart_id.clone()
};
let charts = |wb: &Workbook| wb.get_sheet_by_idx(0).unwrap().get_charts();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
chart_type: Some("line".to_string()),
title: Some("Edited".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
assert_eq!(charts(&wb)[0].chart_type, "line");
wb.handle_action(EditAction::Undo);
let c = charts(&wb);
assert_eq!(c[0].chart_type, "col", "undo restores the chart type");
assert_eq!(c[0].title, None, "undo restores the title");
wb.handle_action(EditAction::Redo);
let c = charts(&wb);
assert_eq!(c[0].chart_type, "line", "redo re-applies it");
assert_eq!(c[0].title.as_deref(), Some("Edited"));
let (from_row, from_col) = (charts(&wb)[0].from_row, charts(&wb)[0].from_col);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::MoveChart(MoveChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
from_row: from_row + 5,
from_col: from_col + 2,
from_col_off: 0,
from_row_off: 0,
to_row: from_row + 20,
to_col: from_col + 10,
to_col_off: 0,
to_row_off: 0,
})],
undoable: true,
init: false,
}));
assert_eq!(charts(&wb)[0].from_row, from_row + 5);
wb.handle_action(EditAction::Undo);
assert_eq!(
(charts(&wb)[0].from_row, charts(&wb)[0].from_col),
(from_row, from_col),
"undo restores the anchor"
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteChart(DeleteChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
})],
undoable: true,
init: false,
}));
assert!(charts(&wb).is_empty());
wb.handle_action(EditAction::Undo);
let c = charts(&wb);
assert_eq!(c.len(), 1, "undo brings the chart back");
assert_eq!(c[0].chart_id, chart_id);
assert_eq!(c[0].series.len(), 3, "with its data intact");
assert!(c[0].series[0].color.is_some(), "and its styling");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "chartUndo".to_string(),
chart_type: "pie".to_string(),
from_row: 20,
from_col: 1,
from_col_off: 0,
from_row_off: 0,
to_row: 30,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: None,
series: vec![CreateChartSeries {
name: None,
value_ref: "Sheet1!$B$2:$E$2".to_string(),
color: None,
size_ref: None,
series_type: None,
}],
block_source: None,
})],
undoable: true,
init: false,
}));
assert_eq!(charts(&wb).len(), 2);
wb.handle_action(EditAction::Undo);
assert_eq!(charts(&wb).len(), 1, "undo removes the created chart");
wb.handle_action(EditAction::Redo);
assert_eq!(charts(&wb).len(), 2, "redo brings it back");
}
#[test]
fn create_bubble_chart_with_live_sizes() {
let mut wb = Workbook::default();
let cells = [
(0usize, 0usize, "10"), (1, 0, "20"),
(0, 1, "5"), (1, 1, "8"),
(0, 2, "100"), (1, 2, "400"),
];
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: cells
.iter()
.map(|(r, c, v)| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: *r,
col: *c,
content: v.to_string(),
})
})
.collect(),
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "bubble1".to_string(),
chart_type: "bubble".to_string(),
from_row: 4,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 18,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: Some("Bubbles".to_string()),
categories_ref: Some("Sheet1!$A$1:$A$2".to_string()),
series: vec![CreateChartSeries {
name: Some("Products".to_string()),
value_ref: "Sheet1!$B$1:$B$2".to_string(),
color: None,
size_ref: Some("Sheet1!$C$1:$C$2".to_string()),
series_type: None,
}],
block_source: None,
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.chart_type, "bubble");
assert_eq!(c.series[0].values, vec![Some(5.0), Some(8.0)]);
assert_eq!(c.series[0].sizes, vec![Some(100.0), Some(400.0)]);
assert_eq!(c.series[0].size_ref.as_deref(), Some("Sheet1!$C$1:$C$2"));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 2,
content: "900".to_string(),
})],
undoable: true,
init: false,
}));
assert_eq!(
wb.get_sheet_by_idx(0).unwrap().get_charts()[0].series[0].sizes[0],
Some(900.0)
);
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "r".to_string()).unwrap();
let c = &wb2.get_sheet_by_idx(0).unwrap().get_charts()[0];
assert_eq!(c.chart_type, "bubble");
assert_eq!(c.series[0].sizes, vec![Some(900.0), Some(400.0)]);
}
#[test]
fn switch_a_chart_to_radar_and_bubble() {
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let chart_id = wb.get_sheet_by_idx(0).unwrap().get_charts()[0]
.chart_id
.clone();
let switch = |wb: &mut Workbook, ty: &str| {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: chart_id.clone(),
chart_type: Some(ty.to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
};
switch(&mut wb, "radar");
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.chart_type, "radar");
assert_eq!(c.series.len(), 3, "series survive the switch");
assert_eq!(
c.series[0].values,
vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)]
);
assert!(c.series[0].color.is_some(), "colors survive");
}
switch(&mut wb, "bubble");
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.chart_type, "bubble");
assert!(c.series[0].sizes.is_empty());
}
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "r".to_string()).unwrap();
assert_eq!(
wb2.get_sheet_by_idx(0).unwrap().get_charts()[0].chart_type,
"bubble"
);
}
#[test]
fn create_stock_of_pie_and_surface_charts() {
let mut wb = Workbook::default();
let mut payloads: Vec<EditPayload> = vec![];
for row in 0..4usize {
for col in 0..4usize {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: ((row + 1) * 10 + col).to_string(),
}));
}
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
let make = |wb: &mut Workbook, id: &str, ty: &str, series: Vec<CreateChartSeries>| {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: id.to_string(),
chart_type: ty.to_string(),
from_row: 6,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 20,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: Some("Sheet1!$A$1:$A$4".to_string()),
series,
block_source: None,
})],
undoable: true,
init: false,
}));
};
let ser = |name: &str, col: char| CreateChartSeries {
name: Some(name.to_string()),
value_ref: format!("Sheet1!${}$1:${}$4", col, col),
color: None,
size_ref: None,
series_type: None,
};
make(
&mut wb,
"stock1",
"stock",
vec![
ser("Open", 'A'),
ser("High", 'B'),
ser("Low", 'C'),
ser("Close", 'D'),
],
);
make(&mut wb, "ofpie1", "ofPie", vec![ser("Share", 'B')]);
make(
&mut wb,
"surf1",
"surface",
vec![ser("r1", 'B'), ser("r2", 'C'), ser("r3", 'D')],
);
let check = |wb: &Workbook| {
let ws = wb.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
let by = |id: &str| {
charts
.iter()
.find(|c| c.chart_id == id)
.unwrap_or_else(|| {
panic!(
"{} missing, have {:?}",
id,
charts.iter().map(|c| &c.chart_id).collect::<Vec<_>>()
)
})
.clone()
};
let stock = by("stock1");
assert_eq!(stock.chart_type, "stock");
assert_eq!(stock.series.len(), 4, "all four price series");
assert_eq!(stock.series[3].name.as_deref(), Some("Close"));
assert_eq!(stock.series[0].values[0], Some(10.0));
let of_pie = by("ofpie1");
assert_eq!(of_pie.chart_type, "ofPie");
assert_eq!(of_pie.series.len(), 1, "of-pie plots one series");
let surface = by("surf1");
assert_eq!(surface.chart_type, "surface");
assert_eq!(surface.series.len(), 3, "one series per grid row");
};
check(&wb);
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
check(&wb2);
}
#[test]
fn update_chart_sets_the_of_pie_split() {
use crate::edit_action::OfPieSplitUpdate;
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: (0..6usize)
.map(|r| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: 0,
content: (10 - r).to_string(),
})
})
.collect(),
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "op".to_string(),
chart_type: "ofPie".to_string(),
from_row: 8,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 20,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: None,
series: vec![CreateChartSeries {
name: None,
value_ref: "Sheet1!$A$1:$A$6".to_string(),
color: None,
size_ref: None,
series_type: None,
}],
block_source: None,
})],
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: "op".to_string(),
of_pie_split: Some(OfPieSplitUpdate {
by: Some("pos".to_string()),
pos: Some(2.0),
second_size: Some(500.0),
}),
..Default::default()
})],
undoable: true,
init: false,
}));
let check = |wb: &Workbook| {
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.of_pie_split.by.as_deref(), Some("pos"));
assert_eq!(c.of_pie_split.pos, Some(2.0));
assert_eq!(
c.of_pie_split.second_size, None,
"out-of-range size dropped"
);
};
check(&wb);
let bytes = wb.save().unwrap();
check(&Workbook::from_file(&bytes, "r".to_string()).unwrap());
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: "op".to_string(),
chart_type: Some("surface".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert_eq!(ws.get_charts()[0].chart_type, "surface");
}
#[test]
fn create_a_combo_chart_and_keep_it_through_edits() {
let mut wb = Workbook::default();
let mut payloads: Vec<EditPayload> = vec![];
for row in 0..4usize {
for (col, base) in [(0usize, 100), (1, 20), (2, 3)] {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: (base + row).to_string(),
}));
}
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "combo".to_string(),
chart_type: "col".to_string(),
from_row: 6,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 20,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: None,
series: vec![
CreateChartSeries {
name: Some("Revenue".to_string()),
value_ref: "Sheet1!$A$1:$A$4".to_string(),
color: None,
size_ref: None,
series_type: None,
},
CreateChartSeries {
name: Some("Margin".to_string()),
value_ref: "Sheet1!$B$1:$B$4".to_string(),
color: None,
size_ref: None,
series_type: Some("line".to_string()),
},
CreateChartSeries {
name: Some("Churn".to_string()),
value_ref: "Sheet1!$C$1:$C$4".to_string(),
color: None,
size_ref: None,
series_type: Some("area".to_string()),
},
],
block_source: None,
})],
undoable: true,
init: false,
}));
let check = |wb: &Workbook, note: &str| {
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.chart_type, "col", "{}", note);
assert_eq!(c.series.len(), 3, "{}", note);
assert_eq!(c.series[0].series_type, None, "{}: follows the chart", note);
assert_eq!(
c.series[1].series_type.as_deref(),
Some("line"),
"{}: line override",
note
);
assert_eq!(
c.series[2].series_type.as_deref(),
Some("area"),
"{}: area override",
note
);
assert_eq!(c.series[1].values[0], Some(20.0), "{}", note);
};
check(&wb, "after create");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: "combo".to_string(),
title: Some("Combo".to_string()),
..Default::default()
})],
undoable: true,
init: false,
}));
check(&wb, "after an unrelated edit");
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
check(&wb2, "after reload");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::UpdateChart(UpdateChart {
sheet_idx: 0,
chart_id: "combo".to_string(),
series: Some(vec![
CreateChartSeries {
name: Some("Revenue".to_string()),
value_ref: "Sheet1!$A$1:$A$4".to_string(),
color: None,
size_ref: None,
series_type: None,
},
CreateChartSeries {
name: Some("Margin".to_string()),
value_ref: "Sheet1!$B$2:$B$4".to_string(),
color: None,
size_ref: None,
series_type: None,
},
]),
..Default::default()
})],
undoable: true,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.series.len(), 2);
assert_eq!(
c.series[1].series_type.as_deref(),
Some("line"),
"the slot's kind is kept when the caller does not restate it"
);
assert_eq!(c.series[1].val_ref.as_deref(), Some("Sheet1!$B$2:$B$4"));
}
#[test]
fn three_d_chart_types_round_trip_through_the_api() {
let mut wb = Workbook::default();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: (0..4usize)
.map(|r| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: 0,
content: ((r + 1) * 5).to_string(),
})
})
.collect(),
undoable: true,
init: false,
}));
for (i, ty) in ["col3d", "bar3d", "line3d", "area3d", "pie3d"]
.iter()
.enumerate()
{
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: format!("c3d{}", i),
chart_type: ty.to_string(),
from_row: 6 + i * 2,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 20 + i * 2,
to_col: 8,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: None,
series: vec![CreateChartSeries {
name: Some(ty.to_string()),
value_ref: "Sheet1!$A$1:$A$4".to_string(),
color: None,
size_ref: None,
series_type: None,
}],
block_source: None,
})],
undoable: true,
init: false,
}));
}
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws = wb2.get_sheet_by_idx(0).unwrap();
let charts = ws.get_charts();
assert_eq!(charts.len(), 5);
let mut kinds: Vec<&str> = charts.iter().map(|c| c.chart_type.as_str()).collect();
kinds.sort_unstable();
assert_eq!(kinds, ["area3d", "bar3d", "col3d", "line3d", "pie3d"]);
assert_eq!(
charts[0].series[0].values,
vec![Some(5.0), Some(10.0), Some(15.0), Some(20.0)]
);
}
#[test]
fn chart_bound_to_block_follows_it() {
use crate::edit_action::{
BindFormSchema, ChartBlockSource, CreateBlock, InsertRows, InsertRowsInBlock,
};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let cell = |row: usize, col: usize, content: &str| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: content.to_string(),
})
};
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 1,
master_col: 1,
row_cnt: 3,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "r0"),
SchemaFieldSpec::new("qty", "r1"),
SchemaFieldSpec::new("price", "r2"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (i, (name, qty)) in [("a", "10"), ("b", "20"), ("c", "30")].iter().enumerate() {
payloads.push(cell(1 + i, 1, name));
payloads.push(cell(1 + i, 2, qty));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "chart1".to_string(),
chart_type: "col".to_string(),
from_row: 6,
from_col: 1,
from_col_off: 0,
from_row_off: 0,
to_row: 16,
to_col: 6,
to_col_off: 0,
to_row_off: 0,
title: Some("Sales".to_string()),
categories_ref: None,
series: vec![],
block_source: Some(ChartBlockSource {
block_id: bid,
category_field: Some("name".to_string()),
value_fields: vec!["qty".to_string()],
}),
})],
undoable: true,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.cat_ref.as_deref(), Some("Sheet1!$B$2:$B$4"));
assert_eq!(c.series.len(), 1);
assert_eq!(c.series[0].name.as_deref(), Some("qty"));
assert_eq!(c.series[0].val_ref.as_deref(), Some("Sheet1!$C$2:$C$4"));
assert_eq!(c.series[0].values, vec![Some(10.0), Some(20.0), Some(30.0)]);
assert_eq!(c.categories, vec!["a", "b", "c"]);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 3,
cnt: 1,
}),
cell(4, 1, "d"),
cell(4, 2, "40"),
],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(
c.series[0].val_ref.as_deref(),
Some("Sheet1!$C$2:$C$5"),
"the range grew with the block"
);
assert_eq!(
c.series[0].values,
vec![Some(10.0), Some(20.0), Some(30.0), Some(40.0)]
);
assert_eq!(c.categories, vec!["a", "b", "c", "d"]);
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 0,
count: 1,
})],
undoable: false,
init: false,
}));
{
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(
c.series[0].val_ref.as_deref(),
Some("Sheet1!$C$3:$C$6"),
"the range shifted with the block"
);
assert_eq!(
c.series[0].values,
vec![Some(10.0), Some(20.0), Some(30.0), Some(40.0)]
);
}
}
#[test]
fn block_bound_chart_survives_save() {
use crate::edit_action::{BindFormSchema, ChartBlockSource, CreateBlock, InsertRowsInBlock};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let cell = |row: usize, col: usize, content: &str| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col,
content: content.to_string(),
})
};
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("name", "r0"),
SchemaFieldSpec::new("qty", "r1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
cell(0, 0, "a"),
cell(0, 1, "10"),
cell(1, 0, "b"),
cell(1, 1, "20"),
EditPayload::CreateChart(CreateChart {
sheet_idx: 0,
chart_id: "chart1".to_string(),
chart_type: "col".to_string(),
from_row: 5,
from_col: 0,
from_col_off: 0,
from_row_off: 0,
to_row: 15,
to_col: 5,
to_col_off: 0,
to_row_off: 0,
title: None,
categories_ref: None,
series: vec![],
block_source: Some(ChartBlockSource {
block_id: bid,
category_field: Some("name".to_string()),
value_fields: vec!["qty".to_string()],
}),
}),
],
undoable: false,
init: false,
}));
let bytes = wb.save().unwrap();
let mut wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
{
let ws = wb2.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(c.series[0].val_ref.as_deref(), Some("Sheet1!$B$1:$B$2"));
assert_eq!(c.series[0].values, vec![Some(10.0), Some(20.0)]);
}
let bid2 = {
let ws = wb2.get_sheet_by_idx(0).unwrap();
ws.get_all_blocks()[0].block_id
};
wb2.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid2,
start: 2,
cnt: 1,
}),
cell(2, 0, "c"),
cell(2, 1, "30"),
],
undoable: false,
init: false,
}));
let ws = wb2.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(
c.series[0].val_ref.as_deref(),
Some("Sheet1!$B$1:$B$3"),
"the reloaded chart is still bound to the block"
);
assert_eq!(c.series[0].values, vec![Some(10.0), Some(20.0), Some(30.0)]);
}
#[test]
fn text_and_round_agree_at_excel_precision() {
let mut wb = Workbook::default();
let cases = [
("=TEXT(1.005,\"0.00\")", "1.01"),
("=TEXT(1.005,\"#,##0.00\")", "1.01"),
("=TEXT(4.935,\"0.00\")", "4.94"),
("=TEXT(2.675,\"#,##0.00\")", "2.68"),
("=FIXED(1.005,2)", "1.01"),
("=DOLLAR(1.005,2)", "$1.01"),
("=TEXT(ROUND(1.005,2),\"0.00\")", "1.01"),
];
let mut payloads = vec![];
for (i, (e, _)) in cases.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: i,
col: 0,
content: e.to_string(),
}));
}
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: false,
init: false,
}));
let ws = wb.get_sheet_by_idx(0).unwrap();
for (i, (e, want)) in cases.iter().enumerate() {
let got = match ws.get_value(i, 0).unwrap() {
crate::controller::display::Value::Str(s) => s,
other => panic!("{e} did not render as text: {other:?}"),
};
assert_eq!(got, *want, "{e}");
}
}
#[test]
fn block_description_and_permissions_are_undoable() {
use crate::edit_action::{
BlockOp, BlockPermissions, ModifyPolicy, SetBlockDescription, SetBlockPermissions,
};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 2,
owner: Some("craft-a".to_string()),
modify_policy: None,
permissions: None,
description: Some("first".to_string()),
analyzes: None,
pivot: None,
})],
undoable: true,
init: false,
}));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::SetBlockDescription(SetBlockDescription {
sheet_idx: 0,
block_id: bid,
description: "second".to_string(),
}),
EditPayload::SetBlockPermissions(SetBlockPermissions {
sheet_idx: 0,
block_id: bid,
permissions: BlockPermissions {
insert_delete_lines: Some(ModifyPolicy::OwnerOnly),
..Default::default()
},
modify_policy: None,
}),
],
undoable: true,
init: false,
}));
let state = |wb: &Workbook| {
let m = wb.get_block_modify_info(0, bid).unwrap();
(
m.description.clone(),
m.permissions.explicit(BlockOp::InsertDeleteLines),
)
};
assert_eq!(
state(&wb),
("second".to_string(), Some(ModifyPolicy::OwnerOnly))
);
wb.handle_action(EditAction::Undo);
assert_eq!(
state(&wb),
("first".to_string(), None),
"undo restores both the description and the permissions"
);
wb.handle_action(EditAction::Redo);
assert_eq!(
state(&wb),
("second".to_string(), Some(ModifyPolicy::OwnerOnly)),
"and redo puts them back"
);
}
#[test]
fn plain_chart_follows_row_and_column_edits() {
use crate::edit_action::{InsertCols, InsertRows};
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
let read = |wb: &Workbook| {
let ws = wb.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
(
c.series[0].val_ref.clone(),
c.series[0].values.clone(),
c.cat_ref.clone(),
)
};
let (ref0, values0, cat0) = read(&wb);
assert_eq!(ref0.as_deref(), Some("Sheet1!$B$2:$E$2"));
assert_eq!(
values0,
vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)]
);
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 0,
count: 1,
})],
undoable: true,
init: false,
}));
let (ref1, values1, _) = read(&wb);
assert_eq!(
ref1.as_deref(),
Some("Sheet1!$B$3:$E$3"),
"the range moved with its cells"
);
assert_eq!(values1, values0, "and reads the same numbers");
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertCols(InsertCols {
sheet_idx: 0,
start: 0,
count: 1,
})],
undoable: true,
init: false,
}));
let (ref2, values2, _) = read(&wb);
assert_eq!(ref2.as_deref(), Some("Sheet1!$C$3:$F$3"));
assert_eq!(values2, values0);
wb.handle_action(EditAction::Undo);
wb.handle_action(EditAction::Undo);
let (ref3, values3, cat3) = read(&wb);
assert_eq!(ref3, ref0, "undo puts the range back");
assert_eq!(values3, values0);
assert_eq!(cat3, cat0);
}
#[test]
fn a_moved_chart_range_survives_a_save() {
use crate::edit_action::InsertRows;
let buf = std::fs::read("../../tests/graph.xlsx").unwrap();
let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 0,
count: 1,
})],
undoable: false,
init: false,
}));
let bytes = wb.save().unwrap();
let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap();
let ws = wb2.get_sheet_by_idx(0).unwrap();
let c = &ws.get_charts()[0];
assert_eq!(
c.series[0].val_ref.as_deref(),
Some("Sheet1!$B$3:$E$3"),
"the file carries where the cells ended up"
);
assert_eq!(
c.series[0].values,
vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)]
);
}
#[cfg(test)]
fn block_with_keys(keys: [&str; 3]) -> (Workbook, logisheets_base::BlockId) {
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0"),
SchemaFieldSpec::new("amt", "r1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (row, key) in keys.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: key.to_string(),
}));
}
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"setup should succeed: {:?}",
effect.error_message
);
(wb, bid)
}
#[cfg(test)]
fn key_at(wb: &Workbook, row: usize) -> String {
use crate::controller::display::Value;
match wb.get_sheet_by_idx(0).unwrap().get_value(row, 0).unwrap() {
Value::Str(s) => s,
Value::Number(n) => n.to_string(),
Value::Empty => String::new(),
other => panic!("unexpected key value {:?}", other),
}
}
#[test]
fn a_write_that_repeats_a_row_key_is_refused() {
let (mut wb, _bid) = block_with_keys(["a", "b", "c"]);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "a".to_string(),
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"renaming row 1's key to \"a\" collides with row 0 and must be refused"
);
let msg = effect.error_message.unwrap_or_default();
assert!(
msg.contains("rec") && msg.contains('a'),
"the refusal should name the block and the key, got {:?}",
msg
);
assert_eq!(
key_at(&wb, 1),
"b",
"a refused transaction must leave the key untouched"
);
}
#[test]
fn a_repeated_key_inside_one_transaction_is_refused() {
let (mut wb, _bid) = block_with_keys(["a", "b", "c"]);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "x".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 0,
content: "x".to_string(),
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"two records given the same key in one transaction must be refused"
);
assert_eq!(key_at(&wb, 1), "b", "neither write may have landed");
assert_eq!(key_at(&wb, 2), "c", "neither write may have landed");
}
#[test]
fn two_records_may_swap_keys_in_one_transaction() {
let (mut wb, _bid) = block_with_keys(["a", "b", "c"]);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "b".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "a".to_string(),
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"swapping two keys leaves them unique: {:?}",
effect.error_message
);
assert_eq!(key_at(&wb, 0), "b");
assert_eq!(key_at(&wb, 1), "a");
}
#[test]
fn empty_keys_are_not_duplicates_of_each_other() {
use crate::edit_action::InsertRowsInBlock;
let (mut wb, bid) = block_with_keys(["a", "b", "c"]);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: bid,
start: 3,
cnt: 2,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"two blank key cells are not a collision: {:?}",
effect.error_message
);
assert_eq!(key_at(&wb, 3), "");
assert_eq!(key_at(&wb, 4), "");
}
#[test]
fn rewriting_a_key_with_its_own_value_is_not_a_collision() {
let (mut wb, _bid) = block_with_keys(["a", "b", "c"]);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "b".to_string(),
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"a key colliding only with itself is fine: {:?}",
effect.error_message
);
}
#[cfg(test)]
fn block_with_legacy_duplicates() -> (Workbook, logisheets_base::BlockId) {
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 4,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})];
for (row, key) in [(0usize, "dup"), (1, "solo"), (2, "dup")] {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: key.to_string(),
}));
}
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"writing two equal values into a column that is not yet a key column \
is nobody's collision: {:?}",
effect.error_message
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0"),
SchemaFieldSpec::new("amt", "r1"),
],
row: true,
header_idx: None,
unique_together: None,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"binding a schema writes no cells, so it has nothing for the guard to \
judge: {:?}",
effect.error_message
);
(wb, bid)
}
#[test]
fn a_block_that_already_has_duplicates_stays_editable() {
let (mut wb, _bid) = block_with_legacy_duplicates();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 1,
content: "10".to_string(),
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"a non-key write into a block with pre-existing duplicates must still \
land: {:?}",
effect.error_message
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 0,
content: "other".to_string(),
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"repairing a duplicate must be allowed: {:?}",
effect.error_message
);
assert_eq!(key_at(&wb, 2), "other");
}
#[test]
fn duplicate_block_keys_reports_what_the_guard_could_not_refuse() {
let (mut wb, bid) = block_with_legacy_duplicates();
let dups = wb.duplicate_block_keys();
assert_eq!(
dups.len(),
1,
"one repeated key — \"solo\" is unique and the blank row is exempt, got {:?}",
dups
);
let d = &dups[0];
assert_eq!(d.block_name, "rec");
assert_eq!(d.sheet_idx, 0);
assert_eq!(d.block_id, bid);
assert_eq!(d.key, "dup");
assert_eq!(
d.records,
vec![0, 2],
"both records holding the key, in block order"
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 0,
content: "other".to_string(),
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"the repair must be allowed: {:?}",
effect.error_message
);
assert!(
wb.duplicate_block_keys().is_empty(),
"after the repair there is nothing left to report"
);
}
#[test]
fn a_blockrefs_naming_a_field_that_does_not_exist_matches_nothing() {
use crate::controller::display::Value;
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0"),
SchemaFieldSpec::new("amt", "r1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (row, key, amt) in [(0usize, "a", "1"), (1, "b", "2"), (2, "c", "3")] {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: key.to_string(),
}));
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 1,
content: amt.to_string(),
}));
}
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"setup should succeed: {:?}",
effect.error_message
);
let sheet_id = wb
.controller
.status
.sheet_info_manager
.get_sheet_id(0)
.unwrap();
let formulas = [
(
0usize,
format!(r#"=COUNTIF(BLOCKREFSB({sheet_id}, {bid}, "*", "amt"), 2)"#),
),
(
1,
format!(r#"=COUNTIF(BLOCKREFSB({sheet_id}, {bid}, "*", "nope"), 2)"#),
),
(
2,
format!(r#"=COUNTIF(BLOCKREFSB({sheet_id}, {bid}, "*", "nope"), 2) = 1"#),
),
];
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: formulas
.iter()
.map(|(row, f)| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: *row,
col: 4,
content: f.clone(),
})
})
.collect(),
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"the formulas should be accepted: {:?}",
effect.error_message
);
let read = |wb: &Workbook, row: usize| -> Value {
wb.get_sheet_by_idx(0).unwrap().get_value(row, 4).unwrap()
};
assert!(
matches!(read(&wb, 0), Value::Number(n) if n == 1.0),
"sanity: through a field that exists, the value 2 is found once, got {:?}",
read(&wb, 0)
);
assert!(
matches!(read(&wb, 1), Value::Number(n) if n == 0.0),
"a BLOCKREFS naming a field that does not exist must match nothing \
rather than raise, got {:?}",
read(&wb, 1)
);
assert!(
matches!(read(&wb, 2), Value::Bool(false)),
"`COUNTIF(...) = 1` over a dead field name evaluates FALSE, which is \
what a renamed unique field leaves behind, got {:?}",
read(&wb, 2)
);
}
#[cfg(test)]
fn block_with_declared_field(
required: bool,
unique: bool,
field_type: Option<crate::block_manager::schema_manager::field_type::FieldType>,
own_rule: Option<&str>,
) -> (Workbook, logisheets_base::BlockId) {
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let mut spec = SchemaFieldSpec::new("amt", "r1")
.with_required(required)
.with_unique(unique)
.with_validation_formula(own_rule.map(|s| s.to_string()));
if let Some(t) = field_type {
spec = spec.with_field_type(t);
}
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![SchemaFieldSpec::new("key", "r0"), spec],
row: true,
header_idx: None,
unique_together: None,
}),
];
for row in 0..3 {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: format!("k{row}"),
}));
}
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"setup should succeed: {:?}",
effect.error_message
);
(wb, bid)
}
#[cfg(test)]
fn write_amts(wb: &mut Workbook, values: &[&str]) {
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: values
.iter()
.enumerate()
.filter(|(_, v)| !v.is_empty())
.map(|(row, v)| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 1,
content: v.to_string(),
})
})
.collect(),
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"the writes should land: {:?}",
effect.error_message
);
}
#[cfg(test)]
fn amt_violates(wb: &mut Workbook, row: usize) -> Option<bool> {
use crate::controller::display::Value;
use crate::sid_assigner::ShadowKind;
use logisheets_base::CellId;
let scid = wb
.get_shadow_cell_id(0, row, 1, ShadowKind::Validation)
.ok()?;
let CellId::EphemeralCell(eid) = scid.cell_id else {
return None;
};
let info = wb.get_shadow_info_by_id(eid).ok()?;
match info.value {
Value::Bool(b) => Some(!b),
Value::Error(_) => Some(true),
_ => None,
}
}
#[test]
fn required_now_has_a_formula_form_and_flags_the_empty_cell() {
let (mut wb, _bid) = block_with_declared_field(true, false, None, None);
write_amts(&mut wb, &["5", "", "7"]);
assert_eq!(amt_violates(&mut wb, 0), Some(false), "5 is present");
assert_eq!(
amt_violates(&mut wb, 1),
Some(true),
"a required field left empty is a violation"
);
assert_eq!(amt_violates(&mut wb, 2), Some(false), "7 is present");
}
#[test]
fn unique_is_derived_and_exempts_the_empty_cell() {
let (mut wb, _bid) = block_with_declared_field(false, true, None, None);
write_amts(&mut wb, &["5", "5", ""]);
assert_eq!(amt_violates(&mut wb, 0), Some(true), "5 appears twice");
assert_eq!(amt_violates(&mut wb, 1), Some(true), "5 appears twice");
assert_eq!(
amt_violates(&mut wb, 2),
Some(false),
"unique without required must still allow a blank"
);
}
#[test]
fn a_field_declaring_nothing_gets_no_rule_at_all() {
let (mut wb, _bid) = block_with_declared_field(false, false, None, None);
write_amts(&mut wb, &["5", "5", ""]);
assert_eq!(
amt_violates(&mut wb, 0),
None,
"no declaration and no author rule means no shadow to read"
);
}
#[test]
fn an_enum_whitelist_is_derived_from_the_workbooks_own_set() {
use crate::edit_action::{EnumVariantSpec, UpsertEnumSet};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::UpsertEnumSet(UpsertEnumSet {
id: "status".into(),
name: None,
variants: vec![
EnumVariantSpec {
id: "open".into(),
label: None,
},
EnumVariantSpec {
id: "done".into(),
label: None,
},
],
}),
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(crate::edit_action::BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0"),
SchemaFieldSpec::new("amt", "r1").with_field_type(
crate::block_manager::schema_manager::field_type::FieldType::Enum {
set_id: "status".into(),
},
),
],
row: true,
header_idx: None,
unique_together: None,
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"setup should succeed: {:?}",
effect.error_message
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: (0..3)
.map(|row| {
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: format!("k{row}"),
})
})
.collect(),
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
write_amts(&mut wb, &["open", "OPEN", "nope"]);
assert_eq!(amt_violates(&mut wb, 0), Some(false), "an option");
assert_eq!(
amt_violates(&mut wb, 1),
Some(true),
"membership is case-sensitive — variant ids are what cells store"
);
assert_eq!(amt_violates(&mut wb, 2), Some(true), "not an option");
}
#[test]
fn the_authors_own_rule_is_kept_and_anded_with_the_derived_ones() {
let (mut wb, _bid) = block_with_declared_field(true, false, None, Some("#PLACEHOLDER<100"));
write_amts(&mut wb, &["5", "", "500"]);
assert_eq!(
amt_violates(&mut wb, 0),
Some(false),
"present and under 100"
);
assert_eq!(
amt_violates(&mut wb, 1),
Some(true),
"the derived required check still applies"
);
assert_eq!(
amt_violates(&mut wb, 2),
Some(true),
"the author's own rule still applies"
);
}
#[test]
fn the_derived_rule_survives_a_field_rename_because_it_is_regenerated() {
use crate::edit_action::BindFormSchema;
let (mut wb, bid) = block_with_declared_field(false, true, None, None);
write_amts(&mut wb, &["5", "6", "7"]);
assert_eq!(
amt_violates(&mut wb, 0),
Some(false),
"sanity: all distinct"
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0"),
SchemaFieldSpec::new("amount", "r1").with_unique(true),
],
row: true,
header_idx: None,
unique_together: None,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"the rename should succeed: {:?}",
effect.error_message
);
assert_eq!(
amt_violates(&mut wb, 0),
Some(false),
"the unique check still passes after the rename — it names the new field"
);
write_amts(&mut wb, &["6", "6", "7"]);
assert_eq!(amt_violates(&mut wb, 0), Some(true), "6 now appears twice");
}
#[test]
fn a_fields_write_policy_is_declared_on_the_schema_and_survives_a_round_trip() {
use crate::block_manager::schema_manager::field_type::FieldWritePolicy;
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "r0")
.with_write_policy(FieldWritePolicy::OwnerOnly),
SchemaFieldSpec::new("note", "r1").with_write_policy(FieldWritePolicy::Anyone),
SchemaFieldSpec::new("amt", "r2"),
],
row: true,
header_idx: None,
unique_together: None,
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"setup should succeed: {:?}",
effect.error_message
);
let read = |wb: &Workbook| -> Vec<(String, String)> {
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let schema = blocks[0].schema.as_ref().unwrap();
let mut fields = schema.fields.clone();
fields.sort_by_key(|f| f.idx);
fields
.iter()
.map(|f| (f.field.clone(), f.write_policy.clone()))
.collect()
};
assert_eq!(
read(&wb),
vec![
("key".to_string(), "ownerOnly".to_string()),
("note".to_string(), "anyone".to_string()),
("amt".to_string(), "inherit".to_string()),
]
);
let saved = wb.save().unwrap();
let reopened = Workbook::from_file(&saved, "again".to_string()).unwrap();
assert_eq!(read(&reopened), read(&wb));
}
#[test]
fn the_engine_says_which_operation_a_payload_counts_as() {
let wb = Workbook::default();
let table = wb.get_block_op_for_payloads();
let lookup = |t: &str| {
table
.iter()
.find(|e| e.payload_type == t)
.map(|e| e.op.as_wire_str())
};
assert_eq!(lookup("cellInput"), Some("cellInput"));
assert_eq!(lookup("blockInput"), Some("cellInput"));
assert_eq!(lookup("removeBlock"), Some("removeBlock"));
assert_eq!(lookup("insertRowsInBlock"), Some("insertDeleteLines"));
assert_eq!(lookup("reorderBlockLines"), Some("sortByField"));
assert_eq!(lookup("setBlockDescription"), Some("modifyDescription"));
assert_eq!(lookup("setBlockPermissions"), Some("modifySchema"));
assert_eq!(lookup("moveBlock"), None);
assert_eq!(lookup("createSheet"), None);
}
#[test]
fn a_block_reports_whether_it_states_a_policy_at_all() {
use crate::edit_action::{BlockOp, BlockPermissions, ModifyPolicy, SetBlockPermissions};
let mut wb = Workbook::default();
let bid = wb.get_available_block_id(0).unwrap();
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: bid,
master_row: 0,
master_col: 0,
row_cnt: 2,
col_cnt: 2,
owner: Some("some-craft".to_string()),
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: true,
init: false,
}));
let policies = wb.get_block_op_policies(0, bid).unwrap();
assert_eq!(
policies.len(),
BlockOp::ALL.len(),
"every operation is reported, in a fixed order"
);
assert!(
policies.iter().all(|p| p.policy == "all" && !p.stated),
"an owner with no policy states nothing: {:?}",
policies
);
let mut perms = BlockPermissions::default();
perms.set(BlockOp::CellInput, Some(ModifyPolicy::OwnerOnly));
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::SetBlockPermissions(SetBlockPermissions {
sheet_idx: 0,
block_id: bid,
permissions: perms,
modify_policy: None,
})],
undoable: true,
init: false,
}));
let policies = wb.get_block_op_policies(0, bid).unwrap();
let cell_input = policies
.iter()
.find(|p| matches!(p.op, BlockOp::CellInput))
.unwrap();
assert_eq!(cell_input.policy, "ownerOnly");
assert!(cell_input.stated);
assert!(
policies
.iter()
.filter(|p| !matches!(p.op, BlockOp::CellInput))
.all(|p| !p.stated),
"the other operations still say nothing"
);
}
#[cfg(test)]
fn orders_with_analysis(
func: crate::block_manager::schema_manager::field_type::AggFunc,
) -> (Workbook, logisheets_base::BlockId, logisheets_base::BlockId) {
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let src = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: src,
master_row: 0,
master_col: 0,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "s0"),
SchemaFieldSpec::new("amt", "s1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (row, amt) in [(0usize, "10"), (1, "20"), (2, "30")] {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 0,
content: format!("k{row}"),
}));
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 1,
content: amt.to_string(),
}));
}
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"source setup should succeed: {:?}",
effect.error_message
);
let analysis = wb.get_available_block_id(0).unwrap();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: analysis,
master_row: 3,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders_analysis".into(),
sheet_idx: 0,
block_id: analysis,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "a0"),
SchemaFieldSpec::new("amt", "a1").with_aggregate(func, "amt"),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 3,
col: 0,
content: "TOTAL".to_string(),
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"analysis setup should succeed: {:?}",
effect.error_message
);
(wb, src, analysis)
}
#[cfg(test)]
fn cell_num(wb: &Workbook, row: usize, col: usize) -> Option<f64> {
use crate::controller::display::Value;
match wb.get_sheet_by_idx(0).unwrap().get_value(row, col).unwrap() {
Value::Number(n) => Some(n),
_ => None,
}
}
#[test]
fn an_analysis_field_computes_from_its_declaration_with_no_formula_sent() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (mut wb, _src, _analysis) = orders_with_analysis(AggFunc::Sum);
assert_eq!(cell_num(&wb, 3, 1), Some(60.0), "10+20+30");
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 1,
content: "99".to_string(),
})],
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 3, 1), Some(139.0), "10+99+30");
}
#[test]
fn the_total_does_not_count_itself() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, _src, _analysis) = orders_with_analysis(AggFunc::Sum);
assert_eq!(
cell_num(&wb, 3, 1),
Some(60.0),
"60, not 120 — the total is outside the set it sums"
);
}
#[test]
fn every_declared_function_computes() {
use crate::block_manager::schema_manager::field_type::AggFunc;
for (func, expected) in [
(AggFunc::Sum, 60.0),
(AggFunc::Count, 3.0),
(AggFunc::Average, 20.0),
(AggFunc::Min, 10.0),
(AggFunc::Max, 30.0),
] {
let (wb, _src, _analysis) = orders_with_analysis(func);
assert_eq!(
cell_num(&wb, 3, 1),
Some(expected),
"{} over 10/20/30",
func.as_str()
);
}
}
#[test]
fn the_analysis_tracks_the_source_growing() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::{InsertRows, InsertRowsInBlock};
let (mut wb, src, _analysis) = orders_with_analysis(AggFunc::Sum);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::InsertRows(InsertRows {
sheet_idx: 0,
start: 3,
count: 1,
}),
EditPayload::InsertRowsInBlock(InsertRowsInBlock {
sheet_idx: 0,
block_id: src,
start: 3,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 3,
col: 0,
content: "k3".to_string(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 3,
col: 1,
content: "1".to_string(),
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"growing the source should succeed: {:?}",
effect.error_message
);
assert_eq!(cell_num(&wb, 4, 1), Some(61.0), "10+20+30+1");
}
#[test]
fn the_label_column_stays_an_ordinary_cell() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::controller::display::Value;
let (wb, _src, _analysis) = orders_with_analysis(AggFunc::Sum);
assert!(
matches!(
wb.get_sheet_by_idx(0).unwrap().get_value(3, 0).unwrap(),
Value::Str(s) if s == "TOTAL"
),
"the label the transaction wrote is still there"
);
}
#[test]
fn the_result_is_addressable_by_key_from_elsewhere() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (mut wb, _src, _analysis) = orders_with_analysis(AggFunc::Sum);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 5,
content: r#"=BLOCKREF("orders_analysis", "TOTAL", "amt") * 2"#.to_string(),
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"referencing the analysis should succeed: {:?}",
effect.error_message
);
assert_eq!(cell_num(&wb, 0, 5), Some(120.0), "60 * 2");
}
#[test]
fn a_block_may_not_analyse_itself() {
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, src, _analysis) =
orders_with_analysis(crate::block_manager::schema_manager::field_type::AggFunc::Sum);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: src,
analyzes: Some(src),
pivot: None,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"a self-analysis must be refused"
);
assert!(
effect
.error_message
.unwrap_or_default()
.contains("cannot analyse itself")
);
}
#[test]
fn analysing_a_block_that_does_not_exist_is_refused() {
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, _src, analysis) =
orders_with_analysis(crate::block_manager::schema_manager::field_type::AggFunc::Sum);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: analysis,
analyzes: Some(9999),
pivot: None,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"analysing a nonexistent block must be refused"
);
}
#[test]
fn the_declarations_survive_a_save_and_reload() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
let saved = wb.save().unwrap();
let reopened = Workbook::from_file(&saved, "again".to_string()).unwrap();
let ws = reopened.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let a = blocks.iter().find(|b| b.block_id == analysis).unwrap();
assert_eq!(
a.analyzes,
Some(src),
"the analysis marker round-trips: {:?}",
a.analyzes
);
assert_eq!(
cell_num(&reopened, 3, 1),
Some(60.0),
"and the generated formula is rebuilt, so the total still computes"
);
}
#[test]
fn removing_the_source_removes_its_analyses() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::RemoveBlock;
let (mut wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
assert_eq!(
wb.get_sheet_by_idx(0).unwrap().get_all_blocks().len(),
2,
"sanity: the pair exists"
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::RemoveBlock(RemoveBlock {
sheet_idx: 0,
id: src,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"removing the source should succeed: {:?}",
effect.error_message
);
let blocks = wb.get_sheet_by_idx(0).unwrap().get_all_blocks();
assert!(
blocks.is_empty(),
"the analysis went with its source, got {:?}",
blocks.iter().map(|b| b.block_id).collect::<Vec<_>>()
);
assert!(wb.undo());
let blocks = wb.get_sheet_by_idx(0).unwrap().get_all_blocks();
assert_eq!(blocks.len(), 2, "undo restores both");
assert!(blocks.iter().any(|b| b.block_id == analysis));
assert_eq!(
cell_num(&wb, 3, 1),
Some(60.0),
"and the total still computes"
);
}
#[test]
fn removing_an_analysis_leaves_its_source_alone() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::RemoveBlock;
let (mut wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::RemoveBlock(RemoveBlock {
sheet_idx: 0,
id: analysis,
})],
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let blocks = wb.get_sheet_by_idx(0).unwrap().get_all_blocks();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].block_id, src);
}
#[test]
fn renaming_a_source_field_regenerates_the_analysis_formula() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::BindFormSchema;
let (mut wb, src, _analysis) = orders_with_analysis(AggFunc::Sum);
assert_eq!(cell_num(&wb, 3, 1), Some(60.0), "sanity");
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders_analysis".into(),
sheet_idx: 0,
block_id: _analysis,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "a0"),
SchemaFieldSpec::new("amount", "a1").with_aggregate(AggFunc::Sum, "amount"),
],
row: true,
header_idx: None,
unique_together: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "s0"),
SchemaFieldSpec::new("amount", "s1"),
],
row: true,
header_idx: None,
unique_together: None,
}),
],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"the rename should succeed: {:?}",
effect.error_message
);
assert_eq!(
cell_num(&wb, 3, 1),
Some(60.0),
"the total still computes after the source field was renamed"
);
}
#[test]
fn a_block_reports_its_analyses_and_they_report_it() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let source = blocks.iter().find(|b| b.block_id == src).unwrap();
assert_eq!(source.analyzes, None, "the table analyses nothing");
assert_eq!(source.analyzed_by, vec![analysis]);
let a = blocks.iter().find(|b| b.block_id == analysis).unwrap();
assert_eq!(a.analyzes, Some(src));
assert!(a.analyzed_by.is_empty());
let schema = a.schema.as_ref().unwrap();
let amt = schema.fields.iter().find(|f| f.field == "amt").unwrap();
assert_eq!(amt.agg_func.as_deref(), Some("SUM"));
assert_eq!(amt.agg_field.as_deref(), Some("amt"));
let key = schema.fields.iter().find(|f| f.field == "key").unwrap();
assert_eq!(key.agg_func, None, "the label column aggregates nothing");
}
fn reshapable_block(col_cnt: usize) -> (Workbook, logisheets_base::BlockId) {
let mut wb = Workbook::default();
let id = wb.get_available_block_id(0).unwrap();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 0,
master_col: 0,
row_cnt: 1,
col_cnt,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: true,
init: false,
}));
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"block setup: {:?}",
effect.error_message
);
(wb, id)
}
fn bind_t(block_id: logisheets_base::BlockId, fields: &[(&str, Option<&str>)]) -> EditPayload {
use crate::edit_action::BindFormSchema;
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "t".into(),
sheet_idx: 0,
block_id,
field_from: 0,
key_idx: 0,
fields: fields
.iter()
.enumerate()
.map(|(i, (name, formula))| {
let spec = SchemaFieldSpec::new(*name, format!("r{i}"));
spec.with_value_formula(formula.map(String::from))
})
.collect(),
row: true,
header_idx: None,
unique_together: None,
})
}
fn resize_t(
block_id: logisheets_base::BlockId,
rows: Option<usize>,
cols: Option<usize>,
) -> EditPayload {
EditPayload::ResizeBlock(crate::edit_action::ResizeBlock {
sheet_idx: 0,
id: block_id,
new_row_cnt: rows,
new_col_cnt: cols,
})
}
fn field_names(wb: &Workbook, block_id: logisheets_base::BlockId) -> Vec<String> {
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let b = blocks.iter().find(|b| b.block_id == block_id).unwrap();
b.schema
.as_ref()
.map(|s| s.fields.iter().map(|f| f.field.clone()).collect())
.unwrap_or_default()
}
fn apply(wb: &mut Workbook, payloads: Vec<EditPayload>) -> crate::edit_action::ActionEffect {
wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}))
}
#[test]
fn growing_a_block_and_rebinding_covers_the_new_column() {
let (mut wb, id) = reshapable_block(2);
let effect = apply(&mut wb, vec![bind_t(id, &[("key", None), ("a", None)])]);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let effect = apply(
&mut wb,
vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "k0".into(),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let effect = apply(
&mut wb,
vec![
resize_t(id, None, Some(3)),
bind_t(id, &[("key", None), ("a", None), ("b", Some("LEN(#KEY)"))]),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"grow then bind: {:?}",
effect.error_message
);
assert_eq!(field_names(&wb, id), vec!["key", "a", "b"]);
assert_eq!(cell_num(&wb, 0, 2), Some(2.0), "LEN(\"k0\")");
}
#[test]
fn growing_rows_and_rebinding_materializes_the_new_rows() {
let (mut wb, id) = reshapable_block(2);
let effect = apply(
&mut wb,
vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "aa".into(),
}),
bind_t(id, &[("key", None), ("a", Some("LEN(#KEY)"))]),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 0, 1), Some(2.0));
let effect = apply(
&mut wb,
vec![
resize_t(id, Some(3), None),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 1,
col: 0,
content: "bbb".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 2,
col: 0,
content: "cccc".into(),
}),
bind_t(id, &[("key", None), ("a", Some("LEN(#KEY)"))]),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"grow rows then bind: {:?}",
effect.error_message
);
assert_eq!(cell_num(&wb, 0, 1), Some(2.0), "the existing row is intact");
assert_eq!(cell_num(&wb, 1, 1), Some(3.0), "a new row computes");
assert_eq!(cell_num(&wb, 2, 1), Some(4.0), "and so does the next");
}
#[test]
fn binding_before_shrinking_drops_a_column_and_leaves_no_orphan() {
let (mut wb, id) = reshapable_block(3);
let effect = apply(
&mut wb,
vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "k0".into(),
}),
bind_t(id, &[("key", None), ("a", None), ("b", Some("LEN(#KEY)"))]),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 0, 2), Some(2.0), "sanity: b computes");
let effect = apply(
&mut wb,
vec![
bind_t(id, &[("key", None), ("a", None)]),
resize_t(id, None, Some(2)),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"bind then shrink: {:?}",
effect.error_message
);
assert_eq!(field_names(&wb, id), vec!["key", "a"]);
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let b = blocks.iter().find(|b| b.block_id == id).unwrap();
assert_eq!(b.col_cnt, 2, "the block really is narrower");
assert_eq!(
cell_num(&wb, 0, 2),
None,
"and the dropped column's value is gone with it"
);
}
#[test]
fn shrinking_before_binding_reaches_the_same_place() {
let (mut wb, id) = reshapable_block(3);
let effect = apply(
&mut wb,
vec![
bind_t(id, &[("key", None), ("a", None), ("b", Some("LEN(#KEY)"))]),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "k0".into(),
}),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let effect = apply(
&mut wb,
vec![
resize_t(id, None, Some(2)),
bind_t(id, &[("key", None), ("a", None)]),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"shrink then bind: {:?}",
effect.error_message
);
assert_eq!(field_names(&wb, id), vec!["key", "a"]);
assert_eq!(cell_num(&wb, 0, 2), None);
}
#[test]
fn a_reshape_is_one_undo() {
let (mut wb, id) = reshapable_block(2);
let effect = apply(
&mut wb,
vec![
bind_t(id, &[("key", None), ("a", None)]),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "k0".into(),
}),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let effect = apply(
&mut wb,
vec![
resize_t(id, Some(2), Some(3)),
bind_t(id, &[("key", None), ("a", None), ("b", Some("LEN(#KEY)"))]),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(field_names(&wb, id), vec!["key", "a", "b"]);
wb.undo();
assert_eq!(field_names(&wb, id), vec!["key", "a"]);
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let b = blocks.iter().find(|b| b.block_id == id).unwrap();
assert_eq!((b.row_cnt, b.col_cnt), (1, 2), "the old shape is back");
}
#[test]
fn a_key_written_after_the_bind_substitutes_as_empty() {
let (mut wb, id) = reshapable_block(2);
let effect = apply(
&mut wb,
vec![
bind_t(id, &[("key", None), ("a", Some("LEN(#KEY)"))]),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "k0".into(),
}),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(
cell_num(&wb, 0, 1),
Some(0.0),
"LEN(\"\") — the key was not there yet when the template materialized"
);
let effect = apply(
&mut wb,
vec![bind_t(id, &[("key", None), ("a", Some("LEN(#KEY)"))])],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 0, 1), Some(2.0));
}
#[test]
fn editing_a_key_does_not_re_aim_the_row_until_a_rebind() {
let (mut wb, id) = reshapable_block(2);
let effect = apply(
&mut wb,
vec![
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "k0".into(),
}),
bind_t(id, &[("key", None), ("a", Some("LEN(#KEY)"))]),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 0, 1), Some(2.0));
let effect = apply(
&mut wb,
vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: "much-longer".into(),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(
cell_num(&wb, 0, 1),
Some(2.0),
"still LEN(\"k0\"): the formula holds the key it was built with"
);
}
fn pivot_parts() -> crate::block_manager::schema_manager::field_type::PivotSpecParts {
crate::block_manager::schema_manager::field_type::PivotSpecParts {
row_dim: "region".into(),
col_dim: Some("quarter".into()),
measure: "amt".into(),
func: "SUM".into(),
order: Some("ascending".into()),
order_values: None,
filters: None,
}
}
fn orders_with_pivot() -> (Workbook, logisheets_base::BlockId, logisheets_base::BlockId) {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (mut wb, src, _analysis) = orders_with_analysis(AggFunc::Sum);
let pivot = wb.get_available_block_id(0).unwrap();
let effect = apply(
&mut wb,
vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: pivot,
master_row: 10,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: Some(pivot_parts()),
})],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"pivot creation: {:?}",
effect.error_message
);
(wb, src, pivot)
}
fn reported_pivot(
wb: &Workbook,
block_id: logisheets_base::BlockId,
) -> Option<crate::block_manager::schema_manager::field_type::PivotSpecParts> {
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
blocks
.iter()
.find(|b| b.block_id == block_id)
.and_then(|b| b.pivot.clone())
}
#[test]
fn a_pivot_is_declared_as_the_block_is_created_and_reported_back() {
let (wb, src, pivot) = orders_with_pivot();
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(p.analyzes, Some(src), "a pivot is an analysis block");
let spec = p.pivot.as_ref().expect("the recipe is reported");
assert_eq!(spec.row_dim, "region");
assert_eq!(spec.col_dim.as_deref(), Some("quarter"));
assert_eq!(spec.measure, "amt");
assert_eq!(spec.func, "SUM");
let total = blocks
.iter()
.find(|b| b.analyzes == Some(src) && b.block_id != pivot)
.unwrap();
assert_eq!(total.pivot, None);
}
#[test]
fn a_pivot_that_analyses_nothing_is_refused() {
let mut wb = Workbook::default();
let id = wb.get_available_block_id(0).unwrap();
let effect = apply(
&mut wb,
vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 0,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: Some(pivot_parts()),
})],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"a pivot with no source should be refused"
);
assert!(
effect
.error_message
.as_deref()
.unwrap_or_default()
.contains("needs a source block to aggregate"),
"the refusal should say why: {:?}",
effect.error_message
);
}
#[test]
fn a_pivot_over_itself_is_refused() {
let mut wb = Workbook::default();
let id = wb.get_available_block_id(0).unwrap();
let effect = apply(
&mut wb,
vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 0,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(id),
pivot: Some(pivot_parts()),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Err(_)
));
}
#[test]
fn an_uninterpretable_recipe_leaves_a_plain_analysis_block() {
let (mut wb, src, _analysis) =
orders_with_analysis(crate::block_manager::schema_manager::field_type::AggFunc::Sum);
let id = wb.get_available_block_id(0).unwrap();
let effect = apply(
&mut wb,
vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id,
master_row: 10,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: Some(
crate::block_manager::schema_manager::field_type::PivotSpecParts {
func: "MEDIAN".into(),
..pivot_parts()
},
),
})],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"the block should still open: {:?}",
effect.error_message
);
assert_eq!(reported_pivot(&wb, id), None);
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let b = blocks.iter().find(|b| b.block_id == id).unwrap();
assert_eq!(b.analyzes, Some(src), "but it is still an analysis block");
}
#[test]
fn the_recipe_can_be_changed_and_cleared_after_the_fact() {
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, src, pivot) = orders_with_pivot();
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: Some(
crate::block_manager::schema_manager::field_type::PivotSpecParts {
col_dim: None,
func: "AVERAGE".into(),
..pivot_parts()
},
),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let spec = reported_pivot(&wb, pivot).unwrap();
assert_eq!(
spec.col_dim, None,
"a grouped pivot has no column dimension"
);
assert_eq!(spec.func, "AVERAGE");
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: None,
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(reported_pivot(&wb, pivot), None);
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: None,
pivot: None,
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let b = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(b.analyzes, None);
assert_eq!(b.pivot, None);
}
#[test]
fn setting_a_recipe_without_a_source_is_refused() {
let (mut wb, _src, pivot) = orders_with_pivot();
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(
crate::edit_action::SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: None,
pivot: Some(pivot_parts()),
},
)],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Err(_)
));
}
#[test]
fn a_pivot_recipe_survives_a_real_xlsx_round_trip() {
let (wb, src, pivot) = orders_with_pivot();
let bytes = wb.save().expect("save");
let restored = Workbook::from_file(&bytes, "rt".to_string()).expect("load");
let ws = restored.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks
.iter()
.find(|b| b.block_id == pivot)
.expect("the pivot block is back");
assert_eq!(p.analyzes, Some(src));
let spec = p.pivot.as_ref().expect("and so is its recipe");
assert_eq!(spec.row_dim, "region");
assert_eq!(spec.col_dim.as_deref(), Some("quarter"));
assert_eq!(spec.measure, "amt");
assert_eq!(spec.func, "SUM");
assert_eq!(spec.order.as_deref(), Some("ascending"));
let total = blocks
.iter()
.find(|b| b.analyzes == Some(src) && b.block_id != pivot)
.unwrap();
assert_eq!(total.pivot, None);
}
#[test]
fn a_grouped_pivot_survives_without_gaining_a_column_dimension() {
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, src, pivot) = orders_with_pivot();
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: Some(
crate::block_manager::schema_manager::field_type::PivotSpecParts {
col_dim: None,
..pivot_parts()
},
),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let bytes = wb.save().expect("save");
let restored = Workbook::from_file(&bytes, "rt".to_string()).expect("load");
let ws = restored.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(p.pivot.as_ref().unwrap().col_dim, None);
}
#[test]
fn a_pivot_field_cannot_also_declare_its_own_aggregate() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::BindFormSchema;
let (mut wb, _src, pivot) = orders_with_pivot();
let effect = apply(
&mut wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders_pivot".into(),
sheet_idx: 0,
block_id: pivot,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("region", "p0"),
SchemaFieldSpec::new("Q1", "p1").with_aggregate(AggFunc::Sum, "amt"),
],
row: true,
header_idx: None,
unique_together: None,
})],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Err(_)),
"a pivot column carrying its own aggregate should be refused"
);
assert!(
effect
.error_message
.as_deref()
.unwrap_or_default()
.contains("cannot also declare its own aggregate"),
"the refusal should say which field and why: {:?}",
effect.error_message
);
}
#[test]
fn a_field_aggregate_is_still_fine_on_a_block_that_is_not_a_pivot() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, _src, _analysis) = orders_with_analysis(AggFunc::Sum);
assert_eq!(cell_num(&wb, 3, 1), Some(60.0));
}
#[test]
fn declaring_a_pivot_over_a_block_whose_fields_aggregate_is_refused_too() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: analysis,
analyzes: Some(src),
pivot: Some(pivot_parts()),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Err(_)
));
assert_eq!(reported_pivot(&wb, analysis), None);
assert_eq!(cell_num(&wb, 3, 1), Some(60.0));
}
fn sales_block(wb: &mut Workbook) -> logisheets_base::BlockId {
use crate::edit_action::BindFormSchema;
let src = wb.get_available_block_id(0).unwrap();
let rows = [
("o0", "East", "Q1", "10"),
("o1", "East", "Q2", "20"),
("o2", "South", "Q1", "3"),
("o3", "South", "Q1", "4"),
("o4", "South", "Q2", "5"),
("o5", "North", "Q1", "7"),
];
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: src,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 4,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "s0"),
SchemaFieldSpec::new("region", "s1"),
SchemaFieldSpec::new("quarter", "s2"),
SchemaFieldSpec::new("amt", "s3"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (r, (id, region, quarter, amt)) in rows.iter().enumerate() {
for (c, v) in [id, region, quarter, amt].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
let effect = apply(wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"sales setup: {:?}",
effect.error_message
);
src
}
fn sales_with_pivot(
func: &str,
col_dim: Option<&str>,
) -> (Workbook, logisheets_base::BlockId, logisheets_base::BlockId) {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let src = sales_block(&mut wb);
let pivot = wb.get_available_block_id(0).unwrap();
let keys = ["East", "South", "North"];
let value_fields: Vec<&str> = match col_dim {
Some(_) => vec!["Q1", "Q2"],
None => vec!["total"],
};
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: pivot,
master_row: 10,
master_col: 0,
row_cnt: keys.len(),
col_cnt: 1 + value_fields.len(),
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: Some(PivotSpecParts {
row_dim: "region".into(),
col_dim: col_dim.map(String::from),
measure: "amt".into(),
func: func.into(),
order: Some("ascending".into()),
order_values: None,
filters: None,
}),
})];
for (i, k) in keys.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 10 + i,
col: 0,
content: k.to_string(),
}));
}
let mut fields = vec![SchemaFieldSpec::new("region", "p0")];
for (i, f) in value_fields.iter().enumerate() {
fields.push(SchemaFieldSpec::new(*f, format!("p{}", i + 1)));
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales_pivot".into(),
sheet_idx: 0,
block_id: pivot,
field_from: 0,
key_idx: 0,
fields,
row: true,
header_idx: None,
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"pivot setup: {:?}",
effect.error_message
);
(wb, src, pivot)
}
#[test]
fn a_pivot_cross_tabulates_from_the_recipe_alone() {
let (wb, _src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "East Q1");
assert_eq!(cell_num(&wb, 10, 2), Some(20.0), "East Q2");
assert_eq!(
cell_num(&wb, 11, 1),
Some(7.0),
"South Q1 sums BOTH records"
);
assert_eq!(cell_num(&wb, 11, 2), Some(5.0), "South Q2");
assert_eq!(cell_num(&wb, 12, 1), Some(7.0), "North Q1");
}
#[test]
fn a_cell_with_no_matching_records_reads_zero_rather_than_erroring() {
let (wb, _src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
assert_eq!(cell_num(&wb, 12, 2), Some(0.0), "North Q2");
}
#[test]
fn the_key_column_of_a_pivot_stays_an_ordinary_cell() {
let (wb, _src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
let ws = wb.get_sheet_by_idx(0).unwrap();
assert!(
matches!(
ws.get_value(10, 0).unwrap(),
crate::controller::display::Value::Str(ref s) if s == "East"
),
"the key cell still holds the typed dimension value"
);
}
#[test]
fn a_pivot_tracks_an_edit_to_the_source() {
let (mut wb, src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
assert_eq!(cell_num(&wb, 11, 1), Some(7.0));
let effect = apply(
&mut wb,
vec![EditPayload::BlockInput(crate::edit_action::BlockInput {
sheet_idx: 0,
block_id: src,
row: 3,
col: 3,
input: "40".into(),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 11, 1), Some(43.0), "3 + 40");
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "other cells untouched");
}
#[test]
fn a_pivot_tracks_a_record_added_to_a_group_it_already_shows() {
let (mut wb, src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
let effect = apply(
&mut wb,
vec![
EditPayload::InsertRowsInBlock(crate::edit_action::InsertRowsInBlock {
sheet_idx: 0,
block_id: src,
start: 6,
cnt: 1,
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 6,
col: 0,
content: "o6".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 6,
col: 1,
content: "East".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 6,
col: 2,
content: "Q1".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 6,
col: 3,
content: "100".into(),
}),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"{:?}",
effect.error_message
);
assert_eq!(cell_num(&wb, 11, 1), Some(7.0), "South Q1 unchanged");
assert_eq!(cell_num(&wb, 10, 1), Some(110.0), "East Q1 picked it up");
}
#[test]
fn a_grouped_pivot_totals_each_group_over_every_column() {
let (wb, _src, _pivot) = sales_with_pivot("SUM", None);
assert_eq!(cell_num(&wb, 10, 1), Some(30.0), "East = 10 + 20");
assert_eq!(cell_num(&wb, 11, 1), Some(12.0), "South = 3 + 4 + 5");
assert_eq!(cell_num(&wb, 12, 1), Some(7.0), "North = 7");
}
#[test]
fn a_pivot_can_average_and_count_as_well_as_sum() {
let (wb, _src, _pivot) = sales_with_pivot("AVERAGE", Some("quarter"));
assert_eq!(cell_num(&wb, 11, 1), Some(3.5), "South Q1 = (3 + 4) / 2");
let (wb, _src, _pivot) = sales_with_pivot("COUNT", Some("quarter"));
assert_eq!(cell_num(&wb, 11, 1), Some(2.0), "South Q1 has two records");
assert_eq!(cell_num(&wb, 12, 2), Some(0.0), "North Q2 has none");
}
#[test]
fn a_pivot_result_is_addressable_by_key_and_field() {
let (mut wb, _src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
let effect = apply(
&mut wb,
vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 20,
col: 5,
content: "=BLOCKREF(\"sales_pivot\",\"South\",\"Q1\")*2".into(),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 20, 5), Some(14.0), "7 * 2");
}
#[test]
fn a_pivot_does_not_count_itself() {
let (wb, _src, _pivot) = sales_with_pivot("SUM", None);
assert_eq!(
cell_num(&wb, 10, 1).unwrap()
+ cell_num(&wb, 11, 1).unwrap()
+ cell_num(&wb, 12, 1).unwrap(),
49.0
);
}
#[test]
fn multi_criteria_sumifs_keeps_its_criteria_in_step() {
let mut wb = Workbook::default();
let rows = [
("East", "Q1", "10"),
("East", "Q2", "20"),
("South", "Q1", "3"),
("South", "Q1", "4"),
("South", "Q2", "5"),
("North", "Q1", "7"),
];
let mut payloads = vec![];
for (r, (region, quarter, amt)) in rows.iter().enumerate() {
for (c, v) in [region, quarter, amt].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
for (row, formula) in [
(10, "=SUMIFS(C1:C6,A1:A6,\"South\",B1:B6,\"Q1\")"),
(11, "=COUNTIFS(A1:A6,\"South\",B1:B6,\"Q1\")"),
(12, "=AVERAGEIFS(C1:C6,A1:A6,\"South\",B1:B6,\"Q1\")"),
(13, "=MAXIFS(C1:C6,A1:A6,\"South\",B1:B6,\"Q1\")"),
(14, "=MINIFS(C1:C6,A1:A6,\"South\",B1:B6,\"Q1\")"),
(
15,
"=SUMIFS(C1:C6,A1:A6,\"South\",B1:B6,\"Q1\",C1:C6,\">3\")",
),
(16, "=SUMIFS(C1:C6,A1:A6,\"South\")"),
] {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 5,
content: formula.to_string(),
}));
}
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 10, 5), Some(7.0), "SUMIFS South x Q1 = 3 + 4");
assert_eq!(cell_num(&wb, 11, 5), Some(2.0), "COUNTIFS");
assert_eq!(cell_num(&wb, 12, 5), Some(3.5), "AVERAGEIFS");
assert_eq!(cell_num(&wb, 13, 5), Some(4.0), "MAXIFS");
assert_eq!(cell_num(&wb, 14, 5), Some(3.0), "MINIFS");
assert_eq!(
cell_num(&wb, 15, 5),
Some(4.0),
"three criteria: only the 4"
);
assert_eq!(cell_num(&wb, 16, 5), Some(12.0), "one criterion: 3 + 4 + 5");
}
#[test]
fn minifs_and_maxifs_do_not_fold_from_zero() {
let mut wb = Workbook::default();
let mut payloads = vec![];
for (r, (tag, v)) in [("a", "3"), ("a", "5"), ("b", "-9"), ("a", "-2")]
.iter()
.enumerate()
{
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: 0,
content: tag.to_string(),
}));
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: 1,
content: v.to_string(),
}));
}
for (row, formula) in [
(10, "=MINIFS(B1:B2,A1:A2,\"a\")"),
(11, "=MAXIFS(B3:B3,A3:A3,\"b\")"),
(12, "=MINIFS(B1:B4,A1:A4,\"a\")"),
(13, "=MINIFS(B1:B4,A1:A4,\"zzz\")"),
] {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row,
col: 5,
content: formula.to_string(),
}));
}
let effect = apply(&mut wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 10, 5), Some(3.0), "MINIFS over 3 and 5");
assert_eq!(cell_num(&wb, 11, 5), Some(-9.0), "MAXIFS over -9");
assert_eq!(cell_num(&wb, 12, 5), Some(-2.0), "MINIFS over 3, 5, -2");
assert_eq!(cell_num(&wb, 13, 5), Some(0.0), "no matches");
}
#[test]
fn a_pivots_key_column_must_name_a_group_that_occurs_in_the_source() {
let (mut wb, _src, _pivot) = sales_with_pivot("SUM", Some("quarter"));
let real = wb
.check_field_validation(0, 10, 0, "North".to_string())
.unwrap();
assert!(real.has_rule, "a pivot's key column carries a derived rule");
assert!(!real.violates, "North occurs in the source");
assert!(
real.rule.contains("COUNTIFS(") && real.rule.contains("region"),
"the rule asks the source's own column: {}",
real.rule
);
let typo = wb
.check_field_validation(0, 10, 0, "Norht".to_string())
.unwrap();
assert!(
typo.violates,
"a group that does not occur in the source is flagged"
);
let value_col = wb
.check_field_validation(0, 10, 1, "123".to_string())
.unwrap();
assert!(
!value_col.has_rule,
"only the key column gets the membership rule"
);
}
#[test]
fn a_total_rows_key_column_gets_no_membership_rule() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (mut wb, _src, _analysis) = orders_with_analysis(AggFunc::Sum);
let v = wb
.check_field_validation(0, 3, 0, "Total".to_string())
.unwrap();
assert!(!v.has_rule);
}
fn add_sale(
wb: &mut Workbook,
src: logisheets_base::BlockId,
at: usize,
id: &str,
region: &str,
quarter: &str,
amt: &str,
) {
let mut payloads = vec![EditPayload::InsertRowsInBlock(
crate::edit_action::InsertRowsInBlock {
sheet_idx: 0,
block_id: src,
start: at,
cnt: 1,
},
)];
for (c, v) in [id, region, quarter, amt].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: at,
col: c,
content: v.to_string(),
}));
}
let effect = apply(wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"add_sale: {:?}",
effect.error_message
);
}
#[test]
fn a_fresh_pivot_is_not_stale() {
let (wb, _src, pivot) = sales_with_pivot("SUM", Some("quarter"));
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.keys, vec!["East", "North", "South"]);
assert_eq!(plan.fields, vec!["Q1", "Q2"]);
assert!(!plan.is_stale, "it shows exactly what the source justifies");
assert!(plan.missing_keys.is_empty());
assert!(plan.extra_keys.is_empty());
assert_eq!(plan.unassigned_records, 0);
}
#[test]
fn a_reorder_alone_is_not_staleness() {
let (wb, _src, pivot) = sales_with_pivot("SUM", Some("quarter"));
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_ne!(plan.keys, plan.current_keys, "the order does differ");
assert!(!plan.is_stale);
}
#[test]
fn a_new_group_in_the_source_makes_the_pivot_stale_and_says_which() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
add_sale(&mut wb, src, 6, "o6", "Northwest", "Q1", "99");
let plan = wb.pivot_plan(0, pivot).unwrap();
assert!(plan.is_stale);
assert_eq!(plan.missing_keys, vec!["Northwest"]);
assert!(plan.extra_keys.is_empty());
assert!(plan.keys.contains(&"Northwest".to_string()));
assert_eq!(cell_num(&wb, 10, 1), Some(10.0));
}
#[test]
fn a_new_column_value_is_reported_as_a_missing_field() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
add_sale(&mut wb, src, 6, "o6", "East", "Q3", "99");
let plan = wb.pivot_plan(0, pivot).unwrap();
assert!(plan.is_stale);
assert_eq!(plan.missing_fields, vec!["Q3"]);
assert!(plan.missing_keys.is_empty(), "East is already shown");
}
#[test]
fn a_group_that_vanishes_from_the_source_is_reported_as_extra() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
let effect = apply(
&mut wb,
vec![EditPayload::DeleteRowsInBlock(
crate::edit_action::DeleteRowsInBlock {
sheet_idx: 0,
block_id: src,
start: 5,
cnt: 1,
},
)],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"{:?}",
effect.error_message
);
let plan = wb.pivot_plan(0, pivot).unwrap();
assert!(plan.is_stale);
assert_eq!(plan.extra_keys, vec!["North"]);
assert!(plan.missing_keys.is_empty());
assert_eq!(cell_num(&wb, 12, 1), Some(0.0), "and its row now reads 0");
}
#[test]
fn records_with_a_blank_dimension_are_counted_rather_than_dropped_silently() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
add_sale(&mut wb, src, 6, "o6", "", "Q1", "99");
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.unassigned_records, 1);
assert!(
!plan.keys.iter().any(|k| k.is_empty()),
"a blank does not become a group of its own"
);
assert!(
!plan.is_stale,
"no group is missing — the record is nowhere"
);
}
#[test]
fn the_plan_orders_dimensions_deterministically() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
add_sale(&mut wb, src, 6, "o6", "Northwest", "Q3", "1");
let a = wb.pivot_plan(0, pivot).unwrap();
let b = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(a.keys, b.keys, "twice in a row gives the same order");
assert_eq!(a.fields, vec!["Q1", "Q2", "Q3"], "sorted, not first-seen");
}
#[test]
fn first_seen_order_keeps_the_sources_own_sequence() {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: Some(PivotSpecParts {
row_dim: "region".into(),
col_dim: Some("quarter".into()),
measure: "amt".into(),
func: "SUM".into(),
order: Some("firstSeen".into()),
order_values: None,
filters: None,
}),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.keys, vec!["East", "South", "North"]);
}
#[test]
fn a_grouped_pivot_plans_rows_only() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", None);
assert!(wb.pivot_plan(0, pivot).unwrap().fields.is_empty());
add_sale(&mut wb, src, 6, "o6", "Northwest", "Q9", "1");
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.missing_keys, vec!["Northwest"], "rows still track");
assert!(plan.missing_fields.is_empty(), "columns do not");
}
#[test]
fn planning_a_block_that_is_not_a_pivot_says_so() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
for id in [src, analysis] {
let err = wb.pivot_plan(0, id).unwrap_err();
assert!(format!("{err:?}").contains("is not a pivot"), "got {err:?}");
}
}
#[test]
fn a_recipe_naming_a_field_the_source_lost_is_an_error_not_an_empty_plan() {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
use crate::edit_action::SetBlockAnalyzes;
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: Some(PivotSpecParts {
row_dim: "nope".into(),
col_dim: Some("quarter".into()),
measure: "amt".into(),
func: "SUM".into(),
order: None,
order_values: None,
filters: None,
}),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let err = wb.pivot_plan(0, pivot).unwrap_err();
assert!(
format!("{err:?}").contains("which block") && format!("{err:?}").contains("nope"),
"the error should name the field: {err:?}"
);
}
#[test]
fn a_dimension_with_too_many_values_is_refused_with_the_count() {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
use crate::edit_action::{BindFormSchema, SetBlockAnalyzes};
let mut wb = Workbook::default();
let src = wb.get_available_block_id(0).unwrap();
const N: usize = 250;
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: src,
master_row: 0,
master_col: 0,
row_cnt: N,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "wide".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "w0"),
SchemaFieldSpec::new("tag", "w1"),
SchemaFieldSpec::new("amt", "w2"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for r in 0..N {
for (c, v) in [format!("k{r}"), format!("t{r}"), "1".to_string()]
.iter()
.enumerate()
{
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.clone(),
}));
}
}
let effect = apply(&mut wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"wide setup: {:?}",
effect.error_message
);
let pivot = wb.get_available_block_id(0).unwrap();
let effect = apply(
&mut wb,
vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: pivot,
master_row: 300,
master_col: 0,
row_cnt: 1,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: None,
}),
EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: Some(PivotSpecParts {
row_dim: "amt".into(),
col_dim: Some("tag".into()),
measure: "amt".into(),
func: "SUM".into(),
order: None,
order_values: None,
filters: None,
}),
}),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"pivot setup: {:?}",
effect.error_message
);
let err = wb.pivot_plan(0, pivot).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("tag"), "names the field: {msg}");
assert!(msg.contains("200"), "names the limit: {msg}");
assert!(msg.contains("columns"), "says which axis: {msg}");
let effect = apply(
&mut wb,
vec![EditPayload::SetBlockAnalyzes(SetBlockAnalyzes {
sheet_idx: 0,
block_id: pivot,
analyzes: Some(src),
pivot: Some(PivotSpecParts {
row_dim: "amt".into(),
col_dim: None,
measure: "amt".into(),
func: "SUM".into(),
order: None,
order_values: None,
filters: None,
}),
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(wb.pivot_plan(0, pivot).unwrap().keys, vec!["1"]);
}
#[test]
fn refreshing_a_pivot_with_a_trailing_no_op_resize_loses_the_new_row() {
use crate::edit_action::BindFormSchema;
let refresh = |wb: &mut Workbook, pivot, trailing: bool| {
let mut payloads = vec![
resize_t(pivot, Some(4), Some(3)),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 11,
col: 0,
content: "East".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 12,
col: 0,
content: "North".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 13,
col: 0,
content: "South".into(),
}),
EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 14,
col: 0,
content: "Northwest".into(),
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales_pivot".into(),
sheet_idx: 0,
block_id: pivot,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("region", "p0"),
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Q2", "p2"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
if trailing {
payloads.push(resize_t(pivot, Some(4), Some(3)));
}
let effect = apply(wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"refresh: {:?}",
effect.error_message
);
};
let grow_source = |wb: &mut Workbook, src| {
let mut payloads = vec![
EditPayload::InsertRows(crate::edit_action::InsertRows {
sheet_idx: 0,
start: 6,
count: 1,
}),
EditPayload::InsertRowsInBlock(crate::edit_action::InsertRowsInBlock {
sheet_idx: 0,
block_id: src,
start: 6,
cnt: 1,
}),
];
for (c, v) in ["o6", "Northwest", "Q1", "99"].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 6,
col: c,
content: v.to_string(),
}));
}
let effect = apply(wb, payloads);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
};
let (mut a, src_a, pivot_a) = sales_with_pivot("SUM", Some("quarter"));
grow_source(&mut a, src_a);
refresh(&mut a, pivot_a, false);
assert_eq!(cell_num(&a, 14, 1), Some(99.0), "Northwest Q1");
let (mut b, src_b, pivot_b) = sales_with_pivot("SUM", Some("quarter"));
grow_source(&mut b, src_b);
refresh(&mut b, pivot_b, true);
assert_eq!(cell_num(&b, 11, 1), Some(10.0), "existing rows survive");
assert_eq!(
cell_num(&b, 14, 1),
None,
"but the row the bind just materialized lost its numbers"
);
}
fn sales_with_columns(
spec: crate::block_manager::schema_manager::field_type::PivotSpecParts,
columns: &[SchemaFieldSpec],
keys: &[&str],
) -> (Workbook, logisheets_base::BlockId, logisheets_base::BlockId) {
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let src = sales_block(&mut wb);
let pivot = wb.get_available_block_id(0).unwrap();
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: pivot,
master_row: 10,
master_col: 0,
row_cnt: keys.len(),
col_cnt: 1 + columns.len(),
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: Some(spec),
})];
for (i, k) in keys.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 10 + i,
col: 0,
content: k.to_string(),
}));
}
let mut fields = vec![SchemaFieldSpec::new("region", "p0")];
fields.extend(columns.iter().cloned());
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales_pivot".into(),
sheet_idx: 0,
block_id: pivot,
field_from: 0,
key_idx: 0,
fields,
row: true,
header_idx: None,
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"pivot setup: {:?}",
effect.error_message
);
(wb, src, pivot)
}
fn cross_tab_spec() -> crate::block_manager::schema_manager::field_type::PivotSpecParts {
crate::block_manager::schema_manager::field_type::PivotSpecParts {
row_dim: "region".into(),
col_dim: Some("quarter".into()),
measure: "amt".into(),
func: "SUM".into(),
order: Some("ascending".into()),
order_values: None,
filters: None,
}
}
fn gappy_block(wb: &mut Workbook) -> logisheets_base::BlockId {
use crate::edit_action::BindFormSchema;
let src = wb.get_available_block_id(0).unwrap();
let rows = [
("o0", "East", "10"),
("o1", "East", ""),
("o2", "South", "n/a"),
("o3", "South", "5"),
];
let mut payloads = vec![
EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: src,
master_row: 0,
master_col: 0,
row_cnt: rows.len(),
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "gappy".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "g0"),
SchemaFieldSpec::new("region", "g1"),
SchemaFieldSpec::new("amt", "g2"),
],
row: true,
header_idx: None,
unique_together: None,
}),
];
for (r, (id, region, amt)) in rows.iter().enumerate() {
for (c, v) in [id, region, amt].iter().enumerate() {
if v.is_empty() {
continue; }
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: r,
col: c,
content: v.to_string(),
}));
}
}
let effect = apply(wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"gappy setup: {:?}",
effect.error_message
);
src
}
#[test]
fn counta_counts_present_values_where_count_counts_numbers() {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
use crate::edit_action::BindFormSchema;
let mut wb = Workbook::default();
let src = gappy_block(&mut wb);
let pivot = wb.get_available_block_id(0).unwrap();
let spec = PivotSpecParts {
row_dim: "region".into(),
col_dim: None,
measure: "amt".into(),
func: "COUNTA".into(),
order: Some("ascending".into()),
order_values: None,
filters: None,
};
let mut payloads = vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: pivot,
master_row: 10,
master_col: 0,
row_cnt: 2,
col_cnt: 3,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: Some(src),
pivot: Some(spec),
})];
for (i, k) in ["East", "South"].iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 10 + i,
col: 0,
content: k.to_string(),
}));
}
payloads.push(EditPayload::BindFormSchema(BindFormSchema {
ref_name: "gappy_pivot".into(),
sheet_idx: 0,
block_id: pivot,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("region", "p0"),
SchemaFieldSpec::new("filled", "p1"),
SchemaFieldSpec::new("records", "p2").with_pivot_column(
None,
crate::block_manager::schema_manager::field_type::AggFunc::Count,
"amt",
),
],
row: true,
header_idx: None,
unique_together: None,
}));
let effect = apply(&mut wb, payloads);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"pivot setup: {:?}",
effect.error_message
);
assert_eq!(cell_num(&wb, 10, 1), Some(1.0), "East: one amt present");
assert_eq!(
cell_num(&wb, 11, 1),
Some(2.0),
"South: \"n/a\" is present even though it is not a number"
);
assert_eq!(cell_num(&wb, 10, 2), Some(2.0), "East: two records");
assert_eq!(cell_num(&wb, 11, 2), Some(2.0), "South: two records");
}
#[test]
fn a_counta_recipe_survives_a_real_xlsx_round_trip() {
use crate::block_manager::schema_manager::field_type::{AggFunc, PivotSpecParts};
let spec = PivotSpecParts {
func: "COUNTA".into(),
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("Q1", "p1")],
&["East", "South"],
);
let bytes = wb.save().expect("save");
let restored = Workbook::from_file(&bytes, "rt".to_string()).expect("load");
let ws = restored.get_sheet_by_idx(0).unwrap();
let p = ws
.get_all_blocks()
.into_iter()
.find(|b| b.block_id == pivot)
.expect("the pivot survived");
assert_eq!(
p.pivot.as_ref().unwrap().func,
AggFunc::CountA.as_str(),
"the aggregate came back as COUNTA, not as a guess"
);
}
#[test]
fn a_row_total_column_spans_every_value_of_the_column_dimension() {
let (wb, _src, _pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Q2", "p2"),
SchemaFieldSpec::new("Total", "p3").with_pivot_total(),
],
&["East", "South", "North"],
);
assert_eq!(cell_num(&wb, 10, 1), Some(10.0));
assert_eq!(cell_num(&wb, 10, 2), Some(20.0));
assert_eq!(cell_num(&wb, 10, 3), Some(30.0), "East total");
assert_eq!(cell_num(&wb, 11, 3), Some(12.0), "South total");
assert_eq!(cell_num(&wb, 12, 3), Some(7.0), "North total");
}
#[test]
fn a_pivot_can_carry_a_second_measure() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, _src, _pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Q1 Orders", "p2").with_pivot_column(
Some("Q1"),
AggFunc::Count,
"amt",
),
SchemaFieldSpec::new("Mean", "p3").with_pivot_column(None, AggFunc::Average, "amt"),
],
&["East", "South"],
);
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "East Q1 amount");
assert_eq!(cell_num(&wb, 10, 2), Some(1.0), "East has one Q1 record");
assert_eq!(cell_num(&wb, 11, 1), Some(7.0), "South Q1 = 3 + 4");
assert_eq!(cell_num(&wb, 11, 2), Some(2.0), "South has two Q1 records");
assert_eq!(cell_num(&wb, 11, 3), Some(4.0), "South average over all");
}
#[test]
fn a_refresh_keeps_the_columns_a_person_declared() {
use crate::block_manager::schema_manager::field_type::AggFunc;
let (wb, _src, pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Q2", "p2"),
SchemaFieldSpec::new("Total", "p3").with_pivot_total(),
SchemaFieldSpec::new("Orders", "p4").with_pivot_column(None, AggFunc::Count, "amt"),
],
&["East", "South", "North"],
);
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(
plan.fields,
vec!["Q1", "Q2", "Total", "Orders"],
"derived columns first, hand-declared ones after"
);
assert!(
plan.extra_fields.is_empty(),
"a declared column is never 'extra': {:?}",
plan.extra_fields
);
assert!(!plan.is_stale);
}
#[test]
fn a_declared_column_on_a_block_that_is_not_a_pivot_is_refused() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::BindFormSchema;
let (mut wb, src, _analysis) = orders_with_analysis(AggFunc::Sum);
let effect = apply(
&mut wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "s0"),
SchemaFieldSpec::new("amt", "s1").with_pivot_total(),
],
row: true,
header_idx: None,
unique_together: None,
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Err(_)
));
assert!(
effect
.error_message
.as_deref()
.unwrap_or_default()
.contains("is not a pivot"),
"{:?}",
effect.error_message
);
}
#[test]
fn a_custom_order_puts_the_dimensions_where_the_business_wants_them() {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
let spec = PivotSpecParts {
order: Some("custom".into()),
order_values: Some(vec!["North".into(), "East".into(), "South".into()]),
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("Q1", "p1")],
&["East", "South", "North"],
);
assert_eq!(
wb.pivot_plan(0, pivot).unwrap().keys,
vec!["North", "East", "South"]
);
}
#[test]
fn a_value_the_custom_order_does_not_mention_still_gets_a_row() {
use crate::block_manager::schema_manager::field_type::PivotSpecParts;
let spec = PivotSpecParts {
order: Some("custom".into()),
order_values: Some(vec!["North".into(), "East".into()]),
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("Q1", "p1")],
&["East", "South", "North"],
);
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.keys, vec!["North", "East", "South"]);
assert!(
plan.keys.contains(&"South".to_string()),
"an unlisted value is ordered last, never dropped"
);
}
#[test]
fn a_pivot_whose_measure_is_gone_is_broken_not_merely_zero() {
use crate::edit_action::BindFormSchema;
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
assert!(wb.pivot_plan(0, pivot).is_ok(), "healthy to begin with");
let effect = apply(
&mut wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
row: true,
header_idx: None,
unique_together: None,
fields: vec![
SchemaFieldSpec::new("id", "s0"),
SchemaFieldSpec::new("region", "s1"),
SchemaFieldSpec::new("quarter", "s2"),
SchemaFieldSpec::new("gone", "s9").with_field_type(
crate::block_manager::schema_manager::field_type::FieldType::Number,
),
],
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 10, 1), Some(0.0));
let err = wb
.pivot_plan(0, pivot)
.expect_err("a recipe measuring a field that is gone is broken");
let msg = format!("{err:?}");
assert!(
msg.contains("measures") && msg.contains("amt"),
"the reason names the measure the RECIPE wants — which is the field a \
reader has to go and restore or rename: {msg}"
);
}
#[test]
fn a_key_excluded_by_a_filter_is_flagged_even_though_it_occurs_in_the_source() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "region".into(),
criteria: "<>North".into(),
}]),
col_dim: None,
..cross_tab_spec()
};
let (mut wb, _src, _pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("amt", "p1")],
&["East", "South"],
);
let included = wb
.check_field_validation(0, 10, 0, "South".to_string())
.unwrap();
assert!(
!included.violates,
"South survives the filter, so it is a group this pivot has"
);
let excluded = wb
.check_field_validation(0, 10, 0, "North".to_string())
.unwrap();
assert!(
excluded.violates,
"North is a real region the filter excludes — its row would read 0 in \
every cell, so the key must be flagged: {}",
excluded.rule
);
assert!(
excluded.rule.contains("<>North"),
"the rule carries the pivot's own filters: {}",
excluded.rule
);
let unreal = wb
.check_field_validation(0, 10, 0, "Atlantis".to_string())
.unwrap();
assert!(
unreal.violates,
"and a value that never occurs is still flagged"
);
}
#[test]
fn a_source_filter_narrows_the_numbers_and_the_rows_together() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "quarter".into(),
criteria: "Q1".into(),
}]),
col_dim: None,
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("amt", "p1")],
&["East", "South", "North"],
);
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "East without its Q2 20");
assert_eq!(cell_num(&wb, 11, 1), Some(7.0), "South without its Q2 5");
assert_eq!(cell_num(&wb, 12, 1), Some(7.0), "North");
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.keys, vec!["East", "North", "South"], "ascending");
assert!(!plan.is_stale);
}
#[test]
fn a_filter_that_excludes_a_group_entirely_removes_its_row() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "quarter".into(),
criteria: "Q2".into(),
}]),
col_dim: None,
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("amt", "p1")],
&["East", "South", "North"],
);
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(plan.keys, vec!["East", "South"], "North is filtered out");
assert_eq!(plan.extra_keys, vec!["North"]);
assert!(plan.is_stale, "and the pivot is told to drop that row");
}
#[test]
fn a_numeric_filter_uses_the_same_condition_syntax_as_the_formula() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "amt".into(),
criteria: ">5".into(),
}]),
col_dim: None,
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("amt", "p1")],
&["East", "North"],
);
assert_eq!(cell_num(&wb, 10, 1), Some(30.0), "East 10 + 20");
assert_eq!(cell_num(&wb, 11, 1), Some(7.0), "North");
let plan = wb.pivot_plan(0, pivot).unwrap();
assert_eq!(
plan.keys,
vec!["East", "North"],
"South has no record over 5"
);
}
#[test]
fn a_filter_naming_a_field_the_source_lacks_is_an_error() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "nope".into(),
criteria: "x".into(),
}]),
..cross_tab_spec()
};
let (wb, _src, pivot) =
sales_with_columns(spec, &[SchemaFieldSpec::new("Q1", "p1")], &["East"]);
let err = wb.pivot_plan(0, pivot).unwrap_err();
assert!(
format!("{err:?}").contains("filters on") && format!("{err:?}").contains("nope"),
"{err:?}"
);
}
#[test]
fn a_pivots_inherited_number_formats_survive_a_round_trip() {
use crate::edit_action::UpsertFieldRenderInfo;
let money = "\"$\"#,##0.00";
let (mut wb, _src, pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Total", "p2").with_pivot_total(),
],
&["East", "South"],
);
let effect = apply(
&mut wb,
vec![
EditPayload::UpsertFieldRenderInfo(UpsertFieldRenderInfo {
render_id: "p1".to_string(),
diy_render: false,
style_update: crate::edit_action::StyleUpdateType {
set_num_fmt: Some(money.to_string()),
..Default::default()
},
}),
EditPayload::UpsertFieldRenderInfo(UpsertFieldRenderInfo {
render_id: "p2".to_string(),
diy_render: false,
style_update: crate::edit_action::StyleUpdateType {
set_num_fmt: Some(money.to_string()),
..Default::default()
},
}),
],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let bytes = wb.save().expect("save");
let restored = Workbook::from_file(&bytes, "rt".to_string()).expect("load");
let ws = restored.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
let fmt_of = |render_id: &str| {
p.field_renders
.iter()
.find(|r| r.render_id == render_id)
.and_then(|r| r.style.as_ref())
.map(|s| s.formatter.clone())
.unwrap_or_default()
};
assert_eq!(fmt_of("p1"), money, "the derived column");
assert_eq!(fmt_of("p2"), money, "and the declared total");
assert_eq!(cell_num(&restored, 10, 1), Some(10.0), "East Q1");
assert_eq!(cell_num(&restored, 11, 1), Some(7.0), "South Q1");
}
fn saved_pivot_parts(
wb: &Workbook,
) -> Option<(
logisheets_workbook::workbook::PivotCache,
logisheets_workbook::workbook::PivotTablePart,
)> {
let saved = crate::file_saver::save_file(&wb.controller, false).expect("save");
let cache = saved.xl.pivot_caches.first()?.clone();
let table = saved
.xl
.worksheets
.values()
.flat_map(|w| w.pivot_tables.iter())
.next()?
.clone();
Some((cache, table))
}
#[test]
fn a_plain_pivot_is_also_written_as_a_real_ooxml_pivot_table() {
let (wb, _src, _pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Q2", "p2"),
],
&["East", "South"],
);
let (cache, table) = saved_pivot_parts(&wb).expect("a pivot part was generated");
assert_eq!(
cache
.definition
.cache_source
.worksheet_source
.as_ref()
.unwrap()
.name
.as_deref(),
Some("sales")
);
assert!(cache.definition.refresh_on_load, "Excel builds the records");
assert!(cache.records.is_none(), "so we ship none");
assert_eq!(table.definition.row_fields.as_ref().unwrap().field[0].x, 1);
assert_eq!(table.definition.col_fields.as_ref().unwrap().field[0].x, 2);
assert_eq!(
table.definition.data_fields.as_ref().unwrap().data_field[0].fld,
3
);
assert_eq!(table.definition.location.reference, "A11:C12");
}
#[test]
fn a_host_can_ask_why_a_pivot_would_not_survive_excel() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let (wb, _src, pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Q2", "p2"),
],
&["East", "South"],
);
assert!(wb.pivot_excel_note(0, pivot).unwrap().expressible);
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "quarter".into(),
criteria: "Q1".into(),
}]),
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("Q1", "p1")],
&["East", "South"],
);
let note = wb.pivot_excel_note(0, pivot).unwrap();
assert!(!note.expressible, "a filtered pivot cannot be expressed");
let why = note.reason.expect("and it says why");
assert!(
why.contains("filters"),
"the reason names the part that does not map: {why}"
);
let (wb, src, _pivot) = sales_with_columns(
cross_tab_spec(),
&[SchemaFieldSpec::new("Q1", "p1")],
&["East"],
);
let note = wb.pivot_excel_note(0, src).unwrap();
assert!(note.expressible && note.reason.is_none());
}
#[test]
fn a_pivot_excel_cannot_express_gets_no_pivot_part() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
filters: Some(vec![PivotFilter {
field: "quarter".into(),
criteria: "Q1".into(),
}]),
..cross_tab_spec()
};
let (wb, _src, _pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("Q1", "p1")],
&["East", "South"],
);
assert!(
saved_pivot_parts(&wb).is_none(),
"a filtered pivot is not expressible as an OOXML pivot table"
);
}
#[test]
fn a_generated_pivot_part_leaves_no_formulas_in_its_own_range() {
let (wb, _src, _pivot) = sales_with_columns(
cross_tab_spec(),
&[SchemaFieldSpec::new("Q1", "p1")],
&["East", "South"],
);
let saved = crate::file_saver::save_file(&wb.controller, false).expect("save");
let ws = saved.xl.worksheets.values().next().unwrap();
for row in ws.worksheet_part.sheet_data.rows.iter() {
for cell in row.cells.iter() {
let Some(r) = cell.r.as_deref() else { continue };
let Some((ri, ci)) = crate::sqref::a1_to_row_col(r) else {
continue;
};
if (10..12).contains(&ri) && ci < 2 {
assert!(
cell.f.is_none(),
"{r} still carries a formula inside the pivot's range"
);
}
}
}
let has_values = ws.worksheet_part.sheet_data.rows.iter().any(|row| {
row.cells
.iter()
.any(|c| c.r.as_deref() == Some("B11") && c.v.is_some())
});
assert!(has_values, "the computed values are still written");
}
#[test]
fn everything_in_section_nine_survives_a_real_xlsx_round_trip() {
use crate::block_manager::schema_manager::field_type::{AggFunc, PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
order: Some("custom".into()),
order_values: Some(vec!["North".into(), "East".into()]),
filters: Some(vec![PivotFilter {
field: "quarter".into(),
criteria: "Q1".into(),
}]),
..cross_tab_spec()
};
let (wb, _src, pivot) = sales_with_columns(
spec,
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Total", "p2").with_pivot_total(),
SchemaFieldSpec::new("Orders", "p3").with_pivot_column(None, AggFunc::Count, "amt"),
],
&["North", "East", "South"],
);
let bytes = wb.save().expect("save");
let restored = Workbook::from_file(&bytes, "rt".to_string()).expect("load");
let ws = restored.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
let s = p.pivot.as_ref().expect("the recipe survived");
assert_eq!(s.order.as_deref(), Some("custom"));
assert_eq!(s.order_values.as_deref().unwrap(), ["North", "East"]);
let filters = s.filters.as_deref().unwrap();
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].field, "quarter");
assert_eq!(filters[0].criteria, "Q1");
let fields = &p.schema.as_ref().unwrap().fields;
let total = fields.iter().find(|f| f.field == "Total").unwrap();
assert_eq!(total.pivot_col_value.as_deref(), Some("*"));
let count = fields.iter().find(|f| f.field == "Orders").unwrap();
assert_eq!(count.pivot_col_value.as_deref(), Some("*"));
assert_eq!(count.pivot_func.as_deref(), Some("COUNT"));
assert_eq!(count.pivot_measure.as_deref(), Some("amt"));
let q1 = fields.iter().find(|f| f.field == "Q1").unwrap();
assert_eq!(q1.pivot_col_value, None);
assert_eq!(cell_num(&restored, 10, 1), Some(7.0), "North Q1");
}
fn rename_region_to_area(wb: &mut Workbook, src: logisheets_base::BlockId) {
use crate::edit_action::BindFormSchema;
let effect = apply(
wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "s0"),
SchemaFieldSpec::new("area", "s1"),
SchemaFieldSpec::new("quarter", "s2"),
SchemaFieldSpec::new("amt", "s3"),
],
row: true,
header_idx: None,
unique_together: None,
})],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"rename: {:?}",
effect.error_message
);
}
#[test]
fn renaming_a_source_field_carries_into_a_pivots_recipe() {
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "sanity");
rename_region_to_area(&mut wb, src);
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "East Q1 still computes");
assert_eq!(cell_num(&wb, 11, 1), Some(7.0), "South Q1 = 3 + 4");
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(
p.pivot.as_ref().unwrap().row_dim,
"area",
"the recipe followed the rename"
);
assert_eq!(wb.pivot_plan(0, pivot).unwrap().keys.len(), 3);
}
#[test]
fn renaming_a_measure_carries_into_the_recipe_and_the_declared_columns() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::BindFormSchema;
let (mut wb, src, pivot) = sales_with_columns(
cross_tab_spec(),
&[
SchemaFieldSpec::new("Q1", "p1"),
SchemaFieldSpec::new("Mean", "p2").with_pivot_column(None, AggFunc::Average, "amt"),
],
&["East", "South"],
);
assert_eq!(cell_num(&wb, 10, 2), Some(15.0), "East average of 10, 20");
let effect = apply(
&mut wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "s0"),
SchemaFieldSpec::new("region", "s1"),
SchemaFieldSpec::new("quarter", "s2"),
SchemaFieldSpec::new("amount", "s3"),
],
row: true,
header_idx: None,
unique_together: None,
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "the derived column");
assert_eq!(cell_num(&wb, 10, 2), Some(15.0), "and the declared one");
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(p.pivot.as_ref().unwrap().measure, "amount");
let declared = p
.schema
.as_ref()
.unwrap()
.fields
.iter()
.find(|f| f.field == "Mean")
.unwrap();
assert_eq!(declared.pivot_measure.as_deref(), Some("amount"));
}
#[test]
fn renaming_a_filtered_field_carries_into_the_filter() {
use crate::block_manager::schema_manager::field_type::{PivotFilter, PivotSpecParts};
let spec = PivotSpecParts {
col_dim: None,
filters: Some(vec![PivotFilter {
field: "quarter".into(),
criteria: "Q1".into(),
}]),
..cross_tab_spec()
};
let (mut wb, src, pivot) = sales_with_columns(
spec,
&[SchemaFieldSpec::new("amt", "p1")],
&["East", "South", "North"],
);
assert_eq!(cell_num(&wb, 10, 1), Some(10.0), "East Q1 only");
use crate::edit_action::BindFormSchema;
let effect = apply(
&mut wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "s0"),
SchemaFieldSpec::new("region", "s1"),
SchemaFieldSpec::new("quarter", "s2"),
SchemaFieldSpec::new("amt", "s3"),
],
row: true,
header_idx: None,
unique_together: None,
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 10, 1), Some(10.0));
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(
p.pivot.as_ref().unwrap().filters.as_ref().unwrap()[0].field,
"quarter"
);
}
#[test]
fn renaming_a_source_field_carries_into_a_total_rows_aggregate() {
use crate::block_manager::schema_manager::field_type::AggFunc;
use crate::edit_action::BindFormSchema;
let (mut wb, src, analysis) = orders_with_analysis(AggFunc::Sum);
assert_eq!(cell_num(&wb, 3, 1), Some(60.0), "sanity");
let effect = apply(
&mut wb,
vec![EditPayload::BindFormSchema(BindFormSchema {
ref_name: "orders".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("key", "s0"),
SchemaFieldSpec::new("amount", "s1"),
],
row: true,
header_idx: None,
unique_together: None,
})],
);
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
assert_eq!(cell_num(&wb, 3, 1), Some(60.0), "the total still totals");
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let a = blocks.iter().find(|b| b.block_id == analysis).unwrap();
let amt = a
.schema
.as_ref()
.unwrap()
.fields
.iter()
.find(|f| f.agg_field.is_some())
.unwrap();
assert_eq!(amt.agg_field.as_deref(), Some("amount"));
}
#[test]
fn adding_and_reordering_fields_is_not_read_as_a_rename() {
use crate::edit_action::BindFormSchema;
let (mut wb, src, pivot) = sales_with_pivot("SUM", Some("quarter"));
let effect = apply(
&mut wb,
vec![
EditPayload::InsertColsInBlock(crate::edit_action::InsertColsInBlock {
sheet_idx: 0,
block_id: src,
start: 4,
cnt: 1,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "sales".into(),
sheet_idx: 0,
block_id: src,
field_from: 0,
key_idx: 0,
fields: vec![
SchemaFieldSpec::new("id", "s0"),
SchemaFieldSpec::new("region", "s1"),
SchemaFieldSpec::new("quarter", "s2"),
SchemaFieldSpec::new("amt", "s3"),
SchemaFieldSpec::new("note", "s4"),
],
row: true,
header_idx: None,
unique_together: None,
}),
],
);
assert!(
matches!(effect.status, crate::edit_action::StatusCode::Ok(_)),
"{:?}",
effect.error_message
);
let ws = wb.get_sheet_by_idx(0).unwrap();
let blocks = ws.get_all_blocks();
let p = blocks.iter().find(|b| b.block_id == pivot).unwrap();
assert_eq!(p.pivot.as_ref().unwrap().row_dim, "region", "untouched");
assert_eq!(cell_num(&wb, 10, 1), Some(10.0));
}
#[test]
fn calc_condition_is_read_only() {
let mut wb = Workbook::default();
let input = |content: &str| PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx: 0,
row: 0,
col: 0,
content: content.to_string(),
})],
undoable: true,
init: false,
};
wb.handle_action(EditAction::Payloads(input("5")));
wb.handle_action_in_temp_status(input("7"));
let revision = wb.get_version();
assert_eq!(wb.calc_condition(0, "=A1>6".to_string()).unwrap(), true);
assert!(
wb.is_in_temp_mode(),
"evaluating must not discard the temp branch"
);
assert_eq!(wb.get_version(), revision);
assert!(wb.calc_condition(0, "=1/0".to_string()).is_err());
wb.clean_temp_status();
assert_eq!(wb.calc_condition(0, "=A1=5".to_string()).unwrap(), true);
}
#[test]
fn calc_condition_bad_sheet_idx_is_err_not_panic() {
let mut wb = Workbook::default();
assert!(wb.calc_condition(9, "=TRUE".to_string()).is_err());
}
#[test]
fn workbook_without_styles_part_loads() {
let buf = std::fs::read("../../tests/no_styles.xlsx").unwrap();
let wb = Workbook::from_file(&buf, "no_styles".to_string()).unwrap();
let ws = wb.get_sheet_by_idx(0).unwrap();
let (max_row, max_col) = {
let d = ws.get_sheet_dimension().unwrap();
(d.max_row, d.max_col)
};
for r in 0..=max_row.min(20) {
for c in 0..=max_col.min(20) {
ws.get_cell_info(r, c).unwrap();
ws.get_style(r, c).unwrap();
}
}
let saved = wb.save().unwrap();
Workbook::from_file(&saved, "resaved".to_string()).unwrap();
}
#[test]
fn block_display_window_covers_the_whole_block() {
let mut wb = Workbook::default();
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CreateBlock(CreateBlock {
sheet_idx: 0,
id: 1,
master_row: 1,
master_col: 1,
row_cnt: 3,
col_cnt: 2,
owner: None,
modify_policy: None,
permissions: None,
description: None,
analyzes: None,
pivot: None,
})],
undoable: false,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
let ws = wb.get_sheet_by_idx(0).unwrap();
let window = ws.get_display_window_for_block(1).unwrap();
assert_eq!(window.cells.len(), 3 * 2);
}
fn three_sheets_with_a1(values: [&str; 3]) -> Workbook {
let mut wb = Workbook::default();
let mut payloads = vec![
EditPayload::CreateSheet(crate::edit_action::CreateSheet {
idx: 1,
new_name: "Sheet2".to_string(),
}),
EditPayload::CreateSheet(crate::edit_action::CreateSheet {
idx: 2,
new_name: "Sheet3".to_string(),
}),
];
for (sheet_idx, v) in values.iter().enumerate() {
payloads.push(EditPayload::CellInput(CellInput {
sheet_idx,
row: 0,
col: 0,
content: v.to_string(),
}));
}
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads,
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
wb
}
fn input_at(wb: &mut Workbook, sheet_idx: usize, row: usize, col: usize, content: &str) {
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::CellInput(CellInput {
sheet_idx,
row,
col,
content: content.to_string(),
})],
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
}
fn value_at(
wb: &Workbook,
sheet_idx: usize,
row: usize,
col: usize,
) -> crate::controller::display::Value {
wb.get_sheet_by_idx(sheet_idx)
.unwrap()
.get_value(row, col)
.unwrap()
}
#[test]
fn three_d_references_evaluate_across_sheets() {
let mut wb = three_sheets_with_a1(["1", "2", "4"]);
input_at(&mut wb, 0, 5, 0, "=SUM(Sheet1:Sheet3!A1)");
input_at(&mut wb, 0, 5, 1, "=SUM(Sheet1:Sheet3!A1:B2)");
input_at(&mut wb, 0, 5, 2, "=COUNT(Sheet2:Sheet3!A1)");
assert!(
matches!(value_at(&wb, 0, 5, 0), crate::controller::display::Value::Number(n) if n == 7.0)
);
assert!(
matches!(value_at(&wb, 0, 5, 1), crate::controller::display::Value::Number(n) if n == 7.0)
);
assert!(
matches!(value_at(&wb, 0, 5, 2), crate::controller::display::Value::Number(n) if n == 2.0)
);
input_at(&mut wb, 1, 0, 0, "20");
assert!(
matches!(value_at(&wb, 0, 5, 0), crate::controller::display::Value::Number(n) if n == 25.0)
);
}
#[test]
fn three_d_reference_with_the_sheets_reversed_spans_the_same_sheets() {
let mut wb = three_sheets_with_a1(["1", "2", "4"]);
input_at(&mut wb, 0, 5, 0, "=SUM(Sheet3:Sheet1!A1)");
assert!(
matches!(value_at(&wb, 0, 5, 0), crate::controller::display::Value::Number(n) if n == 7.0)
);
}
#[test]
fn external_reference_is_ref_error_not_panic() {
let mut wb = Workbook::default();
input_at(&mut wb, 0, 0, 0, "=[Book2.xlsx]Sheet1!A1");
input_at(&mut wb, 0, 0, 1, "=SUM([Book2.xlsx]Sheet1!A1:B2)");
assert!(matches!(
value_at(&wb, 0, 0, 0),
crate::controller::display::Value::Error(_)
));
assert!(matches!(
value_at(&wb, 0, 0, 1),
crate::controller::display::Value::Error(_)
));
}
#[test]
fn three_d_reference_survives_losing_its_end_sheet() {
let mut wb = three_sheets_with_a1(["1", "2", "4"]);
input_at(&mut wb, 0, 5, 0, "=SUM(Sheet3:Sheet1!A1)");
input_at(&mut wb, 1, 0, 0, "20");
assert!(
matches!(value_at(&wb, 0, 5, 0), crate::controller::display::Value::Number(n) if n == 25.0)
);
let effect = wb.handle_action(EditAction::Payloads(PayloadsAction {
payloads: vec![EditPayload::DeleteSheet(crate::edit_action::DeleteSheet {
idx: 2,
})],
undoable: true,
init: false,
}));
assert!(matches!(
effect.status,
crate::edit_action::StatusCode::Ok(_)
));
input_at(&mut wb, 1, 3, 3, "9");
input_at(&mut wb, 0, 3, 3, "9");
assert!(matches!(
value_at(&wb, 0, 5, 0),
crate::controller::display::Value::Error(_)
));
}