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