diff --git a/corpus/outline-edit/automatic-title/README.md b/corpus/outline-edit/automatic-title/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d12b244ebdd2906f209576760c541ff2fe3ebf07 --- /dev/null +++ b/corpus/outline-edit/automatic-title/README.md @@ -0,0 +1,29 @@ +# Automatic titles after outline movement + +Three native pages distinguish vertical ordering, horizontal ordering and an +explicit title. Moving the second outline ahead of the first changes the two +automatic titles to `Second 🦋 é`; the explicit title remains unchanged. + +`before` is the native source. `native` records the corresponding COM moves and +an independent cold reopen. `candidate` is the Rust edit; `cold` is its independent +OneNote reopen. Cold captures preserve exact active graphs. Rust movement retains +all text identities, content, formatting and history. Identical artifacts link +to one copy; `provenance.json` records the native runs and clone teardown. + +The Rust regression originally left both automatic-title fields unchanged. +Position edits now update the existing page and metadata caches within the same +revision as the coordinates. The offline regression additionally changes the +remote title-producing text, loses the publication reply and reopens the cache +before confirmation and a dependent edit. + +Run `tools.test_outline_edit.OutlineEditTest.test_automatic_titles_follow_native_and_rust_outline_movement` +through Python unittest after building the workspace examples with all features. +The Rust test `native_automatic_and_explicit_titles_follow_outline_movement` can +export a new candidate directory through `ONESTORE_OUTLINE_TITLE_OUTPUT`. + +To regenerate the native controls, copy `fixture.json` and the synthetic section +from `corpus/native-delete/deletion-05/before` into a new input notebook directory. +Capture it with `tools/native_runner.py --author tools/native/pages.ps1 +--collect-notebook`. Capture that result's notebook with +`--author tools/native/outline-title.ps1 --collect-notebook`, then independently +cold-open both the native result and the Rust candidate without an author script. diff --git a/corpus/outline-edit/automatic-title/before/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/before/notebook/TitleControl.one new file mode 100644 index 0000000000000000000000000000000000000000..b29efe83f94312361f3e77e6754f11adf0c9907d Binary files /dev/null and b/corpus/outline-edit/automatic-title/before/notebook/TitleControl.one differ diff --git a/corpus/outline-edit/automatic-title/before/read/hierarchy.xml b/corpus/outline-edit/automatic-title/before/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..fc93eb0778808de69cf3d5307bfb9dd450894e13 --- /dev/null +++ b/corpus/outline-edit/automatic-title/before/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/before/read/page-001.xml b/corpus/outline-edit/automatic-title/before/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..14027d9a74ab9ee14a9c32ab2f5454b3e88f4915 --- /dev/null +++ b/corpus/outline-edit/automatic-title/before/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/before/read/page-003.xml b/corpus/outline-edit/automatic-title/before/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..677b0132259a3cf8bed5426f06a2f598846f481c --- /dev/null +++ b/corpus/outline-edit/automatic-title/before/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/before/read/page-004.xml b/corpus/outline-edit/automatic-title/before/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..87aaadbf74c8d772aeecff926d8ca33af9d47d26 --- /dev/null +++ b/corpus/outline-edit/automatic-title/before/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/candidate/TitleControl.one b/corpus/outline-edit/automatic-title/candidate/TitleControl.one new file mode 100644 index 0000000000000000000000000000000000000000..884cec54059c0cbacd87a91f7be00f8941d18f1a Binary files /dev/null and b/corpus/outline-edit/automatic-title/candidate/TitleControl.one differ diff --git a/corpus/outline-edit/automatic-title/cold/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/cold/notebook/TitleControl.one new file mode 100644 index 0000000000000000000000000000000000000000..7c18e9b97196cc84c8623e8e1bcd0950c92cadb8 Binary files /dev/null and b/corpus/outline-edit/automatic-title/cold/notebook/TitleControl.one differ diff --git a/corpus/outline-edit/automatic-title/cold/read/hierarchy.xml b/corpus/outline-edit/automatic-title/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..4990dda9d774ae5de1c41a5ae72ef85ae37775ed --- /dev/null +++ b/corpus/outline-edit/automatic-title/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/cold/read/page-000.xml b/corpus/outline-edit/automatic-title/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..4b40442d60db1cf787076775f245b6cd52101fa7 --- /dev/null +++ b/corpus/outline-edit/automatic-title/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/cold/read/page-001.xml b/corpus/outline-edit/automatic-title/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..dd27ba5829a56dd160c7cb5d07b8f2dfb2f54cc0 --- /dev/null +++ b/corpus/outline-edit/automatic-title/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/cold/read/page-002.xml b/corpus/outline-edit/automatic-title/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..543bc61d4760baac2e20fa3d42ae46d6382c2502 --- /dev/null +++ b/corpus/outline-edit/automatic-title/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/fixture.json b/corpus/outline-edit/automatic-title/fixture.json new file mode 100644 index 0000000000000000000000000000000000000000..da384c090ad4c9543b224f1eeb40de7b7aed3583 --- /dev/null +++ b/corpus/outline-edit/automatic-title/fixture.json @@ -0,0 +1 @@ +{"sections": ["TitleControl.one"], "pages": [{"section": "TitleControl.one", "xml": "Vertical firstSecond \ud83e\udd8b e\u0301"}, {"section": "TitleControl.one", "xml": "Horizontal firstSecond \ud83e\udd8b e\u0301"}, {"section": "TitleControl.one", "xml": "Explicit titleExplicit titleSecond \ud83e\udd8b e\u0301"}]} \ No newline at end of file diff --git a/corpus/outline-edit/automatic-title/native/cold/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/native/cold/notebook/TitleControl.one new file mode 120000 index 0000000000000000000000000000000000000000..c2ccdfd7698b3d0ff28d305952cd0b9aaaf7fcbb --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/cold/notebook/TitleControl.one @@ -0,0 +1 @@ +../../notebook/TitleControl.one \ No newline at end of file diff --git a/corpus/outline-edit/automatic-title/native/cold/read/hierarchy.xml b/corpus/outline-edit/automatic-title/native/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..db0e819665e073c38d325b8cc121e9292e4f94d7 --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/cold/read/page-002.xml b/corpus/outline-edit/automatic-title/native/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..8219b1b55f4a0250899b3b251476fe2b986e710f --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/cold/read/page-003.xml b/corpus/outline-edit/automatic-title/native/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..d06176fd09b2314ce79c511ea036d80511d223cd --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/cold/read/page-004.xml b/corpus/outline-edit/automatic-title/native/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb9eb371e4ebb4b29ac03593ac2dfb7c53d067c7 --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/notebook/TitleControl.one b/corpus/outline-edit/automatic-title/native/notebook/TitleControl.one new file mode 100644 index 0000000000000000000000000000000000000000..d7c68995a31f0bceeb86c4587d93dad51d811cd9 Binary files /dev/null and b/corpus/outline-edit/automatic-title/native/notebook/TitleControl.one differ diff --git a/corpus/outline-edit/automatic-title/native/read/hierarchy.xml b/corpus/outline-edit/automatic-title/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..c9c6498bc9b12465d27f2e6f0a846247b59f1833 --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/read/page-000.xml b/corpus/outline-edit/automatic-title/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..48a2ab148379f8dd2b33312f7100cf84f6a5b639 --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/read/page-001.xml b/corpus/outline-edit/automatic-title/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..5334eb46b1e11826af3c1e04aa34c67fee59c7b4 --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/native/read/page-003.xml b/corpus/outline-edit/automatic-title/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..9d6adce81eaa9f312c05bbe4a9b99da177d4557a --- /dev/null +++ b/corpus/outline-edit/automatic-title/native/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/outline-edit/automatic-title/provenance.json b/corpus/outline-edit/automatic-title/provenance.json new file mode 100644 index 0000000000000000000000000000000000000000..ceecd4a3f008ddc0efbbf367ca9121dc367af0b2 --- /dev/null +++ b/corpus/outline-edit/automatic-title/provenance.json @@ -0,0 +1,116 @@ +{ + "outline-title-native-before": { + "capture": "evidence/m10/outline-title-native-before", + "machine": { + "name": "m6-cfe84d87", + "hostname": "ONE-M6-CFE84D87" + }, + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-input", + "expected_pages": -1, + "author": "tools/native/pages.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "teardown": { + "absent": true + } + }, + "outline-title-native-after": { + "capture": "evidence/m10/outline-title-native-after", + "machine": { + "name": "m6-49c5ad30", + "hostname": "ONE-M6-49C5AD30" + }, + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-native-before/notebook", + "expected_pages": -1, + "author": "evidence/m10/outline-title-move.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "9d8779a521fb60ff3063d320babe742deeda1ca7e5cf89f54f5617473e28b998", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "teardown": { + "absent": true + } + }, + "outline-title-native-cold": { + "capture": "evidence/m10/outline-title-native-cold", + "machine": { + "name": "m6-3512bf26", + "hostname": "ONE-M6-3512BF26" + }, + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-native-after/notebook", + "expected_pages": 5, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "teardown": { + "absent": true + } + }, + "outline-title-rust-cold": { + "capture": "evidence/m10/outline-title-rust-cold", + "machine": { + "name": "m6-61cabd16", + "hostname": "ONE-M6-61CABD16" + }, + "run": { + "notebook": "/Users/clo/dev/one/evidence/m10/outline-title-rust-candidate", + "expected_pages": 3, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } + }, + "teardown": { + "absent": true + } + } +} diff --git a/crates/onestore-offline/tests/sync/outline.rs b/crates/onestore-offline/tests/sync/outline.rs index 87e6e1156c054bfc53acb7ad9b0d4ae8edc96ba5..bd021e0c0b77f555bf664bdee9fa603ddedbcee9 100644 --- a/crates/onestore-offline/tests/sync/outline.rs +++ b/crates/onestore-offline/tests/sync/outline.rs @@ -108,6 +108,81 @@ fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() } } +#[test] +fn rebased_outline_movement_uses_remote_title_text_and_confirms_after_reopen() { + let (source, sid, outline, _, text_id) = fixture(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (_, page) = document.pages().unwrap()[0]; + let space = &document.spaces[&sid]; + let metadata = space.revisions[&space.contexts[&ExGuid::default()]].roots[&2]; + let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &second) + .unwrap() + .as_bytes() + .to_vec(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("titles.sqlite"); + let cache = Replica::create(&path, &source).unwrap(); + let movement = cache + .outline( + &source, + sid, + outline, + Change::Position { x: 216.0, y: 72.0 }, + ) + .unwrap() + .unwrap(); + let local = cache.snapshot().unwrap(); + assert_eq!(node(&local, sid, metadata)["kind"]["title"], "Second"); + let dependent = cache + .edit_text(&local, sid, text_id, 0..0, "Local ") + .unwrap() + .unwrap(); + drop(cache); + let remote = + PreparedEdit::text(&source, sid, second.text_object(), 0..6, "Remote second 🐈").unwrap(); + let mut server = Server::new(remote.as_bytes()); + server.fault = Fault::UnknownAfter; + let cache = Replica::open(&path).unwrap(); + assert!(cache.sync_once(&mut server).is_err()); + assert!(matches!( + cache.status(movement).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + assert_eq!( + node(&server.durable, sid, metadata)["kind"]["title"], + "Original 🦀 é" + ); + assert_eq!( + node(&server.visible, sid, metadata)["kind"]["title"], + "Remote second 🐈" + ); + drop(cache); + for id in [movement, dependent] { + let cache = Replica::open(&path).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + assert_eq!( + node(&server.durable, sid, metadata)["kind"]["title"], + "Remote second 🐈" + ); + assert_eq!( + node(&server.durable, sid, page)["kind"]["alternate_title"], + "Remote second 🐈" + ); + } + let cache = Replica::open(&path).unwrap(); + assert!(cache.pending().unwrap().is_empty()); + assert_eq!( + node(&cache.snapshot().unwrap(), sid, text_id)["kind"]["text"], + "Local Original 🦀 é" + ); + assert_eq!(server.publications, 2); +} + #[test] fn competing_layout_requires_current_review_and_preserves_dependent_edits() { let (source, sid, outline, _, text_id) = fixture(); diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs index 3c4f01f7cf20aed1e605190ac87eea4ace672711..c58fe5569c1d023b63b4943689972f39becbf974 100644 --- a/crates/onestore/src/outline.rs +++ b/crates/onestore/src/outline.rs @@ -2,7 +2,7 @@ use crate::{ Error, ExGuid, RevisionIndex, Store, create::current_timestamps, document::{Document, Kind}, - edit::editable_parents, + edit::{editable_parents, update_title}, write::{PropertyObject, write_revision}, }; use serde::{Deserialize, Serialize}; @@ -13,6 +13,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[serde(deny_unknown_fields)] pub enum OutlineEdit { /// Moves an ordinary page outline to coordinates measured in points. + /// Refreshes automatic page titles when the leading outline changes. Position { x: f32, y: f32 }, /// Changes an ordinary outline's maximum width in points, at least 36. /// `user_set` distinguishes an explicit width from an automatic layout hint. @@ -56,7 +57,7 @@ impl OutlineEdit { let store = Store::parse(source)?; let index = RevisionIndex::parse(&store)?; index.validate_current()?; - let document = Document::parse(&index)?; + let mut document = Document::parse(&index)?; let pages: Vec<_> = document .pages()? .into_iter() @@ -65,9 +66,12 @@ impl OutlineEdit { let [page] = pages.as_slice() else { return Err(invalid("Outline editing requires a single active page")); }; - let semantic = &document.spaces[&space]; - let view = &semantic.revisions[&semantic.contexts[&ExGuid::default()]]; - let parents = editable_parents(view, &pages, object)?; + let mut semantic = document.spaces.remove(&space).unwrap(); + let view = semantic + .revisions + .remove(&semantic.contexts[&ExGuid::default()]) + .unwrap(); + let parents = editable_parents(&view, &pages, object)?; let node = &view.nodes[&object]; match self { Self::Collapsed(_) => { @@ -128,6 +132,9 @@ impl OutlineEdit { changed.insert(id, ancestor); } } + if matches!(self, Self::Position { .. }) { + update_title(&store, raw, view, &pages, &mut changed)?; + } Ok(changed) }) } diff --git a/crates/onestore/tests/outline.rs b/crates/onestore/tests/outline.rs index 94be3a4363fbff3f9aa083cd98f53e77cb942bcb..d3f9e9238d2587eea35aa08220bf10bf92c02907 100644 --- a/crates/onestore/tests/outline.rs +++ b/crates/onestore/tests/outline.rs @@ -271,6 +271,146 @@ fn resizing_a_native_reserved_width_preserves_content() { } } +#[test] +fn outline_movement_updates_both_automatic_title_fields_without_changing_content() { + let source = onestore::create_section("titles.one", "First", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (sid, page) = document.pages().unwrap()[0]; + let insertion = + onestore::Insertion::outline(page, 144.0, 144.0, "Second 🦋 é", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &insertion) + .unwrap() + .as_bytes() + .to_vec(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&sid]; + let rid = space.contexts[&ExGuid::default()]; + let before = &space.revisions[&rid]; + for (x, y, expected) in [ + (144.0, 0.0, "Second 🦋 é"), + (0.0, 36.0, "Second 🦋 é"), + (144.0, 36.0, "First"), + (0.0, 144.0, "First"), + ] { + let edit = PreparedEdit::outline(&source, sid, insertion.object(), Edit::Position { x, y }) + .unwrap(); + let saved = Store::parse(edit.as_bytes()).unwrap(); + let saved_index = RevisionIndex::parse(&saved).unwrap(); + saved_index.validate_current().unwrap(); + let saved_document = Document::parse(&saved_index).unwrap(); + let saved_space = &saved_document.spaces[&sid]; + let after = &saved_space.revisions[&saved_space.contexts[&ExGuid::default()]]; + let metadata = before.roots[&2]; + assert!( + matches!(&after.nodes[&metadata].kind, Kind::Metadata { title: Some(title), .. } if title == expected) + ); + assert!( + matches!(&after.nodes[&page].kind, Kind::Page { alternate_title: Some(title), .. } if title == expected) + ); + assert_eq!( + before.nodes.keys().collect::>(), + after.nodes.keys().collect::>() + ); + for (oid, node) in &before.nodes { + if ![page, metadata, insertion.object()].contains(oid) { + assert_eq!( + serde_json::to_value(node).unwrap(), + serde_json::to_value(&after.nodes[oid]).unwrap() + ); + } + } + assert_eq!( + format!("{:?}", index.resolve(sid, rid).unwrap()), + format!("{:?}", saved_index.resolve(sid, rid).unwrap()) + ); + assert_eq!( + PreparedEdit::outline( + edit.as_bytes(), + sid, + insertion.object(), + Edit::Position { x, y } + ) + .unwrap() + .as_bytes(), + edit.as_bytes() + ); + } +} + +#[test] +fn native_automatic_and_explicit_titles_follow_outline_movement() { + let source = include_bytes!( + "../../../corpus/outline-edit/automatic-title/before/notebook/TitleControl.one" + ); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut candidate = source.to_vec(); + let pages = document.pages().unwrap(); + assert_eq!(pages.len(), 3); + for (sid, page) in pages { + let space = &document.spaces[&sid]; + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let Kind::Metadata { + title: Some(title), .. + } = &view.nodes[&view.roots[&2]].kind + else { + panic!() + }; + let outline = *view.nodes[&page].children.iter().find(|oid| { + view.nodes[oid].children.iter().any(|paragraph| view.nodes[paragraph].content.iter().any(|text| { + matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Second")) + })) + }).unwrap(); + let change = Edit::Position { + x: 36.0, + y: if title == "Horizontal first" { + 108.0 + } else { + 72.0 + }, + }; + candidate = PreparedEdit::outline(&candidate, sid, outline, change) + .unwrap() + .as_bytes() + .to_vec(); + let store = Store::parse(&candidate).unwrap(); + let saved = RevisionIndex::parse(&store).unwrap(); + saved.validate_current().unwrap(); + let document = Document::parse(&saved).unwrap(); + let space = &document.spaces[&sid]; + let after = &space.revisions[&space.contexts[&ExGuid::default()]]; + let expected = if title == "Explicit title" { + "Explicit title" + } else { + "Second 🦋 é" + }; + assert!( + matches!(&after.nodes[&after.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == expected) + ); + for (oid, node) in &view.nodes { + assert_eq!(node.children, after.nodes[oid].children); + assert_eq!(node.content, after.nodes[oid].content); + if matches!(node.kind, Kind::RichText { .. }) { + assert_eq!( + serde_json::to_value(node).unwrap(), + serde_json::to_value(&after.nodes[oid]).unwrap() + ); + } + } + } + if let Some(output) = std::env::var_os("ONESTORE_OUTLINE_TITLE_OUTPUT") { + let output = std::path::PathBuf::from(output); + assert!(output.is_absolute()); + std::fs::create_dir(&output).unwrap(); + std::fs::write(output.join("TitleControl.one"), candidate).unwrap(); + } +} + #[test] fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() { let source = onestore::create_section("layout.one", "Before 🦀 after", "Author").unwrap(); @@ -286,6 +426,11 @@ fn outline_publication_interruptions_reopen_as_complete_old_or_new_layout() { .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. })) .unwrap(); let paragraph = view.nodes[&outline].children[0]; + let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); + let source = PreparedEdit::insert(&source, sid, &second) + .unwrap() + .as_bytes() + .to_vec(); for (object, operation) in [ (outline, Edit::Position { x: 216.0, y: 72.0 }), ( @@ -344,6 +489,11 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() { .unwrap(); let paragraph = view.nodes[&outline].children[0]; let text = view.nodes[¶graph].content[0]; + let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap(); + let original = PreparedEdit::insert(&original, sid, &second) + .unwrap() + .as_bytes() + .to_vec(); for seed in 1..=16_u64 { let mut rng = seed; let mut source = original.clone(); @@ -388,6 +538,19 @@ fn repeated_geometry_changes_and_expansion_match_an_independent_model() { let document = Document::parse(&index).unwrap(); let space = &document.spaces[&sid]; let current = &space.revisions[&space.contexts[&ExGuid::default()]]; + let x = layout["x"].as_f64().unwrap(); + let y = layout["y"].as_f64().unwrap(); + let title = if y < 36.0 || (y == 36.0 && x <= 144.0) { + "Before 🦀 é" + } else { + "Second" + }; + assert!( + matches!(¤t.nodes[¤t.roots[&2]].kind, Kind::Metadata { title: Some(actual), .. } if actual == title) + ); + assert!( + matches!(¤t.nodes[&page].kind, Kind::Page { alternate_title: Some(actual), .. } if actual == title) + ); assert_eq!( serde_json::to_value(¤t.nodes[&outline].layout).unwrap(), layout diff --git a/fuzz/fuzz_targets/paragraph.rs b/fuzz/fuzz_targets/paragraph.rs index dbb663bf828dd4e32e13aee1b59fb24635a7db19..5d9d4d801966225ce38a15848bb0ddb24aab6ca3 100644 --- a/fuzz/fuzz_targets/paragraph.rs +++ b/fuzz/fuzz_targets/paragraph.rs @@ -33,6 +33,8 @@ static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| { .with_formatting(4..7, &[A::Italic(true), A::Color(Some([12, 34, 56]))]) .unwrap(); let edited = PreparedEdit::insert(&source, sid, &intent).unwrap(); + let second = Insertion::outline(page, 144.0, 36.0, "Fixed second", "Author").unwrap(); + let edited = PreparedEdit::insert(edited.as_bytes(), sid, &second).unwrap(); (edited.as_bytes().to_vec(), sid, outline) }); @@ -187,6 +189,14 @@ fuzz_target!(|input: &[u8]| { let after_document = Document::parse(&after_index).unwrap(); let space = &after_document.spaces[sid]; let after_view = &space.revisions[&space.contexts[&ExGuid::default()]]; + let layout = &after_view.nodes[outline].layout; + if layout.y.unwrap() > 36.0 || (layout.y == Some(36.0) && layout.x.unwrap() > 144.0) { + let (_, page) = after_document.pages().unwrap()[0]; + assert!(matches!(&after_view.nodes[&after_view.roots[&2]].kind, + Kind::Metadata { title: Some(title), .. } if title == "Fixed second")); + assert!(matches!(&after_view.nodes[&page].kind, + Kind::Page { alternate_title: Some(title), .. } if title == "Fixed second")); + } if let Some(expected) = expected_layout { assert_eq!( serde_json::to_value(&after_view.nodes[outline].layout).unwrap(), diff --git a/tools/native/outline-title.ps1 b/tools/native/outline-title.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..87299b07f00b9476ab4d2d0bb98d513314fbd2a0 --- /dev/null +++ b/tools/native/outline-title.ps1 @@ -0,0 +1,48 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$app = New-Object -ComObject OneNote.Application +$notebook = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0) + $section = '' + $app.OpenHierarchy('TitleControl.one', $notebook, [ref]$section, 0) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -eq 3) { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + if ($pages.Count -ne 3) { throw 'Expected three outline title controls.' } + $inputs = Join-Path $Root 'inputs' + [void](New-Item -ItemType Directory -Path $inputs) + foreach ($page in $pages) { + $name = $page.GetAttribute('name') + if ($name -notin @('Vertical first', 'Horizontal first', 'Explicit title')) { throw 'Unexpected control title.' } + $content = '' + $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 1, 1) + [xml]$xml = $content + $outlines = @($xml.SelectNodes('/*/*[local-name()="Outline"]') | Where-Object { $_.InnerText -match 'Second' }) + if ($outlines.Count -ne 1) { throw 'Expected one movable outline.' } + $outline = $outlines[0] + $position = $outline.SelectSingleNode('./*[local-name()="Position"]') + $position.SetAttribute('x', '36') + $position.SetAttribute('y', $(if ($name -eq 'Horizontal first') { '108' } else { '72' })) + foreach ($child in @($xml.DocumentElement.ChildNodes)) { + if ($child -ne $outline -and $child.LocalName -notin @('QuickStyleDef', 'TagDef')) { + [void]$xml.DocumentElement.RemoveChild($child) + } + } + [IO.File]::WriteAllText((Join-Path $inputs ($name + '.xml')), $xml.OuterXml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $false) + } + $app.SyncHierarchy($notebook) +} finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/tools/test_outline_edit.py b/tools/test_outline_edit.py index 4a02c4778c67229a5af116b6159993e0c25868ec..84ba019a8acb28898344219cb151c65c3e71a3d8 100644 --- a/tools/test_outline_edit.py +++ b/tools/test_outline_edit.py @@ -19,6 +19,39 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] class OutlineEditTest(unittest.TestCase): + def test_automatic_titles_follow_native_and_rust_outline_movement(self): + fixture = FIXTURE / 'automatic-title' + models = {} + with TemporaryDirectory() as temporary: + for phase, notebook, capture in [ + ('before', fixture / 'before/notebook', fixture / 'before/read'), + ('native', fixture / 'native/notebook', fixture / 'native/read'), + ('native-cold', fixture / 'native/cold/notebook', fixture / 'native/cold/read'), + ('rust', fixture / 'candidate', fixture / 'cold/read'), + ('rust-cold', fixture / 'cold/notebook', fixture / 'cold/read'), + ]: + folder = Path(temporary) / phase + shutil.copytree(capture, folder / 'read') + compare(notebook, folder / 'read') + subprocess.run([EXPORTER, notebook / 'TitleControl.one', folder / 'model'], check=True) + document = json.loads((folder / 'model/document.json').read_text()) + models[phase] = list(ordered_pages(document)) + self.assertEqual(len(models[phase]), 3) + for phase, pages in models.items(): + expected = ['Vertical first', 'Horizontal first', 'Explicit title'] if phase == 'before' else ['Second 🦋 é', 'Second 🦋 é', 'Explicit title'] + self.assertEqual([r['nodes'][r['roots']['2']]['kind']['title'] for _, _, r, _ in pages], expected) + for before, after in [('native', 'native-cold'), ('rust', 'rust-cold')]: + for (sid, _, a, page), (saved_sid, _, b, saved_page) in zip(models[before], models[after], strict=True): + self.assertEqual((sid, page), (saved_sid, saved_page)) + self.assertEqual(dict(walk(a, page)), dict(walk(b, page))) + for (sid, _, a, page), (saved_sid, _, b, saved_page) in zip(models['before'], models['rust'], strict=True): + self.assertEqual((sid, page), (saved_sid, saved_page)) + for oid, node in walk(a, page): + self.assertEqual(node['children'], b['nodes'][oid]['children']) + self.assertEqual(node['content'], b['nodes'][oid]['content']) + if node['kind']['type'] == 'RichText': + self.assertEqual(node, b['nodes'][oid]) + def test_rust_subtree_moves_and_deletions_survive_cold_native_reopen(self): for name, pages in [ ('rust-tree/ordinary', 15), ('rust-tree/groups-cells', 12),