Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ jobs:
- laravel: 13.*
testbench: ^11.0

# A SchemaFetcherTest failure diffs two copies of the whole Linear schema,
# which PHPUnit takes about 15 minutes to render. Leave room for that, so a
# real failure is reported rather than cancelled at the timeout.
timeout-minutes: 30
timeout-minutes: 15

name: "PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }} (${{ matrix.dependency-version }})"

Expand Down
90 changes: 90 additions & 0 deletions tests/Feature/BoundedDiffTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace Glhd\Linearavel\Tests\Feature;

use Glhd\Linearavel\Tests\Support\BoundedDiff;
use Glhd\Linearavel\Tests\TestCase;

class BoundedDiffTest extends TestCase
{
public function test_it_reports_nothing_when_both_documents_match(): void
{
$document = implode("\n", ['one', 'two', 'three']);

$this->assertSame(
'The two documents contain the same lines.',
(new BoundedDiff())->describe($document, $document)
);
}

public function test_it_names_the_changed_line_and_where_it_is(): void
{
$expected = implode("\n", ['one', 'two', 'three', 'four', 'five']);
$actual = implode("\n", ['one', 'two', 'THREE', 'four', 'five']);

$described = (new BoundedDiff())->describe($expected, $actual);

$this->assertStringContainsString('@@ expected line 3, actual line 3 @@', $described);
$this->assertStringContainsString('- three', $described);
$this->assertStringContainsString('+ THREE', $described);
$this->assertStringContainsString('1 difference(s)', $described);
}

public function test_it_lines_the_documents_up_again_after_an_insertion(): void
{
$expected = implode("\n", ['one', 'two', 'three', 'four', 'five', 'six']);
$actual = implode("\n", ['one', 'two', 'extra', 'three', 'four', 'five', 'six']);

$described = (new BoundedDiff())->describe($expected, $actual);

// An inserted line shifts everything after it, so a diff that can't re-sync
// would report every remaining line as changed
$this->assertStringContainsString('1 difference(s)', $described);
$this->assertStringContainsString('+ extra', $described);
$this->assertStringNotContainsString('- three', $described);
}

public function test_it_stops_after_the_hunk_limit(): void
{
$lines = range(1, 200);
$expected = implode("\n", array_map(fn($line) => "line {$line}", $lines));
$actual = implode("\n", array_map(fn($line) => 0 === $line % 10 ? "changed {$line}" : "line {$line}", $lines));

$described = (new BoundedDiff(max_hunks: 3))->describe($expected, $actual);

$this->assertSame(3, substr_count($described, '@@ expected line'));
$this->assertStringContainsString('More differences follow, starting at expected line 40.', $described);
}

public function test_it_gives_up_when_the_documents_never_line_up_again(): void
{
$expected = implode("\n", array_map(fn($line) => "left {$line}", range(1, 300)));
$actual = implode("\n", array_map(fn($line) => "right {$line}", range(1, 300)));

$described = (new BoundedDiff(window: 20))->describe($expected, $actual);

$this->assertStringContainsString('never line up again within 20 lines', $described);
$this->assertStringContainsString('- left 1', $described);
$this->assertStringContainsString('+ right 1', $described);
$this->assertStringContainsString('more line(s)', $described);
}

public function test_it_describes_long_documents_quickly(): void
{
$lines = array_map(fn($line) => "line {$line}", range(1, 40_000));
$expected = implode("\n", $lines);

$lines[2] = 'changed';
$actual = implode("\n", $lines);

$start = microtime(true);
$described = (new BoundedDiff())->describe($expected, $actual);
$elapsed = microtime(true) - $start;

$this->assertStringContainsString('+ changed', $described);

// A diff that weighs every line against every other takes minutes on a
// document this long, which is the whole reason this class exists
$this->assertLessThan(5, $elapsed, "Describing two 40,000 line documents took {$elapsed} seconds.");
}
}
16 changes: 13 additions & 3 deletions tests/Feature/SchemaFetcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Glhd\Linearavel\Tests\Feature;

use Glhd\Linearavel\Support\CodeGeneration\SchemaFetcher;
use Glhd\Linearavel\Tests\Support\BoundedDiff;
use Glhd\Linearavel\Tests\TestCase;
use GraphQL\GraphQL;
use GraphQL\Type\Introspection;
Expand Down Expand Up @@ -53,11 +54,20 @@ public function test_it_matches_the_schema_committed_to_this_repository(): void
$committed = file_get_contents($path);

$fetched = (new SchemaFetcher('token', 'https://example.test/graphql', $this->respondWith($committed)))->sdl();
$printed = SchemaFetcher::print(BuildSchema::build($committed, options: ['assumeValid' => true]));

// Both sides are the whole Linear schema, and PHPUnit's differ takes about
// fifteen minutes to compare two documents that long. Match on a hash, and
// only describe the first few differences when they don't match.
if (hash('xxh128', $printed) === hash('xxh128', $fetched)) {
$this->addToAssertionCount(1);

return;
}

