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
//! `BatchDocument` — the headless, thread-free document a backend batch uses.
//!
//! The point of this type is that it is *cheap enough to make hundreds of*. So these
//! tests check two things: that it round-trips Djot faithfully (a replace that mangles
//! markup is worse than no replace at all), and that it genuinely costs no thread —
//! because if it did, a project-wide rename would spawn one per touched scene.
use text_document::{BatchDocument, DjotExportOptions, DjotImportOptions, FindOptions};
fn round_trip(djot: &str) -> String {
let batch = BatchDocument::new().expect("BatchDocument::new");
batch
.set_djot(djot, &DjotImportOptions::default())
.expect("set_djot");
batch
.to_djot(&DjotExportOptions::default())
.expect("to_djot")
}
/// Prose survives the trip. This is the load-bearing property: a replace parses the
/// Djot, rewrites text, and serialises it back — so anything the round trip damages,
/// a replace would damage across the whole manuscript.
#[test]
fn a_batch_document_round_trips_djot() {
for source in [
"Just a plain paragraph.",
"A paragraph with *emphasis* and _more_ of it.",
"# A heading\n\nAnd a paragraph beneath it.",
"One paragraph.\n\nAnd a second one.",
] {
let out = round_trip(source);
assert_eq!(
out.trim(),
source.trim(),
"round-tripping this Djot changed it:\n in: {source:?}\n out: {out:?}"
);
}
}
/// A **tight** list comes back **loose** — the one place the round trip is not an
/// identity. Pinned rather than wished away, because a `BatchDocument` is what a
/// project-wide replace runs through, and this is the shape of the only reformatting
/// it can cause.
///
/// This is a documented limitation of the model, not a bug in this type: the entity
/// model has no tight/loose distinction, and the exporter's blank line between blocks
/// is what lets an indented sub-list *nest* instead of folding into its parent item's
/// paragraph (see `export_djot_uc`'s own comment). Removing it would trade this for a
/// worse bug.
///
/// It is also **not new to replace**: the editor round-trips a scene's Djot through a
/// document on every flush, so opening and editing a scene already loosens its tight
/// lists. Replace adds no damage that saving does not already do. Fixing it properly
/// means teaching the entity model about tight lists — a separate job.
#[test]
fn a_tight_list_comes_back_loose_and_that_is_known() {
let out = round_trip("- a list item\n- another one");
assert_eq!(
out.trim(),
"- a list item\n\n- another one",
"if this now round-trips tightly, the model learned tight/loose — delete this \
test and fold the case back into `a_batch_document_round_trips_djot`"
);
}
/// The whole reason this type exists. `TextDocument` starts an `EventHubClient` thread
/// per document; `BatchDocument` must not — otherwise a rename touching 120 scenes
/// spawns 120 threads.
///
/// Counted from the process's own thread count rather than mocked, so it fails if a
/// future refactor quietly reintroduces the spawn.
#[test]
fn a_batch_document_costs_no_thread() {
fn threads() -> usize {
// Linux exposes the live thread count of the process directly.
std::fs::read_to_string("/proc/self/status")
.expect("/proc/self/status")
.lines()
.find_map(|l| l.strip_prefix("Threads:"))
.and_then(|v| v.trim().parse().ok())
.expect("Threads: line")
}
let before = threads();
let docs: Vec<BatchDocument> = (0..32)
.map(|i| {
let b = BatchDocument::new().expect("BatchDocument::new");
b.set_djot(
&format!("Scene {i}: she called his name into the trees."),
&DjotImportOptions::default(),
)
.expect("set_djot");
b
})
.collect();
let after = threads();
assert_eq!(docs.len(), 32);
assert!(
after <= before + 2,
"32 BatchDocuments started {} extra thread(s) ({before} -> {after}). \
BatchDocument exists precisely so a batch does not pay a thread per document; \
something reintroduced EventHubClient::start.",
after.saturating_sub(before)
);
}
/// Search runs against the *parsed text*, not the Djot source — so markup never matches
/// and never shifts an offset. `*Aurélien*` must be found at the position of the name,
/// not the position of the asterisk.
#[test]
fn find_all_searches_the_prose_not_the_markup() {
let batch = BatchDocument::new().expect("BatchDocument::new");
batch
.set_djot(
"She called *Aurélien* into the trees, and Aurélien did not answer.",
&DjotImportOptions::default(),
)
.expect("set_djot");
let hits = batch
.find_all("Aurélien", &FindOptions::default())
.expect("find_all");
assert_eq!(
hits.len(),
2,
"both occurrences, including the emphasised one"
);
// The first hit must sit where the NAME is in the prose ("She called " = 11 chars),
// not where it is in the source (which the `*` would push along by one).
assert_eq!(
hits[0].position, 11,
"the offset must be into the parsed text; a Djot-source offset would be 12 \
(the emphasis marker) and would corrupt any replace built on it"
);
assert_eq!(hits[0].length, "Aurélien".chars().count());
}
/// Reusing one document across several imports must not accumulate the old contents —
/// a batch loop refills the same document for every scene it touches.
#[test]
fn set_djot_replaces_rather_than_appends() {
let batch = BatchDocument::new().expect("BatchDocument::new");
batch
.set_djot("the first scene", &DjotImportOptions::default())
.expect("first");
batch
.set_djot("the second scene", &DjotImportOptions::default())
.expect("second");
let out = batch
.to_djot(&DjotExportOptions::default())
.expect("to_djot");
assert!(out.contains("second"), "the newest import must be present");
assert!(
!out.contains("first"),
"the previous import must be gone, not appended: {out:?}"
);
}