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