From 576bc4aca5a4063a7dd4fb162a74f85b3cefbd20 Mon Sep 17 00:00:00 2001 From: Rudolph Gottesheim Date: Thu, 23 Jul 2026 15:26:10 +0200 Subject: [PATCH] Rework the E2e case harness into named, typed sections Split from #88. E2eCase now parses a case file as an expression plus named sections -- Output, Expression type, Types, Functions, Syntax error, Type error, Note -- rather than a fixed source + Output (+ Input) shape. It validates the section names, so a typo is a failure rather than a case that quietly stops asserting; carries a Declarations (aliases and declared function signatures) and an optional E2eError instead of a bare expected value; and lets a case assert an evaluated Output, an Expression type, or an exact error message. EndToEndTest runs each case only as far as the sections it writes reach: an error case stops at the parse, a type case never evaluates. ParsesTypeSyntax wraps TypeParser's two @internal entry points in one place. The new sections are wired into nothing yet: every existing fixture uses only Input/Output/Types and stays green, so this lands ahead of the generics feature that fills them in. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XaVJhFFYHJee3UokQwFG6E --- tests/unit/E2eCase.php | 209 +++++++++++++++++++++++++------- tests/unit/E2eError.php | 24 ++++ tests/unit/EndToEndTest.php | 35 +++++- tests/unit/ParsesTypeSyntax.php | 28 +++++ 4 files changed, 251 insertions(+), 45 deletions(-) create mode 100644 tests/unit/E2eError.php create mode 100644 tests/unit/ParsesTypeSyntax.php diff --git a/tests/unit/E2eCase.php b/tests/unit/E2eCase.php index 72a0edc..341d615 100644 --- a/tests/unit/E2eCase.php +++ b/tests/unit/E2eCase.php @@ -5,9 +5,11 @@ namespace Eventjet\Ausdruck\Test\Unit; use Eventjet\Ausdruck\AbstractLiteral; +use Eventjet\Ausdruck\Parser\Declarations; use Eventjet\Ausdruck\Parser\ExpressionParser; +use Eventjet\Ausdruck\Parser\SyntaxError; use Eventjet\Ausdruck\Parser\TypeError; -use Eventjet\Ausdruck\Parser\TypeParser; +use Eventjet\Ausdruck\Parser\TypeNode; use Eventjet\Ausdruck\Parser\Types; use Eventjet\Ausdruck\StructLiteral; use Eventjet\Ausdruck\Type; @@ -17,7 +19,10 @@ use RuntimeException; use SplFileInfo; +use function array_diff; use function array_key_exists; +use function array_keys; +use function array_map; use function explode; use function file_get_contents; use function implode; @@ -27,21 +32,66 @@ use function str_starts_with; use function strlen; use function substr; +use function trim; use const DIRECTORY_SEPARATOR; +/** + * One case, written as a file under `cases/`. + * + * A case file is an expression followed by named sections. The expression is everything before the first `-- Name --` + * line; each section runs to the next one. What a case asserts is decided by the sections it writes: + * + * - `Output` — a literal the expression has to evaluate to, with `Input` supplying the variables it reads. + * - `Expression type` — the type the expression has, printed. A case may write both, and then has to satisfy both. + * - `Syntax error` / `Type error` — the message the source has to be rejected with. Which of the two sections is used + * is what says which error is expected, so only the message is written out. + * + * `Types` declares the aliases the source may name, and `Functions` the signatures it may call, both as `Name: ` + * declarations. A declared function has a signature but no implementation, so a case that declares one describes what + * its calls mean rather than what they compute, and can't ask to be evaluated. + * + * `Note` asserts nothing: it is where a case says why it exists, for the cases whose name can't carry the whole reason. + */ final readonly class E2eCase { + use ParsesTypeSyntax; + private const ROOT = __DIR__ . DIRECTORY_SEPARATOR . 'cases/'; + /** + * The sections that say the source is rejected, and what each of them expects it to be rejected with. + * + * @var array> + */ + private const ERROR_SECTIONS = ['Syntax error' => SyntaxError::class, 'Type error' => TypeError::class]; + /** + * Every section a case may write. A name that isn't one of these is a typo, and a typo that went unnoticed would be + * a case that quietly stopped asserting what it was written to assert. + * + * @var list + */ + private const SECTIONS = [ + 'Source', + 'Input', + 'Output', + 'Expression type', + 'Types', + 'Functions', + 'Syntax error', + 'Type error', + 'Note', + ]; /** * @param array $input */ - public function __construct( + private function __construct( public string $source, - public mixed $expected, + public Declarations $declarations = new Declarations(), + public AbstractLiteral|null $output = null, public array $input = [], - public Types $types = new Types(), + public string|null $expressionType = null, + public E2eError|null $error = null, ) { } @@ -71,6 +121,53 @@ private static function get(string $name): self } private static function parse(string $contents): self + { + $sections = self::split($contents); + $unknown = array_diff(array_keys($sections), self::SECTIONS); + if ($unknown !== []) { + throw new RuntimeException( + sprintf('Unknown section %s; a case has %s', implode(' and ', $unknown), implode(', ', self::SECTIONS)), + ); + } + $types = new Types(array_key_exists('Types', $sections) ? self::parseTypes($sections['Types']) : []); + $functions = array_key_exists('Functions', $sections) + ? self::parseFunctions($sections['Functions'], $types) + : []; + $output = array_key_exists('Output', $sections) ? self::parseOutput($sections['Output']) : null; + $expressionType = $sections['Expression type'] ?? null; + $error = self::parseError($sections); + if ($error !== null && ($output !== null || $expressionType !== null)) { + throw new RuntimeException( + 'A case that expects an error can\'t expect an output or a type as well: a source that is rejected has ' + . 'neither', + ); + } + if ($error === null && $output === null && $expressionType === null) { + throw new RuntimeException('A case must expect something: an Output, an Expression type, or an error'); + } + if ($output !== null && $functions !== []) { + throw new RuntimeException( + 'A case that declares functions can\'t be evaluated: a declaration says what a function\'s calls mean, ' + . 'and there is no implementation here to compute them', + ); + } + return new self( + $sections['Source'], + new Declarations(types: $types, functions: $functions), + $output, + array_key_exists('Input', $sections) ? self::parseInput($sections['Input']) : [], + $expressionType, + $error, + ); + } + + /** + * The source is the section before there is one, so it is the section every file has and the only one that isn't + * named. + * + * @return array + */ + private static function split(string $contents): array { $sectionLines = ['Source' => []]; $section = null; @@ -80,42 +177,53 @@ private static function parse(string $contents): self throw new RuntimeException('Test file must start with a source section'); } $section = substr($line, 3, -3); + $sectionLines[$section] ??= []; continue; } - if ($section === null) { - $sectionLines['Source'][] = $line; - continue; - } - if (!array_key_exists($section, $sectionLines)) { - $sectionLines[$section] = []; - } - $sectionLines[$section][] = $line; + $sectionLines[$section ?? 'Source'][] = $line; } - $sections = []; - foreach ($sectionLines as $name => $lines) { - $sections[$name] = implode("\n", $lines); - } - $output = ExpressionParser::parse($sections['Output']); + return array_map(static fn(array $lines): string => trim(implode("\n", $lines)), $sectionLines); + } + + private static function parseOutput(string $src): AbstractLiteral + { + $output = ExpressionParser::parse($src); if (!$output instanceof AbstractLiteral) { throw new RuntimeException(sprintf('Output section must be a literal, got %s', $output)); } - /** @var mixed $output */ - $output = $output->value(); - if (array_key_exists('Input', $sections)) { - $inputStruct = ExpressionParser::parse($sections['Input']); - if (!$inputStruct instanceof StructLiteral) { - throw new RuntimeException('Input section must be a struct literal'); - } - /** @var array $input */ - $input = (array)$inputStruct->value(); - } else { - $input = []; + return $output; + } + + /** + * @return array + */ + private static function parseInput(string $src): array + { + $inputStruct = ExpressionParser::parse($src); + if (!$inputStruct instanceof StructLiteral) { + throw new RuntimeException('Input section must be a struct literal'); } - $types = []; - if (array_key_exists('Types', $sections)) { - $types = self::parseTypes($sections['Types']); + /** @var array $input */ + $input = (array)$inputStruct->value(); + return $input; + } + + /** + * @param array $sections + */ + private static function parseError(array $sections): E2eError|null + { + $error = null; + foreach (self::ERROR_SECTIONS as $section => $class) { + if (!array_key_exists($section, $sections)) { + continue; + } + if ($error !== null) { + throw new RuntimeException('A case expects one error, not both a syntax error and a type error'); + } + $error = new E2eError($class, $sections[$section]); } - return new self($sections['Source'], $output, $input, new Types($types)); + return $error; } /** @@ -127,17 +235,34 @@ private static function parse(string $contents): self private static function parseTypes(string $src): array { $aliases = []; - /** - * @psalm-suppress InternalClass - * @psalm-suppress InternalMethod - */ - foreach (TypeParser::parseDeclarations($src) as $name => $node) { - $type = (new Types($aliases))->resolve($node); - if ($type instanceof TypeError) { - throw $type; - } - $aliases[$name] = $type; + foreach (self::parseTypeDeclarations($src) as $name => $node) { + $aliases[$name] = self::resolve($node, new Types($aliases)); } return $aliases; } + + /** + * A Functions section declares call signatures, in the same shape a Types section declares aliases. Every alias the + * case declares is in scope for all of them, so a signature can be written in terms of the case's own types no + * matter which order the two sections are in. + * + * @return array + */ + private static function parseFunctions(string $src, Types $types): array + { + $functions = []; + foreach (self::parseTypeDeclarations($src) as $name => $node) { + $functions[$name] = self::resolve($node, $types); + } + return $functions; + } + + private static function resolve(TypeNode $node, Types $types): Type + { + $type = $types->resolve($node); + if ($type instanceof TypeError) { + throw $type; + } + return $type; + } } diff --git a/tests/unit/E2eError.php b/tests/unit/E2eError.php new file mode 100644 index 0000000..3421013 --- /dev/null +++ b/tests/unit/E2eError.php @@ -0,0 +1,24 @@ + $class + */ + public function __construct(public string $class, public string $message) + { + } +} diff --git a/tests/unit/EndToEndTest.php b/tests/unit/EndToEndTest.php index 95841a7..aaaee6a 100644 --- a/tests/unit/EndToEndTest.php +++ b/tests/unit/EndToEndTest.php @@ -6,11 +6,15 @@ use Eventjet\Ausdruck\Parser\Declarations; use Eventjet\Ausdruck\Parser\ExpressionParser; +use Eventjet\Ausdruck\Parser\SyntaxError; +use Eventjet\Ausdruck\Parser\TypeError; use Eventjet\Ausdruck\Scope; use Eventjet\Ausdruck\Type; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function sprintf; + final class EndToEndTest extends TestCase { use AssertsEvaluatedValues; @@ -25,15 +29,40 @@ public static function cases(): iterable } } + /** + * A case says what it expects by which sections it writes, so this runs the source as far as those sections reach: + * a case that expects an error stops at the parse, and one that expects a type never has to be evaluated. + * + * A case that expects an error is asserted by catching it and comparing the message with assertSame rather than + * PHPUnit's expectExceptionMessage, which only asserts a substring: a case that pins `Unknown type T` would still + * pass against a message that merely contains `Unknown type`, and every fixture here exists to pin an exact + * message. + */ #[DataProvider('cases')] public function testRun(E2eCase $case): void { - $expression = ExpressionParser::parse($case->source, new Declarations(types: $case->types)); + if ($case->error !== null) { + try { + ExpressionParser::parse($case->source, $case->declarations); + } catch (SyntaxError|TypeError $e) { + self::assertInstanceOf($case->error->class, $e); + self::assertSame($case->error->message, $e->getMessage()); + return; + } + self::fail(sprintf('Expected %s, but the source was accepted', $case->error->class)); + } + $expression = ExpressionParser::parse($case->source, $case->declarations); + + if ($case->expressionType !== null) { + self::assertSame($case->expressionType, (string)$expression->getType()); + } + if ($case->output === null) { + return; + } /** @var mixed $actual */ $actual = $expression->evaluate(new Scope($case->input)); - - self::assertEvaluatesTo($case->expected, $actual); + self::assertEvaluatesTo($case->output->value(), $actual); } /** diff --git a/tests/unit/ParsesTypeSyntax.php b/tests/unit/ParsesTypeSyntax.php new file mode 100644 index 0000000..edc6e30 --- /dev/null +++ b/tests/unit/ParsesTypeSyntax.php @@ -0,0 +1,28 @@ + + * + * @psalm-suppress InternalMethod + * @psalm-suppress InternalClass + */ + private static function parseTypeDeclarations(string $declarations): array + { + return TypeParser::parseDeclarations($declarations); + } +}