use crate::edit_action::{
AddComment, AuthorInput, CellInput, CommentMention, CreateBlock, CreateChart,
CreateChartSeries, DeleteCellImage, DeleteChart, DeleteComment, EditComment, EditPayload,
LineStyleUpdate, ModifyPolicy, MoveChart, PayloadsAction, 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 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 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 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
);
}