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