Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 167 additions & 42 deletions tests/unit/E2eCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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: <type>`
* 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<string, class-string<SyntaxError | TypeError>>
*/
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<string>
*/
private const SECTIONS = [
'Source',
'Input',
'Output',
'Expression type',
'Types',
'Functions',
'Syntax error',
'Type error',
'Note',
];

/**
* @param array<string, mixed> $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,
) {
}

Expand Down Expand Up @@ -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<string, string>
*/
private static function split(string $contents): array
{
$sectionLines = ['Source' => []];
$section = null;
Expand All @@ -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<string, mixed> $input */
$input = (array)$inputStruct->value();
} else {
$input = [];
return $output;
}

/**
* @return array<string, mixed>
*/
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<string, mixed> $input */
$input = (array)$inputStruct->value();
return $input;
}

/**
* @param array<string, string> $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;
}

/**
Expand All @@ -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<string, Type>
*/
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;
}
}
24 changes: 24 additions & 0 deletions tests/unit/E2eError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Eventjet\Ausdruck\Test\Unit;

use Eventjet\Ausdruck\Parser\SyntaxError;
use Eventjet\Ausdruck\Parser\TypeError;

/**
* The error an {@see E2eCase} expects its source to be rejected with.
*
* A case names the class by choosing the section it writes the message in, so the two always travel together: there is
* no case that expects a message without a class, or a class without a message.
*/
final readonly class E2eError
{
/**
* @param class-string<SyntaxError | TypeError> $class
*/
public function __construct(public string $class, public string $message)
{
}
}
35 changes: 32 additions & 3 deletions tests/unit/EndToEndTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

/**
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/ParsesTypeSyntax.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Eventjet\Ausdruck\Test\Unit;

use Eventjet\Ausdruck\Parser\TypeNode;
use Eventjet\Ausdruck\Parser\TypeParser;

/**
* The one door the test suite has to {@see TypeParser::parseDeclarations()}: it is `@psalm-internal
* Eventjet\Ausdruck\Parser`, so a test that calls it directly -- reaching in from outside that namespace -- is
* otherwise an InternalClass/InternalMethod violation Psalm has to be told to allow, repeated at every call site.
* Suppressing it once here, in the one place it is wrapped, is what {@see E2eCase} reaches for.
*/
trait ParsesTypeSyntax
{
/**
* @return array<string, TypeNode>
*
* @psalm-suppress InternalMethod
* @psalm-suppress InternalClass
*/
private static function parseTypeDeclarations(string $declarations): array
{
return TypeParser::parseDeclarations($declarations);
}
}
Loading