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