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