packages/sql-faker/tests/Unit/Generation/Coverage/SnapshotValidationTest.php
1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\SqlFaker\Generation\Coverage;
6
7use JsonException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use SqlFaker\Generation\Choice\ByteChoices;
13use SqlFaker\Generation\Coverage\CoverageException;
14use SqlFaker\Generation\Coverage\CoverageSets;
15use SqlFaker\Generation\Coverage\CoverageSnapshotStore;
16use SqlFaker\Generation\Coverage\GenerationTrace;
17use SqlFaker\Generation\Coverage\GeneratorRevision;
18use SqlFaker\Generation\Coverage\GrammarCoverage;
19use SqlFaker\Generation\Coverage\GrammarCoverageInventory;
20use SqlFaker\Generation\Coverage\LexicalObservation;
21use SqlFaker\Generation\Coverage\SnapshotValidation;
22use SqlFaker\Generation\Derivation\CompletionCosts;
23use SqlFaker\Generation\Derivation\DerivationNode;
24use SqlFaker\Grammar\Model\Grammar;
25use SqlFaker\Grammar\Model\NonTerminal;
26use SqlFaker\Grammar\Model\Production;
27use SqlFaker\Grammar\Model\ProductionRule;
28use SqlFaker\Grammar\Model\Terminal;
29
30#[CoversClass(SnapshotValidation::class)]
31#[UsesClass(Grammar::class)]
32#[UsesClass(Production::class)]
33#[UsesClass(ProductionRule::class)]
34#[UsesClass(Terminal::class)]
35#[UsesClass(NonTerminal::class)]
36#[UsesClass(GrammarCoverageInventory::class)]
37#[UsesClass(CoverageException::class)]
38#[UsesClass(GrammarCoverage::class)]
39#[UsesClass(GeneratorRevision::class)]
40#[UsesClass(CoverageSnapshotStore::class)]
41#[UsesClass(GenerationTrace::class)]
42#[UsesClass(CoverageSets::class)]
43#[UsesClass(ByteChoices::class)]
44#[UsesClass(CompletionCosts::class)]
45#[UsesClass(DerivationNode::class)]
46#[UsesClass(LexicalObservation::class)]
47final class SnapshotValidationTest extends TestCase
48{
49 /**
50 * @throws JsonException
51 */
52
53 public function testDecodeRestoresOnlyWellFormedSnapshotData(): void
54 {
55 $grammarCoverage = new GrammarCoverage(null);
56 $grammarCoverage->register(
57 new GrammarCoverageInventory(
58 (new Grammar(
59 'stmt',
60 [
61 'stmt' => new ProductionRule(
62 'stmt',
63 [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
64 ),
65 'expr' => new ProductionRule(
66 'expr',
67 [
68 new Production([new Terminal('1')]),
69 new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
70 new Production([]),
71 ],
72 ),
73 'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
74 ],
75 ))->identified(),
76 'stmt',
77 'test-v1',
78 ),
79 'revision-a',
80 );
81 $snapshot = $grammarCoverage->snapshot();
82 self::assertSame($snapshot, (new SnapshotValidation())->decode(json_encode($snapshot, JSON_THROW_ON_ERROR)));
83 }
84
85 public function testDecodeRejectsCorruptJsonInsteadOfStartingEmptyHistory(): void
86 {
87 $this->expectException(CoverageException::class);
88 (new SnapshotValidation())->decode('{"broken":');
89 }
90
91 public function testDecodeRejectsAnUnknownFormatVersion(): void
92 {
93 $this->expectException(CoverageException::class);
94 (new SnapshotValidation())->decode('{"formatVersion":2}');
95 }
96
97 public function testCompatibleRejectsUnknownProductionIds(): void
98 {
99 $coverage = new GrammarCoverage(null);
100 $coverage->register(
101 new GrammarCoverageInventory(
102 (new Grammar(
103 'stmt',
104 [
105 'stmt' => new ProductionRule(
106 'stmt',
107 [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
108 ),
109 'expr' => new ProductionRule(
110 'expr',
111 [
112 new Production([new Terminal('1')]),
113 new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
114 new Production([]),
115 ],
116 ),
117 'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
118 ],
119 ))->identified(),
120 'stmt',
121 'test-v1',
122 ),
123 'revision-a',
124 );
125 $snapshot = $coverage->snapshot();
126 $snapshot['cumulative']['reachedIds'] = ['unknown'];
127 $this->expectException(CoverageException::class);
128 (new SnapshotValidation())->compatible($snapshot, $coverage->snapshot(), $coverage->inventory());
129 }
130
131 public function testCompatibleRejectsAnUntrustedInventoryDigest(): void
132 {
133 $coverage = new GrammarCoverage(null);
134 $coverage->register(
135 new GrammarCoverageInventory(
136 (new Grammar(
137 'stmt',
138 [
139 'stmt' => new ProductionRule(
140 'stmt',
141 [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
142 ),
143 'expr' => new ProductionRule(
144 'expr',
145 [
146 new Production([new Terminal('1')]),
147 new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
148 new Production([]),
149 ],
150 ),
151 'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
152 ],
153 ))->identified(),
154 'stmt',
155 'test-v1',
156 ),
157 'revision-a',
158 );
159 $snapshot = $coverage->snapshot();
160 $snapshot['inventoryDigest'] = 'wrong';
161 $this->expectException(CoverageException::class);
162 (new SnapshotValidation())->compatible($snapshot, $coverage->snapshot(), $coverage->inventory());
163 }
164
165 public function testCompatibleRejectsEmittedProductionsThatWereNeverReached(): void
166 {
167 $coverage = new GrammarCoverage(null);
168 $coverage->register(
169 new GrammarCoverageInventory(
170 (new Grammar(
171 'stmt',
172 [
173 'stmt' => new ProductionRule(
174 'stmt',
175 [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
176 ),
177 'expr' => new ProductionRule(
178 'expr',
179 [
180 new Production([new Terminal('1')]),
181 new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
182 new Production([]),
183 ],
184 ),
185 'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
186 ],
187 ))->identified(),
188 'stmt',
189 'test-v1',
190 ),
191 'revision-a',
192 );
193 $snapshot = $coverage->snapshot();
194 $snapshot['cumulative']['emittedIds'] = [$coverage->inventory()->denominator[0]];
195 $this->expectException(CoverageException::class);
196 (new SnapshotValidation())->compatible($snapshot, $coverage->snapshot(), $coverage->inventory());
197 }
198
199 public function testCheckpointRequiresTypedDiagnosticFields(): void
200 {
201 $validator = new SnapshotValidation();
202 self::assertTrue(
203 $validator->checkpoint((object) ['savedAt' => 'time', 'runId' => 'run', 'generationsObservedInRun' => 1, 'generationInProgress' => false]),
204 );
205 self::assertFalse($validator->checkpoint((object) ['savedAt' => 'time']));
206 }
207
208 /**
209 * @return list<array{string}>
210 * @throws JsonException
211 */
212
213 public static function providerMalformedHistories(): array
214 {
215 $grammarCoverage = new GrammarCoverage(null);
216 $grammarCoverage->register(
217 new GrammarCoverageInventory(
218 (new Grammar(
219 'stmt',
220 [
221 'stmt' => new ProductionRule(
222 'stmt',
223 [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
224 ),
225 'expr' => new ProductionRule(
226 'expr',
227 [
228 new Production([new Terminal('1')]),
229 new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
230 new Production([]),
231 ],
232 ),
233 'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
234 ],
235 ))->identified(),
236 'stmt',
237 'test-v1',
238 ),
239 'revision-a',
240 );
241 $base = $grammarCoverage->snapshot();
242 $cases = [null, [], ['formatVersion' => '1'], ['formatVersion' => 1]];
243 foreach (['grammarFingerprint', 'generatorRevision', 'root', 'inventoryDigest', 'cumulative', 'checkpoint'] as $field) {
244 $missing = $base;
245 unset($missing[$field]);
246 $cases[] = $missing;
247 $cases[] = array_replace($base, [$field => 7]);
248 }
249 foreach (['reachedIds', 'emittedIds'] as $field) {
250 foreach ([null, [false], ['name' => 'id']] as $invalid) {
251 $cases[] = array_replace($base, ['cumulative' => array_replace($base['cumulative'], [$field => $invalid])]);
252 }
253 }
254 foreach (['savedAt' => 1, 'runId' => false, 'generationsObservedInRun' => '2', 'generationInProgress' => 0] as $field => $invalid) {
255 $cases[] = array_replace($base, ['checkpoint' => array_replace($base['checkpoint'], [$field => $invalid])]);
256 }
257 return array_values(array_map(static fn ($case): array => [json_encode($case, JSON_THROW_ON_ERROR)], $cases));
258 }
259 #[DataProvider('providerMalformedHistories')]
260
261 public function testDecodeRejectsMalformedHistoryFields(string $json): void
262 {
263 $this->expectException(CoverageException::class);
264 (new SnapshotValidation())->decode($json);
265 }
266
267 /**
268 * @return list<array{'grammarFingerprint'|'generatorRevision'|'root'|'inventoryDigest'}>
269 */
270
271 public static function providerIdentityFields(): array
272 {
273 return [['grammarFingerprint'], ['generatorRevision'], ['root'], ['inventoryDigest']];
274 }
275
276 /**
277 * @param 'grammarFingerprint'|'generatorRevision'|'root'|'inventoryDigest' $field
278 */
279 #[DataProvider('providerIdentityFields')]
280
281 public function testCompatibleRequiresEveryIdentityComponent(string $field): void
282 {
283 $coverage = new GrammarCoverage(null);
284 $coverage->register(
285 new GrammarCoverageInventory(
286 (new Grammar(
287 'stmt',
288 [
289 'stmt' => new ProductionRule(
290 'stmt',
291 [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
292 ),
293 'expr' => new ProductionRule(
294 'expr',
295 [
296 new Production([new Terminal('1')]),
297 new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
298 new Production([]),
299 ],
300 ),
301 'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
302 ],
303 ))->identified(),
304 'stmt',
305 'test-v1',
306 ),
307 'revision-a',
308 );
309 $snapshot = $coverage->snapshot();
310 $different = $snapshot;
311 $different[$field] = 'different';
312 $this->expectException(CoverageException::class);
313 $this->expectExceptionMessage('Incompatible coverage snapshot: ' . $field);
314 (new SnapshotValidation())->compatible($different, $snapshot, $coverage->inventory());
315 }
316}
317