packages/deriver/spec/Context/PhpContext.php
1<?php
2
3declare(strict_types=1);
4
5namespace Spec\Context;
6
7use Behat\Behat\Context\Context;
8use Behat\Gherkin\Node\TableNode;
9use Behat\Step\Given;
10use Behat\Step\Then;
11use Behat\Step\When;
12use Deriver\Deriver;
13use Deriver\Deriver\BranchEvaluators;
14use Deriver\Deriver\Candidate;
15use Deriver\Deriver\Candidate\CandidateCollection;
16use Deriver\Deriver\Candidate\CandidateStatus;
17use Deriver\Deriver\DeriverConfig;
18use Deriver\Deriver\Evaluator\Term;
19use Deriver\Deriver\Evaluator\TermKind;
20use JsonException;
21use PHPUnit\Framework\Assert;
22use UnexpectedValueException;
23
24/**
25 * Exercises the public derivation API against captured PHP source, without executing fixtures.
26 */
27final class PhpContext implements Context
28{
29 /** @var list<string> */
30 private array $files = [];
31
32 private string $version = '8.1';
33
34 private bool $branchPruning = false;
35
36 /** @var array<string, string> */
37 private array $environment = [];
38
39 private ?CandidateCollection $result = null;
40
41 /**
42 * Selects a checked-in source fixture and its PHP language version.
43 */
44 #[Given('PHP :version source :fixture')]
45 public function source(string $version, string $fixture): void
46 {
47 $this->version = $version;
48 $this->files[] = dirname(__DIR__) . '/Fixture/' . $fixture;
49 Assert::assertFileExists($this->files[array_key_last($this->files)]);
50 }
51
52 /**
53 * Supplies external input explicitly instead of reading the host environment.
54 */
55 #[Given('environment :name equals :value')]
56 public function environment(string $name, string $value): void
57 {
58 $this->environment[$name] = $value;
59 }
60
61 /**
62 * Installs the optional truth-candidate evaluator through public configuration.
63 */
64 #[Given('branch pruning is enabled')]
65 public function branchPruning(): void
66 {
67 $this->branchPruning = true;
68 }
69
70 /**
71 * Verifies that closed coverage leaves unnecessary dependencies unopened.
72 */
73 #[Then('the body :symbol is not expanded')]
74 public function unopened(string $symbol): void
75 {
76 Assert::assertNotNull($this->result);
77 Assert::assertArrayNotHasKey($symbol, $this->result->statistics->expandedBodies);
78 }
79
80 /**
81 * Uses exactly the preparation and query API available to a library consumer.
82 * @throws JsonException If an environment expectation is malformed
83 * @throws UnexpectedValueException If an environment value is not a native input
84 */
85 #[When('I derive :query')]
86 public function derive(string $query): void
87 {
88 $environment = [];
89 foreach ($this->environment as $name => $value) {
90 $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
91 if (!is_array($decoded) && !is_scalar($decoded) && $decoded !== null) {
92 throw new UnexpectedValueException('An environment fixture must contain a native scalar, array or null.');
93 }
94 $environment[$name] = $decoded;
95 }
96 $this->result = Deriver::prepare($this->files, new DeriverConfig(phpVersion: $this->version, environment: $environment, evaluators: $this->branchPruning ? BranchEvaluators::truth() : []))->derive($query);
97 Assert::assertFalse($this->result->interrupted, 'The isolated feature must complete within the default exploration budget.');
98 }
99
100 /**
101 * Compares the entire concrete candidate set, including native scalar types and array keys.
102 * @throws JsonException If an expected value is malformed
103 */
104 #[Then('the candidates are:')]
105 public function candidates(TableNode $expected): void
106 {
107 Assert::assertNotNull($this->result);
108 $actual = [];
109 foreach ($this->result as $candidate) {
110 Assert::assertSame(CandidateStatus::Analyzed, $candidate->type);
111 $actual[] = serialize($candidate->result);
112 }
113 $values = [];
114 foreach ($expected->getHash() as $row) {
115 $values[] = serialize(json_decode($row['value'], true, flags: JSON_THROW_ON_ERROR));
116 }
117 sort($actual);
118 sort($values);
119 Assert::assertSame($values, $actual);
120 }
121
122 /**
123 * Compares complete values and retained expression boundaries in one candidate set.
124 * @throws JsonException If an expected value is malformed
125 */
126 #[Then('the candidates retain:')]
127 public function retained(TableNode $expected): void
128 {
129 Assert::assertNotNull($this->result);
130 $actual = [];
131 foreach ($this->result as $candidate) {
132 $value = $candidate->result;
133 $actual[] = serialize($value instanceof Term
134 ? ['kind' => $value->kind->value, 'reason' => $value->attributes['reason'] ?? null]
135 : ['value' => $value]);
136 }
137 $values = [];
138 foreach ($expected->getHash() as $row) {
139 $values[] = serialize($row['kind'] === ''
140 ? ['value' => json_decode($row['value'], true, flags: JSON_THROW_ON_ERROR)]
141 : ['kind' => $row['kind'], 'reason' => $row['reason']]);
142 }
143 sort($actual);
144 sort($values);
145 Assert::assertSame($values, $actual);
146 }
147
148 /**
149 * Checks that a terminating throw produces no ordinary return value.
150 */
151 #[Then('the candidate has no return value')]
152 public function noReturnValue(): void
153 {
154 Assert::assertNotNull($this->result);
155 Assert::assertCount(1, $this->result);
156 Assert::assertInstanceOf(Term::class, $this->result[0]->result);
157 Assert::assertSame(TermKind::NoValue, $this->result[0]->result->kind);
158 }
159
160 /**
161 * Checks termination that cannot reach an ordinary return.
162 */
163 #[Then('there are no candidates')]
164 public function noCandidates(): void
165 {
166 Assert::assertNotNull($this->result);
167 Assert::assertCount(0, $this->result);
168 }
169}
170