Skip to content

Commit

Permalink
test: data-driven typing tests (#577)
Browse files Browse the repository at this point in the history
Closes partially #541

### Summary of Changes

Implement a data-driven way to test the type computer. Tests should be
much faster to implement than in our old Xtext version now.

---------

Co-authored-by: megalinter-bot <[email protected]>
  • Loading branch information
lars-reimann and megalinter-bot authored Sep 25, 2023
1 parent 8f5d57a commit 2071012
Show file tree
Hide file tree
Showing 8 changed files with 372 additions and 12 deletions.
26 changes: 26 additions & 0 deletions docs/development/typing-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Typing Testing

Typing tests are data-driven instead of being specified explicitly. This document explains how to add a new typing
test.

## Adding a typing test

1. Create a new **folder** (not just a file!) in the `tests/resources/typing` directory or any subdirectory. Give the
folder a descriptive name, since the folder name becomes part of the test name.

!!! tip "Skipping a test"

If you want to skip a test, add the prefix `skip-` to the folder name.

2. Add files with the extension `.sdstest` **directly inside the folder**. All files in a folder will be loaded into the
same workspace, so they can reference each other. Files in different folders are loaded into different workspaces, so
they cannot reference each other.
3. Add the Safe-DS code that you want to test to the file.
4. Surround entire nodes whose type you want to check with test markers, e.g. `1 + 2`. For declarations, it is also
possible to surround only their name, e.g. `class »C«`.
5. For each pair of test markers, add a test comment with one of the formats listed below. Test comments and test markers are
mapped to each other by their position in the file, i.e. the first test comment corresponds to the first test marker,
the second test comment corresponds to the second test marker, etc.
* `// $TEST$ equivalence_class <id>`: Assert that all nodes with the same `<id>` have the same type. All equivalence classes must have at least two entries.
* `// $TEST$ serialization <type>`: Assert that the serialized type of the node is `<type>`.
6. Run the tests. The test runner will automatically pick up the new test.
3 changes: 2 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ nav:
- Development:
- Grammar Testing: development/grammar-testing.md
- Scoping Testing: development/scoping-testing.md
- Formatting Testing: development/formatting-testing.md
- Typing Testing: development/typing-testing.md
- Validation Testing: development/validation-testing.md
- Formatting Testing: development/formatting-testing.md
- Langium Quickstart: development/langium-quickstart.md

# Configuration of MkDocs & Material for MkDocs --------------------------------
Expand Down
19 changes: 19 additions & 0 deletions src/language/typing/typeComputer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AstNode } from 'langium';

export const computeType = (_node: AstNode): Type => {
return {
equals(_other: Type): boolean {
return true;
},

toString(): string {
return 'test';
},
};
};

