- 1
//! Keeping some of a draft's changes (docs/design/72-openxml-documents.md, - 2
//! P3): the choices a draft offers, what each requires, and that a narrower - 3
//! draft is a replay of exactly the kept ops against the same source. - 4
- 5
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 6
- 7
use std::collections::BTreeMap; - 8
- 9
use vak_ooxml::Limits; - 10
use vak_ooxml::diff::ChangeKind; - 11
use vak_ooxml::edit::{self, CellValue, EditContext, OfficeOp, TextValue}; - 12
use vak_ooxml::fixtures; - 13
use vak_ooxml::review::{self, Choice}; - 14
- 15
fn context() -> EditContext { - 16
EditContext { - 17
author: "Mira".into(), - 18
date: "2026-09-24T10:00:00Z".into(), - 19
tracked: true, - 20
} - 21
} - 22
- 23
fn draft(source: &[u8], ops: &[OfficeOp]) -> edit::Applied { - 24
edit::apply(source, ops, &context(), Limits::default(), None).unwrap() - 25
} - 26
- 27
fn choices(source: &[u8], ops: &[OfficeOp]) -> Vec<Choice> { - 28
let draft = draft(source, ops); - 29
review::choices( - 30
source, - 31
ops, - 32
&context(), - 33
Limits::default(), - 34
None, - 35
&draft.document, - 36
) - 37
.unwrap() - 38
} - 39
- 40
fn narrow(source: &[u8], ops: &[OfficeOp], keep: &[&str]) -> Result<String, String> { - 41
let keep: Vec<String> = keep.iter().map(|id| id.to_string()).collect(); - 42
let full = draft(source, ops); - 43
review::narrow( - 44
source, - 45
ops, - 46
&keep, - 47
&context(), - 48
Limits::default(), - 49
None, - 50
&full.document, - 51
) - 52
.map(|applied| applied.document.lines().join("\n")) - 53
.map_err(|error| error.to_string()) - 54
} - 55
- 56
fn insert(anchor: &str, text: &str) -> OfficeOp { - 57
OfficeOp::AddParagraph { - 58
text: text.into(), - 59
style: None, - 60
after: Some(anchor.into()), - 61
} - 62
} - 63
- 64
#[test] - 65
fn a_kept_paragraph_still_follows_the_paragraph_it_was_written_after() { - 66
let source = fixtures::docx(); - 67
// Op 4 names the paragraph op 2 minted (the second new id). Leaving op 1 - 68
// out shifts every later new id down by one, so a replay that did not - 69
// remap would put op 4's text after op 3's paragraph instead. - 70
let ops = vec![ - 71
insert("p@1", "Alpha."), - 72
insert("p@11", "Bravo."), - 73
insert("p@3", "Charlie."), - 74
insert("p:1A000002", "Delta."), - 75
]; - 76
let offered = choices(&source, &ops); - 77
let ids: Vec<&str> = offered.iter().map(|choice| choice.id.as_str()).collect(); - 78
assert_eq!(ids, ["0", "1", "2", "3"]); - 79
assert_eq!(offered[3].requires, ["1"]); - 80
assert!(offered[..3].iter().all(|choice| choice.requires.is_empty())); - 81
assert_eq!(offered[0].label, "New paragraph after p@1: Alpha."); - 82
assert_eq!(offered[0].changes.len(), 1); - 83
assert_eq!(offered[0].changes[0].kind, ChangeKind::Added); - 84
- 85
let text = narrow(&source, &ops, &["1", "2", "3"]).unwrap(); - 86
assert!(!text.contains("Alpha."), "{text}"); - 87
let bravo = text.find("Bravo.").expect("Bravo kept"); - 88
let delta = text.find("Delta.").expect("Delta kept"); - 89
let charlie = text.find("Charlie.").expect("Charlie kept"); - 90
let after_bravo = &text[bravo..]; - 91
let next_line = after_bravo.lines().nth(1).unwrap_or_default(); - 92
assert!(next_line.contains("Delta."), "Delta follows Bravo:\n{text}"); - 93
assert!(charlie < bravo && bravo < delta, "{text}"); - 94
- 95
let error = narrow(&source, &ops, &["0", "3"]).unwrap_err(); - 96
assert!( - 97
error.contains("builds on edit 2 (New paragraph after p@11: Bravo.)"), - 98
"{error}" - 99
); - 100
let error = narrow(&source, &ops, &[]).unwrap_err(); - 101
assert!(error.contains("no change was kept"), "{error}"); - 102
let error = narrow(&source, &ops, &["9"]).unwrap_err(); - 103
assert!(error.contains("\"9\" is not a change"), "{error}"); - 104
} - 105
- 106
#[test] - 107
fn cells_are_chosen_one_at_a_time_and_a_new_sheet_carries_its_cells() { - 108
let source = fixtures::xlsx(); - 109
let ops = vec![ - 110
OfficeOp::SetCells { - 111
sheet: "Budget".into(), - 112
cells: BTreeMap::from([ - 113
("B2".to_string(), CellValue::Number(150.0)), - 114
("b3".to_string(), CellValue::Number(70.0)), - 115
]), - 116
}, - 117
OfficeOp::AddSheet { name: "Q4".into() }, - 118
OfficeOp::SetCells { - 119
sheet: "q4".into(), - 120
cells: BTreeMap::from([("A1".to_string(), CellValue::Text("Plan".into()))]), - 121
}, - 122
]; - 123
let offered = choices(&source, &ops); - 124
let ids: Vec<&str> = offered.iter().map(|choice| choice.id.as_str()).collect(); - 125
assert_eq!(ids, ["0:B2", "0:B3", "1", "2"]); - 126
assert_eq!(offered[0].label, "Set Budget!B2"); - 127
assert_eq!(offered[0].changes.len(), 1, "{:?}", offered[0].changes); - 128
assert_eq!(offered[0].changes[0].anchor, "Budget!B2"); - 129
assert_eq!(offered[3].requires, ["1"]); - 130
- 131
let text = narrow(&source, &ops, &["0:B3"]).unwrap(); - 132
assert!(text.contains("B3: 70"), "{text}"); - 133
assert!(!text.contains("B2: 150"), "{text}"); - 134
assert!(!text.contains("Q4"), "{text}"); - 135
- 136
let error = narrow(&source, &ops, &["2"]).unwrap_err(); - 137
assert!(error.contains("builds on edit 2"), "{error}"); - 138
let error = narrow(&source, &ops, &["0:Z9"]).unwrap_err(); - 139
assert!(error.contains("not a change in this draft"), "{error}"); - 140
} - 141
- 142
#[test] - 143
fn a_kept_edit_to_a_new_slide_still_lands_on_that_slide() { - 144
let source = fixtures::pptx_template(); - 145
let slide = |title: &str| OfficeOp::AddSlideFromLayout { - 146
layout: "Title Slide".into(), - 147
after: None, - 148
placeholders: BTreeMap::from([("title".to_string(), TextValue::One(title.into()))]), - 149
notes: None, - 150
}; - 151
let ops = vec![ - 152
slide("First"), - 153
slide("Second"), - 154
OfficeOp::SetPlaceholderText { - 155
anchor: "slide:258/placeholder:title".into(), - 156
text: TextValue::One("Second, renamed".into()), - 157
}, - 158
]; - 159
let offered = choices(&source, &ops); - 160
assert_eq!(offered[2].requires, ["1"]); - 161
- 162
let text = narrow(&source, &ops, &["1", "2"]).unwrap(); - 163
assert!(!text.contains("First"), "{text}"); - 164
assert!(!text.contains("slide:258"), "{text}"); - 165
assert!( - 166
text.contains("[slide:257] Slide 2: Second, renamed"), - 167
"{text}" - 168
); - 169
} - 170
- 171
#[test] - 172
fn a_draft_the_ops_do_not_reproduce_is_taken_whole() { - 173
let source = fixtures::docx(); - 174
let ops = vec![insert("p@1", "Alpha."), insert("p@3", "Bravo.")]; - 175
let edited_later = draft(&source, &[insert("p@1", "Alpha, edited by hand.")]); - 176
let error = review::choices( - 177
&source, - 178
&ops, - 179
&context(), - 180
Limits::default(), - 181
None, - 182
&edited_later.document, - 183
) - 184
.unwrap_err(); - 185
assert!( - 186
error.contains("not what its recorded edits produce"), - 187
"{error}" - 188
); - 189
let error = review::narrow( - 190
&source, - 191
&ops, - 192
&["0".to_string()], - 193
&context(), - 194
Limits::default(), - 195
None, - 196
&edited_later.document, - 197
) - 198
.unwrap_err(); - 199
assert!( - 200
error - 201
.to_string() - 202
.contains("not what its recorded edits produce"), - 203
"{error}" - 204
); - 205
- 206
let too_many: Vec<OfficeOp> = (0..=review::MAX_OPS) - 207
.map(|_| OfficeOp::SetTitle { title: "x".into() }) - 208
.collect(); - 209
let error = review::choices( - 210
&source, - 211
&too_many, - 212
&context(), - 213
Limits::default(), - 214
None, - 215
&edited_later.document, - 216
) - 217
.unwrap_err(); - 218
assert!(error.contains("choosing among more than"), "{error}"); - 219
} - 220
- 221
#[test] - 222
fn a_word_edit_is_offered_and_kept_as_its_changed_words() { - 223
let source = fixtures::docx(); - 224
let ops = vec![ - 225
OfficeOp::ReplaceParagraphText { - 226
anchor: "p@11".into(), - 227
text: "Growing.".into(), - 228
}, - 229
insert("p@1", "Details"), - 230
]; - 231
let offered = choices(&source, &ops); - 232
assert_eq!(offered.len(), 2); - 233
assert_eq!(offered[0].label, "Edit paragraph p@11"); - 234
assert_eq!( - 235
offered[0].changes[0].after.as_deref(), - 236
Some("[deleted by Mira: Steady][inserted by Mira: Growing].") - 237
); - 238
let narrowed = narrow(&source, &ops, &["0"]).unwrap(); - 239
assert!( - 240
narrowed.contains("[p@11] [deleted by Mira: Steady][inserted by Mira: Growing]."), - 241
"{narrowed}" - 242
); - 243
assert!(!narrowed.contains("Details"), "{narrowed}"); - 244
} - 245
- 246
#[test] - 247
fn in_a_new_document_an_edit_that_names_a_renumbered_paragraph_needs_the_removal() { - 248
let clean = EditContext { - 249
tracked: false, - 250
..context() - 251
}; - 252
let source = fixtures::docx(); - 253
// Two calls: the first removes p@3; the second, written against that - 254
// draft, names p@3 again: the paragraph that followed the removed one. - 255
let ops = vec![ - 256
OfficeOp::DeleteParagraph { - 257
anchor: "p@3".into(), - 258
}, - 259
OfficeOp::ReplaceParagraphText { - 260
anchor: "p@3".into(), - 261
text: "Field".into(), - 262
}, - 263
]; - 264
let draft = edit::apply(&source, &ops, &clean, Limits::default(), None).unwrap(); - 265
let offered = review::choices( - 266
&source, - 267
&ops, - 268
&clean, - 269
Limits::default(), - 270
None, - 271
&draft.document, - 272
) - 273
.unwrap(); - 274
assert_eq!(offered[1].requires, vec!["0".to_string()], "{offered:?}"); - 275
let error = review::narrow( - 276
&source, - 277
&ops, - 278
&["1".to_string()], - 279
&clean, - 280
Limits::default(), - 281
None, - 282
&draft.document, - 283
) - 284
.unwrap_err() - 285
.to_string(); - 286
assert!(error.contains("builds on edit 1"), "{error}"); - 287
} - 288
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.