packages/requirements/tests/Unit/Source/TextSourceTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Source;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Small;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use Requirements\Model\Source;
13use Requirements\Source\LocalFile;
14use Requirements\Source\ResourceLoader;
15use Requirements\Source\ResourceLocation;
16use Requirements\Source\TextSource;
17use Requirements\Source\Unit;
18use RuntimeException;
19use Tests\Fake\ProjectDirectory;
20use Tests\Fake\SourceDocuments;
21
22#[CoversClass(TextSource::class)]
23#[UsesClass(LocalFile::class)]
24#[UsesClass(ResourceLoader::class)]
25#[UsesClass(ResourceLocation::class)]
26#[UsesClass(Source::class)]
27#[UsesClass(Unit::class)]
28#[Small]
29final class TextSourceTest extends TestCase
30{
31    public function testSelectStableDistinctUnits(): void
32    {
33        $project = new ProjectDirectory();
34        $project->put('source.md', SourceDocuments::MARKDOWN);
35        $source = new Source('test', 'source.md', 'text', 'lines:1-5');
36        $extension = new TextSource();
37        $units = $extension->select($source, 'lines:1-5', $project->directory, false);
38
39        self::assertCount(3, $units);
40        self::assertCount(3, array_unique(array_map(static fn (Unit $unit): string => $unit->location, $units)));
41        self::assertSame($units[0]->location, $extension->select($source, 'lines:1-5', $project->directory, false)[0]->location);
42    }
43
44    /**
45     * @param list<Unit> $expected
46     */
47    #[DataProvider('providerSelectUnits')]
48    public function testSelectLocatesNonblankLines(string $document, string $selector, array $expected): void
49    {
50        $project = new ProjectDirectory();
51        $project->put('source.txt', $document);
52
53        self::assertEquals($expected, (new TextSource())->select(new Source('test', 'source.txt', 'text', $selector), $selector, $project->directory, false));
54    }
55
56    /**
57     * @return array<string, array{string, string, list<Unit>}>
58     */
59    public static function providerSelectUnits(): array
60    {
61        return [
62            'range' => [SourceDocuments::MARKDOWN, 'lines:1-5', [
63                new Unit('line:1', '# Rules'),
64                new Unit('line:3', 'First **rule**.'),
65                new Unit('line:5', 'Second rule.'),
66            ]],
67            'single line' => [SourceDocuments::MARKDOWN, 'lines:3', [new Unit('line:3', 'First **rule**.')]],
68            'one-line range' => [SourceDocuments::MARKDOWN, 'lines:3-3', [new Unit('line:3', 'First **rule**.')]],
69            'blank line' => [SourceDocuments::MARKDOWN, 'lines:2', []],
70            'range ending at the last line' => [SourceDocuments::MARKDOWN, 'lines:5-6', [new Unit('line:5', 'Second rule.')]],
71            'normalized' => ["  First\t rule. \n", 'lines:1', [new Unit('line:1', 'First rule.')]],
72            'line endings' => ["First.\r\nSecond.\rThird.\nFourth.", 'lines:1-4', [
73                new Unit('line:1', 'First.'),
74                new Unit('line:2', 'Second.'),
75                new Unit('line:3', 'Third.'),
76                new Unit('line:4', 'Fourth.'),
77            ]],
78            'double-digit lines' => [str_repeat("Rule.\n", 11), 'lines:10-11', [new Unit('line:10', 'Rule.'), new Unit('line:11', 'Rule.')]],
79        ];
80    }
81
82    #[DataProvider('providerSelectMalformed')]
83    public function testSelectRejectsMalformedSelectors(string $selector): void
84    {
85        $project = new ProjectDirectory();
86
87        $this->expectException(RuntimeException::class);
88        $this->expectExceptionMessage('Text selectors use lines:START-END or lines:NUMBER.');
89        (new TextSource())->select(new Source('test', 'missing.txt', 'text', $selector), $selector, $project->directory, false);
90    }
91
92    /**
93     * @return array<string, array{string}>
94     */
95    public static function providerSelectMalformed(): array
96    {
97        return [
98            'zero' => ['lines:0'],
99            'leading zero' => ['lines:01'],
100            'open range' => ['lines:1-'],
101            'zero end' => ['lines:1-0'],
102            'singular' => ['line:1'],
103            'prefix' => ['all lines:1'],
104            'suffix' => ['lines:1-2x'],
105            'trailing newline' => ["lines:1\n"],
106            'css' => ['main p'],
107        ];
108    }
109
110    #[DataProvider('providerSelectOutside')]
111    public function testSelectRejectsRangesOutsideTheSource(string $selector): void
112    {
113        $project = new ProjectDirectory();
114        $project->put('source.md', SourceDocuments::MARKDOWN);
115
116        $this->expectException(RuntimeException::class);
117        $this->expectExceptionMessage('Text line range is outside the source.');
118        (new TextSource())->select(new Source('test', 'source.md', 'text', $selector), $selector, $project->directory, false);
119    }
120
121    /**
122     * @return array<string, array{string}>
123     */
124    public static function providerSelectOutside(): array
125    {
126        return [
127            'reversed' => ['lines:3-2'],
128            'past the end' => ['lines:1-7'],
129            'single line past the end' => ['lines:7'],
130        ];
131    }
132
133    public function testSelectReadsThroughTheGivenLoader(): void
134    {
135        $project = new ProjectDirectory();
136        $project->put('source.txt', 'Original.');
137        $loader = new ResourceLoader();
138        $loader->fetch($project->path('source.txt'));
139        $project->put('source.txt', 'Changed.');
140
141        self::assertEquals([new Unit('line:1', 'Original.')], (new TextSource($loader))->select(new Source('test', 'source.txt', 'text', 'lines:1'), 'lines:1', $project->directory, false));
142    }
143}
144