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