xref: /dokuwiki/_test/tests/Search/Query/QueryEvaluatorTest.php (revision 6734bb8cef71e8b4af23e627d4db5430304d55a2)
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