1#[path = "support/current.rs"]
2mod current;
3#[path = "support/disk.rs"]
4mod disk;
5#[path = "support/ops.rs"]
6mod ops;
7#[path = "support/sweep.rs"]
8mod sweep;
9use onestore::{
10 ExGuid, RevisionIndex, Store, TextAttribute as A,
11 document::{Document, Kind},
12 op::PageOp,
13};
14use serde_json::{Value, json};
15fn target(source: &[u8]) -> (ExGuid, ExGuid) {
16 let store = Store::parse(source).unwrap();
17 let index = RevisionIndex::parse(&store).unwrap();
18 let doc = Document::parse(&index).unwrap();
19 let (sid, page) = doc.pages().unwrap()[0];
20 let space = &doc.spaces[&sid];
21 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
22 let mut pending = view.nodes[&page].children.clone();
23 while let Some(id) = pending.pop() {
24 let n = &view.nodes[&id];
25 if matches!(n.kind, Kind::RichText { .. }) {
26 return (sid, id);
27 }
28 pending.extend(&n.children);
29 pending.extend(&n.content);
30 }
31 panic!("Missing text")
32}
33fn characters(source: &[u8], sid: ExGuid, id: ExGuid) -> Vec<(char, Value)> {
34 let store = Store::parse(source).unwrap();
35 let index = RevisionIndex::parse(&store).unwrap();
36 index.validate_current().unwrap();
37 let doc = Document::parse(&index).unwrap();
38 let s = &doc.spaces[&sid];
39 let view = &s.revisions[&s.contexts[&ExGuid::default()]];
40 view.text_runs(id)
41 .unwrap()
42 .into_iter()
43 .flat_map(|r| {
44 let format = serde_json::to_value(r.format).unwrap();
45 r.text.chars().map(move |c| (c, format.clone()))
46 })
47 .collect()
48}
49#[test]
50fn overlapping_unicode_format_edits_match_an_independent_character_model() {
51 let text = "abcdefgh 東京 🦀 café\rSecond\tline";
52 let original = onestore::create_section("format.one", text, "Author").unwrap();
53 let (sid, id) = target(&original);
54 for seed in sweep::seeds(1..17, 16) {
55 let mut rng = seed;
56 let mut source = original.clone();
57 let mut expected = characters(&source, sid, id);
58 let offsets: Vec<u32> = std::iter::once(0)
59 .chain(text.chars().scan(0, |n, c| {
60 *n += c.len_utf16() as u32;
61 Some(*n)
62 }))
63 .collect();
64 for step in 0..24 {
65 let mut next = || {
66 rng ^= rng << 13;
67 rng ^= rng >> 7;
68 rng ^= rng << 17;
69 rng
70 };
71 let start = next() as usize % expected.len();
72 let end = start + 1 + next() as usize % (expected.len() - start);
73 let enabled = next() & 1 != 0;
74 let (attribute, key, value) = match step % 9 {
75 0 => (A::Bold(enabled), "bold", json!(enabled)),
76 1 => (A::Italic(enabled), "italic", json!(enabled)),
77 2 => (A::Underline(enabled), "underline", json!(enabled)),
78 3 => (A::Strike(enabled), "strike", json!(enabled)),
79 4 => (A::Font("Arial".into()), "font", json!("Arial")),
80 5 => (A::FontSize(13.5), "font_size", json!(13.5)),
81 6 => (
82 A::Color(Some([0x24, 0x68, 0xac])),
83 "color",
84 json!(0xac6824_u32),
85 ),
86 7 => (A::Highlight(None), "highlight", json!(0xff000000_u32)),
87 _ => (
88 A::Highlight(Some([0, 255, 0])),
89 "highlight",
90 json!(0x00ff00_u32),
91 ),
92 };
93 let edit = ops::page_op(
94 &source,
95 sid,
96 PageOp::Format {
97 text: id,
98 range: offsets[start]..offsets[end],
99 set: std::slice::from_ref(&attribute).to_vec(),
100 clear: Vec::new(),
101 },
102 )
103 .unwrap();
104 for (_, style) in &mut expected[start..end] {
105 style[key] = value.clone();
106 }
107 assert_eq!(
108 characters(edit.as_bytes(), sid, id),
109 expected,
110 "seed {seed}, step {step}"
111 );
112 assert_eq!(
113 ops::page_op(
114 edit.as_bytes(),
115 sid,
116 PageOp::Format {
117 text: id,
118 range: offsets[start]..offsets[end],
119 set: vec![attribute],
120 clear: Vec::new()
121 }
122 )
123 .unwrap()
124 .as_bytes(),
125 edit.as_bytes()
126 );
127 let old_store = Store::parse(&source).unwrap();
128 let old = RevisionIndex::parse(&old_store).unwrap();
129 let new_store = Store::parse(edit.as_bytes()).unwrap();
130 let new = RevisionIndex::parse(&new_store).unwrap();
131 let rid = old.spaces[&sid].labels[&(ExGuid::default(), 1)];
132 assert_eq!(
133 format!("{:?}", old.resolve(sid, rid).unwrap()),
134 format!("{:?}", new.resolve(sid, rid).unwrap())
135 );
136 source = edit.as_bytes().to_vec();
137 }
138 }
139}
140#[test]
141fn script_positions_are_exclusive_and_explicit_false_overrides_true() {
142 let source = onestore::create_section("script.one", "abc", "Author").unwrap();
143 let (sid, id) = target(&source);
144 let superscript = ops::page_op(
145 &source,
146 sid,
147 PageOp::Format {
148 text: id,
149 range: 0..3,
150 set: vec![A::Superscript(true), A::Bold(true)],
151 clear: Vec::new(),
152 },
153 )
154 .unwrap();
155 let subscript = ops::page_op(
156 superscript.as_bytes(),
157 sid,
158 PageOp::Format {
159 text: id,
160 range: 1..2,
161 set: vec![A::Subscript(true), A::Bold(false)],
162 clear: Vec::new(),
163 },
164 )
165 .unwrap();
166 let chars = characters(subscript.as_bytes(), sid, id);
167 assert_eq!(chars[0].1["superscript"], true);
168 assert_eq!(chars[0].1["subscript"], false);
169 assert_eq!(chars[0].1["bold"], true);
170 assert_eq!(chars[1].1["superscript"], false);
171 assert_eq!(chars[1].1["subscript"], true);
172 assert_eq!(chars[1].1["bold"], false);
173 assert_eq!(chars[0].1, chars[2].1);
174}
175#[test]
176fn empty_paragraph_style_is_used_by_later_text_edits() {
177 let source = onestore::create_section("empty.one", "", "Author").unwrap();
178 let (sid, id) = target(&source);
179 let formatted = ops::page_op(
180 &source,
181 sid,
182 PageOp::Format {
183 text: id,
184 range: 0..0,
185 set: vec![A::Italic(true), A::FontSize(18.0)],
186 clear: Vec::new(),
187 },
188 )
189 .unwrap();
190 let filled = ops::page_op(
191 formatted.as_bytes(),
192 sid,
193 PageOp::Text {
194 text: id,
195 range: 0..0,
196 with: "Added 🦀".into(),
197 },
198 )
199 .unwrap();
200 for (_, format) in characters(filled.as_bytes(), sid, id) {
201 assert_eq!(format["italic"], true);
202 assert_eq!(format["font_size"], 18.0);
203 }
204}
205#[test]
206fn invalid_ranges_attributes_and_fields_are_rejected() {
207 let source = onestore::create_section("invalid.one", "a🦀b", "Author").unwrap();
208 let (sid, id) = target(&source);
209 for (start, end) in [(2, 3), (1, 2), (0, 8), (2, 2), (3, 1), (1, 1)] {
210 let range = start..end;
211 assert!(
212 ops::page_op(
213 &source,
214 sid,
215 PageOp::Format {
216 text: id,
217 range,
218 set: vec![A::Bold(true)],
219 clear: Vec::new()
220 }
221 )
222 .is_err()
223 );
224 }
225 for attributes in [
226 vec![],
227 vec![A::Bold(true), A::Bold(false)],
228 vec![A::Superscript(true), A::Subscript(true)],
229 vec![A::Font("".into())],
230 vec![A::Font("a\0b".into())],
231 ] {
232 assert!(
233 ops::page_op(
234 &source,
235 sid,
236 PageOp::Format {
237 text: id,
238 range: 0..4,
239 set: attributes.to_vec(),
240 clear: Vec::new()
241 }
242 )
243 .is_err()
244 );
245 }
246 for size in [f32::NAN, f32::INFINITY, 0.0, 5.5, 130.5, 144.0, 144.5, 12.1] {
247 assert!(
248 ops::page_op(
249 &source,
250 sid,
251 PageOp::Format {
252 text: id,
253 range: 0..4,
254 set: vec![A::FontSize(size)],
255 clear: Vec::new()
256 }
257 )
258 .is_err()
259 );
260 }
261 assert!(
262 ops::page_op(
263 &source,
264 sid,
265 PageOp::Format {
266 text: ExGuid::default(),
267 range: 0..4,
268 set: vec![A::Bold(true)],
269 clear: Vec::new()
270 }
271 )
272 .is_err()
273 );
274}
275#[test]
276fn formatting_publication_faults_preserve_complete_old_or_new_styles() {
277 let source = onestore::create_section("atomic.one", "Before 🦀 after", "Author").unwrap();
278 let (sid, id) = target(&source);
279 let edit = ops::page_op(
280 &source,
281 sid,
282 PageOp::Format {
283 text: id,
284 range: 2..10,
285 set: vec![A::Bold(true), A::Color(Some([8, 64, 128]))],
286 clear: Vec::new(),
287 },
288 )
289 .unwrap();
290 let before = current::current(&source);
291 let after = current::current(edit.as_bytes());
292 for write_limit in [17, 4096] {
293 let disk = |fail_at| disk::Disk {
294 visible: source.clone(),
295 durable: source.clone(),
296 operation: 0,
297 fail_at,
298 write_limit,
299 random: 946,
300 };
301 let mut success = disk(None);
302 edit.commit(&mut success).unwrap();
303 assert_eq!(success.durable, edit.as_bytes());
304 for at in 1..=success.operation {
305 let mut interrupted = disk(Some(at));
306 let failure = edit.commit(&mut interrupted).unwrap_err();
307 let actual = current::current(&interrupted.durable);
308 assert!(actual == before || actual == after, "operation {at}");
309 if failure.state == onestore::CommitState::NotCommitted {
310 assert_eq!(actual, before);
311 }
312 }
313 }
314}
315
316#[test]
317#[ignore = "exports public-API formatting candidates for cold native validation"]
318fn export_native_formatting_candidates() {
319 use std::{fs, path::PathBuf};
320 let output = PathBuf::from(std::env::var_os("ONESTORE_FORMAT_OUTPUT").unwrap());
321 assert!(output.is_absolute());
322 fs::create_dir(&output).unwrap();
323 let source = onestore::create_section(
324 "format.one",
325 "Before café 東京 🦀 after",
326 "Formatting author",
327 )
328 .unwrap();
329 let (sid, id) = target(&source);
330 let mut manifest = Vec::new();
331 let mut save =
332 |name: &str, source: &[u8], sid, id, range: std::ops::Range<u32>, attributes: &[A]| {
333 let prepared = ops::page_op(
334 source,
335 sid,
336 PageOp::Format {
337 text: id,
338 range: range.clone(),
339 set: attributes.to_vec(),
340 clear: Vec::new(),
341 },
342 )
343 .unwrap();
344 fs::write(output.join(format!("{name}.one")), prepared.as_bytes()).unwrap();
345 manifest.push(
346 json!({"name":name,"space":sid,"object":id,"range":range,"attributes":attributes}),
347 );
348 prepared.as_bytes().to_vec()
349 };
350 save(
351 "partial-boolean",
352 &source,
353 sid,
354 id,
355 2..18,
356 &[
357 A::Bold(true),
358 A::Italic(true),
359 A::Underline(true),
360 A::Strike(true),
361 ],
362 );
363 let colored = save(
364 "partial-font-color",
365 &source,
366 sid,
367 id,
368 3..18,
369 &[
370 A::Font("Arial".into()),
371 A::FontSize(13.5),
372 A::Color(Some([24, 96, 160])),
373 A::Highlight(Some([255, 255, 0])),
374 ],
375 );
376 save(
377 "clear-color",
378 &colored,
379 sid,
380 id,
381 6..14,
382 &[A::Color(None), A::Highlight(None)],
383 );
384 let scripted = save("subscript", &source, sid, id, 0..23, &[A::Subscript(true)]);
385 save(
386 "superscript",
387 &scripted,
388 sid,
389 id,
390 6..18,
391 &[A::Superscript(true)],
392 );
393 let bold = save(
394 "bold",
395 &source,
396 sid,
397 id,
398 0..23,
399 &[
400 A::Bold(true),
401 A::Italic(true),
402 A::Underline(true),
403 A::Strike(true),
404 ],
405 );
406 save(
407 "clear-boolean",
408 &bold,
409 sid,
410 id,
411 7..14,
412 &[
413 A::Bold(false),
414 A::Italic(false),
415 A::Underline(false),
416 A::Strike(false),
417 ],
418 );
419 let empty = onestore::create_section("empty.one", "", "Author").unwrap();
420 let (empty_sid, empty_id) = target(&empty);
421 let formatted = ops::page_op(
422 &empty,
423 empty_sid,
424 PageOp::Format {
425 text: empty_id,
426 range: 0..0,
427 set: vec![A::FontSize(18.0), A::Italic(true)],
428 clear: Vec::new(),
429 },
430 )
431 .unwrap();
432 let typed = ops::page_op(
433 formatted.as_bytes(),
434 empty_sid,
435 PageOp::Text {
436 text: empty_id,
437 range: 0..0,
438 with: "Typed café 🦀".into(),
439 },
440 )
441 .unwrap();
442 fs::write(output.join("empty-then-type.one"), typed.as_bytes()).unwrap();
443 let native = include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one");
444 let store = Store::parse(native).unwrap();
445 let index = RevisionIndex::parse(&store).unwrap();
446 let document = Document::parse(&index).unwrap();
447 let (native_sid, native_id) = document
448 .spaces
449 .iter()
450 .find_map(|(sid, s)| {
451 let r = &s.revisions[&s.contexts[&ExGuid::default()]];
452 r.nodes.iter().find_map(|(id, n)| {
453 matches!(&n.kind,Kind::RichText{text,..} if text.starts_with("Fictitious:" ))
454 .then_some((*sid, *id))
455 })
456 })
457 .unwrap();
458 save(
459 "native-cross-runs",
460 native,
461 native_sid,
462 native_id,
463 2..26,
464 &[A::Bold(false), A::Italic(true), A::Underline(true)],
465 );
466 save(
467 "native-partial",
468 native,
469 native_sid,
470 native_id,
471 3..8,
472 &[A::FontSize(14.0), A::Color(Some([16, 112, 48]))],
473 );
474 save(
475 "native-font",
476 native,
477 native_sid,
478 native_id,
479 0..27,
480 &[A::Font("Arial".into())],
481 );
482 save("small-font", &source, sid, id, 0..23, &[A::FontSize(6.0)]);
483 save("large-font", &source, sid, id, 0..23, &[A::FontSize(130.0)]);
484 for points in [129.5, 130.0] {
485 save(
486 &format!("font-boundary-{points}"),
487 &source,
488 sid,
489 id,
490 0..23,
491 &[A::FontSize(points)],
492 );
493 }
494 fs::write(output.join("basic-baseline.one"), &source).unwrap();
495 fs::write(output.join("native-baseline.one"), native).unwrap();
496 fs::write(
497 output.join("manifest.json"),
498 serde_json::to_vec_pretty(&manifest).unwrap(),
499 )
500 .unwrap();
501}