use crate::edit_action::{
AddComment, AuthorInput, CellInput, CommentMention, CreateBlock, CreateChart,
CreateChartSeries, CreateDiyCell, DeleteCellImage, DeleteChart, DeleteComment, EditComment,
EditPayload, LineStyleUpdate, ModifyPolicy, MoveChart, PayloadsAction, RemoveDiyCell,
ResolveComment, 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()),
})],
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(),
},
CreateChartSeries {
name: Some("Row2".to_string()),
value_ref: "Sheet1!$B$2:$C$2".to_string(),
},
],
})],
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,
})],
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,
})],
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["v".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["v".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![Some("#PLACEHOLDER>100".into())],
editability_formulas: vec![Some("#PLACEHOLDER>100".into())],
}),
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 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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["v".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
})];
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,
})];
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 1,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["v".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["v".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["v".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
})];
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,
}),
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),
})],
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 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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["name".into(), "age".into()],
render_ids: vec!["r_name".into(), "r_age".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
];
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,
}),
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["Customer Status".into()],
render_ids: vec!["r0".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "people".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["name".into(), "age".into()],
render_ids: vec!["r_name".into(), "r_age".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
})],
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["year".into(), "amount".into()],
render_ids: vec!["r0".into(), "r1".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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,
}),
EditPayload::BindFormSchema(BindFormSchema {
ref_name: "rec".into(),
sheet_idx: 0,
block_id: bid,
field_from: 0,
key_idx: 0,
fields: vec!["id".into(), "qty".into()],
render_ids: vec!["r0".into(), "r1".into()],
row: true,
field_formulas: vec![],
validation_formulas: vec![],
editability_formulas: vec![],
}),
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);
}