packages/requirements/tests/Unit/Source/TextFragmentTest.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\Input\InvalidInputException;
13use Requirements\Source\TextFragment;
14use Requirements\Source\Unit;
15
16#[CoversClass(TextFragment::class)]
17#[UsesClass(Unit::class)]
18#[Small]
19final class TextFragmentTest extends TestCase
20{
21    #[DataProvider('providerIsFragment')]
22    public function testIsFragment(string $selector, bool $expected): void
23    {
24        self::assertSame($expected, TextFragment::isFragment($selector));
25    }
26
27    /**
28     * @return array<string, array{string, bool}>
29     */
30    public static function providerIsFragment(): array
31    {
32        return [
33            'directive' => ['#:~:text=Names', true],
34            'element fragment before directive' => ['#example:~:text=Names', true],
35            'malformed directive' => ['#:~:', true],
36            'css id' => ['#a', false],
37            'css selector' => ['main p', false],
38            'directive without hash' => [':~:text=Names', false],
39            'hash later' => ['main #:~:text=Names', false],
40        ];
41    }
42
43    public function testCreateDirectiveEncodingPreservesSyntaxCharactersAndUnicode(): void
44    {
45        $text = 'UTF-8, A&B + "日本語"';
46        $fragment = TextFragment::create($text);
47
48        self::assertSame('#:~:text=UTF%2D8%2C%20A%26B%20%2B%20%22%E6%97%A5%E6%9C%AC%E8%AA%9E%22', $fragment);
49        self::assertSame($text, TextFragment::text($fragment));
50        self::assertSame($text, TextFragment::text(str_replace('#:', '#example:', $fragment)));
51    }
52
53    public function testCreateNormalizesTheText(): void
54    {
55        self::assertSame('#:~:text=Names%20start.', TextFragment::create("  Names\n start. "));
56    }
57
58    #[DataProvider('providerTextDecodes')]
59    public function testTextDecodesAndNormalizes(string $fragment, string $expected): void
60    {
61        self::assertSame($expected, TextFragment::text($fragment));
62    }
63
64    /**
65     * @return array<string, array{string, string}>
66     */
67    public static function providerTextDecodes(): array
68    {
69        return [
70            'plain' => ['#:~:text=Names', 'Names'],
71            'encoded hyphen' => ['#:~:text=a%2Db', 'a-b'],
72            'lowercase escape' => ['#:~:text=a%2db', 'a-b'],
73            'whitespace normalized' => ['#:~:text=%20Names%20%20start%0A', 'Names start'],
74            'raw unicode' => ['#:~:text=日本語', '日本語'],
75            'plus kept' => ['#:~:text=a+b', 'a+b'],
76        ];
77    }
78
79    #[DataProvider('providerTextUnsupported')]
80    public function testTextUnsupportedOrMalformedDirectivesFailExplicitly(string $fragment): void
81    {
82        $this->expectException(InvalidInputException::class);
83        TextFragment::text($fragment);
84    }
85
86    /**
87     * @return list<array{string}>
88     */
89    public static function providerTextUnsupported(): array
90    {
91        return [
92            ['#:~:text='],
93            ['#:~:text=start,end'],
94            ['#:~:text=prefix-,start'],
95            ['#:~:text=start,-suffix'],
96            ['#:~:text=first&text=second'],
97            ['#:~:text=bad%xx'],
98            ['#:~:text=%FF'],
99            ['#:~:text=%20'],
100            ['#text=missing-directive-marker'],
101        ];
102    }
103
104    #[DataProvider('providerTextNotOneExactDirective')]
105    public function testTextRejectsAnythingButOneExactDirective(string $fragment): void
106    {
107        $this->expectException(InvalidInputException::class);
108        $this->expectExceptionMessage('Use one exact Text Fragment (#:~:text=percent-encoded-text); ranges, context terms and multiple directives are unsupported.');
109        TextFragment::text($fragment);
110    }
111
112    /**
113     * @return array<string, array{string}>
114     */
115    public static function providerTextNotOneExactDirective(): array
116    {
117        return [
118            'empty text' => ['#:~:text='],
119            'range' => ['#:~:text=start,end'],
120            'prefix' => ['#:~:text=prefix-,start'],
121            'several directives' => ['#:~:text=first&text=second'],
122            'bad escape' => ['#:~:text=bad%xx'],
123            'truncated escape' => ['#:~:text=bad%2'],
124            'second hash' => ['#a#:~:text=Names'],
125            'text before hash' => ['main#:~:text=Names'],
126            'other directive' => ['#:~:note=Names'],
127            'raw invalid UTF-8' => ["#:~:text=\xFF"],
128        ];
129    }
130
131    #[DataProvider('providerTextEmptyOrInvalid')]
132    public function testTextRejectsEmptyOrInvalidUtf8(string $fragment): void
133    {
134        $this->expectException(InvalidInputException::class);
135        $this->expectExceptionMessage('Text Fragment text must be nonempty UTF-8.');
136        TextFragment::text($fragment);
137    }
138
139    /**
140     * @return array<string, array{string}>
141     */
142    public static function providerTextEmptyOrInvalid(): array
143    {
144        return [
145            'invalid UTF-8' => ['#:~:text=%FF'],
146            'blank' => ['#:~:text=%20'],
147            'no-break space' => ['#:~:text=%C2%A0'],
148        ];
149    }
150}
151