packages/requirements/tests/Unit/Console/SpecificationTableTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Console;
6
7use Override;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Console\SpecificationTable;
14use Requirements\Console\Text;
15use Requirements\Input\Fields;
16use Requirements\Input\InvalidInputException;
17use Symfony\Component\Console\Input\ArrayInput;
18use Symfony\Component\Console\Output\BufferedOutput;
19use Symfony\Component\Console\Style\SymfonyStyle;
20
21#[CoversClass(SpecificationTable::class)]
22#[UsesClass(Text::class)]
23#[UsesClass(Fields::class)]
24#[Small]
25final class SpecificationTableTest extends TestCase
26{
27    #[Override]
28    protected function setUp(): void
29    {
30        putenv('COLUMNS=120');
31    }
32
33    #[Override]
34    protected function tearDown(): void
35    {
36        putenv('COLUMNS');
37    }
38
39    public function testRenderShowsOneRowPerItemAndTheFailureMessages(): void
40    {
41        $row = ['statement' => 'The reader shall preserve positions.', 'kind' => 'specification', 'source' => 'manual', 'origin' => 'sourced', 'category' => 'reader', 'labels' => ['diagnostics', 'reader'], 'reason' => 'Keep offsets.', 'status' => 'passed', 'passed_targets' => 2, 'total_targets' => 2, 'message' => ''];
42        $output = new BufferedOutput();
43        (new SpecificationTable())->render(new SymfonyStyle(new ArrayInput([]), $output), $output, ['specifications' => [
44            'SPEC-001' => $row,
45            'ORIGINAL-001' => [...$row, 'source' => null, 'origin' => 'original', 'category' => '', 'labels' => [], 'reason' => '', 'status' => 'not-run', 'passed_targets' => null, 'total_targets' => 3],
46            'FAILED' => [...$row, 'status' => 'failed', 'passed_targets' => 0, 'total_targets' => 1, 'message' => 'Sample\\PassingTest::testFailure: Expected <comment>failure</comment>.'],
47        ]]);
48        self::assertSame(<<<'TEXT'
49+--------------+--------------------------------------+---------+-------+
50| ID           | Statement                            | Result  | Tests |
51+--------------+--------------------------------------+---------+-------+
52| SPEC-001     | The reader shall preserve positions. | passed  | 2/2   |
53|              | specification · manual · reader      |         |       |
54|              | Labels: diagnostics, reader          |         |       |
55|              | Reason: Keep offsets.                |         |       |
56| ORIGINAL-001 | The reader shall preserve positions. | not-run | -/3   |
57|              | specification · original             |         |       |
58| FAILED       | The reader shall preserve positions. | failed  | 0/1   |
59|              | specification · manual · reader      |         |       |
60|              | Labels: diagnostics, reader          |         |       |
61|              | Reason: Keep offsets.                |         |       |
62+--------------+--------------------------------------+---------+-------+
63 3 item(s). Tests: passed / linked targets. Use --json for execution counts and complete traceability records.
64 FAILED: Sample\PassingTest::testFailure: Expected <comment>failure</comment>.
65
66TEXT, preg_replace('/ +$/m', '', $output->fetch()));
67    }
68
69    public function testRenderEscapesMarkupAndRemovesControlCharacters(): void
70    {
71        $output = new BufferedOutput();
72        (new SpecificationTable())->render(new SymfonyStyle(new ArrayInput([]), $output), $output, ['specifications' => [
73            '<info>ID</info>' => ['statement' => "The\x07 reader.", 'kind' => "kind\x1b", 'source' => null, 'origin' => "original\x00", 'category' => "cat\x7f", 'labels' => ["label\x01"], 'reason' => "why\x0b", 'status' => "failed\x08", 'passed_targets' => 1, 'total_targets' => 2, 'message' => "<comment>Broken</comment>\x1b[0m"],
74        ]]);
75        self::assertSame(<<<'TEXT'
76+-----------------+-----------------------+--------+-------+
77| ID              | Statement             | Result | Tests |
78+-----------------+-----------------------+--------+-------+
79| <info>ID</info> | The reader.           | failed | 1/2   |
80|                 | kind · original · cat |        |       |
81|                 | Labels: label         |        |       |
82|                 | Reason: why           |        |       |
83+-----------------+-----------------------+--------+-------+
84 1 item(s). Tests: passed / linked targets. Use --json for execution counts and complete traceability records.
85 <info>ID</info>: <comment>Broken</comment>[0m
86
87TEXT, preg_replace('/ +$/m', '', $output->fetch()));
88    }
89
90    public function testRenderShowsAnEmptyTable(): void
91    {
92        $output = new BufferedOutput();
93        (new SpecificationTable())->render(new SymfonyStyle(new ArrayInput([]), $output), $output, ['specifications' => []]);
94        self::assertSame(<<<'TEXT'
95+----+-----------+--------+-------+
96| ID | Statement | Result | Tests |
97+----+-----------+--------+-------+
98 0 item(s). Tests: passed / linked targets. Use --json for execution counts and complete traceability records.
99
100TEXT, preg_replace('/ +$/m', '', $output->fetch()));
101    }
102
103    /**
104     * @param array<string, mixed> $report
105     */
106    #[DataProvider('providerRenderRejected')]
107    public function testRenderRejectsRowsThatAreNotSpecificationRecords(array $report, string $message): void
108    {
109        $this->expectException(InvalidInputException::class);
110        $this->expectExceptionMessage($message);
111        $output = new BufferedOutput();
112        (new SpecificationTable())->render(new SymfonyStyle(new ArrayInput([]), $output), $output, $report);
113    }
114
115    /**
116     * @return array<string, array{array<string, mixed>, string}>
117     */
118    public static function providerRenderRejected(): array
119    {
120        return [
121            'specifications list' => [['specifications' => 'SPEC-001'], 'specifications must be a mapping.'],
122            'integer keys' => [['specifications' => [['statement' => 'The reader shall read.']]], 'specifications must have string keys.'],
123            'row' => [['specifications' => ['SPEC-001' => 'passed']], 'specification must be a mapping.'],
124            'labels' => [['specifications' => ['SPEC-001' => ['statement' => 'The reader shall read.', 'kind' => 'specification', 'source' => null, 'origin' => 'original', 'category' => '', 'labels' => 'grammar']]], 'labels must be a list.'],
125        ];
126    }
127
128    #[DataProvider('providerTableWidths')]
129    public function testTableWrapsTheStatementToTheTerminalWidth(string $columns, string $id, int $expected): void
130    {
131        putenv('COLUMNS=' . $columns);
132        $output = new BufferedOutput();
133        (new SpecificationTable())->table($output, ['ID', 'Statement', 'Result', 'Tests'], [[$id, str_repeat('x', 200), 'ok', '1/1']]);
134        self::assertSame($expected, max(array_map(mb_strwidth(...), explode("\n", $output->fetch()))));
135    }
136
137    /**
138     * @return array<string, array{string, string, int}>
139     */
140    public static function providerTableWidths(): array
141    {
142        return [
143            'narrow terminal keeps 24 columns' => ['40', 'A', 50],
144            'terminal width' => ['90', 'A', 90],
145            'terminal width with wide ID' => ['90', 'ÄÖÜÄÖÜÄÖÜÄ', 90],
146            'wide terminal keeps 72 columns' => ['200', 'A', 98],
147        ];
148    }
149
150    public function testTableCapsTheIdResultAndTestColumns(): void
151    {
152        $output = new BufferedOutput();
153        (new SpecificationTable())->table($output, ['ID', 'Statement', 'Result', 'Tests'], [['ABCDEFGHIJKLMNOPQRSTUVWXYZ1234', 'Short.', 'unsupported-and-more', '1234567890/1234567890']]);
154        self::assertSame(<<<'TEXT'
155+--------------------------+-----------+----------------+--------------+
156| ID                       | Statement | Result         | Tests        |
157+--------------------------+-----------+----------------+--------------+
158| ABCDEFGHIJKLMNOPQRSTUVWX | Short.    | unsupported-an | 1234567890/1 |
159| YZ1234                   |           | d-more         | 234567890    |
160+--------------------------+-----------+----------------+--------------+
161
162TEXT, $output->fetch());
163    }
164
165    public function testTableSizesColumnsByTheirHeaders(): void
166    {
167        putenv('COLUMNS=50');
168        $output = new BufferedOutput();
169        (new SpecificationTable())->table($output, ['Identifier', 'Statement', 'Outcome', 'Linked'], [['A', str_repeat('y', 30), 'ok', '1']]);
170        self::assertSame(<<<'TEXT'
171+------------+--------------------------+---------+--------+
172| Identifier | Statement                | Outcome | Linked |
173+------------+--------------------------+---------+--------+
174| A          | yyyyyyyyyyyyyyyyyyyyyyyy | ok      | 1      |
175|            | yyyyyy                   |         |        |
176+------------+--------------------------+---------+--------+
177
178TEXT, $output->fetch());
179    }
180}
181