packages/requirements/tests/Unit/Model/ItemValidatorTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Model;
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\Ears\ConditionOrder;
13use Requirements\Ears\LiteralMask;
14use Requirements\Ears\SystemResponse;
15use Requirements\Ears\Validator;
16use Requirements\Ears\Wording;
17use Requirements\Input\Fields;
18use Requirements\Input\InvalidInputException;
19use Requirements\Model\Excerpt;
20use Requirements\Model\Item;
21use Requirements\Model\ItemValidator;
22use Requirements\Model\Source;
23use Requirements\Model\TestReference;
24
25#[CoversClass(ItemValidator::class)]
26#[UsesClass(Item::class)]
27#[UsesClass(Excerpt::class)]
28#[UsesClass(TestReference::class)]
29#[UsesClass(Source::class)]
30#[UsesClass(Fields::class)]
31#[UsesClass(Validator::class)]
32#[UsesClass(LiteralMask::class)]
33#[UsesClass(ConditionOrder::class)]
34#[UsesClass(SystemResponse::class)]
35#[UsesClass(Wording::class)]
36#[Small]
37final class ItemValidatorTest extends TestCase
38{
39 #[DataProvider('providerValidateAcceptsItems')]
40 public function testValidateAcceptsItems(Item $item): void
41 {
42 (new ItemValidator())->validate($item);
43 $this->addToAssertionCount(1);
44 }
45
46 /**
47 * @return array<string, array{Item}>
48 */
49 public static function providerValidateAcceptsItems(): array
50 {
51 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'The reader shall read.', 'status' => 'supported', 'source' => new Source('manual', 'source.html', 'html', 'main p'), 'evidence' => [new Excerpt('#a', 'Names shall start with a letter.')], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
52 return [
53 'sourced by evidence' => [new Item(...$item)],
54 'sourced by requirements' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'requirements' => ['REQ-1']])],
55 'with tests' => [new Item(...[...$item, 'tests' => [new TestReference('unit', 'Sample\PassingTest::testPass')]])],
56 'requirement' => [new Item(...[...$item, 'kind' => 'requirement', 'statement' => 'Names start with letters'])],
57 'unsupported with reason' => [new Item(...[...$item, 'status' => 'unsupported', 'reason' => 'Owned elsewhere.'])],
58 'original' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'origin' => 'original', 'reason' => 'Support editors.'])],
59 'undocumented' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'origin' => 'undocumented', 'reason' => 'Observed behavior.'])],
60 'with details' => [new Item(...[...$item, 'data' => ['metadata' => ['owner' => 'me'], 'design' => [['url' => 'https://example.org/'], ['text' => 'Notes'], ['url' => 'https://example.org/', 'text' => 'Notes']]]])],
61 ];
62 }
63
64 #[DataProvider('providerValidateReportsTheFirstBrokenRule')]
65 public function testValidateReportsTheFirstBrokenRule(Item $item, string $message): void
66 {
67 $this->expectException(InvalidInputException::class);
68 $this->expectExceptionMessage($message);
69 (new ItemValidator())->validate($item);
70 }
71
72 /**
73 * @return array<string, array{Item, string}>
74 */
75 public static function providerValidateReportsTheFirstBrokenRule(): array
76 {
77 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'The reader shall read.', 'status' => 'supported', 'source' => new Source('manual', 'source.html', 'html', 'main p'), 'evidence' => [new Excerpt('#a', 'Names shall start with a letter.')], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
78 return [
79 'identity before statement' => [new Item(...[...$item, 'id' => '1', 'status' => 'unsupported']), 'Item IDs must start with a letter'],
80 'statement before disposition' => [new Item(...[...$item, 'status' => 'unsupported', 'origin' => 'original']), 'SPEC-1: unsupported items require a reason.'],
81 'disposition before details' => [new Item(...[...$item, 'source' => null, 'data' => ['metadata' => 'x']]), 'SPEC-1: evidence requires a source.'],
82 'details' => [new Item(...[...$item, 'data' => ['metadata' => 'x']]), 'metadata must be a mapping.'],
83 ];
84 }
85
86 #[DataProvider('providerIdentityAcceptsItems')]
87 public function testIdentityAcceptsItems(Item $item): void
88 {
89 (new ItemValidator())->identity($item);
90 $this->addToAssertionCount(1);
91 }
92
93 /**
94 * @return array<string, array{Item}>
95 */
96 public static function providerIdentityAcceptsItems(): array
97 {
98 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'x', 'status' => 'supported', 'source' => null, 'evidence' => [], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
99 return [
100 'single letter' => [new Item(...[...$item, 'id' => 'a'])],
101 'every allowed character' => [new Item(...[...$item, 'id' => 'Spec_1.2-b'])],
102 'requirement' => [new Item(...[...$item, 'kind' => 'requirement'])],
103 'unsupported' => [new Item(...[...$item, 'status' => 'unsupported'])],
104 'original' => [new Item(...[...$item, 'origin' => 'original'])],
105 'undocumented' => [new Item(...[...$item, 'origin' => 'undocumented'])],
106 ];
107 }
108
109 #[DataProvider('providerIdentityRejectsItems')]
110 public function testIdentityRejectsItems(Item $item, string $message): void
111 {
112 $this->expectException(InvalidInputException::class);
113 $this->expectExceptionMessage($message);
114 (new ItemValidator())->identity($item);
115 }
116
117 /**
118 * @return array<string, array{Item, string}>
119 */
120 public static function providerIdentityRejectsItems(): array
121 {
122 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'x', 'status' => 'supported', 'source' => null, 'evidence' => [], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
123 $format = 'Item IDs must start with a letter and contain letters, digits, dots, underscores or dashes.';
124 return [
125 'leading digit' => [new Item(...[...$item, 'id' => '1SPEC']), $format],
126 'leading dash' => [new Item(...[...$item, 'id' => '-SPEC']), $format],
127 'space' => [new Item(...[...$item, 'id' => 'SPEC 1']), $format],
128 'trailing newline' => [new Item(...[...$item, 'id' => "SPEC\n"]), $format],
129 'trailing symbol' => [new Item(...[...$item, 'id' => 'SPEC#']), $format],
130 'unknown kind' => [new Item(...[...$item, 'kind' => 'feature']), 'SPEC-1: invalid kind or status.'],
131 'unknown status' => [new Item(...[...$item, 'status' => 'planned']), 'SPEC-1: invalid kind or status.'],
132 'unknown origin' => [new Item(...[...$item, 'origin' => 'derived']), 'SPEC-1: invalid origin.'],
133 ];
134 }
135
136 #[DataProvider('providerStatementAcceptsItems')]
137 public function testStatementAcceptsItems(Item $item): void
138 {
139 (new ItemValidator())->statement($item);
140 $this->addToAssertionCount(1);
141 }
142
143 /**
144 * @return array<string, array{Item}>
145 */
146 public static function providerStatementAcceptsItems(): array
147 {
148 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'The reader shall read.', 'status' => 'supported', 'source' => null, 'evidence' => [], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
149 return [
150 'EARS specification' => [new Item(...$item)],
151 'unsupported with reason' => [new Item(...[...$item, 'status' => 'unsupported', 'reason' => 'Owned elsewhere.'])],
152 'requirement in free text' => [new Item(...[...$item, 'kind' => 'requirement', 'statement' => 'Names start with letters'])],
153 ];
154 }
155
156 #[DataProvider('providerStatementRejectsItems')]
157 public function testStatementRejectsItems(Item $item, string $message): void
158 {
159 $this->expectException(InvalidInputException::class);
160 $this->expectExceptionMessage($message);
161 (new ItemValidator())->statement($item);
162 }
163
164 /**
165 * @return array<string, array{Item, string}>
166 */
167 public static function providerStatementRejectsItems(): array
168 {
169 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'The reader shall read.', 'status' => 'supported', 'source' => null, 'evidence' => [], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
170 return [
171 'unsupported without reason' => [new Item(...[...$item, 'status' => 'unsupported']), 'SPEC-1: unsupported items require a reason.'],
172 'unsupported requirement without reason' => [new Item(...[...$item, 'kind' => 'requirement', 'status' => 'unsupported']), 'SPEC-1: unsupported items require a reason.'],
173 'specification breaking EARS' => [new Item(...[...$item, 'statement' => 'The reader will read.']), 'SPEC-1: EARS: expected The <system name> shall <system response>.'],
174 ];
175 }
176
177 #[DataProvider('providerDispositionAcceptsItems')]
178 public function testDispositionAcceptsItems(Item $item): void
179 {
180 (new ItemValidator())->disposition($item);
181 $this->addToAssertionCount(1);
182 }
183
184 /**
185 * @return array<string, array{Item}>
186 */
187 public static function providerDispositionAcceptsItems(): array
188 {
189 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'x', 'status' => 'supported', 'source' => new Source('manual', 'source.html', 'html', 'main p'), 'evidence' => [new Excerpt('#a', 'Names shall start with a letter.')], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
190 return [
191 'evidence' => [new Item(...$item)],
192 'requirements only' => [new Item(...[...$item, 'evidence' => [], 'requirements' => ['REQ-1']])],
193 'requirements without source' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'requirements' => ['REQ-1']])],
194 'source without evidence' => [new Item(...[...$item, 'evidence' => [], 'requirements' => ['REQ-1']])],
195 'requirement with evidence' => [new Item(...[...$item, 'kind' => 'requirement'])],
196 'unsupported specification with tests' => [new Item(...[...$item, 'status' => 'unsupported', 'tests' => [new TestReference('unit', 't')]])],
197 'original' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'origin' => 'original', 'reason' => 'Support editors.'])],
198 'undocumented with tests' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'origin' => 'undocumented', 'reason' => 'Observed.', 'tests' => [new TestReference('unit', 't')]])],
199 'original requirement' => [new Item(...[...$item, 'kind' => 'requirement', 'source' => null, 'evidence' => [], 'origin' => 'original', 'reason' => 'Support editors.'])],
200 ];
201 }
202
203 #[DataProvider('providerDispositionRejectsItems')]
204 public function testDispositionRejectsItems(Item $item, string $message): void
205 {
206 $this->expectException(InvalidInputException::class);
207 $this->expectExceptionMessage($message);
208 (new ItemValidator())->disposition($item);
209 }
210
211 /**
212 * @return array<string, array{Item, string}>
213 */
214 public static function providerDispositionRejectsItems(): array
215 {
216 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'x', 'status' => 'supported', 'source' => new Source('manual', 'source.html', 'html', 'main p'), 'evidence' => [new Excerpt('#a', 'Names shall start with a letter.')], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
217 $requirement = 'SPEC-1: requirements cannot have tests, requirement parents or unsupported status; record disposition on specifications.';
218 $independent = 'SPEC-1: independent items require a reason and cannot claim a source or requirement.';
219 return [
220 'requirement with tests' => [new Item(...[...$item, 'kind' => 'requirement', 'tests' => [new TestReference('unit', 't')]]), $requirement],
221 'requirement with parents' => [new Item(...[...$item, 'kind' => 'requirement', 'requirements' => ['REQ-0']]), $requirement],
222 'unsupported requirement' => [new Item(...[...$item, 'kind' => 'requirement', 'status' => 'unsupported', 'reason' => 'Owned elsewhere.']), $requirement],
223 'evidence without source' => [new Item(...[...$item, 'source' => null]), 'SPEC-1: evidence requires a source.'],
224 'original with source' => [new Item(...[...$item, 'evidence' => [], 'origin' => 'original', 'reason' => 'Support editors.']), $independent],
225 'original with requirements' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'requirements' => ['REQ-1'], 'origin' => 'original', 'reason' => 'Support editors.']), $independent],
226 'original without reason' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'origin' => 'original']), $independent],
227 'undocumented without reason' => [new Item(...[...$item, 'source' => null, 'evidence' => [], 'origin' => 'undocumented']), $independent],
228 'sourced without evidence or requirements' => [new Item(...[...$item, 'evidence' => []]), 'SPEC-1: provide evidence or requirements, or explicitly mark an independent origin with a reason.'],
229 'sourced without source, evidence or requirements' => [new Item(...[...$item, 'source' => null, 'evidence' => []]), 'SPEC-1: provide evidence or requirements, or explicitly mark an independent origin with a reason.'],
230 ];
231 }
232
233 #[DataProvider('providerDetailsAcceptsItems')]
234 public function testDetailsAcceptsItems(Item $item): void
235 {
236 (new ItemValidator())->details($item);
237 $this->addToAssertionCount(1);
238 }
239
240 /**
241 * @return array<string, array{Item}>
242 */
243 public static function providerDetailsAcceptsItems(): array
244 {
245 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'x', 'status' => 'supported', 'source' => null, 'evidence' => [], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
246 return [
247 'no details' => [new Item(...$item)],
248 'metadata' => [new Item(...[...$item, 'data' => ['metadata' => ['owner' => 'me', 'nested' => ['a' => 1]]]])],
249 'empty design list' => [new Item(...[...$item, 'data' => ['design' => []]])],
250 'design url' => [new Item(...[...$item, 'data' => ['design' => [['url' => 'https://example.org/']]]])],
251 'design text' => [new Item(...[...$item, 'data' => ['design' => [['text' => 'Notes']]]])],
252 'design url and text' => [new Item(...[...$item, 'data' => ['design' => [['url' => 'https://example.org/', 'text' => 'Notes'], ['text' => 'More']]]])],
253 ];
254 }
255
256 #[DataProvider('providerDetailsRejectsItems')]
257 public function testDetailsRejectsItems(Item $item, string $message): void
258 {
259 $this->expectException(InvalidInputException::class);
260 $this->expectExceptionMessage($message);
261 (new ItemValidator())->details($item);
262 }
263
264 /**
265 * @return array<string, array{Item, string}>
266 */
267 public static function providerDetailsRejectsItems(): array
268 {
269 $item = ['id' => 'SPEC-1', 'kind' => 'specification', 'statement' => 'x', 'status' => 'supported', 'source' => null, 'evidence' => [], 'tests' => [], 'requirements' => [], 'related' => [], 'labels' => [], 'category' => '', 'origin' => 'sourced', 'reason' => '', 'file' => 'definition.yaml', 'data' => []];
270 return [
271 'metadata not a mapping' => [new Item(...[...$item, 'data' => ['metadata' => 'owner']]), 'metadata must be a mapping.'],
272 'metadata list' => [new Item(...[...$item, 'data' => ['metadata' => ['owner']]]), 'metadata must have string keys.'],
273 'design not a list' => [new Item(...[...$item, 'data' => ['design' => ['url' => 'https://example.org/']]]), 'design must be a list.'],
274 'design entry not a mapping' => [new Item(...[...$item, 'data' => ['design' => ['https://example.org/']]]), 'design must be a mapping.'],
275 'unknown design field' => [new Item(...[...$item, 'data' => ['design' => [['url' => 'https://example.org/', 'title' => 'x']]]]), "design: unknown field 'title'."],
276 'empty design entry' => [new Item(...[...$item, 'data' => ['design' => [['text' => 'Notes'], []]]]), 'SPEC-1: a design reference needs url or text.'],
277 'blank url' => [new Item(...[...$item, 'data' => ['design' => [['url' => ' ']]]]), 'url must be a nonempty string.'],
278 'blank text after url' => [new Item(...[...$item, 'data' => ['design' => [['url' => 'https://example.org/', 'text' => '']]]]), 'text must be a nonempty string.'],
279 'null text in later entry' => [new Item(...[...$item, 'data' => ['design' => [['text' => 'Notes'], ['text' => null]]]]), 'text must be a nonempty string.'],
280 ];
281 }
282}
283