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