interface Type {
equals(other: Type): boolean;

toString(): string;
}
18 changes: 7 additions & 11 deletions tests/language/scoping/testScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, it } from 'vitest';
import { createSafeDsServices } from '../../../src/language/safe-ds-module.js';
import { URI } from 'vscode-uri';
import { NodeFileSystem } from 'langium/node';
import { isRangeEqual, clearDocuments } from 'langium/test';
import { clearDocuments, isRangeEqual } from 'langium/test';
import { AssertionError } from 'assert';
import { isLocationEqual, locationToString } from '../../helpers/location.js';
import { createScopingTests, ExpectedReference } from './creator.js';
Expand Down Expand Up @@ -30,12 +30,8 @@ describe('scoping', async () => {

// Ensure all expected references match
for (const expectedReference of test.expectedReferences) {
const document = services.shared.workspace.LangiumDocuments.getOrCreateDocument(
URI.parse(expectedReference.location.uri),
);

const expectedTargetLocation = expectedReference.targetLocation;
const actualTargetLocation = findActualTargetLocation(document, expectedReference);
const actualTargetLocation = findActualTargetLocation(expectedReference);

// Expected reference to be resolved
if (expectedTargetLocation) {
Expand Down Expand Up @@ -76,15 +72,15 @@ describe('scoping', async () => {
* Find the actual target location of the actual reference that matches the expected reference. If the actual reference
* cannot be resolved, undefined is returned.
*
* @param document The document to search in.
* @param expectedReference The expected reference.
* @returns The actual target location or undefined if the actual reference is not resolved.
* @throws AssertionError If no matching actual reference was found.
*/
const findActualTargetLocation = (
document: LangiumDocument,
expectedReference: ExpectedReference,
): Location | undefined => {
const findActualTargetLocation = (expectedReference: ExpectedReference): Location | undefined => {
const document = services.shared.workspace.LangiumDocuments.getOrCreateDocument(
URI.parse(expectedReference.location.uri),
);

const actualReference = findActualReference(document, expectedReference);

const actualTarget = actualReference.$nodeDescription;
Expand Down
190 changes: 190 additions & 0 deletions tests/language/typing/creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import {
listTestsResourcesGroupedByParentDirectory,
resolvePathRelativeToResources,
} from '../../helpers/testResources.js';
import path from 'path';
import fs from 'fs';
import { findTestChecks } from '../../helpers/testChecks.js';
import { Location } from 'vscode-languageserver';
import { URI } from 'vscode-uri';
import { getSyntaxErrors, SyntaxErrorsInCodeError } from '../../helpers/diagnostics.js';
import { EmptyFileSystem } from 'langium';
import { createSafeDsServices } from '../../../src/language/safe-ds-module.js';

const services = createSafeDsServices(EmptyFileSystem).SafeDs;
const root = 'typing';

export const createTypingTests = (): Promise<TypingTest[]> => {
const pathsGroupedByParentDirectory = listTestsResourcesGroupedByParentDirectory(root);
const testCases = Object.entries(pathsGroupedByParentDirectory).map(([dirname, paths]) =>
createTypingTest(dirname, paths),
);

return Promise.all(testCases);
};

const createTypingTest = async (
relativeParentDirectoryPath: string,
relativeResourcePaths: string[],
): Promise<TypingTest> => {
const uris: string[] = [];
const groupIdToLocations: Map<string, Location[]> = new Map();
const serializationAssertions: SerializationAssertion[] = [];

for (const relativeResourcePath of relativeResourcePaths) {
const absolutePath = resolvePathRelativeToResources(path.join(root, relativeResourcePath));
const uri = URI.file(absolutePath).toString();
uris.push(uri);

const code = fs.readFileSync(absolutePath).toString();

// File must not contain any syntax errors
const syntaxErrors = await getSyntaxErrors(services, code);
if (syntaxErrors.length > 0) {
return invalidTest(
`INVALID TEST FILE [${relativeResourcePath}]`,
new SyntaxErrorsInCodeError(syntaxErrors),
);
}

const checksResult = findTestChecks(code, uri, { failIfFewerRangesThanComments: true });

// Something went wrong when finding test checks
if (checksResult.isErr) {
return invalidTest(`INVALID TEST FILE [${relativeResourcePath}]`, checksResult.error);
}

for (const check of checksResult.value) {
// Expected unresolved reference
const equivalenceClassMatch = /equivalence_class (?<id>.*)/gu.exec(check.comment);
if (equivalenceClassMatch) {
const id = equivalenceClassMatch.groups!.id;
const priorLocationsInEquivalenceClass = groupIdToLocations.get(id) ?? [];
priorLocationsInEquivalenceClass.push(check.location!);
groupIdToLocations.set(id, priorLocationsInEquivalenceClass);
continue;
}

// Expected that reference is resolved and points to the target id
const serializationMatch = /serialization (?<expectedType>.*)/gu.exec(check.comment);
if (serializationMatch) {
const expectedType = serializationMatch.groups!.expectedType;
serializationAssertions.push({
location: check.location!,
expectedType,
});
continue;
}

return invalidTest(`INVALID TEST FILE [${relativeResourcePath}]`, new InvalidCommentError(check.comment));
}
}

// Check that all equivalence classes have at least two locations
for (const [id, locations] of groupIdToLocations) {
if (locations.length < 2) {
return invalidTest(
`INVALID TEST SUITE [${relativeParentDirectoryPath}]`,
new SingletonEquivalenceClassError(id),
);
}
}

return {
testName: `[${relativeParentDirectoryPath}] should be typed correctly`,
uris,
equivalenceClassAssertions: [...groupIdToLocations.values()].map((locations) => ({ locations })),
serializationAssertions,
};
};

/**
* Report a test that has errors.
*
* @param testName The name of the test.
* @param error The error that occurred.
*/
const invalidTest = (testName: string, error: Error): TypingTest => {
return {
testName,
uris: [],
equivalenceClassAssertions: [],
serializationAssertions: [],
error,
};
};

/**
* A description of a typing test.
*/
interface TypingTest {
/**
* The name of the test.
*/
testName: string;

/**
* The URIs of the files that should be loaded into the workspace.
*/
uris: string[];

/**
* All nodes in an equivalence class should get the same type.
*/
equivalenceClassAssertions: EquivalenceClassAssertion[];

/**
* The serialized type of a node should match the expected type.
*/
serializationAssertions: SerializationAssertion[];

/**
* An error that occurred while creating the test. If this is undefined, the test is valid.
*/
error?: Error;
}

/**
* A set of nodes should all get the same type.
*/
interface EquivalenceClassAssertion {
/**
* The locations of the nodes that should all get the same type.
*/
locations: Location[];
}

/**
* The serialized type of a node should match the expected type.
*/
interface SerializationAssertion {
/**
* The location of the node whose serialized type should be checked.
*/
location: Location;

/**
* The expected serialized type of the node.
*/
expectedType: string;
}

/**
* A test comment did not match the expected format.
*/
class InvalidCommentError extends Error {
constructor(readonly comment: string) {
super(
`Invalid test comment (valid values are 'equivalence_class <id>' and 'serialization <type>'): ${comment}`,
);
}
}

/**
* An equivalence class test contains only a single location.
*/
class SingletonEquivalenceClassError extends Error {
constructor(readonly id: string) {
super(`Equivalence class '${id}' only contains a single location. Such an assertion always succeeds.`);
}
}
Loading

0 comments on commit 2071012

Please sign in to comment.