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