1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
//! What a replace does to the formatting it overwrites — end to end, through the real
//! document.
//!
//! There were 11 replace tests in this repo and **none** of them mentioned formatting.
//! The behaviour was emergent: a replace was a delete followed by an insert, so the
//! replacement inherited whatever run preceded it and every run *under* the range was
//! simply dropped. For a writer that means renaming a character whose name is styled —
//! `*Aurélien*` — silently loses the styling, across every occurrence, in one action,
//! with autosave writing the result to disk seconds later.
//!
//! These tests pin all four policies against real Djot, so the choice is visible in the
//! markup a writer would actually get back.
use text_document::{
DjotExportOptions, DjotImportOptions, FindOptions, ReplaceFormatPolicy, ReplaceOptions,
TextDocument,
};
/// Load `djot`, replace `query` with `replacement` under `policy`, and hand back the
/// re-exported Djot — i.e. exactly what would be written to the writer's file.
fn replace_and_export(
djot: &str,
query: &str,
replacement: &str,
policy: ReplaceFormatPolicy,
) -> String {
let doc = TextDocument::new();
doc.set_djot_with_options(djot, DjotImportOptions::default())
.and_then(|op| op.wait())
.expect("set_djot");
let opts = ReplaceOptions::new(FindOptions::default()).with_format_policy(policy);
let count = doc
.replace_text(query, replacement, true, &opts)
.expect("replace_text");
assert!(count > 0, "the query must actually match");
doc.to_djot_with_options(DjotExportOptions::default())
.expect("to_djot")
.trim()
.to_string()
}
/// **The data loss, made visible.** The name is entirely inside a strong run, so the
/// replacement lands on a run that begins *exactly* where it does — and the historical
/// rule ("inherit the run that ends at or straddles the start") sees nothing to inherit.
/// The emphasis is gone from the writer's file.
///
/// This is not a bug being fixed; it is the shipped behaviour being *pinned*, so that
/// choosing it is now a decision rather than an accident.
#[test]
fn the_default_policy_drops_the_styling_it_overwrites() {
let out = replace_and_export(
"She called *Aurélien* into the trees.",
"Aurélien",
"Aurélian",
ReplaceFormatPolicy::InheritPreceding,
);
assert_eq!(
out, "She called Aurélian into the trees.",
"the historical default loses the emphasis — pinned so it cannot change silently"
);
}
/// The policy a character rename actually wants: the name was wholly styled, so the new
/// name is too.
#[test]
fn preserve_if_fully_covered_keeps_a_wholly_styled_name() {
let out = replace_and_export(
"She called *Aurélien* into the trees.",
"Aurélien",
"Aurélian",
ReplaceFormatPolicy::PreserveIfFullyCovered,
);
assert_eq!(
out, "She called *Aurélian* into the trees.",
"a name that was entirely emphasised must stay emphasised across the rename"
);
}
/// …but it refuses to *guess*. When the range is only partly styled, no single run
/// covers it, so it falls back to inheritance rather than inventing a format for text
/// the writer never styled that way.
///
/// Note the query is **plain text**: the search runs against the parsed prose, not the
/// Djot source, so markup never matches and never shifts an offset. Only the surname is
/// emphasised, and the replaced range spans both words.
#[test]
fn preserve_if_fully_covered_does_not_guess_on_a_partly_styled_range() {
let out = replace_and_export(
"She called Aurélien *Dubois* home.",
"Aurélien Dubois",
"Aurélien Duval",
ReplaceFormatPolicy::PreserveIfFullyCovered,
);
assert_eq!(
out, "She called Aurélien Duval home.",
"partly styled → fall back, do not paint the whole replacement with a style that \
only covered part of it"
);
}
/// `PreserveNothing` means what it says, even when the range was wholly styled.
#[test]
fn preserve_nothing_strips_the_styling() {
let out = replace_and_export(
"She called *Aurélien* into the trees.",
"Aurélien",
"Aurélian",
ReplaceFormatPolicy::PreserveNothing,
);
assert_eq!(out, "She called Aurélian into the trees.");
}
/// `KeepDominantRun` keeps the styling that covered most of what it replaced.
#[test]
fn keep_dominant_run_keeps_the_style_that_covered_most_of_the_range() {
let out = replace_and_export(
"She called *Aurélien* into the trees.",
"Aurélien",
"Aurélian",
ReplaceFormatPolicy::KeepDominantRun,
);
assert_eq!(
out, "She called *Aurélian* into the trees.",
"the emphasis covered the whole name, so it dominates"
);
}
/// Text *around* the replaced range must never be disturbed, whatever the policy. A
/// rename that reformatted the rest of the sentence would be far worse than one that
/// lost a style.
#[test]
fn no_policy_disturbs_the_formatting_around_the_replacement() {
for policy in [
ReplaceFormatPolicy::InheritPreceding,
ReplaceFormatPolicy::PreserveIfFullyCovered,
ReplaceFormatPolicy::KeepDominantRun,
ReplaceFormatPolicy::PreserveNothing,
] {
let out = replace_and_export(
"A *bold* word, then Aurélien, then _emphasis_.",
"Aurélien",
"Aurélian",
policy,
);
assert!(
out.contains("*bold*"),
"{policy:?} damaged the strong run before the replacement: {out:?}"
);
assert!(
out.contains("_emphasis_"),
"{policy:?} damaged the emphasis after the replacement: {out:?}"
);
assert!(
out.contains("Aurélian"),
"{policy:?} did not perform the replacement: {out:?}"
);
}
}
/// A replace is still undoable, and undo must restore the *formatting* too — not just
/// the characters. A rename that could not be taken back cleanly is the single most
/// destructive thing this API offers.
#[test]
fn undo_restores_the_formatting_a_replace_changed() {
let source = "She called *Aurélien* into the trees.";
let doc = TextDocument::new();
doc.set_djot_with_options(source, DjotImportOptions::default())
.and_then(|op| op.wait())
.expect("set_djot");
let opts = ReplaceOptions::new(FindOptions::default())
.with_format_policy(ReplaceFormatPolicy::PreserveIfFullyCovered);
doc.replace_text("Aurélien", "Aurélian", true, &opts)
.expect("replace");
assert_eq!(
doc.to_djot_with_options(DjotExportOptions::default())
.unwrap()
.trim(),
"She called *Aurélian* into the trees."
);
doc.undo().expect("undo");
assert_eq!(
doc.to_djot_with_options(DjotExportOptions::default())
.unwrap()
.trim(),
source,
"undo must restore the prose AND its emphasis, exactly"
);
}