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