Skip to content

Commit 10a41e7

Browse files
committed
improved --cider mode (thx Claude)
1 parent 36e73d5 commit 10a41e7

File tree

7 files changed

+269
-4
lines changed

7 files changed

+269
-4
lines changed

phpstan-baseline.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ parameters:
6666
count: 1
6767
path: src/Runner/PhpInterpreter.php
6868

69+
-
70+
message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''jobStarted'' will always evaluate to true\.$#'
71+
identifier: function.alreadyNarrowedType
72+
count: 1
73+
path: src/Runner/Runner.php
74+
75+
-
76+
message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''tick'' will always evaluate to true\.$#'
77+
identifier: function.alreadyNarrowedType
78+
count: 1
79+
path: src/Runner/Runner.php
80+
6981
-
7082
message: '#^If condition is always false\.$#'
7183
identifier: if.alwaysFalse

src/Framework/Ansi.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ public static function hideCursor(): string
7676
}
7777

7878

79+
public static function cursorMove(int $x = 0, int $y = 0): string
80+
{
81+
return match (true) {
82+
$x < 0 => "\e[" . (-$x) . 'D',
83+
$x > 0 => "\e[{$x}C",
84+
default => '',
85+
} . match (true) {
86+
$y < 0 => "\e[" . (-$y) . 'A',
87+
$y > 0 => "\e[{$y}B",
88+
default => '',
89+
};
90+
}
91+
92+
7993
/**
8094
* Returns ANSI sequence to clear from cursor to end of line.
8195
*/
@@ -108,7 +122,57 @@ public static function reset(): string
108122
*/
109123
public static function textWidth(string $text): int
110124
{
125+
$text = self::stripAnsi($text);
111126
return preg_match_all('/./su', $text)
112127
+ preg_match_all('/[\x{1F300}-\x{1F9FF}]/u', $text); // emoji are 2-wide
113128
}
129+
130+
131+
/**
132+
* Pads text to specified display width.
133+
* @param STR_PAD_LEFT|STR_PAD_RIGHT|STR_PAD_BOTH $type
134+
*/
135+
public static function pad(
136+
string $text,
137+
int $width,
138+
string $char = ' ',
139+
int $type = STR_PAD_RIGHT,
140+
): string
141+
{
142+
$padding = $width - self::textWidth($text);
143+
if ($padding <= 0) {
144+
return $text;
145+
}
146+
147+
return match ($type) {
148+
STR_PAD_LEFT => str_repeat($char, $padding) . $text,
149+
STR_PAD_RIGHT => $text . str_repeat($char, $padding),
150+
STR_PAD_BOTH => str_repeat($char, intdiv($padding, 2)) . $text . str_repeat($char, $padding - intdiv($padding, 2)),
151+
};
152+
}
153+
154+
155+
/**
156+
* Truncates text to max display width, adding ellipsis if needed.
157+
*/
158+
public static function truncate(string $text, int $maxWidth, string $ellipsis = ''): string
159+
{
160+
if (self::textWidth($text) <= $maxWidth) {
161+
return $text;
162+
}
163+
164+
$maxWidth -= self::textWidth($ellipsis);
165+
$res = '';
166+
$width = 0;
167+
foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {
168+
$charWidth = preg_match('/[\x{1F300}-\x{1F9FF}]/u', $char) ? 2 : 1;
169+
if ($width + $charWidth > $maxWidth) {
170+
break;
171+
}
172+
$res .= $char;
173+
$width += $charWidth;
174+
}
175+
176+
return $res . $ellipsis;
177+
}
114178
}

src/Runner/Output/ConsolePrinter.php

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99

1010
use Tester;
1111
use Tester\Ansi;
12+
use Tester\Environment;
13+
use Tester\Runner\Job;
1214
use Tester\Runner\Runner;
1315
use Tester\Runner\Test;
14-
use function sprintf, strlen;
15-
use const DIRECTORY_SEPARATOR;
16+
use function count, fwrite, sprintf, str_repeat, strlen;
1617

1718

1819
/**
@@ -24,6 +25,8 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
2425
public const ModeCider = 2;
2526
public const ModeLines = 3;
2627

28+
private const MaxDisplayedThreads = 20;
29+
2730
/** @var resource */
2831
private $file;
2932
private string $buffer;
@@ -33,6 +36,11 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
3336
/** @var array<Test::Passed|Test::Skipped|Test::Failed, int> result type => count */
3437
private array $results;
3538
private ?string $baseDir;
39+
private int $panelWidth = 60;
40+
private int $panelHeight = 0;
41+
42+
/** @var \WeakMap<Job, float> */
43+
private \WeakMap $startTimes;
3644

3745

