|
| 1 | +package app.grapheneos.pdfviewer.test |
| 2 | + |
| 3 | +import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 4 | +import app.grapheneos.pdfviewer.search.DocumentSearch |
| 5 | +import org.junit.Assert.assertEquals |
| 6 | +import org.junit.Assert.assertFalse |
| 7 | +import org.junit.Assert.assertNotEquals |
| 8 | +import org.junit.Assert.assertTrue |
| 9 | +import org.junit.Test |
| 10 | +import org.junit.runner.RunWith |
| 11 | + |
| 12 | +/** |
| 13 | + * Pins the behaviour of the ICU collation matcher, which is the one part of search whose |
| 14 | + * semantics come from the platform rather than from this codebase. |
| 15 | + */ |
| 16 | +@RunWith(AndroidJUnit4::class) |
| 17 | +class PdfViewerSearchMatcherTest { |
| 18 | + |
| 19 | + private val search = DocumentSearch() |
| 20 | + |
| 21 | + /** Matches as (start, length) pairs. */ |
| 22 | + private fun find(text: String, pattern: String): List<Pair<Int, Int>> = |
| 23 | + search.findMatches(text, pattern).toList().chunked(2).map { it[0] to it[1] } |
| 24 | + |
| 25 | + private fun starts(text: String, pattern: String) = find(text, pattern).map { it.first } |
| 26 | + |
| 27 | + @Test |
| 28 | + fun findsPlainSubstring() { |
| 29 | + assertEquals(listOf(4 to 4), find("the MIME database", "MIME")) |
| 30 | + } |
| 31 | + |
| 32 | + @Test |
| 33 | + fun ignoresCase() { |
| 34 | + assertEquals(listOf(4 to 4), find("the mime database", "MIME")) |
| 35 | + assertEquals(listOf(4 to 4), find("the MIME database", "mime")) |
| 36 | + } |
| 37 | + |
| 38 | + @Test |
| 39 | + fun findsEveryOccurrence() { |
| 40 | + assertEquals(listOf(0, 8, 16), starts("cat bat cat mat cat", "cat")) |
| 41 | + } |
| 42 | + |
| 43 | + @Test |
| 44 | + fun matchesAreNotOverlapping() { |
| 45 | + assertEquals(listOf(0, 2), starts("aaaa", "aa")) |
| 46 | + } |
| 47 | + |
| 48 | + @Test |
| 49 | + fun reportsNothingWhenAbsent() { |
| 50 | + assertEquals(emptyList<Pair<Int, Int>>(), find("hello world", "zzz")) |
| 51 | + assertEquals(emptyList<Pair<Int, Int>>(), find("ab", "abcdef")) |
| 52 | + } |
| 53 | + |
| 54 | + @Test |
| 55 | + fun foldsDiacritics() { |
| 56 | + assertEquals(listOf(3 to 6), find("Le Résumé final", "resume")) |
| 57 | + assertEquals(listOf(4 to 6), find("the resume of", "Résumé")) |
| 58 | + } |
| 59 | + |
| 60 | + @Test |
| 61 | + fun foldsDecomposedDiacritics() { |
| 62 | + // "Résumé" written NFD: e + U+0301 for each accent. |
| 63 | + val nfd = "Le Résumé final" |
| 64 | + val hits = find(nfd, "resume") |
| 65 | + assertEquals(1, hits.size) |
| 66 | + assertEquals(3, hits[0].first) |
| 67 | + // Length is in original-string units, so it covers the combining marks too. |
| 68 | + assertTrue("length ${hits[0].second} should cover the combining marks", hits[0].second >= 6) |
| 69 | + } |
| 70 | + |
| 71 | + @Test |
| 72 | + fun foldsLigatures() { |
| 73 | + assertEquals(listOf(4 to 1), find("the file is here", "fi")) |
| 74 | + } |
| 75 | + |
| 76 | + @Test |
| 77 | + fun foldsFullWidth() { |
| 78 | + assertEquals(listOf(5 to 3), find("code ABC here", "ABC")) |
| 79 | + } |
| 80 | + |
| 81 | + @Test |
| 82 | + fun findsCjkSubstring() { |
| 83 | + assertEquals(listOf(0 to 2), find("日本語の本", "日本")) |
| 84 | + } |
| 85 | + |
| 86 | + @Test |
| 87 | + fun softHyphenIsIgnorable() { |
| 88 | + // The end-of-line rule in search.js turns "hyphen-\nation" into "hyphenation"; |
| 89 | + // this is what makes a line-broken word findable as one word. |
| 90 | + val hits = find("hyphenation", "hyphenation") |
| 91 | + assertEquals(1, hits.size) |
| 92 | + assertEquals(0, hits[0].first) |
| 93 | + assertEquals(13, hits[0].second) |
| 94 | + } |
| 95 | + |
| 96 | + @Test |
| 97 | + fun spaceSeparatorJoinsLinesForPhraseSearch() { |
| 98 | + // "Chapter Three" + EOL + "Page Three Content" as search.js emits it: a phrase spanning |
| 99 | + // the line break is findable precisely because the separator is a space. |
| 100 | + assertEquals(listOf(10 to 10), find("3 Chapter Three Page Three Content", "three page")) |
| 101 | + } |
| 102 | + |
| 103 | + @Test |
| 104 | + fun newlineIsNotEqualToSpace() { |
| 105 | + // Documents the reason search.js emits U+0020 and never U+000A at a line end. |
| 106 | + assertEquals(emptyList<Pair<Int, Int>>(), find("line\nnext", "line next")) |
| 107 | + } |
| 108 | + |
| 109 | + @Test |
| 110 | + fun terminatesOnWhollyIgnorablePattern() { |
| 111 | + // A zero-length match would otherwise loop forever. |
| 112 | + assertEquals(emptyList<Pair<Int, Int>>(), find("abc", "")) |
| 113 | + } |
| 114 | + |
| 115 | + @Test |
| 116 | + fun toleratesEmptyInputs() { |
| 117 | + assertEquals(emptyList<Pair<Int, Int>>(), find("abc", "")) |
| 118 | + assertEquals(emptyList<Pair<Int, Int>>(), find("", "abc")) |
| 119 | + } |
| 120 | + |
| 121 | + @Test |
| 122 | + fun tuplesMapOffsetsBackToTextItems() { |
| 123 | + // Item starts: "3"@0 " "@1 "Chapter Three"@2 " "@15 "Page Three Content"@16. |
| 124 | + search.setQuery("three page") |
| 125 | + assertTrue(search.addPage(1, """["3"," ","Chapter Three"," ","Page Three Content"]""")) |
| 126 | + // "Three Page" is offsets 10..20, so it spans three items: the tail of "Chapter Three", |
| 127 | + // the synthetic end-of-line space, and the head of "Page Three Content". |
| 128 | + assertEquals("[[[2,8,5],[3,0,1],[4,0,4]]]", search.tuplesFor(1)) |
| 129 | + assertEquals(1, search.stats().total) |
| 130 | + } |
| 131 | + |
| 132 | + @Test |
| 133 | + fun indexesMatchesAcrossPages() { |
| 134 | + search.setQuery("content") |
| 135 | + for (page in 1..4) { |
| 136 | + assertTrue(search.addPage(page, """["Page $page Content"]""")) |
| 137 | + } |
| 138 | + assertEquals(4, search.stats().total) |
| 139 | + assertEquals(1, search.countOn(3)) |
| 140 | + assertEquals(2, search.ordinalBefore(3)) |
| 141 | + assertEquals(1, search.firstPageFrom(1)) |
| 142 | + assertEquals(3, search.firstPageFrom(3)) |
| 143 | + // Wraps forward off the last page and backward off the first. |
| 144 | + assertEquals(2 to 0, search.step(1, 0, forward = true)) |
| 145 | + assertEquals(1 to 0, search.step(4, 0, forward = true)) |
| 146 | + assertEquals(4 to 0, search.step(1, 0, forward = false)) |
| 147 | + } |
| 148 | + |
| 149 | + @Test |
| 150 | + fun aNewQueryClearsMatchCapTruncation() { |
| 151 | + // A single very common character can cross the match cap. If that latched, extraction |
| 152 | + // would stay stopped and every later query would search only the pages scanned so far. |
| 153 | + search.setQuery("e") |
| 154 | + val dense = "e ".repeat(DocumentSearch.MAX_MATCHES + 1_000) |
| 155 | + // false is the signal to JS that the index is full and extraction should stop. |
| 156 | + assertFalse("the match cap should have tripped", search.addPage(1, """["$dense"]""")) |
| 157 | + assertTrue(search.stats().truncated) |
| 158 | + search.setQuery("content") |
| 159 | + assertFalse("a new query must resume extraction", search.stats().truncated) |
| 160 | + assertTrue(search.addPage(2, """["Page Two Content"]""")) |
| 161 | + assertEquals(1, search.stats().total) |
| 162 | + } |
| 163 | + |
| 164 | + @Test |
| 165 | + fun theIndexVersionChangesWithTheQuery() { |
| 166 | + // The paint effect keys on this: two different queries can select the same (page, index), |
| 167 | + // and without a version change the highlights would keep the old query's offsets. |
| 168 | + search.setQuery("one") |
| 169 | + assertTrue(search.addPage(1, """["one two"]""")) |
| 170 | + val first = search.stats().version |
| 171 | + search.setQuery("two") |
| 172 | + assertNotEquals(first, search.stats().version) |
| 173 | + } |
| 174 | + |
| 175 | + @Test |
| 176 | + fun aQueryStartingOnAnEndOfLineSeparatorStillMapsBack() { |
| 177 | + // "Chapter One" + synthetic EOL space + "Two": a query starting with the space produces a |
| 178 | + // piece whose offset sits at the end of the item's real text. |
| 179 | + search.setQuery(" two") |
| 180 | + assertTrue(search.addPage(1, """["Chapter One ","Two"]""")) |
| 181 | + assertEquals(1, search.stats().total) |
| 182 | + // Offset 11 is past "Chapter One".length is false - it is exactly the length, so the JS |
| 183 | + // side clamps it to a collapsed range and skips it, and item 1 carries the visible part. |
| 184 | + assertEquals("[[[0,11,1],[1,0,3]]]", search.tuplesFor(1)) |
| 185 | + } |
| 186 | + |
| 187 | + /** |
| 188 | + * Steady-state throughput. The first substantial ICU scan in a process pays a large one-time |
| 189 | + * cost (~12s on an x86 emulator) that a short scan does not absorb, so the first pass here is |
| 190 | + * untimed; what matters for search is the rate afterwards, which was ~1s per 244k characters |
| 191 | + * on the same emulator. The bound is loose enough not to measure whichever machine CI |
| 192 | + * allocates, and tight enough to catch an order-of-magnitude regression. |
| 193 | + */ |
| 194 | + @Test |
| 195 | + fun scansALargeCorpusQuickly() { |
| 196 | + val corpus = "The quick brown fox jumps over the lazy dog. MIME type test. ".repeat(4000) |
| 197 | + assertEquals(4000, search.findMatches(corpus, "lazy dog").size / 2) |
| 198 | + val start = System.nanoTime() |
| 199 | + val hits = search.findMatches(corpus, "lazy dog") |
| 200 | + val ms = (System.nanoTime() - start) / 1_000_000 |
| 201 | + assertEquals(4000, hits.size / 2) |
| 202 | + assertTrue("scanning ${corpus.length} chars took ${ms}ms", ms < 10_000) |
| 203 | + } |
| 204 | +} |
0 commit comments