*/ function _fatal_error( string $message ) : void { // throw (new \Exception($message)); error_log(sprintf('FATAL ERROR: %s', $message)); exit(1); } /** * @author Christian Fraß */ 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ß */ function _list_filter( array $list, \Closure $predicate ) : array { return array_values(array_filter($list, $predicate)); } /** * @author Christian Fraß */ 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ß */ function _log( int $level, string $message ) : void { error_log( _string_coin( '{{indentation}}{{message}}', [ 'indentation' => str_repeat(" ", $level-1), 'message' => $message, ] ) ); } /** * @author Christian Fraß */ 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ß */ 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ß */ 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ß */ 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ß */ class class_assertion { /** * @var function<~string,void> * @author Christian Fraß */ private $finalize; /** * @author Christian Fraß */ public function __construct( \Closure $finalize ) { $this->finalize = $finalize; } /** * @author Christian Fraß */ 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ß */ public function fail( $reason = null ) { $message = ($reason ?: '(unexplained fail)'); ($this->finalize)($message); } /** * unspecific generic test * * @author Christian Fraß */ 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ß */ 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ß */ 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ß */ 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ß */ $_root = null; /** * merges two sections * * @author Christian Fraß */ 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ß */ 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ß */ 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ß */ 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 * @author Christian Fraß */ 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ß */ 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 * @return record * @author Christian Fraß */ 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ß */ 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ß */ 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ß */ 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']; }