packages/requirements/tests/Unit/Report/UnitCollectorTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Report;
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\Report\SourceUnit;
14use Requirements\Report\UnitCollector;
15use Requirements\Source\Unit;
16use RuntimeException;
17
18#[CoversClass(UnitCollector::class)]
19#[UsesClass(SourceUnit::class)]
20#[UsesClass(Source::class)]
21#[UsesClass(Unit::class)]
22#[Small]
23final class UnitCollectorTest extends TestCase
24{
25    public function testOpenStartsAScopeWithNoUnits(): void
26    {
27        $collector = new UnitCollector();
28        $collector->open(new Source('manual', 'source.html', 'html', 'main p'));
29        self::assertSame(['manual' => []], $collector->scopes);
30        self::assertSame([], $collector->units);
31    }
32
33    public function testOpenClearsTheUnitKeysOfAReopenedScope(): void
34    {
35        $source = new Source('manual', 'source.html', 'html', 'main p');
36        $collector = new UnitCollector();
37        $collector->open($source);
38        $collector->collect($source, [new Unit('p:1', 'First.')]);
39        $collector->open($source);
40        self::assertSame(['manual' => []], $collector->scopes);
41        self::assertCount(1, $collector->units);
42    }
43
44    public function testCollectAddsTheSelectedUnitsByKey(): void
45    {
46        $source = new Source('manual', 'source.html', 'html', 'main p');
47        $first = new Unit('p:1', 'First.');
48        $second = new Unit('p:2', 'Second.');
49        $collector = new UnitCollector();
50        $collector->open($source);
51        $collector->collect($source, [$first, $second]);
52        self::assertSame(['manual' => [$first->key($source), $second->key($source)]], $collector->scopes);
53        self::assertSame([$first->key($source), $second->key($source)], array_keys($collector->units));
54        self::assertSame($source, $collector->units[$first->key($source)]->source);
55        self::assertSame($second, $collector->units[$second->key($source)]->unit);
56        self::assertSame([], $collector->units[$first->key($source)]->claims);
57    }
58
59    public function testCollectListsARepeatedUnitOnce(): void
60    {
61        $source = new Source('manual', 'source.html', 'html', 'main p');
62        $first = new Unit('p:1', 'First.');
63        $second = new Unit('p:2', 'Second.');
64        $collector = new UnitCollector();
65        $collector->open($source);
66        $collector->collect($source, [$first, new Unit('p:1', 'First.'), $second]);
67        self::assertSame(['manual' => [$first->key($source), $second->key($source)]], $collector->scopes);
68        self::assertCount(2, $collector->units);
69    }
70
71    public function testCollectSharesAUnitBetweenScopesOfTheSameDocument(): void
72    {
73        $manual = new Source('manual', 'source.html', 'html', 'main p');
74        $overlap = new Source('overlap', 'source.html', 'html', '#a');
75        $unit = new Unit('p:1', 'First.');
76        $collector = new UnitCollector();
77        $collector->open($manual);
78        $collector->collect($manual, [$unit, new Unit('p:2', 'Second.')]);
79        $collector->open($overlap);
80        $collector->collect($overlap, [new Unit('p:1', 'First.')]);
81        self::assertCount(2, $collector->units);
82        self::assertSame([$unit->key($manual)], $collector->scopes['overlap']);
83        self::assertSame($manual, $collector->units[$unit->key($manual)]->source);
84        self::assertSame($unit, $collector->units[$unit->key($manual)]->unit);
85    }
86
87    public function testCollectRejectsAnEmptyScope(): void
88    {
89        $source = new Source('manual', 'source.html', 'html', 'main p');
90        $collector = new UnitCollector();
91        $collector->open($source);
92        $this->expectException(RuntimeException::class);
93        $this->expectExceptionMessage('Scope selected no source units.');
94        $collector->collect($source, []);
95    }
96
97    #[DataProvider('providerBlankUnits')]
98    public function testCollectRejectsABlankUnit(string $location, string $text): void
99    {
100        $source = new Source('manual', 'source.html', 'html', 'main p');
101        $collector = new UnitCollector();
102        $collector->open($source);
103        $this->expectException(RuntimeException::class);
104        $this->expectExceptionMessage('Source units must have nonempty locations and text.');
105        $collector->collect($source, [new Unit($location, $text)]);
106    }
107
108    /**
109     * @return array<string, array{string, string}>
110     */
111    public static function providerBlankUnits(): array
112    {
113        return [
114            'no location' => ['', 'First.'],
115            'no text' => ['p:1', ''],
116        ];
117    }
118
119    public function testCollectRejectsConflictingTextOfTheSameUnit(): void
120    {
121        $manual = new Source('manual', 'source.html', 'html', 'main p');
122        $overlap = new Source('overlap', 'source.html', 'html', '#a');
123        $collector = new UnitCollector();
124        $collector->open($manual);
125        $collector->collect($manual, [new Unit('p:1', 'First.')]);
126        $collector->open($overlap);
127        $this->expectException(RuntimeException::class);
128        $this->expectExceptionMessage('Conflicting snapshots for the same source unit.');
129        $collector->collect($overlap, [new Unit('p:1', 'Changed.')]);
130    }
131
132    #[DataProvider('providerOverlappingLocations')]
133    public function testCollectRejectsAncestorsAndDescendantsAcrossScopes(string $existing, string $added): void
134    {
135        $manual = new Source('manual', 'source.html', 'html', 'main p');
136        $overlap = new Source('overlap', 'source.html', 'html', 'main');
137        $collector = new UnitCollector();
138        $collector->open($manual);
139        $collector->collect($manual, [new Unit($existing, 'First.')]);
140        $collector->open($overlap);
141        $this->expectException(RuntimeException::class);
142        $this->expectExceptionMessage('Overlapping ancestor and descendant units across source scopes.');
143        $collector->collect($overlap, [new Unit($added, 'First.')]);
144    }
145
146    /**
147     * @return array<string, array{string, string}>
148     */
149    public static function providerOverlappingLocations(): array
150    {
151        return [
152            'ancestor added' => ['/html/body/main/p[1]', '/html/body/main'],
153            'descendant added' => ['/html/body/main', '/html/body/main/p[1]'],
154        ];
155    }
156
157    #[DataProvider('providerSeparateUnits')]
158    public function testCollectAcceptsUnitsThatDoNotOverlap(string $uri, string $format, string $existing, string $added): void
159    {
160        $manual = new Source('manual', 'source.html', 'html', 'main p');
161        $other = new Source('other', $uri, $format, 'main');
162        $collector = new UnitCollector();
163        $collector->open($manual);
164        $collector->collect($manual, [new Unit($existing, 'First.')]);
165        $collector->open($other);
166        $collector->collect($other, [new Unit($added, 'Second.')]);
167        self::assertCount(2, $collector->units);
168        self::assertCount(1, $collector->scopes['other']);
169    }
170
171    /**
172     * @return array<string, array{string, string, string, string}>
173     */
174    public static function providerSeparateUnits(): array
175    {
176        return [
177            'other document' => ['other.html', 'html', '/html/body/main/p[1]', '/html/body/main'],
178            'other format' => ['source.html', 'xml', '/html/body/main', '/html/body/main/p[1]'],
179            'shared prefix without separator' => ['source.html', 'html', 'line:1', 'line:10'],
180            'longer sibling' => ['source.html', 'html', 'line:10', 'line:1'],
181        ];
182    }
183
184    public function testCollectKeepsTheUnitsAddedBeforeARejectedUnit(): void
185    {
186        $source = new Source('manual', 'source.html', 'html', 'main p');
187        $first = new Unit('p:1', 'First.');
188        $collector = new UnitCollector();
189        $collector->open($source);
190        try {
191            $collector->collect($source, [$first, new Unit('p:2', '')]);
192            self::fail('Expected a blank unit to be rejected.');
193        } catch (RuntimeException $error) {
194            self::assertSame('Source units must have nonempty locations and text.', $error->getMessage());
195        }
196        self::assertSame([$first->key($source)], array_keys($collector->units));
197        self::assertSame(['manual' => [$first->key($source)]], $collector->scopes);
198    }
199}
200