packages/deriver/tests/Unit/Deriver/Evaluator/Domain/DomainOperationsTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\Deriver\Evaluator\Domain;
6
7use Deriver\Deriver\Evaluator\Domain\AbstractDomain;
8use Deriver\Deriver\Evaluator\Domain\DomainFact;
9use Deriver\Deriver\Evaluator\Domain\DomainLaws;
10use Deriver\Deriver\Evaluator\Domain\DomainOperations;
11use Deriver\Deriver\Evaluator\Projection;
12use Deriver\Deriver\Evaluator\Term;
13use Deriver\Deriver\Evaluator\TermKind;
14use Deriver\Exception\EvaluationContractException;
15use Override;
16use PHPUnit\Framework\Attributes\CoversClass;
17use PHPUnit\Framework\Attributes\Small;
18use PHPUnit\Framework\Attributes\UsesClass;
19use PHPUnit\Framework\TestCase;
20use RuntimeException;
21
22#[CoversClass(DomainOperations::class)]
23#[UsesClass(DomainLaws::class)]
24#[UsesClass(DomainFact::class)]
25#[UsesClass(Projection::class)]
26#[UsesClass(Term::class)]
27#[Small]
28final class DomainOperationsTest extends TestCase
29{
30    public function testRegistrationChecksTheLatticeBeforeAcceptingItsIdentity(): void
31    {
32        self::assertSame(['example.policy', '1'], (new DomainOperations())->registration(new class () implements AbstractDomain {
33            /**
34             * @return string Domain identity
35             */
36            #[Override]
37            public function id(): string
38            {
39                return 'example.policy';
40            }
41            /**
42             * @return string Semantic version
43             */
44            #[Override]
45            public function version(): string
46            {
47                return '1';
48            }
49            /**
50             * @return DomainFact Empty set
51             */
52            #[Override]
53            public function bottom(): DomainFact
54            {
55                return new DomainFact($this->id(), new Term(TermKind::Bottom));
56            }
57            /**
58             * @return DomainFact All policy records
59             */
60            #[Override]
61            public function top(): DomainFact
62            {
63                return new DomainFact($this->id(), new Term(TermKind::Top));
64            }
65            /**
66             * @param DomainFact $a Contained fact
67             * @param DomainFact $b Containing fact
68             * @return bool Flat-lattice inclusion
69             */
70            #[Override]
71            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
72            {
73                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
74            }
75            /**
76             * @param DomainFact $a First possibility
77             * @param DomainFact $b Second possibility
78             * @return DomainFact Inclusive alternative
79             */
80            #[Override]
81            public function join(DomainFact $a, DomainFact $b): DomainFact
82            {
83                if ($this->lessOrEqual($a, $b)) {
84                    return $b;
85                }
86                return $this->lessOrEqual($b, $a) ? $a : $this->top();
87            }
88            /**
89             * @param DomainFact $previous Previous approximation
90             * @param DomainFact $next New approximation
91             * @return DomainFact Finite widening
92             */
93            #[Override]
94            public function widen(DomainFact $previous, DomainFact $next): DomainFact
95            {
96                return $this->join($previous, $next);
97            }
98            /**
99             * @param DomainFact $value Policy record
100             * @param Projection $projection Selected fields
101             * @return DomainFact Projected fact
102             */
103            #[Override]
104            public function project(DomainFact $value, Projection $projection): DomainFact
105            {
106                $term = $value->representation;
107                foreach ($projection->path as $key) {
108                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
109                }
110                return new DomainFact($this->id(), $term);
111            }
112        }));
113    }
114    public function testApplyJoinContainsBothInputFacts(): void
115    {
116        $domain = new class () implements AbstractDomain {
117            /**
118             * @return string Domain identity
119             */
120            #[Override]
121            public function id(): string
122            {
123                return 'example.policy';
124            }
125            /**
126             * @return string Semantic version
127             */
128            #[Override]
129            public function version(): string
130            {
131                return '1';
132            }
133            /**
134             * @return DomainFact Empty set
135             */
136            #[Override]
137            public function bottom(): DomainFact
138            {
139                return new DomainFact($this->id(), new Term(TermKind::Bottom));
140            }
141            /**
142             * @return DomainFact All policy records
143             */
144            #[Override]
145            public function top(): DomainFact
146            {
147                return new DomainFact($this->id(), new Term(TermKind::Top));
148            }
149            /**
150             * @param DomainFact $a Contained fact
151             * @param DomainFact $b Containing fact
152             * @return bool Flat-lattice inclusion
153             */
154            #[Override]
155            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
156            {
157                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
158            }
159            /**
160             * @param DomainFact $a First possibility
161             * @param DomainFact $b Second possibility
162             * @return DomainFact Inclusive alternative
163             */
164            #[Override]
165            public function join(DomainFact $a, DomainFact $b): DomainFact
166            {
167                if ($this->lessOrEqual($a, $b)) {
168                    return $b;
169                }
170                return $this->lessOrEqual($b, $a) ? $a : $this->top();
171            }
172            /**
173             * @param DomainFact $previous Previous approximation
174             * @param DomainFact $next New approximation
175             * @return DomainFact Finite widening
176             */
177            #[Override]
178            public function widen(DomainFact $previous, DomainFact $next): DomainFact
179            {
180                return $this->join($previous, $next);
181            }
182            /**
183             * @param DomainFact $value Policy record
184             * @param Projection $projection Selected fields
185             * @return DomainFact Projected fact
186             */
187            #[Override]
188            public function project(DomainFact $value, Projection $projection): DomainFact
189            {
190                $term = $value->representation;
191                foreach ($projection->path as $key) {
192                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
193                }
194                return new DomainFact($this->id(), $term);
195            }
196        };
197        $value = (new DomainOperations())->apply($domain, 'join', $domain->bottom()->term(), $domain->top()->term());
198        self::assertSame(TermKind::Top, $value->operands['representation']->kind);
199    }
200    public function testContainsRequiresTheDeclaredInclusionDirection(): void
201    {
202        $domain = new class () implements AbstractDomain {
203            /**
204             * @return string Domain identity
205             */
206            #[Override]
207            public function id(): string
208            {
209                return 'example.policy';
210            }
211            /**
212             * @return string Semantic version
213             */
214            #[Override]
215            public function version(): string
216            {
217                return '1';
218            }
219            /**
220             * @return DomainFact Empty set
221             */
222            #[Override]
223            public function bottom(): DomainFact
224            {
225                return new DomainFact($this->id(), new Term(TermKind::Bottom));
226            }
227            /**
228             * @return DomainFact All policy records
229             */
230            #[Override]
231            public function top(): DomainFact
232            {
233                return new DomainFact($this->id(), new Term(TermKind::Top));
234            }
235            /**
236             * @param DomainFact $a Contained fact
237             * @param DomainFact $b Containing fact
238             * @return bool Flat-lattice inclusion
239             */
240            #[Override]
241            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
242            {
243                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
244            }
245            /**
246             * @param DomainFact $a First possibility
247             * @param DomainFact $b Second possibility
248             * @return DomainFact Inclusive alternative
249             */
250            #[Override]
251            public function join(DomainFact $a, DomainFact $b): DomainFact
252            {
253                if ($this->lessOrEqual($a, $b)) {
254                    return $b;
255                }
256                return $this->lessOrEqual($b, $a) ? $a : $this->top();
257            }
258            /**
259             * @param DomainFact $previous Previous approximation
260             * @param DomainFact $next New approximation
261             * @return DomainFact Finite widening
262             */
263            #[Override]
264            public function widen(DomainFact $previous, DomainFact $next): DomainFact
265            {
266                return $this->join($previous, $next);
267            }
268            /**
269             * @param DomainFact $value Policy record
270             * @param Projection $projection Selected fields
271             * @return DomainFact Projected fact
272             */
273            #[Override]
274            public function project(DomainFact $value, Projection $projection): DomainFact
275            {
276                $term = $value->representation;
277                foreach ($projection->path as $key) {
278                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
279                }
280                return new DomainFact($this->id(), $term);
281            }
282        };
283        $boundary = new DomainOperations();
284        self::assertTrue($boundary->contains($domain, $domain->top()->term(), $domain->bottom()->term()));
285        self::assertFalse($boundary->contains($domain, $domain->bottom()->term(), $domain->top()->term()));
286    }
287    public function testRegistrationPropagatesProviderFailures(): void
288    {
289        $plugin = self::createStub(AbstractDomain::class);
290        $plugin->method('bottom')->willThrowException(new RuntimeException('sensitive plugin detail'));
291        $this->expectException(RuntimeException::class);
292        $this->expectExceptionMessage('sensitive plugin detail');
293        (new DomainOperations())->registration($plugin);
294    }
295    public function testRegistrationRejectsViolationsOfTheDeclaredLaws(): void
296    {
297        $domain = self::createStub(AbstractDomain::class);
298        $fact = new DomainFact('broken', Term::constant(1));
299        $domain->method('id')->willReturn('broken');
300        $domain->method('bottom')->willReturn($fact);
301        $domain->method('top')->willReturn($fact);
302        $domain->method('join')->willReturn($fact);
303        $domain->method('widen')->willReturn($fact);
304        $domain->method('lessOrEqual')->willReturn(false);
305        $this->expectException(EvaluationContractException::class);
306        $this->expectExceptionMessage('Invalid domain registration:');
307        (new DomainOperations())->registration($domain);
308    }
309    public function testApplyProjectsTheDomainRepresentation(): void
310    {
311        $domain = new class () implements AbstractDomain {
312            /**
313             * @return string Domain identity
314             */
315            #[Override]
316            public function id(): string
317            {
318                return 'example.policy';
319            }
320            /**
321             * @return string Semantic version
322             */
323            #[Override]
324            public function version(): string
325            {
326                return '1';
327            }
328            /**
329             * @return DomainFact Empty set
330             */
331            #[Override]
332            public function bottom(): DomainFact
333            {
334                return new DomainFact($this->id(), new Term(TermKind::Bottom));
335            }
336            /**
337             * @return DomainFact All policy records
338             */
339            #[Override]
340            public function top(): DomainFact
341            {
342                return new DomainFact($this->id(), new Term(TermKind::Top));
343            }
344            /**
345             * @param DomainFact $a Contained fact
346             * @param DomainFact $b Containing fact
347             * @return bool Flat-lattice inclusion
348             */
349            #[Override]
350            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
351            {
352                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
353            }
354            /**
355             * @param DomainFact $a First possibility
356             * @param DomainFact $b Second possibility
357             * @return DomainFact Inclusive alternative
358             */
359            #[Override]
360            public function join(DomainFact $a, DomainFact $b): DomainFact
361            {
362                if ($this->lessOrEqual($a, $b)) {
363                    return $b;
364                }
365                return $this->lessOrEqual($b, $a) ? $a : $this->top();
366            }
367            /**
368             * @param DomainFact $previous Previous approximation
369             * @param DomainFact $next New approximation
370             * @return DomainFact Finite widening
371             */
372            #[Override]
373            public function widen(DomainFact $previous, DomainFact $next): DomainFact
374            {
375                return $this->join($previous, $next);
376            }
377            /**
378             * @param DomainFact $value Policy record
379             * @param Projection $projection Selected fields
380             * @return DomainFact Projected fact
381             */
382            #[Override]
383            public function project(DomainFact $value, Projection $projection): DomainFact
384            {
385                $term = $value->representation;
386                foreach ($projection->path as $key) {
387                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
388                }
389                return new DomainFact($this->id(), $term);
390            }
391        };
392        $input = new Term(TermKind::Domain, $domain->id(), ['representation' => Term::fromNative(['x' => 'private'])]);
393        $projected = (new DomainOperations())->apply($domain, 'project', $input, projection: new Projection(['x']));
394        self::assertSame('private', $projected->operands['representation']->native());
395    }
396    public function testApplyRejectsFactsOwnedByAnotherDomain(): void
397    {
398        $domain = self::createStub(AbstractDomain::class);
399        $domain->method('id')->willReturn('example');
400        $domain->method('project')->willReturn(new DomainFact('different', Term::constant(1)));
401        $input = (new DomainFact('example', Term::constant(2)))->term();
402        $this->expectException(EvaluationContractException::class);
403        (new DomainOperations())->apply($domain, 'project', $input);
404    }
405    public function testApplyRejectsWideningThatDoesNotContainBothInputs(): void
406    {
407        $domain = self::createStub(AbstractDomain::class);
408        $domain->method('id')->willReturn('example');
409        $domain->method('widen')->willReturn(new DomainFact('example', Term::constant(1)));
410        $domain->method('lessOrEqual')->willReturn(false);
411        $a = (new DomainFact('example', Term::constant(1)))->term();
412        $b = (new DomainFact('example', Term::constant(2)))->term();
413        $this->expectException(EvaluationContractException::class);
414        (new DomainOperations())->apply($domain, 'widen', $a, $b);
415    }
416    public function testApplyRejectsUnknownOperations(): void
417    {
418        $domain = new class () implements AbstractDomain {
419            /**
420             * @return string Domain identity
421             */
422            #[Override]
423            public function id(): string
424            {
425                return 'example.policy';
426            }
427            /**
428             * @return string Semantic version
429             */
430            #[Override]
431            public function version(): string
432            {
433                return '1';
434            }
435            /**
436             * @return DomainFact Empty set
437             */
438            #[Override]
439            public function bottom(): DomainFact
440            {
441                return new DomainFact($this->id(), new Term(TermKind::Bottom));
442            }
443            /**
444             * @return DomainFact All policy records
445             */
446            #[Override]
447            public function top(): DomainFact
448            {
449                return new DomainFact($this->id(), new Term(TermKind::Top));
450            }
451            /**
452             * @param DomainFact $a Contained fact
453             * @param DomainFact $b Containing fact
454             * @return bool Flat-lattice inclusion
455             */
456            #[Override]
457            public function lessOrEqual(DomainFact $a, DomainFact $b): bool
458            {
459                return $a->representation->kind === TermKind::Bottom || $b->representation->kind === TermKind::Top || $a->representation->isConcrete() && $b->representation->isConcrete() && $a->representation->native() === $b->representation->native();
460            }
461            /**
462             * @param DomainFact $a First possibility
463             * @param DomainFact $b Second possibility
464             * @return DomainFact Inclusive alternative
465             */
466            #[Override]
467            public function join(DomainFact $a, DomainFact $b): DomainFact
468            {
469                if ($this->lessOrEqual($a, $b)) {
470                    return $b;
471                }
472                return $this->lessOrEqual($b, $a) ? $a : $this->top();
473            }
474            /**
475             * @param DomainFact $previous Previous approximation
476             * @param DomainFact $next New approximation
477             * @return DomainFact Finite widening
478             */
479            #[Override]
480            public function widen(DomainFact $previous, DomainFact $next): DomainFact
481            {
482                return $this->join($previous, $next);
483            }
484            /**
485             * @param DomainFact $value Policy record
486             * @param Projection $projection Selected fields
487             * @return DomainFact Projected fact
488             */
489            #[Override]
490            public function project(DomainFact $value, Projection $projection): DomainFact
491            {
492                $term = $value->representation;
493                foreach ($projection->path as $key) {
494                    $term = $term->operands[$key] ?? new Term(TermKind::Top);
495                }
496                return new DomainFact($this->id(), $term);
497            }
498        };
499        $this->expectException(EvaluationContractException::class);
500        (new DomainOperations())->apply($domain, 'invalid', $domain->top()->term());
501    }
502    public function testApplyPropagatesProviderFailures(): void
503    {
504        $domain = self::createStub(AbstractDomain::class);
505        $domain->method('id')->willReturn('example');
506        $failure = new RuntimeException('domain failure');
507        $domain->method('join')->willThrowException($failure);
508        $this->expectExceptionObject($failure);
509        (new DomainOperations())->apply($domain, 'join', (new DomainFact('example', Term::constant(1)))->term());
510    }
511    public function testContainsCannotTreatAPluginExceptionAsConvergence(): void
512    {
513        $domain = self::createStub(AbstractDomain::class);
514        $domain->method('id')->willReturn('example');
515        $domain->method('lessOrEqual')->willThrowException(new RuntimeException());
516        $input = (new DomainFact('example', Term::constant(1)))->term();
517        $this->expectException(RuntimeException::class);
518        (new DomainOperations())->contains($domain, $input, $input);
519    }
520}
521