Skip to content

Commit 156347a

Browse files
committed
improved --cider mode (thx Claude)
1 parent b28bec7 commit 156347a

File tree

7 files changed

+273
-4
lines changed

7 files changed

+273
-4
lines changed

phpstan-baseline.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ parameters:
9090
count: 1
9191
path: src/Runner/CliTester.php
9292

93+
-
94+
message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''jobStarted'' will always evaluate to true\.$#'
95+
identifier: function.alreadyNarrowedType
96+
count: 1
97+
path: src/Runner/Runner.php
98+
99+
-
100+
message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''tick'' will always evaluate to true\.$#'
101+
identifier: function.alreadyNarrowedType
102+
count: 1
103+
path: src/Runner/Runner.php
104+
93105
-
94106
message: '#^If condition is always false\.$#'
95107
identifier: if.alwaysFalse

src/Framework/Ansi.php

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

8080

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

src/Runner/Output/ConsolePrinter.php

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

1212
use Tester;
1313
use Tester\Ansi;
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;
1819

1920

2021
/**
@@ -26,6 +27,8 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
2627
public const ModeCider = 2;
2728
public const ModeLines = 3;
2829

30+
private const MaxDisplayedThreads = 20;
31+
2932
/** @var resource */
3033
private $file;
3134
private string $buffer;
@@ -35,6 +38,11 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
3538
/** @var array<int, int> result type (Test::*) => count */
3639
private array $results;
3740
private ?string $baseDir;
41+
private int $panelWidth = 60;
42+
private int $panelHeight = 0;
43+
44+
/** @var \WeakMap<Job, float> */
45+
private \WeakMap $startTimes;
3846

3947

4048
public function __construct(
@@ -45,6 +53,7 @@ public function __construct(
4553
private int $mode = self::ModeDots,
4654
) {
4755
$this->file = fopen($file ?? 'php://output', 'w') ?: throw new \RuntimeException("Cannot open file '$file' for writing.");
56+
$this->startTimes = new \WeakMap;
4857
}
4958

5059

@@ -55,6 +64,9 @@ public function begin(): void
5564
$this->baseDir = null;
5665
$this->results = [Test::Passed => 0, Test::Skipped => 0, Test::Failed => 0];
5766
$this->time = -microtime(as_float: true);
67+
if ($this->mode === self::ModeCider && $this->runner->threadCount < 2) {
68+
$this->mode = self::ModeLines;
69+
}
5870
fwrite($this->file, $this->runner->getInterpreter()->getShortInfo()
5971
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
6072
. " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n");
@@ -91,7 +103,7 @@ public function finish(Test $test): void
91103
$this->results[$result]++;
92104
fwrite($this->file, match ($this->mode) {
93105
self::ModeDots => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Ansi::colorize('F', 'white/red')][$result],
94-
self::ModeCider => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'][$result],
106+
self::ModeCider => '',
95107
self::ModeLines => $this->generateFinishLine($test),
96108
});
97109

@@ -108,6 +120,12 @@ public function finish(Test $test): void
108120

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

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: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class Runner
4444

4545
/** @var array<string, int> test signature => result (Test::Prepared|Passed|Failed|Skipped) */
4646
private array $lastResults = [];
47+
private int $jobCount = 0;
48+
private int $finishedCount = 0;
4749

4850

4951
public function __construct(PhpInterpreter $interpreter)
@@ -95,6 +97,8 @@ public function run(): bool
9597
foreach ($this->paths as $path) {
9698
$this->findTests($path);
9799
}
100+
$this->finishedCount = 0;
101+
$this->jobCount = count($this->jobs);
98102

99103
if ($this->tempDir) {
100104
usort(
@@ -104,18 +108,28 @@ public function run(): bool
104108
}
105109

106110
$threads = range(1, $this->threadCount);
107-
108111
$async = $this->threadCount > 1 && count($this->jobs) > 1;
109112

110113
try {
111114
while (($this->jobs || $running) && !$this->interrupted) {
112115
while ($threads && $this->jobs) {
113116
$running[] = $job = array_shift($this->jobs);
114117
$job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads));
118+
foreach ($this->outputHandlers as $handler) {
119+
if (method_exists($handler, 'jobStarted')) {
120+
$handler->jobStarted($job);
121+
}
122+
}
115123
$job->run(async: $async);
116124
}
117125

118126
if ($async) {
127+
foreach ($this->outputHandlers as $handler) {
128+
if (method_exists($handler, 'tick')) {
129+
$handler->tick($running);
130+
}
131+
}
132+
119133
Job::waitForActivity($running);
120134
}
121135

@@ -126,6 +140,7 @@ public function run(): bool
126140

127141
if (!$job->isRunning()) {
128142
$threads[] = $job->getEnvironmentVariable(Environment::VariableThread);
143+
$this->finishedCount++;
129144
$this->testHandler->assess($job);
130145
unset($running[$key]);
131146
}
@@ -216,6 +231,18 @@ public function getInterpreter(): PhpInterpreter
216231
}
217232

218233

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

tests/Framework/Ansi.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\Ansi;
6+
use Tester\Assert;
7+
8+
require __DIR__ . '/../bootstrap.php';
9+
10+
11+
// pad() - right (default)
12+
Assert::same('hello ', Ansi::pad('hello', 10));
13+
Assert::same('hello ', Ansi::pad('hello', 10, ' ', STR_PAD_RIGHT));
14+
15+
// pad() - left
16+
Assert::same(' hello', Ansi::pad('hello', 10, ' ', STR_PAD_LEFT));
17+
18+
// pad() - center (both)
19+
Assert::same(' hello ', Ansi::pad('hello', 10, ' ', STR_PAD_BOTH));
20+
Assert::same(' hi ', Ansi::pad('hi', 8, ' ', STR_PAD_BOTH)); // odd padding: 3 left, 3 right
21+
Assert::same(' hi ', Ansi::pad('hi', 7, ' ', STR_PAD_BOTH)); // odd padding: 2 left, 3 right
22+
23+
// pad() - text already at width
24+
Assert::same('hello', Ansi::pad('hello', 5));
25+
Assert::same('hello', Ansi::pad('hello', 3)); // over width, no change
26+
27+
// pad() - unicode padding character
28+
Assert::same('hello─────', Ansi::pad('hello', 10, ''));
29+
Assert::same('─────hello', Ansi::pad('hello', 10, '', STR_PAD_LEFT));
30+
Assert::same('──hello───', Ansi::pad('hello', 10, '', STR_PAD_BOTH));
31+
32+
// pad() - text with emoji (emoji is 2-wide)
33+
Assert::same('🍏×5 ', Ansi::pad('🍏×5', 8)); // 🍏(2) + ×(1) + 5(1) = 4 width, need 4 spaces
34+
Assert::same(' 🍏×5', Ansi::pad('🍏×5', 8, ' ', STR_PAD_LEFT));
35+
36+
// pad() - empty string
37+
Assert::same(' ', Ansi::pad('', 5));

0 commit comments

Comments
 (0)