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