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