| 1 | #[path = "support/checkpoint.rs"] |
| 2 | mod checkpoint; |
| 3 | #[path = "support/current.rs"] |
| 4 | mod current; |
| 5 | #[path = "support/disk.rs"] |
| 6 | mod disk; |
| 7 | #[path = "support/ops.rs"] |
| 8 | mod ops; |
| 9 | #[path = "support/sweep.rs"] |
| 10 | mod sweep; |
| 11 | |
| 12 | use onestore::{ |
| 13 | ExGuid, RevisionIndex, Store, TextAttribute as A, |
| 14 | document::{Document, Kind}, |
| 15 | op::{Edit, Op, PageOp}, |
| 16 | }; |
| 17 | use std::ops::Range; |
| 18 | |
| 19 | /// Where an insertion goes: a paragraph in a container before a child of it, or last; or a |
| 20 | /// new outline at a point on the page. |
| 21 | #[derive(Clone, Copy)] |
| 22 | enum Place { |
| 23 | In(ExGuid, Option<ExGuid>), |
| 24 | Outline(f32, f32), |
| 25 | } |
| 26 | |
| 27 | /// The ops inserting `text` at `place` with `formats` set over UTF-16 ranges of it, and the |
| 28 | /// identities of the new paragraph (or outline) and text. |
| 29 | fn insertion( |
| 30 | space: ExGuid, |
| 31 | place: Place, |
| 32 | text: &str, |
| 33 | formats: &[(Range<u32>, Vec<A>)], |
| 34 | ) -> (Vec<Op>, ExGuid, ExGuid) { |
| 35 | let paragraph = ops::paragraph(text); |
| 36 | let text = paragraph.text().unwrap().id; |
| 37 | let (op, object) = match place { |
| 38 | Place::In(container, before) => { |
| 39 | let id = paragraph.id; |
| 40 | let paragraphs = vec![paragraph]; |
| 41 | ( |
| 42 | PageOp::Insert { |
| 43 | container, |
| 44 | before, |
| 45 | paragraphs, |
| 46 | }, |
| 47 | id, |
| 48 | ) |
| 49 | } |
| 50 | Place::Outline(x, y) => { |
| 51 | let object = ops::outline(x, y, vec![paragraph]); |
| 52 | let id = object.id(); |
| 53 | ( |
| 54 | PageOp::Add { |
| 55 | object, |
| 56 | before: None, |
| 57 | }, |
| 58 | id, |
| 59 | ) |
| 60 | } |
| 61 | }; |
| 62 | let mut result = vec![Op::Page { space, op }]; |
| 63 | for (range, set) in formats { |
| 64 | let op = PageOp::Format { |
| 65 | text, |
| 66 | range: range.clone(), |
| 67 | set: set.clone(), |
| 68 | clear: Vec::new(), |
| 69 | }; |
| 70 | result.push(Op::Page { space, op }); |
| 71 | } |
| 72 | (result, object, text) |
| 73 | } |
| 74 | |
| 75 | fn targets(source: &[u8]) -> (ExGuid, ExGuid, ExGuid, ExGuid, ExGuid) { |
| 76 | let store = Store::parse(source).unwrap(); |
| 77 | let index = RevisionIndex::parse(&store).unwrap(); |
| 78 | let document = Document::parse(&index).unwrap(); |
| 79 | let (sid, page) = document.pages().unwrap()[0]; |
| 80 | let space = &document.spaces[&sid]; |
| 81 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 82 | let outline = view.nodes[&page] |
| 83 | .children |
| 84 | .iter() |
| 85 | .copied() |
| 86 | .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) |
| 87 | .unwrap(); |
| 88 | let paragraph = view.nodes[&outline].children[0]; |
| 89 | let text = view.nodes[&paragraph].content[0]; |
| 90 | (sid, page, outline, paragraph, text) |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn atomic_formatted_insertions_match_a_character_model_and_preserve_history() { |
| 95 | use serde_json::json; |
| 96 | let source = onestore::create_section("rich.one", "Original", "Author").unwrap(); |
| 97 | let (sid, page, outline, _, _) = targets(&source); |
| 98 | let text = "abπ¦ e\u{301} ζ±δΊ¬\rEnd\t!"; |
| 99 | let (plain, _, plain_text) = insertion(sid, Place::In(outline, None), text, &[]); |
| 100 | let base = ops::edited(&source, plain).unwrap(); |
| 101 | let base_store = Store::parse(&base).unwrap(); |
| 102 | let base_index = RevisionIndex::parse(&base_store).unwrap(); |
| 103 | let base_doc = Document::parse(&base_index).unwrap(); |
| 104 | let base_space = &base_doc.spaces[&sid]; |
| 105 | let base_view = &base_space.revisions[&base_space.contexts[&ExGuid::default()]]; |
| 106 | let default = |
| 107 | serde_json::to_value(&base_view.text_runs(plain_text).unwrap()[0].format).unwrap(); |
| 108 | let offsets: Vec<u32> = std::iter::once(0) |
| 109 | .chain(text.chars().scan(0, |n, c| { |
| 110 | *n += c.len_utf16() as u32; |
| 111 | Some(*n) |
| 112 | })) |
| 113 | .collect(); |
| 114 | let old_store = Store::parse(&source).unwrap(); |
| 115 | let old_index = RevisionIndex::parse(&old_store).unwrap(); |
| 116 | for seed in sweep::seeds(1..49, 48) { |
| 117 | let mut random = seed; |
| 118 | let mut expected: Vec<_> = text.chars().map(|c| (c, default.clone())).collect(); |
| 119 | let mut formats = Vec::new(); |
| 120 | // Descending construction also exercises the order formats apply in. |
| 121 | for i in (0..expected.len()).rev() { |
| 122 | random ^= random << 13; |
| 123 | random ^= random >> 7; |
| 124 | random ^= random << 17; |
| 125 | let (attributes, values) = match random % 8 { |
| 126 | 0 => continue, |
| 127 | 1 => ( |
| 128 | vec![A::Bold(true), A::Underline(true)], |
| 129 | json!({"bold":true,"underline":true}), |
| 130 | ), |
| 131 | 2 => ( |
| 132 | vec![A::Italic(true), A::Strike(true)], |
| 133 | json!({"italic":true,"strike":true}), |
| 134 | ), |
| 135 | 3 => ( |
| 136 | vec![A::Font("Arial".into()), A::FontSize(13.5)], |
| 137 | json!({"font":"Arial","font_size":13.5}), |
| 138 | ), |
| 139 | 4 => ( |
| 140 | vec![ |
| 141 | A::Color(Some([12, 34, 56])), |
| 142 | A::Highlight(Some([255, 255, 0])), |
| 143 | ], |
| 144 | json!({"color":0x38220c,"highlight":0x00ffff}), |
| 145 | ), |
| 146 | 5 => ( |
| 147 | vec![A::Superscript(true)], |
| 148 | json!({"superscript":true,"subscript":false}), |
| 149 | ), |
| 150 | 6 => ( |
| 151 | vec![A::Subscript(true)], |
| 152 | json!({"subscript":true,"superscript":false}), |
| 153 | ), |
| 154 | _ => ( |
| 155 | vec![A::Bold(false), A::Color(None), A::Highlight(None)], |
| 156 | json!({"bold":false,"color":0xff000000_u32,"highlight":0xff000000_u32}), |
| 157 | ), |
| 158 | }; |
| 159 | formats.push((offsets[i]..offsets[i + 1], attributes)); |
| 160 | for (key, value) in values.as_object().unwrap() { |
| 161 | expected[i].1[key] = value.clone(); |
| 162 | } |
| 163 | } |
| 164 | let place = if seed % 2 == 0 { |
| 165 | Place::Outline(72.0, 144.0) |
| 166 | } else { |
| 167 | Place::In(outline, None) |
| 168 | }; |
| 169 | let (ops, _, inserted) = insertion(sid, place, text, &formats); |
| 170 | let edit = Edit { |
| 171 | at: ops::now(), |
| 172 | ops, |
| 173 | }; |
| 174 | let restored: Edit = serde_json::from_slice(&serde_json::to_vec(&edit).unwrap()).unwrap(); |
| 175 | assert_eq!(restored, edit); |
| 176 | let edited = ops::edited(&source, restored.ops).unwrap(); |
| 177 | let store = Store::parse(&edited).unwrap(); |
| 178 | assert_eq!( |
| 179 | store.header.transaction_count, |
| 180 | old_store.header.transaction_count + 1 |
| 181 | ); |
| 182 | let index = RevisionIndex::parse(&store).unwrap(); |
| 183 | index.validate_current().unwrap(); |
| 184 | assert_eq!( |
| 185 | index.spaces[&sid].revisions.len(), |
| 186 | old_index.spaces[&sid].revisions.len() + 1 |
| 187 | ); |
| 188 | for (space, old) in &old_index.spaces { |
| 189 | for revision in old.revisions.keys() { |
| 190 | assert_eq!( |
| 191 | format!("{:?}", old_index.resolve(*space, *revision).unwrap()), |
| 192 | format!("{:?}", index.resolve(*space, *revision).unwrap()) |
| 193 | ); |
| 194 | } |
| 195 | } |
| 196 | let document = Document::parse(&index).unwrap(); |
| 197 | let space = &document.spaces[&sid]; |
| 198 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 199 | if let Place::Outline(..) = place { |
| 200 | assert!(view.nodes[&page].children.len() > 1); |
| 201 | } |
| 202 | let actual: Vec<_> = view |
| 203 | .text_runs(inserted) |
| 204 | .unwrap() |
| 205 | .into_iter() |
| 206 | .flat_map(|run| { |
| 207 | let format = serde_json::to_value(run.format).unwrap(); |
| 208 | run.text.chars().map(move |c| (c, format.clone())) |
| 209 | }) |
| 210 | .collect(); |
| 211 | assert_eq!(actual, expected, "seed {seed}"); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | #[test] |
| 216 | fn formatted_empty_insertions_preserve_the_insertion_style_for_later_typing() { |
| 217 | let source = onestore::create_section("empty-rich.one", "Original", "Author").unwrap(); |
| 218 | let (sid, _, outline, _, _) = targets(&source); |
| 219 | for place in [Place::Outline(72.0, 144.0), Place::In(outline, None)] { |
| 220 | let style = [(0..0, vec![A::Italic(true), A::FontSize(18.0)])]; |
| 221 | let (ops, _, text) = insertion(sid, place, "", &style); |
| 222 | let inserted = ops::edited(&source, ops).unwrap(); |
| 223 | let typed = ops::page_edited( |
| 224 | &inserted, |
| 225 | sid, |
| 226 | vec![PageOp::Text { |
| 227 | text, |
| 228 | range: 0..0, |
| 229 | with: "Typed π¦".into(), |
| 230 | }], |
| 231 | ) |
| 232 | .unwrap(); |
| 233 | for (bytes, expected) in [(&inserted, ""), (&typed, "Typed π¦")] { |
| 234 | let store = Store::parse(bytes).unwrap(); |
| 235 | let index = RevisionIndex::parse(&store).unwrap(); |
| 236 | index.validate_current().unwrap(); |
| 237 | let document = Document::parse(&index).unwrap(); |
| 238 | let space = &document.spaces[&sid]; |
| 239 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 240 | let runs = view.text_runs(text).unwrap(); |
| 241 | assert_eq!(runs.len(), 1); |
| 242 | assert_eq!(runs[0].text, expected); |
| 243 | assert_eq!(runs[0].format.italic, Some(true)); |
| 244 | assert_eq!(runs[0].format.font_size, Some(18.0)); |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | #[ignore = "exports formatted insertion candidates for cold native validation"] |
| 251 | fn export_native_formatted_insertions() { |
| 252 | use std::{fs, path::PathBuf}; |
| 253 | let output = PathBuf::from(std::env::var_os("ONESTORE_INSERT_OUTPUT").unwrap()); |
| 254 | assert!(output.is_absolute()); |
| 255 | fs::create_dir(&output).unwrap(); |
| 256 | let generated = onestore::create_section("rich.one", "Original", "Author").unwrap(); |
| 257 | let unicode = include_bytes!( |
| 258 | "../../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one" |
| 259 | ); |
| 260 | let table = include_bytes!( |
| 261 | "../../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one" |
| 262 | ); |
| 263 | let text = "Bold π¦ italic e\u{301} color ζ±δΊ¬\rEnd"; |
| 264 | let mut manifest = Vec::new(); |
| 265 | for (name, source, empty) in [ |
| 266 | ("outline", generated.as_slice(), false), |
| 267 | ("native-paragraph", unicode.as_slice(), false), |
| 268 | ("native-cell", table.as_slice(), false), |
| 269 | ("empty", generated.as_slice(), true), |
| 270 | ("empty-typed", generated.as_slice(), true), |
| 271 | ] { |
| 272 | let (sid, _, outline, paragraph, _) = targets(source); |
| 273 | let place = match name { |
| 274 | "native-paragraph" => Place::In(outline, Some(paragraph)), |
| 275 | "native-cell" => { |
| 276 | let store = Store::parse(source).unwrap(); |
| 277 | let index = RevisionIndex::parse(&store).unwrap(); |
| 278 | let document = Document::parse(&index).unwrap(); |
| 279 | let space = &document.spaces[&sid]; |
| 280 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 281 | let cell = *view |
| 282 | .nodes |
| 283 | .iter() |
| 284 | .find(|(_, node)| matches!(node.kind, Kind::Cell { .. })) |
| 285 | .unwrap() |
| 286 | .0; |
| 287 | Place::In(cell, None) |
| 288 | } |
| 289 | _ => Place::Outline(72.0, 144.0), |
| 290 | }; |
| 291 | let formats = if empty { |
| 292 | vec![(0..0, vec![A::Italic(true), A::FontSize(18.0)])] |
| 293 | } else { |
| 294 | [ |
| 295 | ("Bold", vec![A::Bold(true), A::Underline(true)]), |
| 296 | ("π¦", vec![A::Font("Arial".into()), A::FontSize(13.5)]), |
| 297 | ("italic", vec![A::Italic(true), A::Strike(true)]), |
| 298 | ("e\u{301}", vec![A::Superscript(true)]), |
| 299 | ( |
| 300 | "color", |
| 301 | vec![ |
| 302 | A::Color(Some([12, 34, 56])), |
| 303 | A::Highlight(Some([255, 255, 0])), |
| 304 | ], |
| 305 | ), |
| 306 | ("ζ±δΊ¬", vec![A::Subscript(true)]), |
| 307 | ( |
| 308 | "End", |
| 309 | vec![ |
| 310 | A::Bold(false), |
| 311 | A::Italic(false), |
| 312 | A::Color(None), |
| 313 | A::Highlight(None), |
| 314 | ], |
| 315 | ), |
| 316 | ] |
| 317 | .into_iter() |
| 318 | .map(|(label, attributes)| { |
| 319 | let start = text[..text.find(label).unwrap()].encode_utf16().count() as u32; |
| 320 | ( |
| 321 | start..start + label.encode_utf16().count() as u32, |
| 322 | attributes, |
| 323 | ) |
| 324 | }) |
| 325 | .collect() |
| 326 | }; |
| 327 | let (mut ops, object, inserted) = |
| 328 | insertion(sid, place, if empty { "" } else { text }, &formats); |
| 329 | if name == "empty-typed" { |
| 330 | let op = PageOp::Text { |
| 331 | text: inserted, |
| 332 | range: 0..0, |
| 333 | with: "Typed café π¦".into(), |
| 334 | }; |
| 335 | ops.push(Op::Page { space: sid, op }); |
| 336 | } |
| 337 | let edit = Edit { |
| 338 | at: ops::now(), |
| 339 | ops, |
| 340 | }; |
| 341 | let bytes = ops::edited(source, edit.ops.clone()).unwrap(); |
| 342 | current::current(&bytes); |
| 343 | let notebook = output.join(name); |
| 344 | fs::create_dir(&notebook).unwrap(); |
| 345 | fs::write(notebook.join("synthetic.one"), &bytes).unwrap(); |
| 346 | manifest.push(serde_json::json!({ |
| 347 | "name": name, "space": sid, "object": object, "text": inserted, "edit": edit |
| 348 | })); |
| 349 | } |
| 350 | fs::write( |
| 351 | output.join("manifest.json"), |
| 352 | serde_json::to_vec_pretty(&manifest).unwrap(), |
| 353 | ) |
| 354 | .unwrap(); |
| 355 | } |
| 356 | |
| 357 | #[test] |
| 358 | fn paragraph_and_outline_insertions_publish_metadata_and_references_together() { |
| 359 | let source = onestore::create_section("insertion.one", "Original", "Original author").unwrap(); |
| 360 | let (sid, page, outline, paragraph, original_text) = targets(&source); |
| 361 | for (place, expected_parent, expected_x, expected_y) in [ |
| 362 | (Place::In(outline, Some(paragraph)), outline, None, None), |
| 363 | (Place::Outline(144.0, 18.0), page, Some(144.0), Some(18.0)), |
| 364 | ] { |
| 365 | let (ops, object, text) = insertion(sid, place, "First π¦\rSecond", &[]); |
| 366 | let transaction = ops::transaction(&source, "New author", ops) |
| 367 | .unwrap() |
| 368 | .unwrap(); |
| 369 | let mut written = source.clone(); |
| 370 | transaction.apply(&mut written).unwrap(); |
| 371 | let store = Store::parse(&written).unwrap(); |
| 372 | assert_eq!( |
| 373 | store.header.transaction_count, |
| 374 | Store::parse(&source).unwrap().header.transaction_count + 1 |
| 375 | ); |
| 376 | let index = RevisionIndex::parse(&store).unwrap(); |
| 377 | index.validate_current().unwrap(); |
| 378 | let document = Document::parse(&index).unwrap(); |
| 379 | let space = &document.spaces[&sid]; |
| 380 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 381 | assert!(view.nodes[&expected_parent].children.contains(&object)); |
| 382 | assert_eq!(view.nodes[&object].layout.x, expected_x); |
| 383 | assert_eq!(view.nodes[&object].layout.y, expected_y); |
| 384 | assert!( |
| 385 | matches!(&view.nodes[&original_text].kind, Kind::RichText { text, .. } if text == "Original") |
| 386 | ); |
| 387 | assert!( |
| 388 | matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text == "First π¦\rSecond") |
| 389 | ); |
| 390 | assert!( |
| 391 | matches!(&view.nodes[&page].kind, Kind::Page { alternate_title, .. } if alternate_title.as_deref() == Some("First π¦")) |
| 392 | ); |
| 393 | assert!( |
| 394 | matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title, .. } if title.as_deref() == Some("First π¦")) |
| 395 | ); |
| 396 | assert!( |
| 397 | view.nodes |
| 398 | .values() |
| 399 | .any(|node| matches!(&node.kind, Kind::Author { name } if name.as_deref() == Some("New author"))) |
| 400 | ); |
| 401 | let runs = view.text_runs(text).unwrap(); |
| 402 | assert_eq!(runs.len(), 1); |
| 403 | assert_eq!(runs[0].format.font.as_deref(), Some("Calibri")); |
| 404 | assert_eq!(runs[0].format.font_size, Some(11.0)); |
| 405 | let previous_store = Store::parse(&source).unwrap(); |
| 406 | let previous = RevisionIndex::parse(&previous_store).unwrap(); |
| 407 | for (old_sid, old_space) in &previous.spaces { |
| 408 | for rid in old_space.revisions.keys() { |
| 409 | assert_eq!( |
| 410 | format!("{:?}", previous.resolve(*old_sid, *rid).unwrap()), |
| 411 | format!("{:?}", index.resolve(*old_sid, *rid).unwrap()) |
| 412 | ); |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | #[test] |
| 419 | fn repeated_insertions_and_formatting_share_immutable_objects() { |
| 420 | let mut source = onestore::create_section("shared.one", "Original", "Same author").unwrap(); |
| 421 | let (sid, _, outline, _, _) = targets(&source); |
| 422 | let mut texts = Vec::new(); |
| 423 | for _ in 0..12 { |
| 424 | let (ops, _, text) = insertion(sid, Place::In(outline, None), "Repeated paragraph", &[]); |
| 425 | let transaction = ops::transaction(&source, "Same author", ops) |
| 426 | .unwrap() |
| 427 | .unwrap(); |
| 428 | transaction.apply(&mut source).unwrap(); |
| 429 | let bold = PageOp::Format { |
| 430 | text, |
| 431 | range: 1..8, |
| 432 | set: vec![A::Bold(true), A::FontSize(20.0)], |
| 433 | clear: Vec::new(), |
| 434 | }; |
| 435 | let transaction = ops::transaction( |
| 436 | &source, |
| 437 | "Same author", |
| 438 | vec![Op::Page { |
| 439 | space: sid, |
| 440 | op: bold, |
| 441 | }], |
| 442 | ) |
| 443 | .unwrap() |
| 444 | .unwrap(); |
| 445 | transaction.apply(&mut source).unwrap(); |
| 446 | texts.push(text); |
| 447 | } |
| 448 | let store = Store::parse(&source).unwrap(); |
| 449 | let index = RevisionIndex::parse(&store).unwrap(); |
| 450 | index.validate_current().unwrap(); |
| 451 | let raw = index |
| 452 | .resolve(sid, index.spaces[&sid].labels[&(ExGuid::default(), 1)]) |
| 453 | .unwrap(); |
| 454 | let mut unique = std::collections::BTreeSet::new(); |
| 455 | let mut counts = Vec::new(); |
| 456 | for id in raw.reachable().unwrap() { |
| 457 | let object = &raw.objects[&id]; |
| 458 | if object.jcid & 0x100000 == 0 { |
| 459 | continue; |
| 460 | } |
| 461 | let onestore::ObjectData::Properties(bytes) = object.data else { |
| 462 | panic!() |
| 463 | }; |
| 464 | assert!( |
| 465 | unique.insert((object.jcid, bytes.to_vec())), |
| 466 | "Duplicate immutable object" |
| 467 | ); |
| 468 | counts.push((object.jcid, object.reference_count)); |
| 469 | } |
| 470 | counts.sort(); |
| 471 | assert_eq!(counts, [(0x120001, 26), (0x12004d, 12), (0x12004d, 25)]); |
| 472 | let document = Document::parse(&index).unwrap(); |
| 473 | let space = &document.spaces[&sid]; |
| 474 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 475 | for text in texts { |
| 476 | let runs = view.text_runs(text).unwrap(); |
| 477 | assert_eq!(runs.len(), 3); |
| 478 | assert_eq!(runs[1].format.bold, Some(true)); |
| 479 | assert_eq!(runs[1].format.font_size, Some(20.0)); |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | #[test] |
| 484 | fn serialized_insertions_rebase_with_the_same_objects_and_preserve_remote_edits() { |
| 485 | let source = onestore::create_section("rebase.one", "Original", "Author").unwrap(); |
| 486 | let (sid, _, outline, paragraph, text) = targets(&source); |
| 487 | let formats = [(0..3, vec![A::Bold(true), A::Color(Some([12, 34, 56]))])]; |
| 488 | let (ops, object, inserted) = insertion( |
| 489 | sid, |
| 490 | Place::In(outline, Some(paragraph)), |
| 491 | "Inserted", |
| 492 | &formats, |
| 493 | ); |
| 494 | let encoded = serde_json::to_vec(&ops).unwrap(); |
| 495 | let restored: Vec<Op> = serde_json::from_slice(&encoded).unwrap(); |
| 496 | assert_eq!(ops, restored); |
| 497 | let original = ops::edited(&source, ops).unwrap(); |
| 498 | let remote = ops::page_edited( |
| 499 | &source, |
| 500 | sid, |
| 501 | vec![PageOp::Text { |
| 502 | text, |
| 503 | range: 0..0, |
| 504 | with: "Remote ".into(), |
| 505 | }], |
| 506 | ) |
| 507 | .unwrap(); |
| 508 | let updated = ops::edited(&remote, restored.clone()).unwrap(); |
| 509 | let store = Store::parse(&updated).unwrap(); |
| 510 | let index = RevisionIndex::parse(&store).unwrap(); |
| 511 | let document = Document::parse(&index).unwrap(); |
| 512 | let space = &document.spaces[&sid]; |
| 513 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 514 | assert_eq!(view.nodes[&outline].children, [object, paragraph]); |
| 515 | assert!( |
| 516 | matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text == "Remote Original") |
| 517 | ); |
| 518 | assert!( |
| 519 | matches!(&view.nodes[&inserted].kind, Kind::RichText { text, .. } if text == "Inserted") |
| 520 | ); |
| 521 | let runs = view.text_runs(inserted).unwrap(); |
| 522 | assert_eq!(runs[0].text, "Ins"); |
| 523 | assert_eq!(runs[0].format.bold, Some(true)); |
| 524 | assert_eq!(runs[0].format.color, Some(0x38220c)); |
| 525 | assert_eq!(runs[1].text, "erted"); |
| 526 | assert_ne!(runs[1].format.bold, Some(true)); |
| 527 | assert!(ops::edited(&original, restored.clone()).is_err()); |
| 528 | assert!(ops::edited(&updated, restored).is_err()); |
| 529 | } |
| 530 | |
| 531 | #[test] |
| 532 | #[cfg(any(unix, windows))] |
| 533 | fn filesystem_insertion_commits_once_and_rejects_stale_replay() { |
| 534 | use std::{fs, io::Write}; |
| 535 | let source = onestore::create_section("file.one", "Original", "Author").unwrap(); |
| 536 | let (sid, _, outline, _, _) = targets(&source); |
| 537 | let (ops, object, _) = insertion(sid, Place::In(outline, None), "File insertion", &[]); |
| 538 | let path = std::env::temp_dir().join(format!("onestore-insertion-{object}.one")); |
| 539 | let mut file = fs::OpenOptions::new() |
| 540 | .write(true) |
| 541 | .create_new(true) |
| 542 | .open(&path) |
| 543 | .unwrap(); |
| 544 | file.write_all(&source).unwrap(); |
| 545 | file.sync_all().unwrap(); |
| 546 | drop(file); |
| 547 | let transaction = ops::transaction(&source, "Author", ops).unwrap().unwrap(); |
| 548 | let result = transaction.commit_file(&path); |
| 549 | let written = onestore::read_file(&path).unwrap(); |
| 550 | let repeated = transaction.commit_file(&path).unwrap_err(); |
| 551 | fs::remove_file(path).unwrap(); |
| 552 | result.unwrap(); |
| 553 | let mut expected = source.clone(); |
| 554 | transaction.apply(&mut expected).unwrap(); |
| 555 | assert_eq!(written, expected); |
| 556 | assert_eq!(repeated.state, onestore::CommitState::NotCommitted); |
| 557 | assert_eq!(repeated.error.kind(), std::io::ErrorKind::ResourceBusy); |
| 558 | } |
| 559 | |
| 560 | #[test] |
| 561 | fn anchors_targets_identities_and_text_are_validated_before_publication() { |
| 562 | let source = onestore::create_section("invalid.one", "Original", "Author").unwrap(); |
| 563 | let (sid, page, outline, _, text) = targets(&source); |
| 564 | let refused = |source: &[u8], place, content: &str| { |
| 565 | ops::edited(source, insertion(sid, place, content, &[]).0).is_err() |
| 566 | }; |
| 567 | assert!(refused(&source, Place::Outline(f32::NAN, 0.0), "Text")); |
| 568 | assert!(refused(&source, Place::Outline(0.0, f32::INFINITY), "Text")); |
| 569 | for content in ["a\0b", "a\nb", "a\u{fffc}b"] { |
| 570 | assert!(refused(&source, Place::In(outline, None), content)); |
| 571 | } |
| 572 | assert!( |
| 573 | ops::transaction( |
| 574 | &source, |
| 575 | "a\0b", |
| 576 | insertion(sid, Place::In(outline, None), "Text", &[]).0 |
| 577 | ) |
| 578 | .is_err() |
| 579 | ); |
| 580 | for place in [ |
| 581 | Place::In(outline, Some(text)), |
| 582 | Place::In(text, None), |
| 583 | Place::In(page, None), |
| 584 | ] { |
| 585 | assert!(refused(&source, place, "Text")); |
| 586 | } |
| 587 | let (ops, object, _) = insertion(sid, Place::In(outline, None), "Text", &[]); |
| 588 | let created = ops::edited(&source, ops.clone()).unwrap(); |
| 589 | let before_created = Place::In(outline, Some(object)); |
| 590 | assert!(refused(&source, before_created, "Anchored")); |
| 591 | assert!(!refused(&created, before_created, "Anchored")); |
| 592 | // A new object's identity may not already be on the page. |
| 593 | assert!(ops::edited(&created, ops).is_err()); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn insertions_into_nested_paragraphs_and_native_table_cells_preserve_structure() { |
| 598 | let source = onestore::create_section("nested.one", "Original", "Author").unwrap(); |
| 599 | let (sid, _, outline, paragraph, _) = targets(&source); |
| 600 | let mut nested = ops::paragraph("Nested"); |
| 601 | nested.level = 2; |
| 602 | let child = nested.id; |
| 603 | let insert = PageOp::Insert { |
| 604 | container: paragraph, |
| 605 | before: None, |
| 606 | paragraphs: vec![nested], |
| 607 | }; |
| 608 | let changed = ops::page_edited(&source, sid, vec![insert]).unwrap(); |
| 609 | let store = Store::parse(&changed).unwrap(); |
| 610 | let index = RevisionIndex::parse(&store).unwrap(); |
| 611 | let document = Document::parse(&index).unwrap(); |
| 612 | let space = &document.spaces[&sid]; |
| 613 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 614 | assert_eq!(view.nodes[&outline].children, [paragraph]); |
| 615 | assert_eq!(view.nodes[&paragraph].children, [child]); |
| 616 | let source = include_bytes!( |
| 617 | "../../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one" |
| 618 | ); |
| 619 | let store = Store::parse(source).unwrap(); |
| 620 | let index = RevisionIndex::parse(&store).unwrap(); |
| 621 | let document = Document::parse(&index).unwrap(); |
| 622 | let (sid, cell) = document |
| 623 | .spaces |
| 624 | .iter() |
| 625 | .find_map(|(sid, space)| { |
| 626 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 627 | view.nodes.iter().find_map(|(id, node)| { |
| 628 | matches!(node.kind, Kind::Cell { .. }).then_some((*sid, *id)) |
| 629 | }) |
| 630 | }) |
| 631 | .unwrap(); |
| 632 | let (ops, _, _) = insertion(sid, Place::In(cell, None), "Added to cell", &[]); |
| 633 | current::current(&ops::edited(source, ops).unwrap()); |
| 634 | } |
| 635 | |
| 636 | #[test] |
| 637 | fn insertion_publication_faults_expose_only_complete_graphs_and_title_caches() { |
| 638 | let original = onestore::create_section("atomic.one", "Original", "Author").unwrap(); |
| 639 | let (sid, _, _, _, text) = targets(&original); |
| 640 | let checkpoint = checkpoint::pending(&original, sid, text); |
| 641 | for source in [ |
| 642 | original.as_slice(), |
| 643 | include_bytes!("../../../corpus/append/round-01/tx-255/notebook/synthetic.one"), |
| 644 | &checkpoint, |
| 645 | ] { |
| 646 | let (sid, _, outline, paragraph, _) = targets(source); |
| 647 | for place in [ |
| 648 | Place::In(outline, Some(paragraph)), |
| 649 | Place::Outline(0.0, 0.0), |
| 650 | ] { |
| 651 | let formats = [ |
| 652 | (0..3, vec![A::Bold(true), A::FontSize(18.0)]), |
| 653 | ( |
| 654 | 6..8, |
| 655 | vec![A::Italic(true), A::Highlight(Some([255, 255, 0]))], |
| 656 | ), |
| 657 | ]; |
| 658 | let (ops, _, _) = insertion(sid, place, "First π¦", &formats); |
| 659 | let edit = ops::transaction(source, "New author", ops) |
| 660 | .unwrap() |
| 661 | .unwrap(); |
| 662 | let mut written = source.to_vec(); |
| 663 | edit.apply(&mut written).unwrap(); |
| 664 | let before = current::current(source); |
| 665 | let after = current::current(&written); |
| 666 | for write_limit in [17, 4096] { |
| 667 | let disk = |fail_at| disk::Disk { |
| 668 | visible: source.to_vec(), |
| 669 | durable: source.to_vec(), |
| 670 | operation: 0, |
| 671 | fail_at, |
| 672 | write_limit, |
| 673 | random: 945, |
| 674 | }; |
| 675 | let mut successful = disk(None); |
| 676 | edit.commit(&mut successful).unwrap(); |
| 677 | assert_eq!(successful.durable, written); |
| 678 | for at in 1..=successful.operation { |
| 679 | let mut interrupted = disk(Some(at)); |
| 680 | let failure = edit.commit(&mut interrupted).unwrap_err(); |
| 681 | let state = current::current(&interrupted.durable); |
| 682 | assert!(state == before || state == after, "interruption {at}"); |
| 683 | if failure.state == onestore::CommitState::NotCommitted { |
| 684 | assert_eq!(state, before); |
| 685 | } |
| 686 | if failure.state == onestore::CommitState::Committed { |
| 687 | assert_eq!(state, after); |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | } |
| 692 | } |
| 693 | } |