1//! The search engine against OneNote 2010's own results on the sample notebook
2//! (`corpus/search`), and its folding, word starts, ranking and snippets.
3
4use canvas::{
5 document::TextDocument,
6 editor::CanvasEditor,
7 layout::TextEngine,
8 search::{Entry, Index, Query, fold, page_matches, paragraph_match},
9};
10use onestore::{
11 ExGuid, RevisionIndex, Store,
12 document::{Document, Format},
13 page::{Page, text::Paragraph},
14};
15use std::path::Path;
16
17/// Each page of the corpus notebook's section `name`.
18fn section(name: &str) -> Vec<(ExGuid, Page)> {
19 let file = Path::new(env!("CARGO_MANIFEST_DIR"))
20 .join("../../corpus/search/notebook")
21 .join(format!("{name}.one"));
22 let bytes = std::fs::read(file).unwrap();
23 let store = Store::parse(&bytes).unwrap();
24 let index = RevisionIndex::parse(&store).unwrap();
25 let document = Document::parse(&index).unwrap();
26 document
27 .pages()
28 .unwrap()
29 .into_iter()
30 .map(|(space, id)| {
31 let page = Page::from_revision(document.active(space).unwrap(), id).unwrap();
32 (space, page)
33 })
34 .collect()
35}
36
37fn notebook() -> Index {
38 let mut index = Index::default();
39 for name in ["Garden", "Home", "Journal", "Kitchen", "Reading", "Trips"] {
40 for (space, page) in section(name) {
41 index.set(Entry::new(name, space, &page, 0));
42 }
43 }
44 index
45}
46
47fn titles(found: &[canvas::search::Found], in_title: bool) -> Vec<&str> {
48 let mut titles: Vec<&str> = found
49 .iter()
50 .filter(|found| found.in_title == in_title)
51 .map(|found| found.title.as_str())
52 .collect();
53 titles.sort();
54 titles
55}
56
57#[test]
58fn a_notebook_search_finds_what_onenote_finds() {
59 let index = notebook();
60 let found = index.search(&Query::new("tom"), |_| true);
61 assert_eq!(titles(&found, true), ["Tomatoes"]);
62 assert_eq!(
63 titles(&found, false),
64 [
65 "Harvest log 2025",
66 "Seed inventory",
67 "Spring planting plan",
68 "Weeknight dal"
69 ]
70 );
71 assert!(found[0].in_title, "title hits come first");
72 let found = index.search(&Query::new("sungold tom"), |_| true);
73 assert_eq!(titles(&found, true), Vec::<&str>::new());
74 assert_eq!(
75 titles(&found, false),
76 [
77 "Harvest log 2025",
78 "Seed inventory",
79 "Spring planting plan",
80 "Tomatoes"
81 ]
82 );
83 assert!(index.search(&Query::new("zzq"), |_| true).is_empty());
84 let garden = index.search(&Query::new("tom"), |section| section == "Kitchen");
85 assert_eq!(titles(&garden, false), ["Weeknight dal"]);
86}
87
88#[test]
89fn snippets_show_the_first_match_marked() {
90 let index = notebook();
91 let found = index.search(&Query::new("lentils"), |_| true);
92 let dal = found
93 .iter()
94 .find(|found| found.title == "Weeknight dal")
95 .unwrap();
96 assert!(dal.snippet.contains("lentils"), "{:?}", dal.snippet);
97 let [hit] = dal.snippet_hits.as_slice() else {
98 panic!("{:?}", dal.snippet_hits)
99 };
100 assert_eq!(dal.snippet[hit.clone()].to_lowercase(), "lentils");
101 assert!(!dal.in_title && dal.title_hits.is_empty());
102}
103
104#[test]
105fn find_on_page_counts_what_onenote_counts() {
106 let (_, page) = section("Garden")
107 .into_iter()
108 .find(|(_, page)| page.title == "Spring planting plan")
109 .unwrap();
110 let mut engine = TextEngine::default();
111 let editor = CanvasEditor::from_page(page, &mut engine).unwrap();
112 let matches = page_matches(&editor, &Query::new("bed"));
113 assert_eq!(matches.len(), 6, "OneNote shows Match 1 of 6");
114 for (outline, selection) in &matches {
115 let outline = editor
116 .outlines()
117 .iter()
118 .find(|candidate| candidate.id == *outline)
119 .unwrap();
120 let [start, end] = selection.positions;
121 let paragraph = outline
122 .document()
123 .paragraphs()
124 .nth(start.paragraph)
125 .unwrap();
126 let text: String = paragraph
127 .text()
128 .encode_utf16()
129 .skip(start.offset as usize)
130 .take((end.offset - start.offset) as usize)
131 .map(|unit| char::from_u32(u32::from(unit)).unwrap())
132 .collect();
133 assert_eq!(text.to_lowercase(), "bed");
134 }
135 assert!(page_matches(&editor, &Query::new("zzq")).is_empty());
136}
137
138#[test]
139fn find_on_page_keeps_unicode_matches_on_character_boundaries() {
140 let mut engine = TextEngine::default();
141 let page = page(&mut engine, "Unicode", "雪 ☃ 🦀 café");
142 let editor = CanvasEditor::from_page(page, &mut engine).unwrap();
143 for (query, offsets) in [
144 ("雪", [0, 1]),
145 ("☃", [2, 3]),
146 ("🦀", [4, 6]),
147 ("cafe", [7, 11]),
148 ] {
149 let matches = page_matches(&editor, &Query::new(query));
150 assert_eq!(matches.len(), 1, "{query}");
151 assert_eq!(
152 matches[0].1.positions.map(|at| at.offset),
153 offsets,
154 "{query}"
155 );
156 }
157}
158
159#[test]
160fn folding_ignores_case_and_diacritics() {
161 assert_eq!(fold("Crème BRÛLÉE"), "creme brulee");
162 assert_eq!(fold("Straße Ærø don’t"), "strasse aero don't");
163 assert_eq!(fold("a\u{a0}b\u{b}c\nd"), "a b c\nd");
164}
165
166/// A page titled `title` holding one paragraph of `text`.
167fn page(engine: &mut TextEngine, title: &str, text: &str) -> Page {
168 let document =
169 TextDocument::new(vec![Paragraph::new(text.to_owned(), Format::default())]).unwrap();
170 let mut page = CanvasEditor::new(engine, document, 480.0)
171 .unwrap()
172 .page()
173 .unwrap();
174 page.title = title.to_owned();
175 page
176}
177
178#[test]
179fn words_match_word_starts_and_pages_rank_by_title_then_recency() {
180 let mut engine = TextEngine::default();
181 let mut index = Index::default();
182 let space = |n: u32| ExGuid {
183 guid: [n as u8; 16],
184 n,
185 };
186 index.set(Entry::new(
187 "a",
188 space(1),
189 &page(&mut engine, "Notes", "café au lait at the bottom"),
190 30,
191 ));
192 index.set(Entry::new(
193 "a",
194 space(2),
195 &page(&mut engine, "Café list", "nothing else"),
196 10,
197 ));
198 index.set(Entry::new(
199 "b",
200 space(3),
201 &page(&mut engine, "Old", "CAFE menu"),
202 20,
203 ));
204 index.set(Entry::new(
205 "b",
206 space(4),
207 &page(&mut engine, "東京", "今日は東京に行く"),
208 5,
209 ));
210 let order = |index: &Index, query: &str| -> Vec<String> {
211 index
212 .search(&Query::new(query), |_| true)
213 .into_iter()
214 .map(|found| found.title)
215 .collect()
216 };
217 assert_eq!(order(&index, "cafe"), ["Café list", "Notes", "Old"]);
218 assert_eq!(
219 order(&index, "tom"),
220 Vec::<String>::new(),
221 "only word starts match"
222 );
223 assert_eq!(order(&index, "\"au lait\""), ["Notes"]);
224 assert_eq!(order(&index, "\"lait au\""), Vec::<String>::new());
225 assert_eq!(order(&index, "京に"), ["東京"]);
226 // Replacing a page, then dropping a section.
227 index.set(Entry::new(
228 "a",
229 space(1),
230 &page(&mut engine, "Notes", "tea"),
231 40,
232 ));
233 assert_eq!(order(&index, "cafe"), ["Café list", "Old"]);
234 index.retain(|entry| entry.section != "b");
235 assert_eq!(order(&index, "cafe"), ["Café list"]);
236 assert_eq!(index.len(), 2);
237}
238
239/// OneNote's tag gallery (`corpus/structural-probe`): a paragraph under each default tag,
240/// listed once for each tag with its stored name, and selected whole from the summary.
241#[test]
242fn tags_summary_lists_each_tagged_paragraph() {
243 let file =
244 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/structural-probe/tag-gallery.one");
245 let bytes = std::fs::read(file).unwrap();
246 let store = Store::parse(&bytes).unwrap();
247 let index = RevisionIndex::parse(&store).unwrap();
248 let document = Document::parse(&index).unwrap();
249 let (space, _) = document.pages().unwrap()[0];
250 let page = Page::from_space(&document, space).unwrap();
251 let mut index = Index::default();
252 index.set(Entry::new("gallery", space, &page, 7));
253 let tagged = index.tagged(|_| true);
254 let mut names: Vec<&str> = tagged.iter().map(|tagged| tagged.name.as_str()).collect();
255 names.sort_unstable();
256 // The probe's T24 and T25 also carry Call back, one entry under each tag.
257 assert_eq!(tagged.len(), names.len());
258 names.dedup();
259 let defaults = canvas::editor::NoteTag::defaults();
260 let mut labels: Vec<&str> = defaults.iter().map(|tag| tag.label.as_str()).collect();
261 labels.sort_unstable();
262 assert_eq!(names, labels);
263 assert!(tagged.iter().all(|tagged| tagged.space == space));
264 assert!(index.tagged(|entry| entry.section != "gallery").is_empty());
265 let mut engine = TextEngine::default();
266 let editor = CanvasEditor::from_page(page, &mut engine).unwrap();
267 let (outline, selection) = paragraph_match(&editor, tagged[0].paragraph).unwrap();
268 let outline = editor
269 .outlines()
270 .iter()
271 .find(|candidate| candidate.id == outline)
272 .unwrap();
273 let [start, end] = selection.positions;
274 let text = outline
275 .document()
276 .paragraphs()
277 .nth(start.paragraph)
278 .unwrap();
279 assert_eq!(start.offset, 0);
280 assert_eq!(end.offset as usize, text.text().encode_utf16().count());
281 assert_eq!(canvas::search::shown(text), tagged[0].text);
282}