packages/deriver/tests/Unit/Deriver/Sources/SourcesTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Deriver\Sources;
6
7use Deriver\Deriver\Sources\Path;
8use Deriver\Deriver\Sources\Source;
9use Deriver\Deriver\Sources\Sources;
10use Deriver\Exception\InvalidInputException;
11use OutOfBoundsException;
12use PHPUnit\Framework\Attributes\CoversClass;
13use PHPUnit\Framework\Attributes\DataProvider;
14use PHPUnit\Framework\Attributes\Small;
15use PHPUnit\Framework\Attributes\UsesClass;
16use PHPUnit\Framework\TestCase;
17use RuntimeException;
18
19#[CoversClass(Sources::class)]
20#[UsesClass(Path::class)]
21#[UsesClass(Source::class)]
22#[Small]
23final class SourcesTest extends TestCase
24{
25    public function testOffsetGetRetainsSourceIdentityAndCallerOrderWithoutReadingFiles(): void
26    {
27        $first = new Source('z.php', '<?php throw new RuntimeException("must not execute");');
28        $second = new Source('a.php', '<?php', true);
29        $sources = new Sources([$first, $second]);
30        self::assertCount(2, $sources);
31        self::assertSame([$first, $second], iterator_to_array($sources));
32        self::assertTrue(isset($sources[0]));
33        self::assertSame($first, $sources[0]);
34        self::assertSame($second, $sources[1]);
35        self::assertArrayNotHasKey('sources', get_object_vars($sources));
36    }
37
38    public function testCountSupportsAnEmptyCollection(): void
39    {
40        $sources = new Sources([]);
41        self::assertCount(0, $sources);
42        self::assertSame([], iterator_to_array($sources));
43        self::assertFalse(isset($sources[0]));
44        self::assertNull($sources[0] ?? null);
45        $this->expectException(OutOfBoundsException::class);
46        $sources[0];
47    }
48
49    /**
50     * @param list<Source> $sources Captured source descriptions
51     */
52    #[DataProvider('providerInvalidSourcePaths')]
53    public function testRejectsEmptyAndRepeatedNormalizedSourceIdentities(array $sources): void
54    {
55        $this->expectException(InvalidInputException::class);
56        $this->expectExceptionMessage('Source paths must be nonempty and unique:');
57        new Sources($sources);
58    }
59
60    /**
61     * @return iterable<string, array{list<Source>}>
62     */
63    public static function providerInvalidSourcePaths(): iterable
64    {
65        yield 'empty' => [[new Source('', '')]];
66        yield 'normalized empty' => [[new Source('./', '')]];
67        yield 'repeated' => [[new Source('src/a.php', 'first'), new Source('src/a.php', 'second')]];
68        yield 'separator alias' => [[new Source('src/a.php', 'first'), new Source('src\\a.php', 'second')]];
69        yield 'dot alias' => [[new Source('src/a.php', 'first'), new Source('src/old/../a.php', 'second')]];
70    }
71
72    #[DataProvider('providerMissingOffsets')]
73    public function testOffsetExistsRejectsMissingAndNonIntegerOffsets(int|string|null $offset): void
74    {
75        $sources = new Sources([new Source('a.php', '')]);
76        self::assertFalse($sources->offsetExists($offset));
77        $this->expectException(OutOfBoundsException::class);
78        $sources->offsetGet($offset);
79    }
80
81    /**
82     * @return iterable<string, array{int|string|null}>
83     */
84    public static function providerMissingOffsets(): iterable
85    {
86        yield 'negative' => [-1];
87        yield 'past the end' => [1];
88        yield 'numeric string' => ['0'];
89        yield 'string' => ['missing'];
90        yield 'null' => [null];
91    }
92
93    public function testOffsetSetRejectsReplacement(): void
94    {
95        $source = new Source('a.php', '');
96        $sources = new Sources([$source]);
97        $this->expectException(RuntimeException::class);
98        try {
99            $sources[0] = new Source('b.php', '');
100        } finally {
101            self::assertCount(1, $sources);
102            self::assertSame($source, $sources[0]);
103        }
104    }
105
106    public function testOffsetSetRejectsAppending(): void
107    {
108        $sources = new Sources([]);
109        $this->expectException(RuntimeException::class);
110        try {
111            $sources[] = new Source('a.php', '');
112        } finally {
113            self::assertCount(0, $sources);
114        }
115    }
116
117    public function testOffsetUnsetRejectsRemoval(): void
118    {
119        $source = new Source('a.php', '');
120        $sources = new Sources([$source]);
121        $this->expectException(RuntimeException::class);
122        try {
123            unset($sources[0]);
124        } finally {
125            self::assertCount(1, $sources);
126            self::assertSame($source, $sources[0]);
127        }
128    }
129
130    public function testGetIteratorCannotChangeTheCollection(): void
131    {
132        $source = new Source('a.php', '');
133        $sources = new Sources([$source]);
134        $iterator = $sources->getIterator();
135        unset($iterator[0]);
136        self::assertSame($source, $sources[0]);
137        self::assertCount(1, $sources);
138    }
139}
140