packages/sql-faker/tests/Unit/Generation/Coverage/GrammarCoverageTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\SqlFaker\Generation\Coverage;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use RuntimeException;
11use SqlFaker\Generation\Candidate\ChoiceLexemeGenerator;
12use SqlFaker\Generation\Candidate\FixedLexemeGenerator;
13use SqlFaker\Generation\Candidate\ValueLexemeGenerator;
14use SqlFaker\Generation\Choice\ByteChoices;
15use SqlFaker\Generation\Coverage\CoverageException;
16use SqlFaker\Generation\Coverage\CoverageSets;
17use SqlFaker\Generation\Coverage\CoverageSnapshotStore;
18use SqlFaker\Generation\Coverage\GenerationTrace;
19use SqlFaker\Generation\Coverage\GeneratorRevision;
20use SqlFaker\Generation\Coverage\GrammarCoverage;
21use SqlFaker\Generation\Coverage\GrammarCoverageInventory;
22use SqlFaker\Generation\Coverage\LexicalObservation;
23use SqlFaker\Generation\Coverage\SequenceObservation;
24use SqlFaker\Generation\Coverage\SnapshotValidation;
25use SqlFaker\Generation\Derivation\CompletionCosts;
26use SqlFaker\Generation\Derivation\DerivationNode;
27use SqlFaker\Generation\Derivation\DerivationTrace;
28use SqlFaker\Generation\Lexeme\Lexeme;
29use SqlFaker\Generation\Lexeme\LexemeCandidates;
30use SqlFaker\Generation\Lexeme\LexemeInput;
31use SqlFaker\Generation\Lexeme\LexemeSequence;
32use SqlFaker\Generation\Lexeme\OutputPart;
33use SqlFaker\Generation\Lexeme\ResolvedOutput;
34use SqlFaker\Generation\Lexeme\SpacingConstraint;
35use SqlFaker\Generation\Output\BoundaryCompletion;
36use SqlFaker\Generation\Output\CandidateResolver;
37use SqlFaker\Generation\Output\CombinedSpacingRule;
38use SqlFaker\Generation\Output\ReverseLexemeGenerator;
39use SqlFaker\Generation\Token\ProductionOccurrence;
40use SqlFaker\Generation\Token\TerminalOccurrence;
41use SqlFaker\Generation\Token\TerminalSequence;
42use SqlFaker\Generation\Value\CharacterDomain;
43use SqlFaker\Generation\Value\ValueChoices;
44use SqlFaker\Grammar\Model\Grammar;
45use SqlFaker\Grammar\Model\NonTerminal;
46use SqlFaker\Grammar\Model\Production;
47use SqlFaker\Grammar\Model\ProductionRule;
48use SqlFaker\Grammar\Model\Terminal;
49use Symfony\Component\Filesystem\Filesystem;
50
51#[CoversClass(GrammarCoverage::class)]
52#[UsesClass(Grammar::class)]
53#[UsesClass(Production::class)]
54#[UsesClass(ProductionRule::class)]
55#[UsesClass(Terminal::class)]
56#[UsesClass(NonTerminal::class)]
57#[UsesClass(GrammarCoverageInventory::class)]
58#[UsesClass(CoverageException::class)]
59#[UsesClass(GeneratorRevision::class)]
60#[UsesClass(CoverageSnapshotStore::class)]
61#[UsesClass(SnapshotValidation::class)]
62#[UsesClass(GenerationTrace::class)]
63#[UsesClass(CoverageSets::class)]
64#[UsesClass(ByteChoices::class)]
65#[UsesClass(CompletionCosts::class)]
66#[UsesClass(DerivationNode::class)]
67#[UsesClass(SequenceObservation::class)]
68#[UsesClass(DerivationTrace::class)]
69#[UsesClass(ChoiceLexemeGenerator::class)]
70#[UsesClass(Lexeme::class)]
71#[UsesClass(LexemeCandidates::class)]
72#[UsesClass(LexemeInput::class)]
73#[UsesClass(LexemeSequence::class)]
74#[UsesClass(ValueLexemeGenerator::class)]
75#[UsesClass(CandidateResolver::class)]
76#[UsesClass(OutputPart::class)]
77#[UsesClass(ResolvedOutput::class)]
78#[UsesClass(ReverseLexemeGenerator::class)]
79#[UsesClass(CombinedSpacingRule::class)]
80#[UsesClass(SpacingConstraint::class)]
81#[UsesClass(ProductionOccurrence::class)]
82#[UsesClass(TerminalOccurrence::class)]
83#[UsesClass(TerminalSequence::class)]
84#[UsesClass(LexicalObservation::class)]
85#[UsesClass(ValueChoices::class)]
86#[UsesClass(BoundaryCompletion::class)]
87#[UsesClass(CharacterDomain::class)]
88#[UsesClass(FixedLexemeGenerator::class)]
89final class GrammarCoverageTest extends TestCase
90{
91    public function testSnapshotSeparatesReachedFromEmittedAndKeepsTheFullDenominator(): void
92    {
93        $coverage = new GrammarCoverage(null);
94        $coverage->register(
95            new GrammarCoverageInventory(
96                (new Grammar(
97                    'stmt',
98                    [
99                        'stmt' => new ProductionRule(
100                            'stmt',
101                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
102                        ),
103                        'expr' => new ProductionRule(
104                            'expr',
105                            [
106                                new Production([new Terminal('1')]),
107                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
108                                new Production([]),
109                            ],
110                        ),
111                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
112                    ],
113                ))->identified(),
114                'stmt',
115                'test-v1',
116            ),
117            'revision-a',
118        );
119        $recordedProductionIds = $coverage->inventory()->denominator;
120        $coverage->beginGeneration('stmt', []);
121        $coverage->beginAttempt(0);
122        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
123        $coverage->discardAttempt('lexical failure');
124        $coverage->beginAttempt(1);
125        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
126        $coverage->commitAttempt('sql-hash');
127        $coverage->endGeneration();
128        self::assertSame(5, $coverage->snapshot()['current']['total']);
129        self::assertSame(2, $coverage->snapshot()['current']['reached']);
130        self::assertSame(1, $coverage->snapshot()['current']['emitted']);
131        self::assertSame(3, count($coverage->snapshot()['current']['notReachedIds']));
132        self::assertSame(0.4, $coverage->snapshot()['current']['reachedRate']);
133    }
134
135    public function testFlushRestoresOnlyCumulativeHistoryAndPreservesCurrentObservations(): void
136    {
137        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
138        (new Filesystem())->mkdir($directory);
139        $coverage = new GrammarCoverage($directory);
140        $coverage->register(
141            new GrammarCoverageInventory(
142                (new Grammar(
143                    'stmt',
144                    [
145                        'stmt' => new ProductionRule(
146                            'stmt',
147                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
148                        ),
149                        'expr' => new ProductionRule(
150                            'expr',
151                            [
152                                new Production([new Terminal('1')]),
153                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
154                                new Production([]),
155                            ],
156                        ),
157                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
158                    ],
159                ))->identified(),
160                'stmt',
161                'test-v1',
162            ),
163            'revision-a',
164        );
165        $recordedProductionIds = $coverage->inventory()->denominator;
166        $coverage->beginGeneration('stmt', []);
167        $coverage->beginAttempt(0);
168        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
169        $coverage->discardAttempt('lexical failure');
170        $coverage->beginAttempt(1);
171        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
172        $coverage->commitAttempt('sql-hash');
173        $coverage->endGeneration();
174        $coverage->flush();
175        $coverage->flush();
176        self::assertSame(2, $coverage->snapshot()['current']['reached']);
177        unset($coverage);
178        $restored = new GrammarCoverage($directory);
179        $restored->register(
180            new GrammarCoverageInventory(
181                (new Grammar(
182                    'stmt',
183                    [
184                        'stmt' => new ProductionRule(
185                            'stmt',
186                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
187                        ),
188                        'expr' => new ProductionRule(
189                            'expr',
190                            [
191                                new Production([new Terminal('1')]),
192                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
193                                new Production([]),
194                            ],
195                        ),
196                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
197                    ],
198                ))->identified(),
199                'stmt',
200                'test-v1',
201            ),
202            'revision-a',
203        );
204        self::assertSame(2, $restored->snapshot()['cumulative']['reached']);
205        self::assertSame(0, $restored->snapshot()['current']['reached']);
206        self::assertNull($restored->lastGeneration());
207        $recordedProductionIds = $restored->inventory()->denominator;
208        $restored->beginGeneration('stmt', []);
209        $restored->beginAttempt(0);
210        $restored->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
211        $restored->discardAttempt('lexical failure');
212        $restored->beginAttempt(1);
213        $restored->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
214        $restored->commitAttempt('sql-hash');
215        $restored->endGeneration();
216        self::assertSame(2, $restored->snapshot()['current']['reached']);
217        self::assertSame(2, $restored->snapshot()['cumulative']['reached']);
218        unset($restored);
219        (new Filesystem())->remove($directory);
220    }
221
222    public function testResetKeepsFlushedHistoryAndClearsTraceAndCurrentCounters(): void
223    {
224        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
225        (new Filesystem())->mkdir($directory);
226        $coverage = new GrammarCoverage($directory);
227        $coverage->register(
228            new GrammarCoverageInventory(
229                (new Grammar(
230                    'stmt',
231                    [
232                        'stmt' => new ProductionRule(
233                            'stmt',
234                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
235                        ),
236                        'expr' => new ProductionRule(
237                            'expr',
238                            [
239                                new Production([new Terminal('1')]),
240                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
241                                new Production([]),
242                            ],
243                        ),
244                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
245                    ],
246                ))->identified(),
247                'stmt',
248                'test-v1',
249            ),
250            'revision-a',
251        );
252        $recordedProductionIds = $coverage->inventory()->denominator;
253        $coverage->beginGeneration('stmt', []);
254        $coverage->beginAttempt(0);
255        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
256        $coverage->discardAttempt('lexical failure');
257        $coverage->beginAttempt(1);
258        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
259        $coverage->commitAttempt('sql-hash');
260        $coverage->endGeneration();
261        $coverage->flush();
262        $coverage->reset();
263        self::assertSame(2, $coverage->snapshot()['cumulative']['reached']);
264        self::assertSame(0, $coverage->snapshot()['current']['reached']);
265        self::assertSame(0, $coverage->snapshot()['checkpoint']['generationsObservedInRun']);
266        self::assertNull($coverage->lastGeneration());
267        unset($coverage);
268        (new Filesystem())->remove($directory);
269    }
270
271    public function testMergeIsIdempotentAndDoesNotCopyHistoricalCounters(): void
272    {
273        $previous = new GrammarCoverage(null);
274        $previous->register(
275            new GrammarCoverageInventory(
276                (new Grammar(
277                    'stmt',
278                    [
279                        'stmt' => new ProductionRule(
280                            'stmt',
281                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
282                        ),
283                        'expr' => new ProductionRule(
284                            'expr',
285                            [
286                                new Production([new Terminal('1')]),
287                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
288                                new Production([]),
289                            ],
290                        ),
291                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
292                    ],
293                ))->identified(),
294                'stmt',
295                'test-v1',
296            ),
297            'revision-a',
298        );
299        $recordedProductionIds = $previous->inventory()->denominator;
300        $previous->beginGeneration('stmt', []);
301        $previous->beginAttempt(0);
302        $previous->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
303        $previous->discardAttempt('lexical failure');
304        $previous->beginAttempt(1);
305        $previous->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
306        $previous->commitAttempt('sql-hash');
307        $previous->endGeneration();
308        $coverage = new GrammarCoverage(null);
309        $coverage->register(
310            new GrammarCoverageInventory(
311                (new Grammar(
312                    'stmt',
313                    [
314                        'stmt' => new ProductionRule(
315                            'stmt',
316                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
317                        ),
318                        'expr' => new ProductionRule(
319                            'expr',
320                            [
321                                new Production([new Terminal('1')]),
322                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
323                                new Production([]),
324                            ],
325                        ),
326                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
327                    ],
328                ))->identified(),
329                'stmt',
330                'test-v1',
331            ),
332            'revision-a',
333        );
334        $coverage->merge($previous->snapshot());
335        $coverage->merge($previous->snapshot());
336        self::assertSame(2, $coverage->snapshot()['cumulative']['reached']);
337        self::assertSame(0, $coverage->snapshot()['current']['reached']);
338        self::assertSame(0, $coverage->snapshot()['checkpoint']['generationsObservedInRun']);
339    }
340
341    public function testMergeRejectsChangedImplementationRevisions(): void
342    {
343        $coverage = new GrammarCoverage(null);
344        $coverage->register(
345            new GrammarCoverageInventory(
346                (new Grammar(
347                    'stmt',
348                    [
349                        'stmt' => new ProductionRule(
350                            'stmt',
351                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
352                        ),
353                        'expr' => new ProductionRule(
354                            'expr',
355                            [
356                                new Production([new Terminal('1')]),
357                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
358                                new Production([]),
359                            ],
360                        ),
361                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
362                    ],
363                ))->identified(),
364                'stmt',
365                'test-v1',
366            ),
367            'revision-a',
368        );
369        $other = new GrammarCoverage(null);
370        $other->register(
371            new GrammarCoverageInventory(
372                (new Grammar(
373                    'stmt',
374                    [
375                        'stmt' => new ProductionRule(
376                            'stmt',
377                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
378                        ),
379                        'expr' => new ProductionRule(
380                            'expr',
381                            [
382                                new Production([new Terminal('1')]),
383                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
384                                new Production([]),
385                            ],
386                        ),
387                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
388                    ],
389                ))->identified(),
390                'stmt',
391                'test-v1',
392            ),
393            'changed',
394        );
395        $this->expectException(CoverageException::class);
396        $coverage->merge($other->snapshot());
397    }
398
399    public function testRegisterLeavesOldHistoryWhenTheImplementationChanges(): void
400    {
401        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
402        (new Filesystem())->mkdir($directory);
403        $coverage = new GrammarCoverage($directory);
404        $coverage->register(
405            new GrammarCoverageInventory(
406                (new Grammar(
407                    'stmt',
408                    [
409                        'stmt' => new ProductionRule(
410                            'stmt',
411                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
412                        ),
413                        'expr' => new ProductionRule(
414                            'expr',
415                            [
416                                new Production([new Terminal('1')]),
417                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
418                                new Production([]),
419                            ],
420                        ),
421                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
422                    ],
423                ))->identified(),
424                'stmt',
425                'test-v1',
426            ),
427            'revision-a',
428        );
429        $recordedProductionIds = $coverage->inventory()->denominator;
430        $coverage->beginGeneration('stmt', []);
431        $coverage->beginAttempt(0);
432        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
433        $coverage->discardAttempt('lexical failure');
434        $coverage->beginAttempt(1);
435        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
436        $coverage->commitAttempt('sql-hash');
437        $coverage->endGeneration();
438        $coverage->flush();
439        unset($coverage);
440        $changed = new GrammarCoverage($directory);
441        $changed->register(
442            new GrammarCoverageInventory(
443                (new Grammar(
444                    'stmt',
445                    [
446                        'stmt' => new ProductionRule(
447                            'stmt',
448                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
449                        ),
450                        'expr' => new ProductionRule(
451                            'expr',
452                            [
453                                new Production([new Terminal('1')]),
454                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
455                                new Production([]),
456                            ],
457                        ),
458                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
459                    ],
460                ))->identified(),
461                'stmt',
462                'test-v1',
463            ),
464            'new-revision',
465        );
466        self::assertSame(0, $changed->snapshot()['cumulative']['reached']);
467        $recordedProductionIds = $changed->inventory()->denominator;
468        $changed->beginGeneration('stmt', []);
469        $changed->beginAttempt(0);
470        $changed->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
471        $changed->discardAttempt('lexical failure');
472        $changed->beginAttempt(1);
473        $changed->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
474        $changed->commitAttempt('sql-hash');
475        $changed->endGeneration();
476        $changed->flush();
477        $files = glob($directory . '/*.json');
478        self::assertNotFalse($files);
479        self::assertCount(2, $files);
480        unset($changed);
481        (new Filesystem())->remove($directory);
482    }
483
484    public function testInventoryRequiresProviderRegistration(): void
485    {
486        $this->expectException(CoverageException::class);
487        (new GrammarCoverage())->inventory();
488    }
489
490    public function testBeginGenerationReplacesThePreviousSuccessfulTrace(): void
491    {
492        $coverage = new GrammarCoverage(null);
493        $coverage->register(
494            new GrammarCoverageInventory(
495                (new Grammar(
496                    'stmt',
497                    [
498                        'stmt' => new ProductionRule(
499                            'stmt',
500                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
501                        ),
502                        'expr' => new ProductionRule(
503                            'expr',
504                            [
505                                new Production([new Terminal('1')]),
506                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
507                                new Production([]),
508                            ],
509                        ),
510                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
511                    ],
512                ))->identified(),
513                'stmt',
514                'test-v1',
515            ),
516            'revision-a',
517        );
518        $recordedProductionIds = $coverage->inventory()->denominator;
519        $coverage->beginGeneration('stmt', []);
520        $coverage->beginAttempt(0);
521        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
522        $coverage->discardAttempt('lexical failure');
523        $coverage->beginAttempt(1);
524        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
525        $coverage->commitAttempt('sql-hash');
526        $coverage->endGeneration();
527        $coverage->beginGeneration('missing', []);
528        $coverage->beginAttempt(0);
529        $coverage->endGeneration();
530        self::assertNotNull($coverage->lastGeneration());
531        self::assertSame('failed', $coverage->lastGeneration()['status']);
532        self::assertSame(2, $coverage->lastGeneration()['generationId']);
533        self::assertSame([], $coverage->lastGeneration()['reachedIds']);
534    }
535
536    public function testDiscardAttemptPreservesFailedReachability(): void
537    {
538        $coverage = new GrammarCoverage(null);
539        $coverage->register(
540            new GrammarCoverageInventory(
541                (new Grammar(
542                    'stmt',
543                    [
544                        'stmt' => new ProductionRule(
545                            'stmt',
546                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
547                        ),
548                        'expr' => new ProductionRule(
549                            'expr',
550                            [
551                                new Production([new Terminal('1')]),
552                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
553                                new Production([]),
554                            ],
555                        ),
556                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
557                    ],
558                ))->identified(),
559                'stmt',
560                'test-v1',
561            ),
562            'revision-a',
563        );
564        $recordedProductionIds = $coverage->inventory()->denominator;
565        $coverage->beginGeneration('stmt', []);
566        $coverage->beginAttempt(0);
567        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
568        $coverage->discardAttempt('lexical failure');
569        $coverage->beginAttempt(1);
570        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
571        $coverage->commitAttempt('sql-hash');
572        $coverage->endGeneration();
573        self::assertNotNull($coverage->lastGeneration());
574        self::assertSame('discarded', $coverage->lastGeneration()['attempts'][0]['status']);
575        self::assertSame('lexical failure', $coverage->lastGeneration()['attempts'][0]['error']);
576    }
577
578    public function testRecordIncludesOccurrenceParentsAndOriginalPositions(): void
579    {
580        $coverage = new GrammarCoverage(null);
581        $coverage->register(
582            new GrammarCoverageInventory(
583                (new Grammar(
584                    'stmt',
585                    [
586                        'stmt' => new ProductionRule(
587                            'stmt',
588                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
589                        ),
590                        'expr' => new ProductionRule(
591                            'expr',
592                            [
593                                new Production([new Terminal('1')]),
594                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
595                                new Production([]),
596                            ],
597                        ),
598                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
599                    ],
600                ))->identified(),
601                'stmt',
602                'test-v1',
603            ),
604            'revision-a',
605        );
606        $coverage->beginGeneration('stmt', []);
607        $coverage->beginAttempt(0);
608        $coverage->record(3, 1, 2, 'expr', $coverage->inventory()->denominator[2], 'input');
609        self::assertNotNull($coverage->lastGeneration());
610        self::assertSame(1, $coverage->lastGeneration()['attempts'][0]['events'][0]['parentNodeId']);
611        self::assertSame(2, $coverage->lastGeneration()['attempts'][0]['events'][0]['rhsPosition']);
612    }
613
614    public function testCommitAttemptAndEndGenerationCommitOnlySuccessfulPaths(): void
615    {
616        $coverage = new GrammarCoverage(null);
617        $coverage->register(
618            new GrammarCoverageInventory(
619                (new Grammar(
620                    'stmt',
621                    [
622                        'stmt' => new ProductionRule(
623                            'stmt',
624                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
625                        ),
626                        'expr' => new ProductionRule(
627                            'expr',
628                            [
629                                new Production([new Terminal('1')]),
630                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
631                                new Production([]),
632                            ],
633                        ),
634                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
635                    ],
636                ))->identified(),
637                'stmt',
638                'test-v1',
639            ),
640            'revision-a',
641        );
642        $recordedProductionIds = $coverage->inventory()->denominator;
643        $coverage->beginGeneration('stmt', []);
644        $coverage->beginAttempt(0);
645        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
646        $coverage->discardAttempt('lexical failure');
647        $coverage->beginAttempt(1);
648        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
649        $coverage->commitAttempt('sql-hash');
650        $coverage->endGeneration();
651        self::assertNotNull($coverage->lastGeneration());
652        self::assertSame('success', $coverage->lastGeneration()['status']);
653        self::assertSame('sql-hash', $coverage->lastGeneration()['attempts'][1]['sqlHash']);
654        self::assertFalse($coverage->snapshot()['checkpoint']['generationInProgress']);
655    }
656
657    public function testLastGenerationStartsEmptyWithoutStorage(): void
658    {
659        $coverage = new GrammarCoverage(null);
660        $coverage->register(
661            new GrammarCoverageInventory(
662                (new Grammar(
663                    'stmt',
664                    [
665                        'stmt' => new ProductionRule(
666                            'stmt',
667                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
668                        ),
669                        'expr' => new ProductionRule(
670                            'expr',
671                            [
672                                new Production([new Terminal('1')]),
673                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
674                                new Production([]),
675                            ],
676                        ),
677                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
678                    ],
679                ))->identified(),
680                'stmt',
681                'test-v1',
682            ),
683            'revision-a',
684        );
685        self::assertNull($coverage->lastGeneration());
686        $coverage->flush();
687        self::assertSame(0, $coverage->snapshot()['cumulative']['reached']);
688    }
689
690    public function testBeginAttemptRetainsAnEmptyAttemptBeforeAnyProductionIsSelected(): void
691    {
692        $coverage = new GrammarCoverage(null);
693        $coverage->register(
694            new GrammarCoverageInventory(
695                (new Grammar(
696                    'stmt',
697                    [
698                        'stmt' => new ProductionRule(
699                            'stmt',
700                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
701                        ),
702                        'expr' => new ProductionRule(
703                            'expr',
704                            [
705                                new Production([new Terminal('1')]),
706                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
707                                new Production([]),
708                            ],
709                        ),
710                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
711                    ],
712                ))->identified(),
713                'stmt',
714                'test-v1',
715            ),
716            'revision-a',
717        );
718        $coverage->beginGeneration('stmt', []);
719        $coverage->beginAttempt(0);
720        self::assertNotNull($coverage->lastGeneration());
721        self::assertSame([], $coverage->lastGeneration()['attempts'][0]['events']);
722    }
723
724    public function testEndGenerationPreservesFailureBeforeAnyAttemptWasOpened(): void
725    {
726        $coverage = new GrammarCoverage(null);
727        $coverage->register(
728            new GrammarCoverageInventory(
729                (new Grammar(
730                    'stmt',
731                    [
732                        'stmt' => new ProductionRule(
733                            'stmt',
734                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
735                        ),
736                        'expr' => new ProductionRule(
737                            'expr',
738                            [
739                                new Production([new Terminal('1')]),
740                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
741                                new Production([]),
742                            ],
743                        ),
744                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
745                    ],
746                ))->identified(),
747                'stmt',
748                'test-v1',
749            ),
750            'revision-a',
751        );
752        $coverage->beginGeneration('stmt', []);
753        $coverage->endGeneration();
754        self::assertNotNull($coverage->lastGeneration());
755        self::assertSame('failed', $coverage->lastGeneration()['status']);
756        self::assertSame([], $coverage->lastGeneration()['attempts']);
757    }
758
759    /**
760     * @throws RuntimeException
761     */
762
763    public function testRestoreRejectsCorruptHistoryOnEveryRegistrationAttemptWithoutOverwritingIt(): void
764    {
765        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
766        (new Filesystem())->mkdir($directory);
767        $grammar = (new Grammar('stmt', ['stmt' => new ProductionRule('stmt', [new Production([new Terminal('SELECT')])])]))->identified();
768        $inventory = new GrammarCoverageInventory($grammar, 'stmt', 'test-v1');
769        $coverage = new GrammarCoverage($directory);
770        $coverage->register($inventory, 'revision-a');
771        $coverage->beginGeneration('stmt', []);
772        $coverage->beginAttempt(0);
773        $coverage->record(0, null, null, 'stmt', $inventory->denominator[0], 'input');
774        $coverage->commitAttempt('sql-hash');
775        $coverage->endGeneration();
776        $coverage->flush();
777        $files = glob($directory . '/*.json');
778        self::assertIsArray($files);
779        self::assertCount(1, $files);
780        unset($coverage);
781        file_put_contents($files[0], '{broken');
782        $coverage = new GrammarCoverage($directory);
783        try {
784            try {
785                $coverage->register($inventory, 'revision-a');
786                self::fail('Corrupt snapshot was accepted.');
787            } catch (CoverageException $failure) {
788                self::assertNotSame('', $failure->getMessage());
789            }
790            try {
791                $coverage->register($inventory, 'revision-a');
792                self::fail('Corrupt snapshot was accepted on the second registration.');
793            } catch (CoverageException $failure) {
794                self::assertNotSame('', $failure->getMessage());
795            }
796            self::assertSame('{broken', file_get_contents($files[0]));
797        } finally {
798            unset($coverage);
799            (new Filesystem())->remove($directory);
800        }
801    }
802
803    public function testSnapshotPublishesTheRegisteredInventoryAndCurrentRunIdentity(): void
804    {
805        $coverage = new GrammarCoverage(null);
806        $coverage->register(
807            new GrammarCoverageInventory(
808                (new Grammar(
809                    'stmt',
810                    [
811                        'stmt' => new ProductionRule(
812                            'stmt',
813                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
814                        ),
815                        'expr' => new ProductionRule(
816                            'expr',
817                            [
818                                new Production([new Terminal('1')]),
819                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
820                                new Production([]),
821                            ],
822                        ),
823                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
824                    ],
825                ))->identified(),
826                'stmt',
827                'test-v1',
828            ),
829            'revision-a',
830        );
831        $inventory = $coverage->inventory();
832        $snapshot = $coverage->snapshot();
833        self::assertSame(1, $snapshot['formatVersion']);
834        self::assertSame($inventory->fingerprint, $snapshot['grammarFingerprint']);
835        self::assertSame($inventory->digest, $snapshot['inventoryDigest']);
836        self::assertSame('revision-a', $snapshot['generatorRevision']);
837        self::assertSame('stmt', $snapshot['root']);
838        self::assertSame($inventory->denominator, $snapshot['denominatorIds']);
839        self::assertSame($inventory->entries, $snapshot['inventory']);
840        self::assertSame([], $snapshot['adaptations']);
841        self::assertNull($snapshot['restoredCheckpoint']);
842        self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $snapshot['checkpoint']['runId']);
843        $coverage->beginGeneration('expr', ['budget' => 7]);
844        self::assertTrue($coverage->snapshot()['checkpoint']['generationInProgress']);
845        self::assertSame($snapshot['checkpoint']['runId'], $coverage->snapshot()['checkpoint']['runId']);
846        $coverage->reset();
847        self::assertFalse($coverage->snapshot()['checkpoint']['generationInProgress']);
848    }
849
850    public function testReadingAMemorySnapshotDoesNotSaveUnflushedObservations(): void
851    {
852        $coverage = new GrammarCoverage(null);
853        $coverage->register(
854            new GrammarCoverageInventory(
855                (new Grammar(
856                    'stmt',
857                    [
858                        'stmt' => new ProductionRule(
859                            'stmt',
860                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
861                        ),
862                        'expr' => new ProductionRule(
863                            'expr',
864                            [
865                                new Production([new Terminal('1')]),
866                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
867                                new Production([]),
868                            ],
869                        ),
870                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
871                    ],
872                ))->identified(),
873                'stmt',
874                'test-v1',
875            ),
876            'revision-a',
877        );
878        $recordedProductionIds = $coverage->inventory()->denominator;
879        $coverage->beginGeneration('stmt', []);
880        $coverage->beginAttempt(0);
881        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
882        $coverage->discardAttempt('lexical failure');
883        $coverage->beginAttempt(1);
884        $coverage->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
885        $coverage->commitAttempt('sql-hash');
886        $coverage->endGeneration();
887        self::assertSame(2, $coverage->snapshot()['cumulative']['reached']);
888        $coverage->flush();
889        $coverage->reset();
890        self::assertSame(0, $coverage->snapshot()['cumulative']['reached']);
891        self::assertSame(0, $coverage->snapshot()['cumulative']['emitted']);
892    }
893
894    public function testHooksWithoutAGenerationKeepTheTraceEmpty(): void
895    {
896        $coverage = new GrammarCoverage(null);
897        $coverage->register(
898            new GrammarCoverageInventory(
899                (new Grammar(
900                    'stmt',
901                    [
902                        'stmt' => new ProductionRule(
903                            'stmt',
904                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
905                        ),
906                        'expr' => new ProductionRule(
907                            'expr',
908                            [
909                                new Production([new Terminal('1')]),
910                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
911                                new Production([]),
912                            ],
913                        ),
914                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
915                    ],
916                ))->identified(),
917                'stmt',
918                'test-v1',
919            ),
920            'revision-a',
921        );
922        $coverage->restore();
923        $coverage->beginAttempt(0);
924        $coverage->discardAttempt('no active generation');
925        $coverage->commitAttempt('hash');
926        $coverage->endGeneration();
927        $coverage->record(0, null, null, 'stmt', $coverage->inventory()->denominator[0], 'input');
928        self::assertNull($coverage->lastGeneration());
929        self::assertSame(1, $coverage->snapshot()['current']['reached']);
930        self::assertSame(0, $coverage->snapshot()['current']['emitted']);
931    }
932
933    public function testFailedReachabilityAndLaterEmissionAreSavedAsSeparateDiscoveries(): void
934    {
935        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
936        (new Filesystem())->mkdir($directory);
937        $coverage = new GrammarCoverage($directory);
938        $coverage->register(
939            new GrammarCoverageInventory(
940                (new Grammar(
941                    'stmt',
942                    [
943                        'stmt' => new ProductionRule(
944                            'stmt',
945                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
946                        ),
947                        'expr' => new ProductionRule(
948                            'expr',
949                            [
950                                new Production([new Terminal('1')]),
951                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
952                                new Production([]),
953                            ],
954                        ),
955                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
956                    ],
957                ))->identified(),
958                'stmt',
959                'test-v1',
960            ),
961            'revision-a',
962        );
963        $path = $directory . '/' . hash('sha256', '1:' . $coverage->inventory()->fingerprint . ':revision-a') . '.json';
964        $id = $coverage->inventory()->denominator[0];
965        $coverage->beginGeneration('stmt', []);
966        $coverage->beginAttempt(0);
967        $coverage->record(0, null, null, 'stmt', $id, 'input');
968        $coverage->discardAttempt('failed');
969        $coverage->endGeneration();
970        $coverage->flush();
971        self::assertFileExists($path);
972        $first = file_get_contents($path);
973        self::assertNotFalse($first);
974        self::assertStringContainsString("\n", $first);
975        unset($coverage);
976        $restored = new GrammarCoverage($directory);
977        $restored->register(
978            new GrammarCoverageInventory(
979                (new Grammar(
980                    'stmt',
981                    [
982                        'stmt' => new ProductionRule(
983                            'stmt',
984                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
985                        ),
986                        'expr' => new ProductionRule(
987                            'expr',
988                            [
989                                new Production([new Terminal('1')]),
990                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
991                                new Production([]),
992                            ],
993                        ),
994                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
995                    ],
996                ))->identified(),
997                'stmt',
998                'test-v1',
999            ),
1000            'revision-a',
1001        );
1002        self::assertSame(1, $restored->snapshot()['cumulative']['reached']);
1003        self::assertSame(0, $restored->snapshot()['cumulative']['emitted']);
1004        self::assertNotNull($restored->snapshot()['restoredCheckpoint']);
1005        $restored->flush();
1006        self::assertSame($first, file_get_contents($path));
1007        $restored->beginGeneration('stmt', []);
1008        $restored->beginAttempt(0);
1009        $restored->record(0, null, null, 'stmt', $id, 'input');
1010        $restored->flush();
1011        self::assertSame($first, file_get_contents($path));
1012        $restored->commitAttempt('success');
1013        $restored->endGeneration();
1014        $restored->flush();
1015        $emitted = file_get_contents($path);
1016        self::assertNotSame($first, $emitted);
1017        $restored->beginGeneration('stmt', []);
1018        $restored->beginAttempt(0);
1019        $restored->record(0, null, null, 'stmt', $id, 'input');
1020        $restored->commitAttempt('same production');
1021        $restored->endGeneration();
1022        $restored->flush();
1023        self::assertSame($emitted, file_get_contents($path));
1024        unset($restored);
1025        $final = new GrammarCoverage($directory);
1026        $final->register(
1027            new GrammarCoverageInventory(
1028                (new Grammar(
1029                    'stmt',
1030                    [
1031                        'stmt' => new ProductionRule(
1032                            'stmt',
1033                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1034                        ),
1035                        'expr' => new ProductionRule(
1036                            'expr',
1037                            [
1038                                new Production([new Terminal('1')]),
1039                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1040                                new Production([]),
1041                            ],
1042                        ),
1043                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1044                    ],
1045                ))->identified(),
1046                'stmt',
1047                'test-v1',
1048            ),
1049            'revision-a',
1050        );
1051        self::assertSame(1, $final->snapshot()['cumulative']['emitted']);
1052        unset($final);
1053        (new Filesystem())->remove($directory);
1054    }
1055
1056    public function testMergePreservesDisjointHistoryAndDoesNotResaveIdenticalHistory(): void
1057    {
1058        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
1059        (new Filesystem())->mkdir($directory);
1060        $coverage = new GrammarCoverage($directory);
1061        $coverage->register(
1062            new GrammarCoverageInventory(
1063                (new Grammar(
1064                    'stmt',
1065                    [
1066                        'stmt' => new ProductionRule(
1067                            'stmt',
1068                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1069                        ),
1070                        'expr' => new ProductionRule(
1071                            'expr',
1072                            [
1073                                new Production([new Terminal('1')]),
1074                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1075                                new Production([]),
1076                            ],
1077                        ),
1078                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1079                    ],
1080                ))->identified(),
1081                'stmt',
1082                'test-v1',
1083            ),
1084            'revision-a',
1085        );
1086        $path = $directory . '/' . hash('sha256', '1:' . $coverage->inventory()->fingerprint . ':revision-a') . '.json';
1087        $other = new GrammarCoverage(null);
1088        $other->register(
1089            new GrammarCoverageInventory(
1090                (new Grammar(
1091                    'stmt',
1092                    [
1093                        'stmt' => new ProductionRule(
1094                            'stmt',
1095                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1096                        ),
1097                        'expr' => new ProductionRule(
1098                            'expr',
1099                            [
1100                                new Production([new Terminal('1')]),
1101                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1102                                new Production([]),
1103                            ],
1104                        ),
1105                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1106                    ],
1107                ))->identified(),
1108                'stmt',
1109                'test-v1',
1110            ),
1111            'revision-a',
1112        );
1113        $recordedProductionIds = $other->inventory()->denominator;
1114        $other->beginGeneration('stmt', []);
1115        $other->beginAttempt(0);
1116        $other->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
1117        $other->discardAttempt('lexical failure');
1118        $other->beginAttempt(1);
1119        $other->record(0, null, null, 'stmt', $recordedProductionIds[1], 'input');
1120        $other->commitAttempt('sql-hash');
1121        $other->endGeneration();
1122        $coverage->merge($other->snapshot());
1123        $coverage->flush();
1124        self::assertFileExists($path);
1125        $first = file_get_contents($path);
1126        $coverage->beginGeneration('stmt', []);
1127        $coverage->merge($other->snapshot());
1128        $coverage->flush();
1129        self::assertSame($first, file_get_contents($path));
1130        $other->reset();
1131        $id = $other->inventory()->denominator[2];
1132        $other->beginGeneration('expr', []);
1133        $other->beginAttempt(0);
1134        $other->record(0, null, null, 'expr', $id, 'input');
1135        $other->commitAttempt('other');
1136        $other->endGeneration();
1137        $coverage->merge($other->snapshot());
1138        self::assertSame(3, $coverage->snapshot()['cumulative']['reached']);
1139        self::assertSame(2, $coverage->snapshot()['cumulative']['emitted']);
1140        $coverage->flush();
1141        unset($coverage);
1142        $restored = new GrammarCoverage($directory);
1143        $restored->register(
1144            new GrammarCoverageInventory(
1145                (new Grammar(
1146                    'stmt',
1147                    [
1148                        'stmt' => new ProductionRule(
1149                            'stmt',
1150                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1151                        ),
1152                        'expr' => new ProductionRule(
1153                            'expr',
1154                            [
1155                                new Production([new Terminal('1')]),
1156                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1157                                new Production([]),
1158                            ],
1159                        ),
1160                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1161                    ],
1162                ))->identified(),
1163                'stmt',
1164                'test-v1',
1165            ),
1166            'revision-a',
1167        );
1168        self::assertSame(3, $restored->snapshot()['cumulative']['reached']);
1169        self::assertSame(2, $restored->snapshot()['cumulative']['emitted']);
1170        unset($restored);
1171        (new Filesystem())->remove($directory);
1172    }
1173
1174    public function testRecordSequenceKeepsTransformedSelectionsOutOfPreservedOutputCoverage(): void
1175    {
1176        $coverage = new GrammarCoverage(null);
1177        $coverage->register(
1178            new GrammarCoverageInventory(
1179                (new Grammar(
1180                    'stmt',
1181                    [
1182                        'stmt' => new ProductionRule(
1183                            'stmt',
1184                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1185                        ),
1186                        'expr' => new ProductionRule(
1187                            'expr',
1188                            [
1189                                new Production([new Terminal('1')]),
1190                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1191                                new Production([]),
1192                            ],
1193                        ),
1194                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1195                    ],
1196                ))->identified(),
1197                'stmt',
1198                'test-v1',
1199            ),
1200            'revision-a',
1201        );
1202        $grammar = (new Grammar(
1203            'stmt',
1204            [
1205                'stmt' => new ProductionRule(
1206                    'stmt',
1207                    [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1208                ),
1209                'expr' => new ProductionRule(
1210                    'expr',
1211                    [
1212                        new Production([new Terminal('1')]),
1213                        new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1214                        new Production([]),
1215                    ],
1216                ),
1217                'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1218            ],
1219        ))->identified();
1220        $trace = new DerivationTrace('stmt');
1221        $trace->expand(0, $grammar->ruleMap['stmt']->alternatives[0], 0);
1222        $trace->expand(1, $grammar->ruleMap['expr']->alternatives[0], 0);
1223        $sequence = $trace->terminals();
1224        $changed = $sequence->replace(0, 1, [$sequence->terminals[0]->replaced('OTHER', 'fixture')], 'fixture');
1225        $coverage->beginGeneration('stmt', []);
1226        $coverage->beginAttempt(0);
1227        $coverage->recordSequence($changed);
1228        $coverage->commitAttempt('sql-hash', (new SequenceObservation())->preserved($changed));
1229        $coverage->endGeneration();
1230        self::assertSame(2, $coverage->snapshot()['current']['reached']);
1231        self::assertSame(1, $coverage->snapshot()['current']['emitted']);
1232        $trace = $coverage->lastGeneration();
1233        self::assertNotNull($trace);
1234        self::assertSame(['fixture'], $trace['rewrites']);
1235    }
1236
1237    public function testRecordOutputExposesCandidateAndBoundaryDecisions(): void
1238    {
1239        $coverage = new GrammarCoverage(null);
1240        $coverage->register(
1241            new GrammarCoverageInventory(
1242                (new Grammar(
1243                    'stmt',
1244                    [
1245                        'stmt' => new ProductionRule(
1246                            'stmt',
1247                            [new Production([new Terminal('SELECT'), new NonTerminal('expr')]), new Production([new Terminal('DELETE')])],
1248                        ),
1249                        'expr' => new ProductionRule(
1250                            'expr',
1251                            [
1252                                new Production([new Terminal('1')]),
1253                                new Production([new NonTerminal('expr'), new Terminal('+'), new NonTerminal('expr')]),
1254                                new Production([]),
1255                            ],
1256                        ),
1257                        'outside' => new ProductionRule('outside', [new Production([new Terminal('OUTSIDE')])]),
1258                    ],
1259                ))->identified(),
1260                'stmt',
1261                'test-v1',
1262            ),
1263            'revision-a',
1264        );
1265        $coverage->beginGeneration('stmt', []);
1266        $coverage->recordOutput(
1267            (new ReverseLexemeGenerator(
1268                new FixedLexemeGenerator('SELECT', 'fixture', 'fixture-literal'),
1269                new CandidateResolver(new CombinedSpacingRule()),
1270                'fixture',
1271            ))->generate(TerminalSequence::fromNames(['SELECT']), null, static fn (int $count): int => 0),
1272        );
1273        $trace = $coverage->lastGeneration();
1274        self::assertNotNull($trace);
1275        self::assertCount(1, $trace['lexicalEvents']);
1276        self::assertSame('', $trace['spacingEvents'][0]['separator']);
1277        self::assertSame([], $trace['spacingEvents'][0]['rules']);
1278    }
1279
1280    public function testEndGenerationSavesUnsavedDiscoveriesEveryHundredGenerations(): void
1281    {
1282        $directory = sys_get_temp_dir() . '/sql-faker-coverage-' . bin2hex(random_bytes(8));
1283        (new Filesystem())->mkdir($directory);
1284        $coverage = new GrammarCoverage($directory);
1285        $coverage->register(
1286            new GrammarCoverageInventory(
1287                (new Grammar(
1288                    'stmt',
1289                    ['stmt' => new ProductionRule('stmt', [new Production([new Terminal('SELECT')]), new Production([new Terminal('DELETE')])])],
1290                ))->identified(),
1291                'stmt',
1292                'test-v1',
1293            ),
1294            'revision-a',
1295        );
1296        $recordedProductionIds = $coverage->inventory()->denominator;
1297        $generate = static function (int $generation) use ($coverage, $recordedProductionIds): void {
1298            $coverage->beginGeneration('stmt', []);
1299            $coverage->beginAttempt(0);
1300            $coverage->record(0, null, null, 'stmt', $recordedProductionIds[0], 'input');
1301            $coverage->commitAttempt('sql-hash-' . $generation);
1302            $coverage->endGeneration();
1303        };
1304        array_map($generate, range(1, GrammarCoverage::FLUSH_INTERVAL - 1));
1305        self::assertSame([], glob($directory . '/*.json'));
1306        $generate(GrammarCoverage::FLUSH_INTERVAL);
1307        $snapshots = glob($directory . '/*.json');
1308        self::assertNotFalse($snapshots);
1309        self::assertCount(1, $snapshots);
1310        unset($coverage, $generate);
1311        $restored = new GrammarCoverage($directory);
1312        $restored->register(
1313            new GrammarCoverageInventory(
1314                (new Grammar(
1315                    'stmt',
1316                    ['stmt' => new ProductionRule('stmt', [new Production([new Terminal('SELECT')]), new Production([new Terminal('DELETE')])])],
1317                ))->identified(),
1318                'stmt',
1319                'test-v1',
1320            ),
1321            'revision-a',
1322        );
1323        self::assertSame(1, $restored->snapshot()['cumulative']['reached']);
1324        unset($restored);
1325        (new Filesystem())->remove($directory);
1326    }
1327}
1328