Skip to content

Commit 13491d1

Browse files
committed
improved --cider mode (thx Claude)
1 parent bb70da3 commit 13491d1

File tree

6 files changed

+254
-5
lines changed

6 files changed

+254
-5
lines changed

src/Framework/Console.php

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
namespace Tester;
1111

12-
use const PHP_SAPI, STDOUT;
12+
use const PHP_SAPI, STDOUT, STR_PAD_BOTH, STR_PAD_LEFT, STR_PAD_RIGHT;
1313

1414

1515
/**
@@ -96,12 +96,70 @@ public static function showCursor(bool $visible): string
9696
}
9797

9898

99+
/**
100+
* Returns ANSI sequence to move cursor up.
101+
*/
102+
public static function cursorUp(int $lines): string
103+
{
104+
return "\e[{$lines}A";
105+
}
106+
107+
99108
/**
100109
* Returns display width of string (number of terminal columns).
101110
*/
102111
public static function textWidth(string $text): int
103112
{
113+
$text = self::stripAnsi($text);
104114
return preg_match_all('/./su', $text)
105115
+ preg_match_all('/[\x{1F300}-\x{1F9FF}]/u', $text); // emoji are 2-wide
106116
}
117+
118+
119+
/**
120+
* Pads text to specified display width.
121+
*/
122+
public static function pad(
123+
string $text,
124+
int $width,
125+
string $char = ' ',
126+
int $type = STR_PAD_RIGHT,
127+
): string
128+
{
129+
$padding = $width - self::textWidth($text);
130+
if ($padding <= 0) {
131+
return $text;
132+
}
133+
134+
return match ($type) {
135+
STR_PAD_LEFT => str_repeat($char, $padding) . $text,
136+
STR_PAD_RIGHT => $text . str_repeat($char, $padding),
137+
STR_PAD_BOTH => str_repeat($char, intdiv($padding, 2)) . $text . str_repeat($char, $padding - intdiv($padding, 2)),
138+
};
139+
}
140+
141+
142+
/**
143+
* Truncates text to max display width, adding ellipsis if needed.
144+
*/
145+
public static function truncate(string $text, int $maxWidth, string $ellipsis = ''): string
146+
{
147+
if (self::textWidth($text) <= $maxWidth) {
148+
return $text;
149+
}
150+
151+
$ellipsisWidth = self::textWidth($ellipsis);
152+
$result = '';
153+
$width = 0;
154+
foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {
155+
$charWidth = preg_match('/[\x{1F300}-\x{1F9FF}]/u', $char) ? 2 : 1;
156+
if ($width + $charWidth + $ellipsisWidth > $maxWidth) {
157+
break;
158+
}
159+
$result .= $char;
160+
$width += $charWidth;
161+
}
162+
163+
return $result . $ellipsis;
164+
}
107165
}

src/Runner/Output/ConsolePrinter.php

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111

1212
use Tester;
1313
use Tester\Console;
14+
use Tester\Environment;
15+
use Tester\Runner\Job;
1416
use Tester\Runner\Runner;
1517
use Tester\Runner\Test;
16-
use function sprintf, strlen;
17-
use const DIRECTORY_SEPARATOR;
18+
use function count, fwrite, sprintf, str_repeat, strlen;
19+
use const DIRECTORY_SEPARATOR, STR_PAD_BOTH;
1820

1921

2022
/**
@@ -26,13 +28,18 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
2628
public const ModeCider = 2;
2729
public const ModeLine = 3;
2830

31+
private const MaxDisplayedThreads = 20;
32+
2933
/** @var resource */
3034
private $file;
3135
private string $buffer;
3236
private float $time;
3337
private int $count;
3438
private array $results;
3539
private ?string $baseDir;
40+
private int $panelWidth = 60;
41+
private int $panelHeight = 0;
42+
private \WeakMap $startTimes;
3643

3744

