packages/requirements/tests/Unit/Report/SnapshotTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Report;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Config\CoverageThresholds;
14use Requirements\Config\DefinitionReader;
15use Requirements\Config\Definitions;
16use Requirements\Config\DocumentReader;
17use Requirements\Config\ExtensionClasses;
18use Requirements\Config\JsonSchemaFile;
19use Requirements\Config\LinkValidator;
20use Requirements\Config\Loader;
21use Requirements\Config\MarkdownDocument;
22use Requirements\Config\SchemaValidator;
23use Requirements\Ears\ConditionOrder;
24use Requirements\Ears\LiteralMask;
25use Requirements\Ears\SystemResponse;
26use Requirements\Ears\Validator;
27use Requirements\Ears\Wording;
28use Requirements\Input\Fields;
29use Requirements\Input\InvalidInputException;
30use Requirements\Model\Excerpt;
31use Requirements\Model\Item;
32use Requirements\Model\ItemValidator;
33use Requirements\Model\Project;
34use Requirements\Model\Source;
35use Requirements\Report\Analysis;
36use Requirements\Report\Analyzer;
37use Requirements\Report\Claims;
38use Requirements\Report\Coverage;
39use Requirements\Report\EvidenceMatcher;
40use Requirements\Report\Snapshot;
41use Requirements\Report\SourceUnit;
42use Requirements\Report\UnitCollector;
43use Requirements\Source\DomSource;
44use Requirements\Source\JsonSource;
45use Requirements\Source\LocalFile;
46use Requirements\Source\Registry;
47use Requirements\Source\ResourceLoader;
48use Requirements\Source\ResourceLocation;
49use Requirements\Source\TextFragment;
50use Requirements\Source\TextSource;
51use Requirements\Source\Unit;
52use Requirements\Test\Registry as RunnerRegistry;
53use Tests\Fake\ProjectDirectory;
54
55#[CoversClass(Snapshot::class)]
56#[UsesClass(Analysis::class)]
57#[UsesClass(Analyzer::class)]
58#[UsesClass(Claims::class)]
59#[UsesClass(Coverage::class)]
60#[UsesClass(EvidenceMatcher::class)]
61#[UsesClass(SourceUnit::class)]
62#[UsesClass(UnitCollector::class)]
63#[UsesClass(Loader::class)]
64#[UsesClass(Fields::class)]
65#[UsesClass(Item::class)]
66#[UsesClass(Project::class)]
67#[UsesClass(Source::class)]
68#[UsesClass(Unit::class)]
69#[UsesClass(CoverageThresholds::class)]
70#[UsesClass(DefinitionReader::class)]
71#[UsesClass(Definitions::class)]
72#[UsesClass(DocumentReader::class)]
73#[UsesClass(ExtensionClasses::class)]
74#[UsesClass(JsonSchemaFile::class)]
75#[UsesClass(LinkValidator::class)]
76#[UsesClass(MarkdownDocument::class)]
77#[UsesClass(SchemaValidator::class)]
78#[UsesClass(ConditionOrder::class)]
79#[UsesClass(LiteralMask::class)]
80#[UsesClass(SystemResponse::class)]
81#[UsesClass(Validator::class)]
82#[UsesClass(Wording::class)]
83#[UsesClass(Excerpt::class)]
84#[UsesClass(ItemValidator::class)]
85#[UsesClass(DomSource::class)]
86#[UsesClass(JsonSource::class)]
87#[UsesClass(LocalFile::class)]
88#[UsesClass(Registry::class)]
89#[UsesClass(ResourceLoader::class)]
90#[UsesClass(ResourceLocation::class)]
91#[UsesClass(TextFragment::class)]
92#[UsesClass(TextSource::class)]
93#[UsesClass(RunnerRegistry::class)]
94#[Small]
95final class SnapshotTest extends TestCase
96{
97    /**
98     * @throws JsonException
99     */
100    public function testCreateListsTheFingerprintOfEveryUnit(): void
101    {
102        $source = new Source('manual', 'source.html', 'html', 'main p');
103        $item = new Item('SPEC-001', 'specification', 'The parser shall read names.', 'supported', $source, [], [], [], [], [], '', 'sourced', '', 'definition.yaml', ['id' => 'SPEC-001']);
104        $claimed = new SourceUnit($source, new Unit('p:1', 'First.'));
105        $claimed->claims = ['SPEC-001' => $item];
106        $uncovered = new SourceUnit($source, new Unit('p:2', 'Second.'));
107        $project = new Project('/', ['SPEC-001' => $item], ['manual' => $source], [], [], [], 0.0, 0.0, [], []);
108        $analysis = new Analysis(['a' => $claimed, 'b' => $uncovered], ['manual' => ['a', 'b']], [], ['SPEC-001' => ['a']]);
109        self::assertSame(['version' => 1, 'type' => 'requirements-snapshot', 'units' => ['a' => ['fingerprint' => $claimed->fingerprint(['SPEC-001' => $item])], 'b' => ['fingerprint' => $uncovered->fingerprint([])]]], (new Snapshot())->create($analysis, $project));
110    }
111
112    /**
113     * @throws JsonException
114     */
115    public function testCreateListsOnlyFingerprintsAndCompareDetectsDrift(): void
116    {
117        $workspace = new ProjectDirectory();
118        $project = (new Loader())->load($workspace->directory . '/requirements.yaml');
119        $analysis = (new Analyzer())->analyze($project);
120        $snapshot = new Snapshot();
121        $data = $snapshot->create($analysis, $project);
122        self::assertSame('requirements-snapshot', $data['type']);
123        self::assertCount(count($analysis->units), $data['units']);
124        self::assertSame(array_fill(0, count($data['units']), ['fingerprint']), array_values(array_map(static fn (array $entry): array => array_keys($entry), $data['units'])));
125        $file = $workspace->directory . '/snapshot.json';
126        file_put_contents($file, json_encode($data, JSON_THROW_ON_ERROR));
127        self::assertSame(['changed' => [], 'removed' => []], $snapshot->compare($analysis, $project, $snapshot->read($file)));
128        file_put_contents($workspace->directory . '/source.html', '<main><p id="a">Changed source.</p></main>');
129        self::assertNotEmpty($snapshot->compare((new Analyzer())->analyze($project), $project, $snapshot->read($file))['changed']);
130    }
131
132    /**
133     * @throws JsonException
134     */
135    public function testReadReturnsTheFingerprintsByUnitKey(): void
136    {
137        $workspace = new ProjectDirectory();
138        $key = hash('sha256', 'unit');
139        $fingerprint = hash('sha256', 'fingerprint');
140        $file = $workspace->put('snapshot.json', json_encode(['version' => 1, 'type' => 'requirements-snapshot', 'units' => [$key => ['fingerprint' => $fingerprint]]], JSON_THROW_ON_ERROR));
141        self::assertSame([$key => $fingerprint], (new Snapshot())->read($file));
142    }
143
144    /**
145     * @throws JsonException
146     */
147    public function testReadAcceptsASnapshotWithoutUnits(): void
148    {
149        $workspace = new ProjectDirectory();
150        $file = $workspace->put('snapshot.json', '{"version":1,"type":"requirements-snapshot","units":{}}');
151        self::assertSame([], (new Snapshot())->read($file));
152    }
153
154    /**
155     * @throws JsonException
156     */
157    public function testReadRejectsAUnitWithoutFingerprint(): void
158    {
159        $workspace = new ProjectDirectory();
160        file_put_contents($workspace->directory . '/bad.json', '{"version":1,"type":"requirements-snapshot","units":{"x":{}}}');
161        $this->expectException(InvalidInputException::class);
162        (new Snapshot())->read($workspace->directory . '/bad.json');
163    }
164
165    /**
166     * @throws JsonException
167     */
168    public function testReadRejectsACoverageReport(): void
169    {
170        $workspace = new ProjectDirectory();
171        $project = (new Loader())->load($workspace->directory . '/requirements.yaml');
172        $file = $workspace->directory . '/coverage.json';
173        file_put_contents($file, json_encode((new Coverage())->report($project, (new Analyzer())->analyze($project)), JSON_THROW_ON_ERROR));
174        $this->expectException(InvalidInputException::class);
175        $this->expectExceptionMessage('--write-snapshot');
176        (new Snapshot())->read($file);
177    }
178
179    /**
180     * @throws JsonException
181     */
182    #[DataProvider('providerUnreadableFiles')]
183    public function testReadRejectsAFileThatCannotBeRead(string $file): void
184    {
185        $workspace = new ProjectDirectory();
186        $this->expectException(InvalidInputException::class);
187        $this->expectExceptionMessage('Cannot read coverage snapshot: ' . $workspace->path($file));
188        (new Snapshot())->read($workspace->path($file));
189    }
190
191    /**
192     * @return array<string, array{string}>
193     */
194    public static function providerUnreadableFiles(): array
195    {
196        return [
197            'missing' => ['missing.json'],
198            'directory' => [''],
199        ];
200    }
201
202    /**
203     * @throws JsonException
204     */
205    public function testReadRejectsAFileThatIsNotJson(): void
206    {
207        $workspace = new ProjectDirectory();
208        $file = $workspace->put('snapshot.json', '{"version":');
209        $this->expectException(JsonException::class);
210        (new Snapshot())->read($file);
211    }
212
213    /**
214     * @throws JsonException
215     */
216    #[DataProvider('providerMalformedSnapshots')]
217    public function testReadRejectsAMalformedSnapshot(string $contents, string $message): void
218    {
219        $workspace = new ProjectDirectory();
220        $file = $workspace->put('snapshot.json', $contents);
221        $this->expectException(InvalidInputException::class);
222        $this->expectExceptionMessage($message);
223        (new Snapshot())->read($file);
224    }
225
226    /**
227     * @return array<string, array{string, string}>
228     */
229    public static function providerMalformedSnapshots(): array
230    {
231        $key = hash('sha256', 'unit');
232        $fingerprint = hash('sha256', 'fingerprint');
233        return [
234            'not a mapping' => ['"snapshot"', 'snapshot must be a mapping.'],
235            'no version' => ['{"type":"requirements-snapshot","units":{}}', 'Unsupported coverage snapshot. Write one with coverage --write-snapshot.'],
236            'other version' => ['{"version":2,"type":"requirements-snapshot","units":{}}', 'Unsupported coverage snapshot. Write one with coverage --write-snapshot.'],
237            'version as text' => ['{"version":"1","type":"requirements-snapshot","units":{}}', 'Unsupported coverage snapshot. Write one with coverage --write-snapshot.'],
238            'no type' => ['{"version":1,"units":{}}', 'Unsupported coverage snapshot. Write one with coverage --write-snapshot.'],
239            'coverage type' => ['{"version":1,"type":"requirements-coverage","units":{}}', 'Unsupported coverage snapshot. Write one with coverage --write-snapshot.'],
240            'no units' => ['{"version":1,"type":"requirements-snapshot"}', 'snapshot.units must be a mapping.'],
241            'unit not a mapping' => ['{"version":1,"type":"requirements-snapshot","units":{"' . $key . '":"' . $fingerprint . '"}}', 'snapshot.unit must be a mapping.'],
242            'short key' => ['{"version":1,"type":"requirements-snapshot","units":{"' . substr($key, 1) . '":{"fingerprint":"' . $fingerprint . '"}}}', 'Invalid coverage snapshot unit.'],
243            'uppercase key' => ['{"version":1,"type":"requirements-snapshot","units":{"' . strtoupper($key) . '":{"fingerprint":"' . $fingerprint . '"}}}', 'Invalid coverage snapshot unit.'],
244            'key with trailing newline' => ['{"version":1,"type":"requirements-snapshot","units":{"' . $key . '\n":{"fingerprint":"' . $fingerprint . '"}}}', 'Invalid coverage snapshot unit.'],
245            'key with prefix' => ['{"version":1,"type":"requirements-snapshot","units":{"x' . $key . '":{"fingerprint":"' . $fingerprint . '"}}}', 'Invalid coverage snapshot unit.'],
246            'short fingerprint' => ['{"version":1,"type":"requirements-snapshot","units":{"' . $key . '":{"fingerprint":"' . substr($fingerprint, 1) . '"}}}', 'Invalid coverage snapshot unit.'],
247            'fingerprint with trailing newline' => ['{"version":1,"type":"requirements-snapshot","units":{"' . $key . '":{"fingerprint":"' . $fingerprint . '\n"}}}', 'Invalid coverage snapshot unit.'],
248            'fingerprint with prefix' => ['{"version":1,"type":"requirements-snapshot","units":{"' . $key . '":{"fingerprint":"x' . $fingerprint . '"}}}', 'Invalid coverage snapshot unit.'],
249            'blank fingerprint' => ['{"version":1,"type":"requirements-snapshot","units":{"' . $key . '":{"fingerprint":""}}}', 'fingerprint must be a nonempty string.'],
250        ];
251    }
252
253    /**
254     * @throws JsonException
255     */
256    public function testCompareListsNewAndChangedUnitsAndRemovedKeys(): void
257    {
258        $source = new Source('manual', 'source.html', 'html', 'main p');
259        $same = new SourceUnit($source, new Unit('p:1', 'First.'));
260        $changed = new SourceUnit($source, new Unit('p:2', 'Second.'));
261        $added = new SourceUnit($source, new Unit('p:3', 'Third.'));
262        $project = new Project('/', [], ['manual' => $source], [], [], [], 0.0, 0.0, [], []);
263        $analysis = new Analysis(['a' => $same, 'b' => $changed, 'c' => $added], ['manual' => ['a', 'b', 'c']], [], []);
264        $snapshot = ['r' => hash('sha256', 'removed'), 'a' => $same->fingerprint([]), 'b' => hash('sha256', 'before'), 's' => hash('sha256', 'also removed')];
265        self::assertSame(['changed' => ['b', 'c'], 'removed' => ['r', 's']], (new Snapshot())->compare($analysis, $project, $snapshot));
266    }
267
268    /**
269     * @throws JsonException
270     */
271    public function testCompareDetectsChangedClaimsNewUnitsAndRemovedScope(): void
272    {
273        $workspace = new ProjectDirectory();
274        $loader = new Loader();
275        $project = $loader->load($workspace->directory . '/requirements.yaml');
276        $analysis = (new Analyzer())->analyze($project);
277        $file = $workspace->directory . '/snapshot.json';
278        file_put_contents($file, json_encode((new Snapshot())->create($analysis, $project), JSON_THROW_ON_ERROR));
279        $snapshot = (new Snapshot())->read($file);
280        self::assertSame(['changed' => [], 'removed' => []], (new Snapshot())->compare($analysis, $project, $snapshot));
281        $definition = $loader->document($workspace->directory . '/definition.yaml');
282        $item = ProjectDirectory::item();
283        $item['statement'] = 'The parser shall reject names beginning with digits.';
284        $definition['items'] = [$item];
285        $workspace->write('definition.yaml', $definition);
286        $changed = $loader->load($workspace->directory . '/requirements.yaml');
287        self::assertCount(1, (new Snapshot())->compare((new Analyzer())->analyze($changed), $changed, $snapshot)['changed']);
288        $definition['source'] = ['id' => 'manual', 'uri' => 'source.html', 'format' => 'html', 'selector' => '#a'];
289        $workspace->write('definition.yaml', $definition);
290        $reduced = $loader->load($workspace->directory . '/requirements.yaml');
291        $report = (new Coverage())->report($reduced, (new Analyzer())->analyze($reduced), $file);
292        self::assertFalse($report['passed']);
293        self::assertIsArray($report['removed']);
294        self::assertCount(2, $report['removed']);
295        self::assertTrue((new Coverage())->report($reduced, (new Analyzer())->analyze($reduced), $file, true)['passed']);
296    }
297}
298