3846
public function __construct(
@@ -43,6 +51,7 @@ public function __construct(
4351
private int $mode = self::ModeDots,
4452
) {
4553
$this->file = fopen($file ?? 'php://output', 'w') ?: throw new \RuntimeException("Cannot open file '$file' for writing.");
54+
$this->startTimes = new \WeakMap;
4655
}
4756

4857

@@ -53,6 +62,9 @@ public function begin(): void
5362
$this->baseDir = null;
5463
$this->results = [Test::Passed => 0, Test::Skipped => 0, Test::Failed => 0];
5564
$this->time = -microtime(as_float: true);
65+
if ($this->mode === self::ModeCider && $this->runner->threadCount < 2) {
66+
$this->mode = self::ModeLines;
67+
}
5668
fwrite($this->file, $this->runner->getInterpreter()->getShortInfo()
5769
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
5870
. " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n");
@@ -89,7 +101,7 @@ public function finish(Test $test): void
89101
$this->results[$result]++;
90102
fwrite($this->file, match ($this->mode) {
91103
self::ModeDots => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Ansi::colorize('F', 'white/red')][$result],
92-
self::ModeCider => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'][$result],
104+
self::ModeCider => '',
93105
self::ModeLines => $this->generateFinishLine($test),
94106
});
95107

@@ -106,6 +118,12 @@ public function finish(Test $test): void
106118

107119
public function end(): void
108120
{
121+
if ($this->panelHeight) {
122+
fwrite($this->file, Ansi::cursorMove(y: -$this->panelHeight)
123+
. str_repeat(Ansi::clearLine() . "\n", $this->panelHeight)
124+
. Ansi::cursorMove(y: -$this->panelHeight));
125+
}
126+
109127
$run = array_sum($this->results);
110128
fwrite($this->file, !$this->count ? "No tests found\n" :
111129
"\n\n" . $this->buffer . "\n"
@@ -158,4 +176,76 @@ private function generateFinishLine(Test $test): string
158176
$message,
159177
);
160178
}
179+
180+
181+
public function jobStarted(Job $job): void
182+
{
183+
$this->startTimes[$job] = microtime(true);
184+
}
185+
186+
187+
/**
188+
* @param Job[] $running
189+
*/
190+
public function tick(array $running): void
191+
{
192+
if ($this->mode !== self::ModeCider) {
193+
return;
194+
}
195+
196+
// Move cursor up to overwrite previous output
197+
if ($this->panelHeight) {
198+
fwrite($this->file, Ansi::cursorMove(y: -$this->panelHeight));
199+
}
200+
201+
$lines = [];
202+
203+
// Header with progress bar
204+
$barWidth = $this->panelWidth - 12;
205+
$filled = (int) round($barWidth * ($this->runner->getFinishedCount() / $this->runner->getJobCount()));
206+
$lines[] = '' . Ansi::pad(' ' . str_repeat('', $filled) . str_repeat('', $barWidth - $filled) . ' ', $this->panelWidth - 2, '', STR_PAD_BOTH) . '';
207+
208+
$threadJobs = [];
209+
foreach ($running as $job) {
210+
$threadJobs[(int) $job->getEnvironmentVariable(Environment::VariableThread)] = $job;
211+
}
212+
213+
// Thread lines
214+
$numWidth = strlen((string) $this->runner->threadCount);
215+
$displayCount = min($this->runner->threadCount, self::MaxDisplayedThreads);
216+
217+
for ($t = 1; $t <= $displayCount; $t++) {
218+
if (isset($threadJobs[$t])) {
219+
$job = $threadJobs[$t];
220+
$name = basename($job->getTest()->getFile());
221+
$time = sprintf('%0.1fs', microtime(true) - ($this->startTimes[$job] ?? microtime(true)));
222+
$nameWidth = $this->panelWidth - $numWidth - strlen($time) - 7;
223+
$name = Ansi::pad(Ansi::truncate($name, $nameWidth), $nameWidth);
224+
$line = Ansi::colorize(sprintf("%{$numWidth}d:", $t), 'lime') . " $name " . Ansi::colorize($time, 'yellow');
225+
} else {
226+
$line = Ansi::pad(Ansi::colorize(sprintf("%{$numWidth}d: -", $t), 'gray'), $this->panelWidth - 4);
227+
}
228+
$lines[] = '' . $line . '';
229+
}
230+
231+
if ($this->runner->threadCount > self::MaxDisplayedThreads) {
232+
$more = $this->runner->threadCount - self::MaxDisplayedThreads;
233+
$ellipsis = Ansi::colorize("… and $more more", 'gray');
234+
$lines[] = '' . Ansi::pad($ellipsis, $this->panelWidth - 2) . '';
235+
}
236+
237+
// Footer: (85 tests, 🍏×74 🍎×2, 9.0s)
238+
$summary = "($this->count tests, "
239+
. ($this->results[Test::Passed] ? "🍏×{$this->results[Test::Passed]}" : '')
240+
. ($this->results[Test::Failed] ? " 🍎×{$this->results[Test::Failed]}" : '')
241+
. ', ' . sprintf('%0.1fs', $this->time + microtime(true)) . ')';
242+
$lines[] = '' . Ansi::pad($summary, $this->panelWidth - 2, '', STR_PAD_BOTH) . '';
243+
244+
foreach ($lines as $line) {
245+
fwrite($this->file, "\r" . $line . Ansi::clearLine() . "\n");
246+
}
247+
fflush($this->file);
248+
249+
$this->panelHeight = count($lines);
250+
}
161251
}