3845
public function __construct(
@@ -42,6 +49,7 @@ public function __construct(
4249
private int $mode = self::ModeDots,
4350
) {
4451
$this->file = fopen($file ?? 'php://output', 'w');
52+
$this->startTimes = new \WeakMap;
4553
}
4654

4755

@@ -52,6 +60,9 @@ public function begin(): void
5260
$this->baseDir = null;
5361
$this->results = [Test::Passed => 0, Test::Skipped => 0, Test::Failed => 0];
5462
$this->time = -microtime(as_float: true);
63+
if ($this->mode === self::ModeCider && $this->runner->threadCount < 2) {
64+
$this->mode = self::ModeLine;
65+
}
5566
fwrite($this->file, Console::showCursor(false)
5667
. $this->runner->getInterpreter()->getShortInfo()
5768
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
@@ -89,7 +100,7 @@ public function finish(Test $test): void
89100
$this->results[$result]++;
90101
fwrite($this->file, match ($this->mode) {
91102
self::ModeDots => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Console::colorize('F', 'white/red')][$result],
92-
self::ModeCider => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'][$result],
103+
self::ModeCider => '',
93104
self::ModeLine => $this->generateFinishLine($test),
94105
});
95106

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

107118
public function end(): void
108119
{
120+
if ($this->panelHeight) {
121+
fwrite($this->file, Console::cursorUp($this->panelHeight)
122+
. str_repeat(Console::ClearLine . "\n", $this->panelHeight)
123+
. Console::cursorUp($this->panelHeight));
124+
}
125+
109126
$run = array_sum($this->results);
110127
fwrite($this->file, Console::showCursor(true));
111128
fwrite($this->file, !$this->count ? "No tests found\n" :
@@ -159,4 +176,76 @@ private function generateFinishLine(Test $test): string
159176
$message,
160177
);
161178
}
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, Console::cursorUp($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[] = '' . Console::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 = Console::pad(Console::truncate($name, $nameWidth), $nameWidth);
224+
$line = Console::colorize(sprintf("%{$numWidth}d:", $t), 'lime') . " $name " . Console::colorize($time, 'yellow');
225+
} else {
226+
$line = Console::pad(Console::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 = Console::colorize("… and $more more", 'gray');
234+
$lines[] = '' . Console::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[] = '' . Console::pad($summary, $this->panelWidth - 2, '', STR_PAD_BOTH) . '';
243+
244+
foreach ($lines as $line) {
245+
fwrite($this->file, "\r" . $line . Console::ClearLine . "\n");
246+
}
247+
fflush($this->file);
248+
249+
$this->panelHeight = count($lines);
250+
}
162251
}

src/Runner/OutputHandler.php

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

1313
/**
1414
* Runner output.
15+
* @method void jobStarted(Job $job) called when a job starts running
16+
* @method void tick(Job[] $running) called periodically during test execution
1517
*/
1618
interface OutputHandler
1719
{

src/Runner/Runner.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class Runner
3939
private ?string $tempDir = null;
4040
private bool $result;
4141
private array $lastResults = [];
42+
private int $jobCount = 0;
43+
private int $finishedCount = 0;
4244

4345

4446
public function __construct(PhpInterpreter $interpreter)
@@ -89,6 +91,8 @@ public function run(): bool
8991
foreach ($this->paths as $path) {
9092
$this->findTests($path);
9193
}
94+
$this->finishedCount = 0;
95+
$this->jobCount = count($this->jobs);
9296

9397
if ($this->tempDir) {
9498
usort(
@@ -98,18 +102,27 @@ public function run(): bool
98102
}
99103

100104
$threads = range(1, $this->threadCount);
101-
102105
$async = $this->threadCount > 1 && count($this->jobs) > 1;
103106

104107
try {
105108
while (($this->jobs || $running) && !$this->interrupted) {
106109
while ($threads && $this->jobs) {
107110
$running[] = $job = array_shift($this->jobs);
108111
$job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads));
112+
foreach ($this->outputHandlers as $handler) {
113+
if (method_exists($handler, 'jobStarted')) {
114+
$handler->jobStarted($job);
115+
}
116+
}
109117
$job->run(async: $async);
110118
}
111119

112120
if ($async) {
121+
foreach ($this->outputHandlers as $handler) {
122+
if (method_exists($handler, 'tick')) {
123+
$handler->tick($running);
124+
}
125+
}
113126
usleep(Job::RunSleep); // stream_select() doesn't work with proc_open()
114127
}
115128

@@ -120,6 +133,7 @@ public function run(): bool
120133

121134
if (!$job->isRunning()) {
122135
$threads[] = $job->getEnvironmentVariable(Environment::VariableThread);
136+
$this->finishedCount++;
123137
$this->testHandler->assess($job);
124138
unset($running[$key]);
125139
}
@@ -210,6 +224,18 @@ public function getInterpreter(): PhpInterpreter
210224
}
211225

212226

227+
public function getJobCount(): int
228+
{
229+
return $this->jobCount;
230+
}
231+
232+
233+
public function getFinishedCount(): int
234+
{
235+
return $this->finishedCount;
236+
}
237+
238+
213239
private function getLastResult(Test $test): int
214240
{
215241
$signature = $test->getSignature();

tests/Framework/Console.pad.phpt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tester\Assert;
6+
use Tester\Console;
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
// pad() - right (default)
12+
Assert::same('hello ', Console::pad('hello', 10));
13+
Assert::same('hello ', Console::pad('hello', 10, ' ', STR_PAD_RIGHT));
14+
15+
// pad() - left
16+
Assert::same(' hello', Console::pad('hello', 10, ' ', STR_PAD_LEFT));
17+
18+
// pad() - center (both)
19+
Assert::same(' hello ', Console::pad('hello', 10, ' ', STR_PAD_BOTH));
20+
Assert::same(' hi ', Console::pad('hi', 8, ' ', STR_PAD_BOTH)); // odd padding: 3 left, 3 right
21+
Assert::same(' hi ', Console::pad('hi', 7, ' ', STR_PAD_BOTH)); // odd padding: 2 left, 3 right
22+
23+
// pad() - text already at width
24+
Assert::same('hello', Console::pad('hello', 5));
25+
Assert::same('hello', Console::pad('hello', 3)); // over width, no change
26+
27+
// pad() - unicode padding character
28+
Assert::same('hello─────', Console::pad('hello', 10, ''));
29+
Assert::same('─────hello', Console::pad('hello', 10, '', STR_PAD_LEFT));
30+
Assert::same('──hello───', Console::pad('hello', 10, '', STR_PAD_BOTH));
31+
32+
// pad() - text with emoji (emoji is 2-wide)
33+
Assert::same('🍏×5 ', Console::pad('🍏×5', 8)); // 🍏(2) + ×(1) + 5(1) = 4 width, need 4 spaces
34+
Assert::same(' 🍏×5', Console::pad('🍏×5', 8, ' ', STR_PAD_LEFT));
35+
36+
// pad() - empty string
37+
Assert::same(' ', Console::pad('', 5));
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tester\Assert;
6+
use Tester\Console;
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
// truncate() - no truncation needed
12+
Assert::same('hello', Console::truncate('hello', 10));
13+
Assert::same('hello', Console::truncate('hello', 5)); // exactly at width
14+
15+
// truncate() - basic truncation
16+
Assert::same('hel…', Console::truncate('hello', 4));
17+
Assert::same('h…', Console::truncate('hello', 2));
18+
Assert::same('hello wor…', Console::truncate('hello world', 10));
19+
20+
// truncate() - custom ellipsis
21+
Assert::same('hello...', Console::truncate('hello world', 8, '...'));
22+
Assert::same('hell...', Console::truncate('hello world', 7, '...'));
23+
24+
// truncate() - unicode text
25+
Assert::same('příl…', Console::truncate('příliš', 5));
26+
Assert::same('žlu…', Console::truncate('žluťoučký', 4));
27+
28+
// truncate() - text with emoji (emoji is 2-wide)
29+
Assert::same('🍏…', Console::truncate('🍏🍎🍏', 4)); // 🍏(2) + …(1) = 3, fits in 4
30+
Assert::same('🍏🍎…', Console::truncate('🍏🍎🍏', 5)); // 🍏(2) + 🍎(2) + …(1) = 5, fits in 5
31+
Assert::same('a🍏…', Console::truncate('a🍏🍎', 4)); // a(1) + 🍏(2) + …(1) = 4, fits in 4
32+
33+
// truncate() - edge case: maxWidth smaller than ellipsis
34+
Assert::same('', Console::truncate('hello', 1));
35+
36+
// truncate() - empty string
37+
Assert::same('', Console::truncate('', 5));

0 commit comments

Comments
 (0)