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