- 1
//! L4 semantic diff (docs/design/72-openxml-documents.md, P3). - 2
- 3
#![allow(clippy::unwrap_used, clippy::expect_used)] - 4
- 5
use std::collections::BTreeMap; - 6
use std::io::Cursor; - 7
- 8
use vak_ooxml::diff::{ChangeKind, ImpactKind, diff, impact}; - 9
use vak_ooxml::edit::{self, CellValue, EditContext, OfficeOp, TextValue}; - 10
use vak_ooxml::{Limits, fixtures, read}; - 11
- 12
fn project(bytes: &[u8]) -> read::Document { - 13
read::read(Cursor::new(bytes.to_vec()), Limits::default()).unwrap() - 14
} - 15
- 16
fn edited(bytes: &[u8], ops: Vec<OfficeOp>) -> Vec<u8> { - 17
let context = EditContext { - 18
author: "Mira".into(), - 19
date: "2026-09-24T10:00:00Z".into(), - 20
tracked: true, - 21
}; - 22
edit::apply(bytes, &ops, &context, Limits::default(), None) - 23
.unwrap() - 24
.bytes - 25
} - 26
- 27
#[test] - 28
fn an_unchanged_file_has_no_changes() { - 29
let bytes = fixtures::docx(); - 30
assert!(diff(Some(&project(&bytes)), &project(&bytes)).is_empty()); - 31
} - 32
- 33
#[test] - 34
fn word_changes_are_grouped_under_their_heading() { - 35
let before = fixtures::docx(); - 36
let after = edited( - 37
&before, - 38
vec![ - 39
OfficeOp::ReplaceParagraphText { - 40
anchor: "p@11".into(), - 41
text: "Growing.".into(), - 42
}, - 43
OfficeOp::AddParagraph { - 44
text: "Details".into(), - 45
style: None, - 46
after: Some("p@1".into()), - 47
}, - 48
], - 49
); - 50
let result = diff(Some(&project(&before)), &project(&after)); - 51
let rewritten = result - 52
.changes - 53
.iter() - 54
.find(|change| change.anchor == "p@11") - 55
.unwrap(); - 56
assert_eq!(rewritten.kind, ChangeKind::Changed); - 57
assert_eq!(rewritten.section, "Outlook"); - 58
assert_eq!(rewritten.before.as_deref(), Some("Steady.")); - 59
assert!( - 60
rewritten - 61
.after - 62
.as_deref() - 63
.unwrap() - 64
.contains("[deleted by Mira: Steady][inserted by Mira: Growing].") - 65
); - 66
let added = result - 67
.changes - 68
.iter() - 69
.find(|change| change.kind == ChangeKind::Added) - 70
.unwrap(); - 71
assert_eq!(added.section, "Summary"); - 72
assert_eq!( - 73
result.summary, - 74
vec!["Summary: 1 added", "Outlook: 1 changed"] - 75
); - 76
} - 77
- 78
#[test] - 79
fn excel_changes_are_cells_and_stale_caches_do_not_count() { - 80
let before = fixtures::xlsx(); - 81
let after = edited( - 82
&before, - 83
vec![ - 84
OfficeOp::SetCells { - 85
sheet: "Budget".into(), - 86
cells: BTreeMap::from([ - 87
("B2".to_string(), CellValue::Number(150.0)), - 88
("F9".to_string(), CellValue::Text("note".into())), - 89
]), - 90
}, - 91
OfficeOp::AddSheet { name: "Q4".into() }, - 92
], - 93
); - 94
let result = diff(Some(&project(&before)), &project(&after)); - 95
let anchors: Vec<(&str, ChangeKind)> = result - 96
.changes - 97
.iter() - 98
.map(|change| (change.anchor.as_str(), change.kind)) - 99
.collect(); - 100
assert_eq!( - 101
anchors, - 102
vec![ - 103
("Budget!B2", ChangeKind::Changed), - 104
("Budget!F9", ChangeKind::Added), - 105
("Q4!", ChangeKind::Added), - 106
], - 107
"B4's cached value turning stale is not reported as a change" - 108
); - 109
let b2 = &result.changes[0]; - 110
assert_eq!( - 111
(b2.before.as_deref(), b2.after.as_deref()), - 112
(Some("100"), Some("150")) - 113
); - 114
} - 115
- 116
#[test] - 117
fn deck_changes_report_added_moved_and_retitled_slides() { - 118
let before = fixtures::pptx(); - 119
let after = edited( - 120
&before, - 121
vec![ - 122
OfficeOp::SetPlaceholderText { - 123
anchor: "slide:256/placeholder:title".into(), - 124
text: TextValue::One("Launch plan v2".into()), - 125
}, - 126
OfficeOp::MoveSlide { - 127
anchor: "slide:257".into(), - 128
after: None, - 129
}, - 130
], - 131
); - 132
let result = diff(Some(&project(&before)), &project(&after)); - 133
assert!( - 134
result - 135
.changes - 136
.iter() - 137
.any(|change| change.kind == ChangeKind::Moved) - 138
); - 139
let retitled = result - 140
.changes - 141
.iter() - 142
.find(|change| change.anchor == "slide:256" && change.kind == ChangeKind::Changed) - 143
.unwrap(); - 144
assert_eq!(retitled.before.as_deref(), Some("Launch plan")); - 145
assert_eq!(retitled.after.as_deref(), Some("Launch plan v2")); - 146
} - 147
- 148
#[test] - 149
fn a_new_file_is_one_added_change() { - 150
let result = diff(None, &project(&fixtures::pptx())); - 151
assert_eq!(result.changes.len(), 1); - 152
assert!( - 153
result.changes[0] - 154
.after - 155
.as_deref() - 156
.unwrap() - 157
.starts_with("new file: 2 slides") - 158
); - 159
} - 160
- 161
#[test] - 162
fn accepting_states_what_happens_to_signatures_and_labels() { - 163
let signed = fixtures::signed_labelled_docx(); - 164
let edit = vec![OfficeOp::ReplaceParagraphText { - 165
anchor: "p@1".into(), - 166
text: "Hello again".into(), - 167
}]; - 168
let impacts = impact(Some(&project(&signed)), &project(&edited(&signed, edit))); - 169
assert_eq!(impacts.len(), 2, "{impacts:?}"); - 170
assert_eq!(impacts[0].kind, ImpactKind::Signature); - 171
assert!(impacts[0].warning); - 172
assert!(impacts[0].message.contains("removes its signatures")); - 173
assert_eq!(impacts[1].kind, ImpactKind::Label); - 174
assert!(!impacts[1].warning); - 175
assert_eq!( - 176
impacts[1].message, - 177
"Labelled Confidential; the label is kept." - 178
); - 179
- 180
// A draft made some other way that kept the signature but changed the - 181
// content: the signature no longer holds, whatever the file claims. - 182
let tampered = fixtures::with_parts( - 183
&signed, - 184
&[( - 185
"word/document.xml", - 186
fixtures::MINIMAL_WORD_BODY - 187
.replace("Hello", "Goodbye") - 188
.as_bytes(), - 189
)], - 190
); - 191
let impacts = impact(Some(&project(&signed)), &project(&tampered)); - 192
assert!( - 193
impacts[0].message.contains("no longer holds"), - 194
"{impacts:?}" - 195
); - 196
- 197
// Replacing a labelled file with an unlabelled one removes the label. - 198
let impacts = impact(Some(&project(&signed)), &project(&fixtures::docx())); - 199
let label = impacts - 200
.iter() - 201
.find(|i| i.kind == ImpactKind::Label) - 202
.unwrap(); - 203
assert!(label.warning); - 204
assert!( - 205
label - 206
.message - 207
.contains("removes the sensitivity label Confidential") - 208
); - 209
- 210
assert!( - 211
impact( - 212
Some(&project(&fixtures::docx())), - 213
&project(&fixtures::docx()) - 214
) - 215
.is_empty() - 216
); - 217
} - 218
- 219
#[test] - 220
fn a_cell_on_a_sheet_whose_name_needs_quotes_is_anchored_as_the_reader_anchors_it() { - 221
let before = edited( - 222
&fixtures::xlsx(), - 223
vec![OfficeOp::AddSheet { - 224
name: "Q4 plan".into(), - 225
}], - 226
); - 227
let after = edited( - 228
&before, - 229
vec![OfficeOp::SetCells { - 230
sheet: "Q4 plan".into(), - 231
cells: BTreeMap::from([("A1".to_string(), CellValue::Text("Target".into()))]), - 232
}], - 233
); - 234
let changes = diff(Some(&project(&before)), &project(&after)).changes; - 235
assert_eq!(changes.len(), 1, "{changes:?}"); - 236
assert_eq!(changes[0].section, "Q4 plan"); - 237
assert_eq!(changes[0].anchor, "'Q4 plan'!A1"); - 238
assert!( - 239
project(&after) - 240
.lines() - 241
.iter() - 242
.any(|line| line.starts_with("['Q4 plan'!A1")), - 243
"the reader uses the same anchor" - 244
); - 245
} - 246
- 247
#[test] - 248
fn a_changed_input_reports_the_formulas_left_showing_old_values() { - 249
let before = fixtures::xlsx(); - 250
let after = edited( - 251
&before, - 252
vec![OfficeOp::SetCells { - 253
sheet: "budget".into(), - 254
cells: BTreeMap::from([("B2".to_string(), CellValue::Number(150.0))]), - 255
}], - 256
); - 257
let impacts = impact(Some(&project(&before)), &project(&after)); - 258
let recalculation = impacts - 259
.iter() - 260
.find(|impact| impact.kind == ImpactKind::Recalculation) - 261
.expect("the stale formula is reported"); - 262
assert!( - 263
recalculation.message.contains("B4"), - 264
"{}", - 265
recalculation.message - 266
); - 267
assert!(!recalculation.warning, "stale values weaken no protection"); - 268
assert!( - 269
impact(Some(&project(&after)), &project(&after)) - 270
.iter() - 271
.all(|impact| impact.kind != ImpactKind::Recalculation), - 272
"a formula already stale before the change is not news" - 273
); - 274
} - 275
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.