1<?php 2 3namespace dokuwiki\test\Search\Query; 4 5use dokuwiki\Search\Collection\Term; 6use dokuwiki\Search\Query\QueryEvaluator; 7 8/** 9 * Tests for the QueryEvaluator class 10 * 11 * These tests verify RPN evaluation with typed stack entries (page sets, 12 * namespace predicates, negated wrappers) independent of actual index data. 13 */ 14class QueryEvaluatorTest extends \DokuWikiTest 15{ 16 /** 17 * Create a Term with pre-resolved entity frequencies 18 * 19 * @param string $word the word this term represents 20 * @param array $frequencies [pageName => frequency] 21 * @return Term 22 */ 23 protected function makeTerm(string $word, array $frequencies): Term 24 { 25 $term = new Term($word); 26 foreach ($frequencies as $page => $freq) { 27 $term->addMatch($page, $word, $freq); 28 } 29 return $term; 30 } 31 32 // region Basic word lookups 33 34 public function testSingleWord() 35 { 36 $terms = [ 37 'dokuwiki' => $this->makeTerm('dokuwiki', ['page1' => 3, 'page2' => 1]), 38 ]; 39 $rpn = ['W+:dokuwiki']; 40 41 $evaluator = new QueryEvaluator($rpn, $terms); 42 $result = $evaluator->evaluate(); 43 44 $this->assertEquals(['page1' => 3, 'page2' => 1], $result); 45 } 46 47 public function testUnknownWord() 48 { 49 $terms = []; 50 $rpn = ['W+:nonexistent']; 51 52 $evaluator = new QueryEvaluator($rpn, $terms); 53 $result = $evaluator->evaluate(); 54 55 $this->assertEquals([], $result); 56 } 57 58 // endregion 59 60 // region AND operation 61 62 public function testAndTwoWords() 63 { 64 $terms = [ 65 'foo' => $this->makeTerm('foo', ['page1' => 2, 'page2' => 3, 'page3' => 1]), 66 'bar' => $this->makeTerm('bar', ['page1' => 1, 'page3' => 4]), 67 ]; 68 // foo AND bar → pages in both, scores summed 69 $rpn = ['W+:foo', 'W+:bar', 'AND']; 70 71 $evaluator = new QueryEvaluator($rpn, $terms); 72 $result = $evaluator->evaluate(); 73 74 $this->assertEquals(['page1' => 3, 'page3' => 5], $result); 75 } 76 77 // endregion 78 79 // region OR operation 80 81 public function testOrTwoWords() 82 { 83 $terms = [ 84 'foo' => $this->makeTerm('foo', ['page1' => 2, 'page2' => 3]), 85 'bar' => $this->makeTerm('bar', ['page1' => 1, 'page3' => 4]), 86 ]; 87 // foo OR bar → union, scores summed where overlapping 88 $rpn = ['W+:foo', 'W+:bar', 'OR']; 89 90 $evaluator = new QueryEvaluator($rpn, $terms); 91 $result = $evaluator->evaluate(); 92 93 $this->assertEquals(['page1' => 3, 'page2' => 3, 'page3' => 4], $result); 94 } 95 96 // endregion 97 98 // region NOT with AND (subtraction) 99 100 public function testNotWithAnd() 101 { 102 // "foo -bar" → foo AND NOT bar → foo minus bar 103 $terms = [ 104 'foo' => $this->makeTerm('foo', ['page1' => 2, 'page2' => 3, 'page3' => 1]), 105 'bar' => $this->makeTerm('bar', ['page2' => 1]), 106 ]; 107 // RPN: foo bar NOT AND 108 $rpn = ['W+:foo', 'W-:bar', 'NOT', 'AND']; 109 110 $evaluator = new QueryEvaluator($rpn, $terms); 111 $result = $evaluator->evaluate(); 112 113 $this->assertEquals(['page1' => 2, 'page3' => 1], $result); 114 } 115 116 public function testNegatedGroupWithAnd() 117 { 118 // "baz -(foo OR bar)" → baz AND NOT(foo OR bar) → baz minus (foo ∪ bar) 119 $terms = [ 120 'foo' => $this->makeTerm('foo', ['page1' => 1, 'page2' => 2]), 121 'bar' => $this->makeTerm('bar', ['page2' => 1, 'page3' => 3]), 122 'baz' => $this->makeTerm('baz', ['page1' => 5, 'page2' => 4, 'page3' => 2, 'page4' => 1]), 123 ]; 124 // RPN: foo bar OR NOT baz AND 125 $rpn = ['W+:foo', 'W+:bar', 'OR', 'NOT', 'W+:baz', 'AND']; 126 127 $evaluator = new QueryEvaluator($rpn, $terms); 128 $result = $evaluator->evaluate(); 129 130 // page1, page2, page3 are in (foo ∪ bar), so only page4 remains 131 $this->assertEquals(['page4' => 1], $result); 132 } 133 134 // endregion 135 136 // region Namespace filtering 137 138 public function testNamespaceInclude() 139 { 140 // "foo @wiki:" → foo AND namespace wiki: 141 $terms = [ 142 'foo' => $this->makeTerm('foo', ['wiki:page1' => 2, 'other:page2' => 3, 'wiki:sub:page3' => 1]), 143 ]; 144 // RPN: foo N+:wiki AND 145 $rpn = ['W+:foo', 'N+:wiki', 'AND']; 146 147 $evaluator = new QueryEvaluator($rpn, $terms); 148 $result = $evaluator->evaluate(); 149 150 $this->assertEquals(['wiki:page1' => 2, 'wiki:sub:page3' => 1], $result); 151 } 152 153 public function testNamespaceExclude() 154 { 155 // "foo ^wiki:" → foo AND NOT namespace wiki: 156 $terms = [ 157 'foo' => $this->makeTerm('foo', ['wiki:page1' => 2, 'other:page2' => 3, 'wiki:sub:page3' => 1]), 158 ]; 159 // RPN: foo N+:wiki NOT AND 160 $rpn = ['W+:foo', 'N+:wiki', 'NOT', 'AND']; 161 162 $evaluator = new QueryEvaluator($rpn, $terms); 163 $result = $evaluator->evaluate(); 164 165 $this->assertEquals(['other:page2' => 3], $result); 166 } 167 168 // endregion 169 170 // region Combined queries 171 172 public function testOrThenNot() 173 { 174 // "(foo OR bar) -baz" → (foo OR bar) AND NOT baz 175 $terms = [ 176 'foo' => $this->makeTerm('foo', ['page1' => 2, 'page2' => 1]), 177 'bar' => $this->makeTerm('bar', ['page2' => 3, 'page3' => 4]), 178 'baz' => $this->makeTerm('baz', ['page2' => 1]), 179 ]; 180 // RPN: foo bar OR baz NOT AND 181 $rpn = ['W+:foo', 'W+:bar', 'OR', 'W-:baz', 'NOT', 'AND']; 182 183 $evaluator = new QueryEvaluator($rpn, $terms); 184 $result = $evaluator->evaluate(); 185 186 $this->assertEquals(['page1' => 2, 'page3' => 4], $result); 187 } 188 189 public function testWordWithNamespaceAndNot() 190 { 191 // "foo -bar @wiki:" → foo AND NOT bar AND @wiki: 192 $terms = [ 193 'foo' => $this->makeTerm('foo', [ 194 'wiki:a' => 5, 'wiki:b' => 3, 'other:c' => 2, 'wiki:d' => 1, 195 ]), 196 'bar' => $this->makeTerm('bar', ['wiki:b' => 1]), 197 ]; 198 // RPN: foo bar NOT AND N+:wiki AND 199 $rpn = ['W+:foo', 'W-:bar', 'NOT', 'AND', 'N+:wiki', 'AND']; 200 201 $evaluator = new QueryEvaluator($rpn, $terms); 202 $result = $evaluator->evaluate(); 203 204 // foo minus bar = wiki:a, other:c, wiki:d 205 // filtered to wiki: = wiki:a, wiki:d 206 $this->assertEquals(['wiki:a' => 5, 'wiki:d' => 1], $result); 207 } 208 209 public function testNamespaceDoesNotMatchPartialPrefix() 210 { 211 // @foo should not match pages in foobar: namespace 212 $terms = [ 213 'test' => $this->makeTerm('test', [ 214 'foo:page1' => 1, 215 'foobar:page2' => 2, 216 'foo:sub:page3' => 3, 217 ]), 218 ]; 219 // RPN: test N+:foo AND 220 $rpn = ['W+:test', 'N+:foo', 'AND']; 221 222 $evaluator = new QueryEvaluator($rpn, $terms); 223 $result = $evaluator->evaluate(); 224 225 // foobar:page2 must NOT match — only foo: prefix pages 226 $this->assertEquals(['foo:page1' => 1, 'foo:sub:page3' => 3], $result); 227 } 228 229 // endregion 230 231 // region Empty result cases 232 233 public function testAndNoOverlap() 234 { 235 $terms = [ 236 'foo' => $this->makeTerm('foo', ['page1' => 1]), 237 'bar' => $this->makeTerm('bar', ['page2' => 1]), 238 ]; 239 $rpn = ['W+:foo', 'W+:bar', 'AND']; 240 241 $evaluator = new QueryEvaluator($rpn, $terms); 242 $result = $evaluator->evaluate(); 243 244 $this->assertEquals([], $result); 245 } 246 247 public function testNotRemovesAll() 248 { 249 $terms = [ 250 'foo' => $this->makeTerm('foo', ['page1' => 1]), 251 'bar' => $this->makeTerm('bar', ['page1' => 2]), 252 ]; 253 // foo -bar where bar covers all foo pages 254 $rpn = ['W+:foo', 'W-:bar', 'NOT', 'AND']; 255 256 $evaluator = new QueryEvaluator($rpn, $terms); 257 $result = $evaluator->evaluate(); 258 259 $this->assertEquals([], $result); 260 } 261 262 public function testEmptyRpn() 263 { 264 $evaluator = new QueryEvaluator([], []); 265 $result = $evaluator->evaluate(); 266 267 $this->assertEquals([], $result); 268 } 269 270 // endregion 271} 272