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