$this->assertSame(
SchemaFetcher::print(BuildSchema::build($committed, options: ['assumeValid' => true])),
$fetched,
$this->fail(
'A sync would rewrite local.graphql even though the schema has not changed.'
."\n\n".(new BoundedDiff())->describe($printed, $fetched)
);
}

Expand Down
172 changes: 172 additions & 0 deletions tests/Support/BoundedDiff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

namespace Glhd\Linearavel\Tests\Support;

/**
* A line diff that stops early.
*
* PHPUnit's differ weighs every line of one document against every line of the
* other, which takes minutes on documents the size of the Linear schema. This
* walks both sides once, re-syncs over short runs of added or removed lines,
* and gives up after a few hunks — enough to name what drifted, cheap enough
* to print straight away.
*/
class BoundedDiff
{
/** Lines that have to line up again before a hunk is considered closed. */
protected const SYNC_LINES = 3;

public function __construct(
protected int $max_hunks = 5,
protected int $context = 2,
protected int $window = 50,
protected int $max_lines_per_side = 10,
) {
}

/** Describe the first few places two documents diverge. */
public function describe(string $expected, string $actual): string
{
$expected_lines = explode("\n", $expected);
$actual_lines = explode("\n", $actual);

$expected_count = count($expected_lines);
$actual_count = count($actual_lines);

$hunks = [];
$note = null;
$i = 0;
$j = 0;

while ($i < $expected_count || $j < $actual_count) {
if (($expected_lines[$i] ?? null) === ($actual_lines[$j] ?? null)) {
$i++;
$j++;
continue;
}

if (count($hunks) >= $this->max_hunks) {
$note = sprintf('More differences follow, starting at expected line %s.', number_format($i + 1));
break;
}

$alignment = $this->realign($expected_lines, $actual_lines, $i, $j);

if (null === $alignment) {
$hunks[] = $this->hunk($expected_lines, $actual_lines, $i, $j, $this->window, $this->window);
$note = "The two documents never line up again within {$this->window} lines, so the rest was not compared.";
break;
}

[$removed, $added] = $alignment;

$hunks[] = $this->hunk($expected_lines, $actual_lines, $i, $j, $removed, $added);

$i += $removed;
$j += $added;
}

if ([] === $hunks) {
return 'The two documents contain the same lines.';
}

$header = sprintf(
'%d difference(s) (expected %s lines, actual %s lines):',
count($hunks),
number_format($expected_count),
number_format($actual_count)
);

$body = implode("\n\n", $hunks);

return null === $note
? $header."\n\n".$body
: $header."\n\n".$body."\n\n".$note;
}

/**
* Find the nearest point at which both sides line up again, searching no
* further than the window. Returns the lines to drop from each side.
*
* @return array{int, int}|null
*/
protected function realign(array $expected, array $actual, int $i, int $j): ?array
{
for ($distance = 1; $distance <= $this->window; $distance++) {
for ($offset = 0; $offset <= $distance; $offset++) {
// Lines removed from the expected side…
if ($this->linesUp($expected, $actual, $i + $distance, $j + $offset)) {
return [$distance, $offset];
}

// …and lines added to the actual side
if ($this->linesUp($expected, $actual, $i + $offset, $j + $distance)) {
return [$offset, $distance];
}
}
}

return null;
}

/** Do both sides carry the same lines from here on (or run out together)? */
protected function linesUp(array $expected, array $actual, int $i, int $j): bool
{
for ($offset = 0; $offset < static::SYNC_LINES; $offset++) {
$left = $expected[$i + $offset] ?? null;
$right = $actual[$j + $offset] ?? null;

if (null === $left && null === $right) {
return true;
}

if ($left !== $right) {
return false;
}
}

return true;
}

/** Render one hunk, with line numbers and a little context either side. */
protected function hunk(array $expected, array $actual, int $i, int $j, int $removed, int $added): string
{
$lines = [sprintf('@@ expected line %s, actual line %s @@', number_format($i + 1), number_format($j + 1))];

foreach (array_slice($expected, max(0, $i - $this->context), min($i, $this->context)) as $line) {
$lines[] = ' '.$line;
}

foreach ($this->body($expected, $i, $removed) as $line) {
$lines[] = $line;
}

foreach ($this->body($actual, $j, $added, '+') as $line) {
$lines[] = $line;
}

foreach (array_slice($expected, $i + $removed, $this->context) as $line) {
$lines[] = ' '.$line;
}

return implode("\n", $lines);
}

/** @return array<int, string> */
protected function body(array $source, int $from, int $count, string $marker = '-'): array
{
$count = min($count, max(0, count($source) - $from));
$shown = min($count, $this->max_lines_per_side);

$lines = array_map(
fn($line) => $marker.' '.$line,
array_slice($source, $from, $shown)
);

if ($count > $shown) {
$lines[] = $marker.' ... '.number_format($count - $shown).' more line(s)';
}

return $lines;
}
}
Loading