rosavox/lib/alveolata/test.php

986 lines
19 KiB
PHP
Raw Permalink Normal View History

2025-05-23 07:33:29 +00:00
<?php
namespace alveolata\test;
/**
* @author Christian Fraß <frass@greenscale.de>
*/
function _fatal_error(
string $message
) : void
{
// throw (new \Exception($message));
error_log(sprintf('FATAL ERROR: %s', $message));
exit(1);
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
function _string_coin(
string $template,
array $arguments = []
) : string
{
$result = $template;
foreach ($arguments as $key => $value) {
$pattern = sprintf('{{%s}}', $key);
$replacement = strval($value);
$result = str_replace($pattern, $replacement, $result);
}
return $result;
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
function _list_filter(
array $list,
\Closure $predicate
) : array
{
return array_values(array_filter($list, $predicate));
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
function _colorize(
string $string,
string $color,
string $mode = 'foreground'
) : string
{
$map = [
'foreground' => [
'blue' => '34',
'green' => '32',
'purple' => '35',
'red' => '31',
'white' => '37',
'yellow' => '33',
],
'background' => [
'blue' => '44',
'green' => '42',
'purple' => '45',
'red' => '41',
'white' => '47',
'yellow' => '43',
],
];
return _string_coin(
"\033[{{colorcode}}m{{string}}\033[0m",
[
'colorcode' => $map[$mode][$color],
'string' => $string,
]
);
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
function _log(
int $level,
string $message
) : void
{
error_log(
_string_coin(
'{{indentation}}{{message}}',
[
'indentation' => str_repeat(" ", $level-1),
'message' => $message,
]
)
);
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
class struct_case
{
public $name;
public $active;
public $procedure;
public function __construct(
string $name,
bool $active,
\Closure $procedure
)
{
$this->name = $name;
$this->active = $active;
$this->procedure = $procedure;
}
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
class struct_section
{
public $name;
public $active;
public $setup;
public $cleanup;
public $subsections;
public $cases;
public function __construct(
string $name,
bool $active,
\Closure $setup,
\Closure $cleanup,
array $subsections,
array $cases
)
{
$this->name = $name;
$this->active = $active;
$this->setup = $setup;
$this->cleanup = $cleanup;
$this->subsections = $subsections;
$this->cases = $cases;
}
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
class struct_state
{
public $name;
public $sign;
public $color;
public function __construct(
$name,
$sign,
$color
)
{
$this->name = $name;
$this->sign = $sign;
$this->color = $color;
}
public static $skipped = null;
public static $aborted = null;
public static $failed = null;
public static $passed = null;
public static function init(
) : void
{
self::$skipped = new static('skipped', 'o', 'yellow');
self::$aborted = new static('aborted', '!', 'purple');
self::$failed = new static('failed', 'x', 'red');
self::$passed = new static('passed', '+', 'green');
}
}
struct_state::init();
/**
* @author Christian Fraß <frass@greenscale.de>
*/
class struct_report
{
public /*array */$path;
public /*struct_state */$state;
public /*string */$messages;
public function __construct(
array $path,
struct_state $state,
array $messages
)
{
$this->path = $path;
$this->state = $state;
$this->messages = $messages;
}
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
class class_assertion
{
/**
* @var function<~string,void>
* @author Christian Fraß <frass@greenscale.de>
*/
private $finalize;
/**
* @author Christian Fraß <frass@greenscale.de>
*/
public function __construct(
\Closure $finalize
)
{
$this->finalize = $finalize;
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
public function not(
) : class_assertion
{
return (
new class_assertion(
function (/*?string */$message) : void {
if (is_null($message)) {
($this->finalize)('should NOT be');
}
else {
($this->finalize)(null);
}
}
)
);
}
/**
* unspecific explicit fail
*
* @author Christian Fraß <frass@greenscale.de>
*/
public function fail(
$reason = null
)
{
$message = ($reason ?: '(unexplained fail)');
($this->finalize)($message);
}
/**
* unspecific generic test
*
* @author Christian Fraß <frass@greenscale.de>
*/
public function is(
bool $value
)
{
$message = ($value ? null : 'condition not met');
($this->finalize)($message);
}
/**
* tests whether the piece of code runs without throwing an error
*
* @param \Closure $procedure
* @author Christian Fraß <frass@greenscale.de>
*/
public function runs(
\Closure $procedure
)
{
try {
$procedure();
$message = null;
}
catch (\Throwable $throwable) {
$message = _string_coin(
'does not run; reason: {{reason}}',
[
'reason' => $throwable->getMessage(),
]
);
}
($this->finalize)($message);
}
/**
* tests whether the piece of code runs with throwing an error
*
* @param \Closure $procedure
* @author Christian Fraß <frass@greenscale.de>
*/
public function crashes(
\Closure $procedure,
?string $throwable_class = null
)
{
try {
$procedure();
$message = _string_coin(
'does run though it is not supposed to do',
[
]
);
}
catch (\Throwable $throwable) {
if (
(is_null($throwable_class))
||
(get_class($throwable) === $throwable_class)
) {
$message = null;
}
else {
$message = _string_coin(
'wrong throwable class: [actual] {{actual}}; [expected] {{expected}}',
[
'actual' => get_class($throwable),
'expected' => $throwable_class,
]
);
}
}
($this->finalize)($message);
}
/**
* tests whether two values are equal
*
* @param any $value_actual
* @param any $value_expected
* @author Christian Fraß <frass@greenscale.de>
*/
public function equal(
$value_actual,
$value_expected
)
{
if ($value_actual === $value_expected) {
$message = null;
}
else {
$message = _string_coin(
'[actual] {{actual}}; [expected] {{expected}}',
[
'actual' => json_encode($value_actual),
'expected' => json_encode($value_expected),
]
);
}
($this->finalize)($message);
}
}
/**
* @var struct_section
* @author Christian Fraß <frass@greenscale.de>
*/
$_root = null;
/**
* merges two sections
*
* @author Christian Fraß <frass@greenscale.de>
*/
function _merge(
struct_section $section1,
struct_section $section2
) : struct_section
{
if (! ($section1->name === $section2->name)) {
throw (new \Exception('sections with different names may not be merged'));
}
else {
if (! ($section1->active === $section2->active)) {
throw (new \Exception('sections with different active states may not be merged'));
}
else {
$subsections = [];
foreach ([$section1, $section2] as $section) {
foreach ($section->subsections as $subsection_name => $subsection) {
if (! array_key_exists($subsection_name, $subsections)) {
$subsections[$subsection_name] = $subsection;
}
else {
$subsections[$subsection_name] = _merge(
$subsections[$subsection_name],
$subsection
);
}
}
}
$section_merged = (
new struct_section(
$section1->name,
$section1->active,
// TODO: subenvironments?
function (&$environment) use ($section1, $section2) : void {
($section1->setup)($environment);
($section2->setup)($environment);
},
function (&$environment) use ($section1, $section2) : void {
($section1->cleanup)($environment);
($section2->cleanup)($environment);
},
$subsections,
array_merge(
$section1->cases,
$section2->cases
)
)
);
return $section_merged;
}
}
}
/**
* adds a regular test definition
*
* @author Christian Fraß <frass@greenscale.de>
*/
function _add(
struct_section &$section_parent,
array $section_raw_given
)
{
$section_raw_default = [
'name' => '?',
'active' => true,
'setup' => (function (&$environment) {}),
'cleanup' => (function (&$environment) {}),
'sections' => [],
'cases' => [],
];
$section_raw = array_merge(
$section_raw_default,
$section_raw_given
);
$section = new struct_section(
$section_raw['name'],
$section_raw['active'],
$section_raw['setup'],
$section_raw['cleanup'],
[],
[]
);
// sections
{
foreach ($section_raw['sections'] as $section_sub_raw) {
_add($section, $section_sub_raw);
}
}
// cases
{
foreach ($section_raw['cases'] as $case_raw_given) {
$case_raw_default = [
'name' => '?',
'active' => true,
'procedure' => function ($assert, &$environment) {
$assert->fail('not implemented');
},
];
$case_raw = array_merge($case_raw_default, $case_raw_given);
$case = new struct_case(
$case_raw['name'],
$case_raw['active'],
$case_raw['procedure']
);
array_push($section->cases, $case);
}
}
// array_push($section_parent->subsections, $section);
{
if (array_key_exists($section->name, $section_parent->subsections)) {
$section_parent->subsections[$section->name] = _merge(
$section_parent->subsections[$section->name],
$section
);
}
else {
$section_parent->subsections[$section->name] = $section;
}
}
}
/**
* adds a json based test definition
*
* @author Christian Fraß <frass@greenscale.de>
*/
function _adapt(
string $spec_path
)
{
global $_root;
$steps = explode('/', $spec_path);
$paths_to_try = [
sprintf('%s/%s.php', implode('/', array_slice($steps, 0, count($steps)-1)), $steps[count($steps)-1]),
sprintf('%s/functions.php', implode('/', array_slice($steps, 0, count($steps)-1))),
];
foreach ($paths_to_try as $path) {
if (file_exists($path)) {
require_once($path);
break;
}
}
$spec = json_decode(file_get_contents(sprintf('%s.spec.json', $spec_path)), true);
if (empty($spec)) {
$message = _string_coin(
'json based test definition "{{path}}" malformed',
[
'path' => $spec_path,
]
);
throw (new \Exception($message));
}
_add(
$_root,
[
'name' => $spec_path,
'active' => $spec['active'],
'sections' => array_map(
function ($section_raw) {
return [
'name' => $section_raw['name'],
'active' => $section_raw['active'],
'cases' => array_map(
function ($case_raw) use ($section_raw) {
return [
'name' => $case_raw['name'],
'active' => $case_raw['active'],
'procedure' => function ($assert, $environment) use ($section_raw, $case_raw) {
$expression = _string_coin(
$section_raw['execution']['call'],
array_map(
function ($value) {
return var_export($value, true);
},
$case_raw['input']
)
);
switch ($case_raw['output']['kind']) {
case 'runs': {
$assert->runs(
function () use (&$expression) {
eval(sprintf('return %s;', $expression));
}
);
break;
}
case 'crashes': {
$assert->crashes(
function () use (&$expression) {
eval(sprintf('return %s;', $expression));
}
);
break;
}
case 'regular': {
$result_actual = eval(sprintf('return %s;', $expression));
$result_expected = $case_raw['output']['value'];
$assert->equal($result_actual, $result_expected);
break;
}
default: {
$message = _string_coin(
'unhadled output kind "{{kind}}"',
[
'kind' => $case['output']['kind']
]
);
throw (new \Exception($message));
break;
}
}
}
];
},
$section_raw['cases']
)
];
},
$spec['sections']
),
]
);
}
/**
* @param array $path
* @return struct_section section
* @author Christian Fraß <frass@greenscale.de>
*/
function _get(
array $path,
struct_section $section = null,
array $path_complete = null
) : ?struct_section
{
global $_root;
if ($section === null) {
$section = $_root;
}
if ($path_complete === null) {
$path_complete = $path;
}
if (count($path) === 0) {
return $section;
}
else {
$name = $path[0];
foreach ($section->subsections as $section_sub_name => $section_sub) {
if ($section_sub->name === $name) {
return _get(
array_slice($path, 1),
$section_sub,
$path_complete
);
}
}
$message = _string_coin(
'no test section "{{path}}"',
[
'path' => implode('.', $path_complete),
]
);
throw (new \Exception($message));
}
}
/**
* @param struct_section $section
* @param bool $log
* @return list<struct_report>
* @author Christian Fraß <frass@greenscale.de>
*/
function _execute(
struct_section $section = null,
bool $log = true,
array $path = [],
&$environment = [],
$active = true
) : array
{
global $_root;
if ($section === null) {
$section = $_root;
}
if (! $active) {
return [];
}
else {
$level = count($path);
$reports = [];
if (count($path) > 0) {
if ($log) {
_log(
$level,
_string_coin(
'<{{name}}>',
[
'name' => $section->name,
]
)
);
}
}
($section->setup)($environment);
foreach ($section->subsections as $section_sub_name => &$section_sub) {
$reports_sub = _execute(
$section_sub,
$log,
array_merge($path, [$section_sub->name]),
$environment,
$active && $section_sub->active
);
$reports = array_merge($reports, $reports_sub);
}
unset($section_sub);
foreach ($section->cases as &$case) {
$messages = [];
if (!$active || !$case->active) {
$state = struct_state::$skipped;
$messages = [];
}
else {
$state = struct_state::$passed;
$assert = new class_assertion(
function ($message) use (&$state, &$messages) {
if ($message === null) {
// do nothing
}
else {
array_push($messages, $message);
$state = struct_state::$failed;
}
}
);
try {
($case->procedure)($assert, $environment);
}
catch (\Throwable $throwable) {
$state = struct_state::$aborted;
$message = _string_coin(
"{{description}} in {{file}}:{{line}}",
[
'description' => $throwable->getMessage(),
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'stacktrace' => $throwable->getTraceAsString(),
]
);
array_push($messages, $message);
}
}
$report = new struct_report(
array_merge($path, [$case->name]),
$state,
$messages
);
if ($log) {
_log(
$level+1,
_colorize(
_string_coin(
'[{{indicator}}] {{name}}',
[
'indicator' => $state->sign,
// 'indicator' => _colorize($state->sign, $state->color),
// 'indicator' => _colorize('x', $state->color, 'background'),
// 'indicator' => _colorize('x', $state->color, 'foreground'),
'name' => $case->name,
]
),
$state->color
)
);
}
array_push($reports, $report);
}
unset($case);
($section->cleanup)($environment);
return $reports;
}
}
/**
* searching and adding test definitions
*
* @author Christian Fraß <frass@greenscale.de>
*/
function _gather(
) : void
{
global $_root;
// .spec.php
{
exec(
'find . -name "*.spec.php" | cut -d "/" -f "2-"',
$paths
);
foreach ($paths as $path) {
include($path);
}
}
// .spec.json
{
exec(
'find . -name "*.spec.json" | sed -e "s:.spec.json::g" | cut -d "/" -f "2-"',
$spec_paths
);
foreach ($spec_paths as $spec_path) {
_adapt($spec_path);
}
}
}
/**
* @param list<struct_report>
* @return record<apt:bool,script:string>
* @author Christian Fraß <frass@greenscale.de>
*/
function _evaluation(
array $reports
) : array
{
$counts = [
struct_state::$skipped->name => 0,
struct_state::$aborted->name => 0,
struct_state::$failed->name => 0,
struct_state::$passed->name => 0,
];
foreach ($reports as $report) {
$counts[$report->state->name] += 1;
}
$count_total = count($reports);
$apt = ($counts[struct_state::$skipped->name] + $counts[struct_state::$passed->name] === $count_total);
$script = '';
$script .= ('--------' . "\n");
{
$script .= ('# summary' . "\n");
$script .= ('' . "\n");
if ($count_total === 0) {
$script .= ('(no tests found)' . "\n");
}
else {
$name = (struct_state::$passed)->name;
$share = (1.0 * $counts[$name] / $count_total);
$script .= (
_string_coin(
'{{count_passed}}/{{count_total}} passed ({{percentage}}%)' . "\n",
[
'count_passed' => sprintf('%d', $counts[struct_state::$passed->name]),
'count_total' => sprintf('%d', $count_total),
'percentage' => sprintf('%.2f', $share*100),
]
)
);
}
$script .= ('' . "\n");
$script .= ('' . "\n");
}
{
$states = [
struct_state::$skipped,
struct_state::$failed,
struct_state::$aborted,
];
foreach ($states as $state) {
$reports_filtered = _list_filter(
$reports,
function ($report) use (&$state) {
return ($report->state === $state);
}
);
if (count($reports_filtered) > 0) {
$script .= (
_string_coin(
'# {{name}}' . "\n",
[
'name' => $state->name
]
)
);
$script .= ('' . "\n");
foreach ($reports_filtered as $report) {
$script .= (
_string_coin(
'- `{{path}}`' . "\n",
[
'path' => implode('.', $report->path),
]
)
);
// $script .= ('' . "\n");
foreach ($report->messages as $message) {
$script .= (
_string_coin(
"\t" . '- {{message}}' . "\n",
[
'message' => $message,
]
)
);
}
}
$script .= ('' . "\n");
$script .= ('' . "\n");
}
}
}
return [
'apt' => $apt,
'script' => $script,
];
}
/**
* @author Christian Fraß <frass@greenscale.de>
*/
function get_data(
string $path = null
) : array
{
if (is_null($path)) {
$directory = dirname(debug_backtrace()[0]['file']);
$path = ($directory . '/testdata.json');
}
$content = file_get_contents($path);
if ($content === false) {
$message = sprintf('testdata file not readable: %s', $path);
_fatal_error($message);
}
else {
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$message = sprintf('testdata json not decodable: %s', $path);
_fatal_error($message);
}
else {
return $data;
}
}
}
/**
* adds a regular test definition
*
* @author Christian Fraß <frass@greenscale.de>
*/
function add(
array $section_raw
) : void
{
global $_root;
_add($_root, $section_raw);
}
/**
* searches for test definitions, executes all the tests the selected branch and writes the summary to stderr
*
* @return true if all non skipped tests have passed; false if there are aborted or failing tests
* @author Christian Fraß <frass@greenscale.de>
*/
function run(
array $path = [],
bool $log = true
) : bool
{
global $_root;
$_root = new struct_section(
'root',
true,
function () {},
function () {},
[],
[]
);
_gather();
$section = _get($path);
$reports = _execute($section, $log);
$evaluation = _evaluation($reports);
error_log($evaluation['script']);
return $evaluation['apt'];
}