src/Runner/OutputHandler.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
/**
1212
* Runner output.
13+
* @method void jobStarted(Job $job) called when a job starts running
14+
* @method void tick(Job[] $running) called periodically during test execution
1315
*/
1416
interface OutputHandler
1517
{

src/Runner/Runner.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class Runner
4242

4343
/** @var array<string, int> test signature => result (Test::Prepared|Passed|Failed|Skipped) */
4444
private array $lastResults = [];
45+
private int $jobCount = 0;
46+
private int $finishedCount = 0;
4547

4648

4749
public function __construct(PhpInterpreter $interpreter)
@@ -93,6 +95,8 @@ public function run(): bool
9395
foreach ($this->paths as $path) {
9496
$this->findTests($path);
9597
}
98+
$this->finishedCount = 0;
99+
$this->jobCount = count($this->jobs);
96100

97101
if ($this->tempDir) {
98102
usort(
@@ -102,18 +106,28 @@ public function run(): bool
102106
}
103107

104108
$threads = range(1, $this->threadCount);
105-
106109
$async = $this->threadCount > 1 && count($this->jobs) > 1;
107110

108111
try {
109112
while (($this->jobs || $running) && !$this->interrupted) {
110113
while ($threads && $this->jobs) {
111114
$running[] = $job = array_shift($this->jobs);
112115
$job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads));
116+
foreach ($this->outputHandlers as $handler) {
117+
if (method_exists($handler, 'jobStarted')) {
118+
$handler->jobStarted($job);
119+
}
120+
}
113121
$job->run(async: $async);
114122
}
115123

116124
if ($async) {
125+
foreach ($this->outputHandlers as $handler) {
126+
if (method_exists($handler, 'tick')) {
127+
$handler->tick($running);
128+
}
129+
}
130+
117131
Job::waitForActivity($running);
118132
}
119133

@@ -124,6 +138,7 @@ public function run(): bool
124138

125139
if (!$job->isRunning()) {
126140
$threads[] = $job->getEnvironmentVariable(Environment::VariableThread);
141+
$this->finishedCount++;
127142
$this->testHandler->assess($job);
128143
unset($running[$key]);
129144
}
@@ -214,6 +229,18 @@ public function getInterpreter(): PhpInterpreter
214229
}
215230

216231

232+
public function getJobCount(): int
233+
{
234+
return $this->jobCount;
235+
}
236+
237+
238+
public function getFinishedCount(): int
239+
{
240+
return $this->finishedCount;
241+
}
242+
243+
217244
private function getLastResult(Test $test): int
218245
{
219246
$signature = $test->getSignature();

tests/Framework/Ansi.pad.phpt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php declare(strict_types=1);
2+
3+
use Tester\Ansi;
4+
use Tester\Assert;
5+
6+
require __DIR__ . '/../bootstrap.php';
7+
8+
9+
// pad() - right (default)
10+
Assert::same('hello ', Ansi::pad('hello', 10));
11+
Assert::same('hello ', Ansi::pad('hello', 10, ' ', STR_PAD_RIGHT));
12+
13+
// pad() - left
14+
Assert::same(' hello', Ansi::pad('hello', 10, ' ', STR_PAD_LEFT));
15+
16+
// pad() - center (both)
17+
Assert::same(' hello ', Ansi::pad('hello', 10, ' ', STR_PAD_BOTH));
18+
Assert::same(' hi ', Ansi::pad('hi', 8, ' ', STR_PAD_BOTH)); // odd padding: 3 left, 3 right
19+
Assert::same(' hi ', Ansi::pad('hi', 7, ' ', STR_PAD_BOTH)); // odd padding: 2 left, 3 right
20+
21+
// pad() - text already at width
22+
Assert::same('hello', Ansi::pad('hello', 5));
23+
Assert::same('hello', Ansi::pad('hello', 3)); // over width, no change
24+
25+
// pad() - unicode padding character
26+
Assert::same('hello─────', Ansi::pad('hello', 10, ''));
27+
Assert::same('─────hello', Ansi::pad('hello', 10, '', STR_PAD_LEFT));
28+
Assert::same('──hello───', Ansi::pad('hello', 10, '', STR_PAD_BOTH));
29+
30+
// pad() - text with emoji (emoji is 2-wide)
31+
Assert::same('🍏×5 ', Ansi::pad('🍏×5', 8)); // 🍏(2) + ×(1) + 5(1) = 4 width, need 4 spaces
32+
Assert::same(' 🍏×5', Ansi::pad('🍏×5', 8, ' ', STR_PAD_LEFT));
33+
34+
// pad() - empty string
35+
Assert::same(' ', Ansi::pad('', 5));

0 commit comments

Comments
 (0)