1#[path = "support/checkpoint.rs"]
2mod checkpoint;
3#[path = "support/disk.rs"]
4mod disk;
5#[path = "support/ops.rs"]
6mod ops;
7#[path = "support/trace.rs"]
8mod trace;
9
10use disk::Disk;
11use onestore::{
12 CommitError, CommitIo, CommitState, ExGuid, RevisionIndex, Store, Transaction,
13 document::{Document, Kind},
14 op::{Op, OpError, PageOp},
15};
16use std::ops::Range;
17
18const SOURCE: &[u8] = include_bytes!(
19 "../../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"
20);
21
22fn target(source: &[u8]) -> (ExGuid, ExGuid) {
23 let store = Store::parse(source).unwrap();
24 let index = RevisionIndex::parse(&store).unwrap();
25 let document = Document::parse(&index).unwrap();
26 for (sid, space) in &document.spaces {
27 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
28 for (oid, node) in &revision.nodes {
29 if matches!(&node.kind, Kind::RichText { text, .. } if text.starts_with("Fictitious:"))
30 {
31 return (*sid, *oid);
32 }
33 }
34 }
35 panic!("Missing native text fixture")
36}
37
38fn typed(space: ExGuid, text: ExGuid, range: Range<u32>, with: &str) -> Vec<Op> {
39 let op = PageOp::Text {
40 text,
41 range,
42 with: with.into(),
43 };
44 vec![Op::Page { space, op }]
45}
46
47/// `source` with `range` of the text object replaced.
48fn replace_text(
49 source: &[u8],
50 space: ExGuid,
51 text: ExGuid,
52 range: Range<u32>,
53 with: &str,
54) -> Result<Vec<u8>, OpError> {
55 ops::edited(source, typed(space, text, range, with))
56}
57
58/// The transaction replacing `range` of the text object in `source`.
59fn text_transaction(
60 source: &[u8],
61 space: ExGuid,
62 text: ExGuid,
63 range: Range<u32>,
64 with: &str,
65) -> Result<Transaction, CommitError> {
66 let refused = |error: String| CommitError {
67 state: CommitState::NotCommitted,
68 error: std::io::Error::new(std::io::ErrorKind::InvalidData, error),
69 };
70 ops::transaction(source, "Author", typed(space, text, range, with))
71 .map_err(|error| refused(error.to_string()))?
72 .ok_or_else(|| refused("The edit changes nothing".into()))
73}
74
75/// `text_transaction` committed under caller-held exclusion.
76fn commit_text(
77 io: &mut impl CommitIo,
78 source: &[u8],
79 space: ExGuid,
80 text: ExGuid,
81 range: Range<u32>,
82 with: &str,
83) -> Result<(), CommitError> {
84 text_transaction(source, space, text, range, with)?.commit(io)
85}
86
87fn text_runs(source: &[u8], sid: ExGuid, oid: ExGuid) -> serde_json::Value {
88 let store = Store::parse(source).unwrap();
89 assert!(store.checksum_mismatches.is_empty());
90 let index = RevisionIndex::parse(&store).unwrap();
91 index.validate_current().unwrap();
92 let document = Document::parse(&index).unwrap();
93 let space = &document.spaces[&sid];
94 serde_json::to_value(
95 space.revisions[&space.contexts[&ExGuid::default()]]
96 .text_runs(oid)
97 .unwrap(),
98 )
99 .unwrap()
100}
101
102fn assert_refreshed(source: &[u8], confirmed: &[u8]) {
103 assert_eq!(&source[..212], &confirmed[..212]);
104 assert_eq!(&source[252..], &confirmed[252..]);
105 let before = Store::parse(source).unwrap().header;
106 let after = Store::parse(confirmed).unwrap().header;
107 assert_ne!(before.version_id, after.version_id);
108 assert_ne!(before.deny_read_id, after.deny_read_id);
109 assert_eq!(after.generation, before.generation + 1);
110}
111
112#[test]
113fn prepared_publication_preserves_its_identity_through_every_io_failure() {
114 let source =
115 onestore::create_section("prepared.one", "Fictitious: café πŸ¦€", "Fixture").unwrap();
116 let (sid, oid) = target(&source);
117 let source = checkpoint::pending(&source, sid, oid);
118 let edit = text_transaction(&source, sid, oid, 0..0, "Prepared 🐈 ").unwrap();
119 let mut written = source.clone();
120 edit.apply(&mut written).unwrap();
121 let store = Store::parse(&written).unwrap();
122 let index = RevisionIndex::parse(&store).unwrap();
123 let planned = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
124 let persisted: ExGuid =
125 serde_json::from_str(&serde_json::to_string(&planned).unwrap()).unwrap();
126 let before_store = Store::parse(&source).unwrap();
127 let before_index = RevisionIndex::parse(&before_store).unwrap();
128 assert!(!before_index.spaces[&sid].revisions.contains_key(&persisted));
129 let before = text_runs(&source, sid, oid);
130 let after = text_runs(&written, sid, oid);
131 for write_limit in [17, 4096] {
132 let disk = |fail_at| Disk {
133 visible: source.clone(),
134 durable: source.clone(),
135 operation: 0,
136 fail_at,
137 write_limit,
138 random: 911,
139 };
140 let mut success = disk(None);
141 edit.commit(&mut success).unwrap();
142 assert_eq!(success.durable, written);
143 let operations = success.operation;
144 let error = edit.commit(&mut success).unwrap_err();
145 assert_eq!(error.state, CommitState::NotCommitted);
146 assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy);
147 assert_eq!(success.durable, written);
148 for at in 1..=operations {
149 let mut interrupted = disk(Some(at));
150 let error = edit.commit(&mut interrupted).unwrap_err();
151 let observed = text_runs(&interrupted.durable, sid, oid);
152 let store = Store::parse(&interrupted.durable).unwrap();
153 let index = RevisionIndex::parse(&store).unwrap();
154 let present = index.spaces[&sid].revisions.contains_key(&persisted);
155 assert_eq!(observed, if present { &after } else { &before }.clone());
156 match error.state {
157 CommitState::NotCommitted => assert!(!present),
158 CommitState::Committed => assert!(present),
159 CommitState::Unknown => {}
160 }
161 if present {
162 let snapshot = interrupted.durable.clone();
163 interrupted.visible.clone_from(&snapshot);
164 interrupted.fail_at = None;
165 interrupted.write_limit = 17;
166 onestore::confirm(&mut interrupted, &onestore::Stamp::of(&snapshot).unwrap())
167 .unwrap();
168 assert_refreshed(&snapshot, &interrupted.durable);
169 }
170 }
171 }
172}
173
174#[test]
175fn snapshot_confirmation_needs_no_surviving_edit_target() {
176 let mut disk = Disk {
177 visible: SOURCE.to_vec(),
178 durable: Vec::new(),
179 operation: 0,
180 fail_at: None,
181 write_limit: 17,
182 random: 911,
183 };
184 assert!(replace_text(SOURCE, ExGuid::default(), ExGuid::default(), 0..0, "").is_err());
185 onestore::confirm(&mut disk, &onestore::Stamp::of(SOURCE).unwrap()).unwrap();
186 assert_refreshed(SOURCE, &disk.durable);
187 let flush = disk.operation;
188 for (failure, state) in [
189 (1, CommitState::NotCommitted),
190 (flush, CommitState::Unknown),
191 ] {
192 disk.visible = SOURCE.to_vec();
193 disk.durable.clear();
194 disk.operation = 0;
195 disk.fail_at = Some(failure);
196 assert_eq!(
197 onestore::confirm(&mut disk, &onestore::Stamp::of(SOURCE).unwrap())
198 .unwrap_err()
199 .state,
200 state
201 );
202 }
203}
204
205#[test]
206fn confirmation_notifies_cached_readers_after_interrupted_version_publication() {
207 let source =
208 onestore::create_section("confirmation.one", "Fictitious: before", "Fixture").unwrap();
209 let (sid, oid) = target(&source);
210 let mut snapshot = replace_text(&source, sid, oid, 0..0, "Recovered ").unwrap();
211 snapshot[212..252].copy_from_slice(&source[212..252]);
212 let expected = text_runs(&snapshot, sid, oid);
213 assert_ne!(expected, text_runs(&source, sid, oid));
214 for write_limit in [1, 17, 40] {
215 let disk = |fail_at| Disk {
216 visible: snapshot.clone(),
217 durable: snapshot.clone(),
218 operation: 0,
219 fail_at,
220 write_limit,
221 random: 911,
222 };
223 let mut success = disk(None);
224 onestore::confirm(&mut success, &onestore::Stamp::of(&snapshot).unwrap()).unwrap();
225 assert_refreshed(&snapshot, &success.durable);
226 for failure in 1..=success.operation {
227 let mut interrupted = disk(Some(failure));
228 let error =
229 onestore::confirm(&mut interrupted, &onestore::Stamp::of(&snapshot).unwrap())
230 .unwrap_err();
231 assert_ne!(error.state, CommitState::Committed);
232 assert_eq!(text_runs(&interrupted.durable, sid, oid), expected);
233 assert_eq!(&interrupted.durable[..212], &snapshot[..212]);
234 assert_eq!(&interrupted.durable[252..], &snapshot[252..]);
235 }
236 }
237}
238
239#[test]
240fn confirming_visible_text_requires_flush_without_another_revision() {
241 let (sid, oid) = target(SOURCE);
242 let visible = replace_text(SOURCE, sid, oid, 0..0, "Recovered ").unwrap();
243 let base = onestore::Stamp::of(&visible).unwrap();
244 let mut disk = Disk {
245 visible: visible.clone(),
246 durable: SOURCE.to_vec(),
247 operation: 0,
248 fail_at: None,
249 write_limit: 64,
250 random: 1,
251 };
252 onestore::confirm(&mut disk, &base).unwrap();
253 assert_refreshed(&visible, &disk.durable);
254 let flush = disk.operation;
255 for (failure, state) in [
256 (1, CommitState::NotCommitted),
257 (flush, CommitState::Unknown),
258 ] {
259 disk.visible.clone_from(&visible);
260 disk.durable = SOURCE.to_vec();
261 disk.operation = 0;
262 disk.fail_at = Some(failure);
263 let error = onestore::confirm(&mut disk, &base).unwrap_err();
264 assert_eq!(error.state, state);
265 assert_eq!(&disk.visible[..212], &visible[..212]);
266 assert_eq!(&disk.visible[252..], &visible[252..]);
267 }
268}
269
270#[test]
271fn confirmation_rejects_a_changed_header_or_length_before_any_write() {
272 let mut source = SOURCE.to_vec();
273 source.resize(3 * 1024 * 1024 + 131, 0);
274 let base = onestore::Stamp::of(&source).unwrap();
275 let mut disk = trace::Trace {
276 bytes: source.clone(),
277 events: Vec::new(),
278 };
279 let confirmed = |events: &[trace::Event]| matches!(events, [trace::Event::Flush, trace::Event::Write(212, version), trace::Event::Flush] if version.len() == 40);
280 onestore::confirm(&mut disk, &base).unwrap();
281 assert!(confirmed(&disk.events));
282 for changed in [96, 128, 212, 1023] {
283 disk.bytes.clone_from(&source);
284 disk.bytes[changed] ^= 1;
285 disk.events.clear();
286 let error = onestore::confirm(&mut disk, &base).unwrap_err();
287 assert_eq!(error.state, CommitState::NotCommitted);
288 assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy);
289 assert!(disk.events.is_empty());
290 }
291 // Committed content changes only with the header, so the body is never compared.
292 for changed in [1024, 1048576, source.len() - 1] {
293 disk.bytes.clone_from(&source);
294 disk.bytes[changed] ^= 1;
295 disk.events.clear();
296 onestore::confirm(&mut disk, &base).unwrap();
297 assert!(confirmed(&disk.events));
298 }
299 for length in [source.len() - 1, source.len() + 1] {
300 disk.bytes.clone_from(&source);
301 disk.bytes.resize(length, 0);
302 disk.events.clear();
303 let error = onestore::confirm(&mut disk, &base).unwrap_err();
304 assert_eq!(error.state, CommitState::NotCommitted);
305 assert!(disk.events.is_empty());
306 }
307}
308
309fn assert_other_objects_preserved(
310 before: &onestore::ResolvedRevision<'_>,
311 after: &onestore::ResolvedRevision<'_>,
312 edited: ExGuid,
313) {
314 use onestore::{ObjectData, PropertySets};
315 let ObjectData::Properties(bytes) = after.objects[&edited].data else {
316 panic!()
317 };
318 let target = PropertySets::parse(bytes).unwrap();
319 let modified = &target.sets[0]
320 .iter()
321 .find(|field| field.id == 0x14001d7a)
322 .unwrap()
323 .value;
324 let reachable = before.reachable().unwrap();
325 assert_eq!(before.objects.len(), after.objects.len());
326 for (id, object) in &before.objects {
327 if *id == edited {
328 continue;
329 }
330 let current = &after.objects[id];
331 assert_eq!(object.jcid, current.jcid);
332 // A rewritten object keeps the table entries it names.
333 assert!(
334 current
335 .global_ids
336 .iter()
337 .all(|(entry, guid)| object.global_ids.get(entry) == Some(guid))
338 );
339 let mut pending = vec![*id];
340 let mut visited = std::collections::BTreeSet::new();
341 while let Some(next) = pending.pop() {
342 if !visited.insert(next) {
343 continue;
344 }
345 pending.extend(before.objects[&next].references().unwrap().objects);
346 }
347 let ancestor = reachable.contains(id) && visited.contains(&edited);
348 let mut allowed = Vec::new();
349 if ancestor {
350 allowed.push(0x14001d7a);
351 }
352 if ancestor && object.jcid == 0x6000b {
353 allowed.push(0x1c001d3c);
354 }
355 if before.roots.get(&2) == Some(id) {
356 allowed.push(0x1c001cf3);
357 }
358 if allowed.is_empty() {
359 assert_eq!(object.data, current.data);
360 continue;
361 }
362 let [old, new] = [object.data, current.data].map(|data| {
363 let ObjectData::Properties(bytes) = data else {
364 panic!()
365 };
366 PropertySets::parse(bytes).unwrap()
367 });
368 if ancestor {
369 let old_time = old.sets[0].iter().find(|field| field.id == 0x14001d7a);
370 let new_time = new.sets[0].iter().find(|field| field.id == 0x14001d7a);
371 assert_eq!(old_time.is_some(), new_time.is_some());
372 if let Some(time) = new_time {
373 assert_eq!(&time.value, modified);
374 }
375 }
376 let [old, new] = [old, new].map(|mut fields| {
377 fields.sets[0].retain(|field| !allowed.contains(&field.id));
378 fields.sets
379 });
380 assert_eq!(old, new);
381 }
382}
383
384#[test]
385fn text_splices_preserve_formats_objects_and_history_in_one_transaction() {
386 let (sid, oid) = target(SOURCE);
387 let old = Store::parse(SOURCE).unwrap();
388 let old_index = RevisionIndex::parse(&old).unwrap();
389 let before = text_runs(SOURCE, sid, oid);
390 for replacement in ["", "πŸ¦€", "A longer replacement with ζ—₯本θͺž and e\u{301}"] {
391 let edited = replace_text(SOURCE, sid, oid, 0..10, replacement).unwrap();
392 let store = Store::parse(&edited).unwrap();
393 assert_eq!(
394 store.header.transaction_count,
395 old.header.transaction_count + 1
396 );
397 let index = RevisionIndex::parse(&store).unwrap();
398 for (space_id, space) in &old_index.spaces {
399 for rid in space.revisions.keys() {
400 let previous = old_index.resolve(*space_id, *rid).unwrap();
401 let preserved = index.resolve(*space_id, *rid).unwrap();
402 assert_eq!(previous.roots, preserved.roots);
403 for (id, object) in previous.objects {
404 assert_eq!(object.data, preserved.objects[&id].data);
405 }
406 }
407 let previous = old_index
408 .resolve(*space_id, space.labels[&(ExGuid::default(), 1)])
409 .unwrap();
410 let current = index
411 .resolve(
412 *space_id,
413 index.spaces[space_id].labels[&(ExGuid::default(), 1)],
414 )
415 .unwrap();
416 assert_eq!(previous.objects.len(), current.objects.len());
417 if *space_id == sid {
418 assert_other_objects_preserved(&previous, &current, oid);
419 } else {
420 for (id, object) in previous.objects {
421 assert_eq!(object.data, current.objects[&id].data);
422 }
423 }
424 }
425 let mut expected = before.clone();
426 expected[0]["text"] = format!(
427 "{replacement}{}",
428 &before[0]["text"].as_str().unwrap()[10..]
429 )
430 .into();
431 assert_eq!(text_runs(&edited, sid, oid), expected);
432 }
433}
434
435#[test]
436fn invalid_text_edits_never_touch_storage() {
437 let (sid, oid) = target(SOURCE);
438 let source = replace_text(SOURCE, sid, oid, 0..0, "πŸ¦€").unwrap();
439 for (range, replacement) in [
440 (1..1, "x"),
441 (0..999, "x"),
442 (0..0, "\n"),
443 (0..0, "\0"),
444 (0..0, "\u{fffc}"),
445 ] {
446 let mut disk = Disk {
447 visible: source.clone(),
448 durable: source.clone(),
449 operation: 0,
450 fail_at: None,
451 write_limit: 1024,
452 random: 1,
453 };
454 let error = commit_text(&mut disk, &source, sid, oid, range, replacement).unwrap_err();
455 assert_eq!(error.state, CommitState::NotCommitted);
456 assert_eq!(disk.operation, 0);
457 assert_eq!(disk.durable, source);
458 }
459}
460
461#[test]
462fn cross_run_splices_match_a_character_model_and_preserve_history() {
463 let (sid, oid) = target(SOURCE);
464 let characters = |source: &[u8]| {
465 let runs = text_runs(source, sid, oid);
466 let runs = runs.as_array().unwrap();
467 let mut result = Vec::new();
468 for run in runs {
469 result.extend(
470 run["text"]
471 .as_str()
472 .unwrap()
473 .chars()
474 .map(|c| (c, run["format"].clone())),
475 );
476 }
477 result.push(('\0', runs.last().unwrap()["format"].clone()));
478 result
479 };
480 let before = characters(SOURCE);
481 let offsets: Vec<_> = std::iter::once(0)
482 .chain(before[..before.len() - 1].iter().scan(0, |at, (c, _)| {
483 *at += c.len_utf16() as u32;
484 Some(*at)
485 }))
486 .collect();
487 let store = Store::parse(SOURCE).unwrap();
488 let index = RevisionIndex::parse(&store).unwrap();
489 for a in 0..before.len() {
490 for b in a..before.len() {
491 let replacement = ["", "πŸ¦€e\u{301}", "ζ—₯本θͺž"][b % 3];
492 let mut expected = before.clone();
493 expected.splice(a..b, replacement.chars().map(|c| (c, before[a].1.clone())));
494 let edited =
495 replace_text(SOURCE, sid, oid, offsets[a]..offsets[b], replacement).unwrap();
496 assert_eq!(characters(&edited), expected, "characters {a}..{b}");
497 let current_store = Store::parse(&edited).unwrap();
498 assert_eq!(
499 current_store.header.transaction_count,
500 store.header.transaction_count
501 + usize::from(a != b || !replacement.is_empty()) as u32
502 );
503 let current = RevisionIndex::parse(&current_store).unwrap();
504 for (space, history) in &index.spaces {
505 for revision in history.revisions.keys() {
506 let old = index.resolve(*space, *revision).unwrap();
507 let retained = current.resolve(*space, *revision).unwrap();
508 assert_eq!(old.roots, retained.roots);
509 for (id, object) in old.objects {
510 assert_eq!(object.data, retained.objects[&id].data);
511 }
512 }
513 }
514 }
515 }
516 let cleared = replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), "").unwrap();
517 let typed = replace_text(&cleared, sid, oid, 0..0, "new").unwrap();
518 let format = &before.last().unwrap().1;
519 assert_eq!(
520 characters(&typed),
521 "new\0"
522 .chars()
523 .map(|c| (c, format.clone()))
524 .collect::<Vec<_>>()
525 );
526 let text: String = before[..before.len() - 1].iter().map(|(c, _)| *c).collect();
527 assert_eq!(
528 replace_text(SOURCE, sid, oid, 0..*offsets.last().unwrap(), &text).unwrap(),
529 SOURCE
530 );
531}
532
533#[test]
534#[ignore = "exports cross-run edits for independent native validation"]
535fn export_native_cross_run_edits() {
536 use std::{fs, path::PathBuf};
537 let output = PathBuf::from(std::env::var_os("ONESTORE_CROSS_RUN_OUTPUT").unwrap());
538 assert!(output.is_absolute());
539 fs::create_dir(&output).unwrap();
540 let mut manifest = Vec::new();
541 for (name, fixture, range, replacement, retype) in [
542 ("partial", "native-paragraph", 2..16, "δΈ­πŸ¦€", false),
543 ("table", "native-cell", 0..24, "Across 🐈 ", false),
544 ("clear", "native-paragraph", 0..u32::MAX, "", false),
545 (
546 "replace",
547 "native-paragraph",
548 0..u32::MAX,
549 "Replacement e\u{301}πŸ¦€",
550 false,
551 ),
552 ("retype", "native-paragraph", 0..u32::MAX, "", true),
553 ("boundary", "native-paragraph", 4..8, "Middle", false),
554 ] {
555 let input = format!("../../corpus/formatted-insertion/{fixture}/candidate/synthetic.one");
556 let source = fs::read(&input).unwrap();
557 let store = Store::parse(&source).unwrap();
558 let index = RevisionIndex::parse(&store).unwrap();
559 let document = Document::parse(&index).unwrap();
560 let (sid, oid, length) = document
561 .spaces
562 .iter()
563 .find_map(|(sid, space)| {
564 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
565 view.nodes.iter().find_map(|(oid, node)| {
566 let Kind::RichText { text, .. } = &node.kind else {
567 return None;
568 };
569 text.starts_with("Bold ")
570 .then(|| (*sid, *oid, text.encode_utf16().count() as u32))
571 })
572 })
573 .unwrap();
574 let range = range.start..range.end.min(length);
575 let mut edited = replace_text(&source, sid, oid, range.clone(), replacement).unwrap();
576 if retype {
577 edited = replace_text(&edited, sid, oid, 0..0, "Retyped πŸ¦€").unwrap();
578 }
579 let candidate = output.join(name).join("candidate");
580 fs::create_dir_all(&candidate).unwrap();
581 fs::write(candidate.join("synthetic.one"), &edited).unwrap();
582 manifest.push(serde_json::json!({"name":name,"source":input,"space":sid,"object":oid,"range":range,"replacement":replacement,"retype":retype}));
583 }
584 fs::write(
585 output.join("manifest.json"),
586 serde_json::to_vec_pretty(&manifest).unwrap(),
587 )
588 .unwrap();
589}
590
591#[test]
592fn insertion_at_a_style_boundary_uses_the_following_style() {
593 let (sid, oid) = target(SOURCE);
594 let before = text_runs(SOURCE, sid, oid);
595 let edited = replace_text(SOURCE, sid, oid, 18..18, "πŸ¦€").unwrap();
596 let mut expected = before.clone();
597 expected[1]["text"] = format!("πŸ¦€{}", before[1]["text"].as_str().unwrap()).into();
598 assert_eq!(text_runs(&edited, sid, oid), expected);
599}
600
601#[test]
602fn automatic_titles_follow_native_line_and_utf16_limits() {
603 for (text, expected) in [
604 (
605 " first\tline \rsecond".to_string(),
606 "first\tline".to_string(),
607 ),
608 ("A".repeat(500), "A".repeat(255)),
609 (
610 format!("{}πŸ¦€after", "A".repeat(254)),
611 format!("{}πŸ¦€", "A".repeat(254)),
612 ),
613 (" \t ".to_string(), "".to_string()),
614 ] {
615 let source = onestore::create_section("title.one", &text, "Title fixture").unwrap();
616 let check = |bytes: &[u8], expected: &str| {
617 let store = Store::parse(bytes).unwrap();
618 let index = RevisionIndex::parse(&store).unwrap();
619 let document = Document::parse(&index).unwrap();
620 let (sid, page) = document.pages().unwrap()[0];
621 let space = &document.spaces[&sid];
622 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
623 let Kind::Metadata { title, .. } = &revision.nodes[&revision.roots[&2]].kind else {
624 panic!()
625 };
626 let Kind::Page {
627 alternate_title, ..
628 } = &revision.nodes[&page].kind
629 else {
630 panic!()
631 };
632 assert_eq!(title.as_deref(), Some(expected));
633 assert_eq!(alternate_title.as_deref(), Some(expected));
634 let (oid, _) = revision
635 .nodes
636 .iter()
637 .find(|(_, n)| matches!(n.kind, Kind::RichText { .. }))
638 .unwrap();
639 (sid, *oid)
640 };
641 let (sid, oid) = check(&source, &expected);
642 let cleared =
643 replace_text(&source, sid, oid, 0..text.encode_utf16().count() as u32, "").unwrap();
644 check(&cleared, "");
645 let written = replace_text(&cleared, sid, oid, 0..0, " new πŸ¦€ name ").unwrap();
646 check(&written, "new πŸ¦€ name");
647 }
648}
649
650#[test]
651fn automatic_titles_follow_native_rtl_and_attachment_order() {
652 let mut checked = 0;
653 for path in [
654 "../../corpus/m7/automatic-titles/rtl-and-attachments.one",
655 "../../corpus/m7/automatic-titles/widths-and-limits.one",
656 ] {
657 let source = std::fs::read(path).unwrap();
658 let store = Store::parse(&source).unwrap();
659 let index = RevisionIndex::parse(&store).unwrap();
660 let document = Document::parse(&index).unwrap();
661 for (sid, page) in document.pages().unwrap() {
662 let space = &document.spaces[&sid];
663 let rid = space.contexts[&ExGuid::default()];
664 let revision = &space.revisions[&rid];
665 let Kind::Page { rtl, .. } = &revision.nodes[&page].kind else {
666 panic!()
667 };
668 if *rtl != Some(true)
669 && !revision
670 .nodes
671 .values()
672 .any(|n| matches!(n.kind, Kind::Attachment { .. }))
673 {
674 continue;
675 }
676 let Kind::Metadata {
677 title: Some(title), ..
678 } = &revision.nodes[&revision.roots[&2]].kind
679 else {
680 panic!()
681 };
682 let original = index.resolve(sid, rid).unwrap();
683 for (oid, node) in &revision.nodes {
684 let Kind::RichText {
685 text,
686 boilerplate: false,
687 ..
688 } = &node.kind
689 else {
690 continue;
691 };
692 if node.extra[0].iter().any(|field| field.id == 0x88001cb4) {
693 continue;
694 }
695 let changed = replace_text(&source, sid, *oid, 0..0, "Edited ").unwrap();
696 let store = Store::parse(&changed).unwrap();
697 let current = RevisionIndex::parse(&store).unwrap();
698 let after = Document::parse(&current).unwrap();
699 let space = &after.spaces[&sid];
700 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
701 let Kind::Metadata {
702 title: Some(actual),
703 ..
704 } = &revision.nodes[&revision.roots[&2]].kind
705 else {
706 panic!()
707 };
708 let expected = if text == title {
709 format!("Edited {title}")
710 } else {
711 title.clone()
712 };
713 assert_eq!(actual, &expected);
714 let active = current
715 .resolve(sid, current.spaces[&sid].labels[&(ExGuid::default(), 1)])
716 .unwrap();
717 assert_other_objects_preserved(&original, &active, *oid);
718 checked += 1;
719 }
720 }
721 }
722 assert_eq!(checked, 10);
723}
724
725#[test]
726fn an_emptied_final_run_retains_its_insertion_style() {
727 let (sid, oid) = target(SOURCE);
728 let before = text_runs(SOURCE, sid, oid);
729 let erased = replace_text(SOURCE, sid, oid, 22..27, "").unwrap();
730 let edited = replace_text(&erased, sid, oid, 22..22, "a").unwrap();
731 let mut expected = before.clone();
732 expected[3]["text"] = "a".into();
733 assert_eq!(text_runs(&edited, sid, oid), expected);
734}
735
736#[test]
737fn title_text_and_navigation_caches_publish_together() {
738 let source =
739 std::fs::read("../../corpus/m6/native-structure-01/notebook/synthetic.one").unwrap();
740 let store = Store::parse(&source).unwrap();
741 let index = RevisionIndex::parse(&store).unwrap();
742 let document = Document::parse(&index).unwrap();
743 let (sid, page) = document
744 .pages()
745 .unwrap()
746 .into_iter()
747 .find(|(sid, _)| {
748 let space = &document.spaces[sid];
749 space.revisions[&space.contexts[&ExGuid::default()]]
750 .nodes
751 .values()
752 .any(|node| {
753 matches!(node.kind, Kind::RichText { .. })
754 && node.extra[0].iter().any(|field| field.id == 0x88001cb4)
755 })
756 })
757 .unwrap();
758 let rid = document.spaces[&sid].contexts[&ExGuid::default()];
759 let revision = &document.spaces[&sid].revisions[&rid];
760 let metadata = revision.roots[&2];
761 let (oid, node) = revision
762 .nodes
763 .iter()
764 .find(|(_, node)| {
765 matches!(node.kind, Kind::RichText { .. })
766 && node.extra[0].iter().any(|field| field.id == 0x88001cb4)
767 })
768 .unwrap();
769 let Kind::RichText { text, runs, .. } = &node.kind else {
770 panic!()
771 };
772 assert_eq!(runs.len(), 1);
773 let end = text.encode_utf16().count() as u32;
774 let state = |bytes: &[u8]| {
775 let store = Store::parse(bytes).unwrap();
776 assert!(store.checksum_mismatches.is_empty());
777 let index = RevisionIndex::parse(&store).unwrap();
778 index.validate_current().unwrap();
779 let document = Document::parse(&index).unwrap();
780 let space = &document.spaces[&sid];
781 let current = &space.revisions[&space.contexts[&ExGuid::default()]];
782 serde_json::json!([
783 &current.nodes[oid].kind,
784 &current.nodes[&metadata].kind,
785 &current.nodes[&page].kind,
786 current
787 .nodes
788 .iter()
789 .map(|(id, node)| (*id, node.modified))
790 .collect::<std::collections::BTreeMap<_, _>>(),
791 ])
792 };
793 let checkpoint = checkpoint::pending(&source, sid, *oid);
794 for (source, write_limit) in [(source.as_slice(), 17), (checkpoint.as_slice(), 257)] {
795 let before = state(source);
796 for replacement in ["Renamed πŸ¦€ ζ—₯本θͺž", ""] {
797 let edit = text_transaction(source, sid, *oid, 0..end, replacement).unwrap();
798 let disk = |fail_at| Disk {
799 visible: source.to_vec(),
800 durable: source.to_vec(),
801 operation: 0,
802 fail_at,
803 write_limit,
804 random: 42,
805 };
806 let mut success = disk(None);
807 edit.commit(&mut success).unwrap();
808 let after = state(&success.durable);
809 assert_eq!(after[0]["text"], replacement);
810 assert_eq!(
811 after[1]["title"],
812 if replacement.is_empty() {
813 "Black highlight, automatic text"
814 } else {
815 replacement
816 }
817 );
818 assert_eq!(
819 after[2]["alternate_title"],
820 if replacement.is_empty() {
821 serde_json::json!("Black highlight, automatic text")
822 } else {
823 serde_json::json!("")
824 }
825 );
826 let written = Store::parse(&success.durable).unwrap();
827 assert_eq!(
828 written.header.transaction_count,
829 Store::parse(source).unwrap().header.transaction_count + 1
830 );
831 let updated = RevisionIndex::parse(&written).unwrap();
832 let old = index.resolve(sid, rid).unwrap();
833 let current = updated
834 .resolve(sid, updated.spaces[&sid].labels[&(ExGuid::default(), 1)])
835 .unwrap();
836 for (id, object) in &old.objects {
837 assert_eq!(
838 object.data,
839 updated.resolve(sid, rid).unwrap().objects[id].data
840 );
841 }
842 assert_other_objects_preserved(&old, &current, *oid);
843 for at in 1..=success.operation {
844 let mut interrupted = disk(Some(at));
845 let error = edit.commit(&mut interrupted).unwrap_err();
846 let observed = state(&interrupted.durable);
847 match error.state {
848 CommitState::NotCommitted => assert_eq!(observed, before),
849 CommitState::Committed => assert_eq!(observed, after),
850 CommitState::Unknown => assert!(observed == before || observed == after),
851 }
852 }
853 }
854 }
855}
856
857#[test]
858fn native_conflict_pages_are_readable_but_not_random_edit_targets() {
859 let source =
860 std::fs::read("../../corpus/m6/live-collaboration-15/vm-restarted/notebook/synthetic.one")
861 .unwrap();
862 let store = Store::parse(&source).unwrap();
863 let index = RevisionIndex::parse(&store).unwrap();
864 let document = Document::parse(&index).unwrap();
865 let pages = document.pages().unwrap();
866 assert_eq!(pages.len(), 1);
867 let main = &document.spaces[&pages[0].0];
868 let main = &main.revisions[&main.contexts[&ExGuid::default()]];
869 let conflict_spaces = &main.nodes[&main.roots[&1]].spaces;
870 assert_eq!(conflict_spaces.len(), 1);
871 let mut conflicts = 0;
872 for sid in conflict_spaces {
873 assert!(pages.iter().all(|(page_space, _)| page_space != sid));
874 let space = &document.spaces[sid];
875 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
876 for (oid, node) in &revision.nodes {
877 if !matches!(node.kind, Kind::RichText { .. }) {
878 continue;
879 }
880 assert!(replace_text(&source, *sid, *oid, 0..0, "edit").is_err());
881 conflicts += 1;
882 }
883 }
884 assert!(conflicts > 0);
885}
886
887#[test]
888fn interrupted_text_commits_never_publish_mismatched_run_boundaries() {
889 let (sid, oid) = target(SOURCE);
890 let checkpoint = checkpoint::pending(SOURCE, sid, oid);
891 for source in [SOURCE, checkpoint.as_slice()] {
892 let make_disk = |fail_at, random| Disk {
893 visible: source.to_vec(),
894 durable: source.to_vec(),
895 operation: 0,
896 fail_at,
897 write_limit: usize::MAX,
898 random,
899 };
900 let before = text_runs(source, sid, oid);
901 let mut success = make_disk(None, 1);
902 commit_text(&mut success, source, sid, oid, 0..23, "🐈 mixed edit").unwrap();
903 let after = text_runs(&success.durable, sid, oid);
904 assert_ne!(before, after);
905 for at in 1..=success.operation {
906 for seed in [1, 42] {
907 let mut disk = make_disk(Some(at), seed);
908 let error =
909 commit_text(&mut disk, source, sid, oid, 0..23, "🐈 mixed edit").unwrap_err();
910 let observed = text_runs(&disk.durable, sid, oid);
911 match error.state {
912 CommitState::NotCommitted => assert_eq!(observed, before),
913 CommitState::Committed => assert_eq!(observed, after),
914 CommitState::Unknown => assert!(observed == before || observed == after),
915 }
916 }
917 }
918 }
919}
920
921#[test]
922fn empty_and_legacy_native_text_gain_unicode_without_losing_existing_properties() {
923 use onestore::{ObjectData, PropertySets, Value};
924 for (path, original) in [
925 (
926 "../../corpus/m6/native-empty-link-01/notebook/synthetic.one",
927 "",
928 ),
929 (
930 "../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one",
931 "Fictitious plain text.",
932 ),
933 ] {
934 let source = std::fs::read(path).unwrap();
935 let store = Store::parse(&source).unwrap();
936 let index = RevisionIndex::parse(&store).unwrap();
937 let document = Document::parse(&index).unwrap();
938 let mut targets = Vec::new();
939 for (sid, space) in &document.spaces {
940 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
941 for (oid, node) in &revision.nodes {
942 if matches!(&node.kind, Kind::RichText { text, boilerplate: false, .. } if text == original)
943 {
944 targets.push((*sid, *oid));
945 }
946 }
947 }
948 assert_eq!(targets.len(), 1);
949 let (sid, oid) = targets[0];
950 let old_rid = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
951 let old = index.resolve(sid, old_rid).unwrap();
952 let ObjectData::Properties(blob) = old.objects[&oid].data else {
953 panic!()
954 };
955 let properties = PropertySets::parse(blob).unwrap();
956 assert!(!properties.sets[0].iter().any(|p| p.id == 0x1c001c22));
957 let expected = format!("πŸ¦€ café {original}");
958 let written = replace_text(&source, sid, oid, 0..0, "πŸ¦€ café ").unwrap();
959 let after_store = Store::parse(&written).unwrap();
960 assert!(after_store.checksum_mismatches.is_empty());
961 let after = RevisionIndex::parse(&after_store).unwrap();
962 after.validate_current().unwrap();
963 let current = after
964 .resolve(sid, after.spaces[&sid].labels[&(ExGuid::default(), 1)])
965 .unwrap();
966 let ObjectData::Properties(blob) = current.objects[&oid].data else {
967 panic!()
968 };
969 let updated = PropertySets::parse(blob).unwrap();
970 assert_eq!(updated.sets[0].len(), properties.sets[0].len() + 1);
971 for property in &properties.sets[0] {
972 if property.id != 0x14001d7a {
973 assert!(updated.sets[0].iter().any(|p| p == property));
974 }
975 }
976 let encoded: Vec<_> = expected
977 .encode_utf16()
978 .chain([0])
979 .flat_map(u16::to_le_bytes)
980 .collect();
981 assert!(
982 updated.sets[0]
983 .iter()
984 .any(|p| p.id == 0x1c001c22 && p.value == Value::Bytes(&encoded))
985 );
986 assert_eq!(
987 after.resolve(sid, old_rid).unwrap().objects[&oid].data,
988 old.objects[&oid].data
989 );
990 assert_other_objects_preserved(&old, &current, oid);
991 let before = text_runs(&source, sid, oid);
992 let observed = text_runs(&written, sid, oid);
993 let mut intended = before.clone();
994 intended[0]["text"] = expected.into();
995 assert_eq!(observed, intended);
996 let disk = |fail_at, random| Disk {
997 visible: source.clone(),
998 durable: source.clone(),
999 operation: 0,
1000 fail_at,
1001 write_limit: 17,
1002 random,
1003 };
1004 let mut success = disk(None, 1);
1005 commit_text(&mut success, &source, sid, oid, 0..0, "πŸ¦€ café ").unwrap();
1006 for at in 1..=success.operation {
1007 for seed in [1, 42] {
1008 let mut interrupted = disk(Some(at), seed);
1009 let error =
1010 commit_text(&mut interrupted, &source, sid, oid, 0..0, "πŸ¦€ café ").unwrap_err();
1011 let actual = text_runs(&interrupted.durable, sid, oid);
1012 match error.state {
1013 CommitState::NotCommitted => assert_eq!(actual, before),
1014 CommitState::Committed => assert_eq!(actual, observed),
1015 CommitState::Unknown => assert!(actual == before || actual == observed),
1016 }
1017 }
1018 }
1019 }
1020}