packages/requirements/tests/Unit/Model/ItemTest.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(Item::class)]
26#[UsesClass(Fields::class)]
27#[UsesClass(Excerpt::class)]
28#[UsesClass(TestReference::class)]
29#[UsesClass(Source::class)]
30#[UsesClass(ItemValidator::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 ItemTest extends TestCase
38{
39 public function testFromReadsASpecificationWithDefaults(): void
40 {
41 $source = new Source('manual', 'source.html', 'html', 'main p');
42 $data = ['id' => 'SPEC-001', 'statement' => 'When a name is read, the parser shall require a leading letter.', 'evidence' => [['selector' => '#a', 'quote' => 'Names shall start with a letter.']]];
43 $item = Item::from($data, $source, 'definition.yaml');
44 self::assertSame('SPEC-001', $item->id);
45 self::assertSame('specification', $item->kind);
46 self::assertSame('When a name is read, the parser shall require a leading letter.', $item->statement);
47 self::assertSame('supported', $item->status);
48 self::assertSame($source, $item->source);
49 self::assertEquals([new Excerpt('#a', 'Names shall start with a letter.')], $item->evidence);
50 self::assertSame([], $item->tests);
51 self::assertSame([], $item->requirements);
52 self::assertSame([], $item->related);
53 self::assertSame([], $item->labels);
54 self::assertSame('', $item->category);
55 self::assertSame('sourced', $item->origin);
56 self::assertSame('', $item->reason);
57 self::assertSame('definition.yaml', $item->file);
58 self::assertSame($data, $item->data);
59 }
60
61 public function testFromReadsEveryField(): void
62 {
63 $data = [
64 'id' => 'SPEC-002',
65 'kind' => 'specification',
66 'statement' => 'The reader shall retain positions.',
67 'status' => 'unsupported',
68 'tests' => [['runner' => 'unit', 'target' => 'Sample\PassingTest::testPass'], ['runner' => 'unit', 'target' => 'Sample\PassingTest::testData']],
69 'related' => ['SPEC-001'],
70 'labels' => ['parser', 'positions'],
71 'category' => 'Reading',
72 'origin' => 'original',
73 'reason' => 'Support editors.',
74 'design' => [['url' => 'https://example.org/design', 'text' => 'Design notes']],
75 'metadata' => ['owner' => 'parser team'],
76 ];
77 $item = Item::from($data, null, 'specs/reader.yaml');
78 self::assertSame('unsupported', $item->status);
79 self::assertNull($item->source);
80 self::assertSame([], $item->evidence);
81 self::assertEquals([new TestReference('unit', 'Sample\PassingTest::testPass'), new TestReference('unit', 'Sample\PassingTest::testData')], $item->tests);
82 self::assertSame(['SPEC-001'], $item->related);
83 self::assertSame(['parser', 'positions'], $item->labels);
84 self::assertSame('Reading', $item->category);
85 self::assertSame('original', $item->origin);
86 self::assertSame('Support editors.', $item->reason);
87 self::assertSame('specs/reader.yaml', $item->file);
88 self::assertSame($data, $item->data);
89 }
90
91 public function testFromReadsARequirementQuotingItsSource(): void
92 {
93 $item = Item::from(['id' => 'REQ-1', 'kind' => 'requirement', 'statement' => 'Names start with letters', 'evidence' => [['selector' => '#a', 'quote' => 'Names shall start with a letter.']]], new Source('manual', 'source.html', 'html', 'main p'), 'definition.yaml');
94 self::assertSame('requirement', $item->kind);
95 self::assertSame('Names start with letters', $item->statement);
96 }
97
98 public function testFromReadsRequirementLinks(): void
99 {
100 $item = Item::from(['id' => 'SPEC-3', 'statement' => 'The reader shall read.', 'requirements' => ['REQ-1', 'REQ-2']], null, 'definition.yaml');
101 self::assertSame(['REQ-1', 'REQ-2'], $item->requirements);
102 }
103
104 #[DataProvider('providerFromRejectsInvalidItems')]
105 public function testFromRejectsInvalidItems(mixed $value, string $message): void
106 {
107 $this->expectException(InvalidInputException::class);
108 $this->expectExceptionMessage($message);
109 Item::from($value, new Source('manual', 'source.html', 'html', 'main p'), 'definition.yaml');
110 }
111
112 /**
113 * @return array<string, array{mixed, string}>
114 */
115 public static function providerFromRejectsInvalidItems(): array
116 {
117 $item = ['id' => 'SPEC-1', 'statement' => 'The reader shall read.', 'evidence' => [['selector' => '#a', 'quote' => 'Names shall start with a letter.']]];
118 return [
119 'not a mapping' => ['SPEC-1', 'item must be a mapping.'],
120 'unknown field' => [[...$item, 'owner' => 'me'], "item: unknown field 'owner'."],
121 'missing id' => [['statement' => 'The reader shall read.'], 'id must be a nonempty string.'],
122 'missing statement' => [['id' => 'SPEC-1'], 'statement must be a nonempty string.'],
123 'blank kind' => [[...$item, 'kind' => ''], 'kind must be a nonempty string.'],
124 'blank status' => [[...$item, 'status' => ' '], 'status must be a nonempty string.'],
125 'evidence not a list' => [[...$item, 'evidence' => ['selector' => '#a']], 'evidence must be a list.'],
126 'invalid evidence' => [[...$item, 'evidence' => [['selector' => '#a']]], 'quote must be a nonempty string.'],
127 'tests not a list' => [[...$item, 'tests' => 'unit'], 'tests must be a list.'],
128 'invalid test' => [[...$item, 'tests' => [['runner' => 'unit']]], 'target must be a nonempty string.'],
129 'duplicate requirements' => [[...$item, 'requirements' => ['REQ-1', 'REQ-1']], 'requirements contains duplicates.'],
130 'related not a list' => [[...$item, 'related' => 'SPEC-2'], 'related must be a list.'],
131 'blank label' => [[...$item, 'labels' => ['']], 'labels must contain nonempty strings.'],
132 'blank category' => [[...$item, 'category' => ''], 'category must be a nonempty string.'],
133 'blank origin' => [[...$item, 'origin' => ' '], 'origin must be a nonempty string.'],
134 'blank reason' => [[...$item, 'reason' => ''], 'reason must be a nonempty string.'],
135 'invalid id' => [[...$item, 'id' => '1-SPEC'], 'Item IDs must start with a letter and contain letters, digits, dots, underscores or dashes.'],
136 'statement breaking EARS' => [[...$item, 'statement' => 'The reader will read.'], 'SPEC-1: EARS: expected The <system name> shall <system response>.'],
137 'invalid design' => [[...$item, 'design' => [[]]], 'SPEC-1: a design reference needs url or text.'],
138 ];
139 }
140}
141