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