diff --git a/DSL/src/language-server/safe-ds-module.ts b/DSL/src/language-server/safe-ds-module.ts index 9c69eef26..89ed2e3f9 100644 --- a/DSL/src/language-server/safe-ds-module.ts +++ b/DSL/src/language-server/safe-ds-module.ts @@ -54,7 +54,7 @@ export const SafeDsModule: Module => { // Must contain exactly one separator if (parts.length !== 2) { - return { - testName: `INVALID TEST FILE [${pathRelativeToResources}]`, - originalCode: '', - expectedFormattedCode: '', - error: new SeparatorError(parts.length - 1), - }; + return invalidTest(pathRelativeToResources, new SeparatorError(parts.length - 1)); } // Original code must not contain syntax errors @@ -35,12 +30,7 @@ export const createFormatterTests = async (): Promise => { ); if (syntaxErrors.length > 0) { - return { - testName: `INVALID TEST FILE [${pathRelativeToResources}]`, - originalCode, - expectedFormattedCode, - error: new SyntaxErrorsInOriginalCodeError(syntaxErrors), - }; + return invalidTest(pathRelativeToResources, new SyntaxErrorsInOriginalCodeError(syntaxErrors)); } return { @@ -53,23 +43,68 @@ export const createFormatterTests = async (): Promise => { return Promise.all(testCases); }; +/** + * Report a test that has errors. + * + * @param pathRelativeToResources The path to the test file relative to the resources directory. + * @param error The error that occurred. + */ +const invalidTest = (pathRelativeToResources: string, error: Error): FormatterTest => { + return { + testName: `INVALID TEST FILE [${pathRelativeToResources}]`, + originalCode: '', + expectedFormattedCode: '', + error, + }; +}; + +/** + * Normalizes line breaks to `\n`. + * + * @param code The code to normalize. + * @return The normalized code. + */ const normalizeLineBreaks = (code: string): string => { return code.replace(/\r\n?/gu, '\n'); }; +/** + * A description of a formatter test. + */ interface FormatterTest { + /** + * The name of the test. + */ testName: string; + + /** + * The original code before formatting. + */ originalCode: string; + + /** + * The expected formatted code. + */ expectedFormattedCode: string; + + /** + * An error that occurred while creating the test. If this is undefined, the test is valid. + */ error?: Error; } +/** + * The file contained no or more than one separator. + */ class SeparatorError extends Error { constructor(readonly number_of_separators: number) { super(`Expected exactly one separator but found ${number_of_separators}.`); } } +/** + * The original code contained syntax errors. + */ class SyntaxErrorsInOriginalCodeError extends Error { constructor(readonly syntaxErrors: Diagnostic[]) { const syntaxErrorsAsString = syntaxErrors.map((e) => `- ${e.message}`).join(`\n`); diff --git a/DSL/tests/formatting/testFormatter.test.ts b/DSL/tests/formatting/testFormatter.test.ts index 10693d6b0..87c74654c 100644 --- a/DSL/tests/formatting/testFormatter.test.ts +++ b/DSL/tests/formatting/testFormatter.test.ts @@ -8,6 +8,7 @@ const services = createSafeDsServices(EmptyFileSystem).SafeDs; const formatterTests = createFormatterTests(); describe('formatter', async () => { + // Test that the original code is formatted correctly it.each(await formatterTests)('$testName', async (test) => { // Test is invalid if (test.error) { @@ -24,6 +25,7 @@ describe('formatter', async () => { await clearDocuments(services); }); + // Test that the expected formatted code stays the same when formatted again it.each(await formatterTests)('$testName (idempotence)', async (test) => { // Test is invalid if (test.error) { diff --git a/DSL/tests/grammar/creator.ts b/DSL/tests/grammar/creator.ts index baaea1a52..98fd3b60d 100644 --- a/DSL/tests/grammar/creator.ts +++ b/DSL/tests/grammar/creator.ts @@ -7,39 +7,24 @@ import { NoCommentsError } from '../helpers/testChecks'; export const createGrammarTests = (): GrammarTest[] => { return listTestResources('grammar').map((pathRelativeToResources): GrammarTest => { const absolutePath = resolvePathRelativeToResources(path.join('grammar', pathRelativeToResources)); - const program = fs.readFileSync(absolutePath).toString(); - const comments = findTestComments(program); + const code = fs.readFileSync(absolutePath).toString(); + const comments = findTestComments(code); // Must contain at least one comment if (comments.length === 0) { - return { - testName: `INVALID TEST FILE [${pathRelativeToResources}]`, - program, - expectedResult: 'invalid', - error: new NoCommentsError(), - }; + return invalidTest(pathRelativeToResources, new NoCommentsError()); } // Must contain no more than one comment if (comments.length > 1) { - return { - testName: `INVALID TEST FILE [${pathRelativeToResources}]`, - program, - expectedResult: 'invalid', - error: new MultipleCommentsError(comments), - }; + return invalidTest(pathRelativeToResources, new MultipleCommentsError(comments)); } const comment = comments[0]; // Must contain a valid comment if (comment !== 'syntax_error' && comment !== 'no_syntax_error') { - return { - testName: `INVALID TEST FILE [${pathRelativeToResources}]`, - program, - expectedResult: 'invalid', - error: new InvalidCommentError(comment), - }; + return invalidTest(pathRelativeToResources, new InvalidCommentError(comment)); } let testName: string; @@ -51,19 +36,49 @@ export const createGrammarTests = (): GrammarTest[] => { return { testName, - program, + code, expectedResult: comment, }; }); }; +/** + * Report a test that has errors. + * + * @param pathRelativeToResources The path to the test file relative to the resources directory. + * @param error The error that occurred. + */ +const invalidTest = (pathRelativeToResources: string, error: Error): GrammarTest => { + return { + testName: `INVALID TEST FILE [${pathRelativeToResources}]`, + code: '', + expectedResult: 'invalid', + error, + }; +}; + /** * A description of a grammar test. */ interface GrammarTest { + /** + * The name of the test. + */ testName: string; - program: string; + + /** + * The code to parse. + */ + code: string; + + /** + * The expected result after parsing the program. + */ expectedResult: 'syntax_error' | 'no_syntax_error' | 'invalid'; + + /** + * An error that occurred while creating the test. If this is undefined, the test is valid. + */ error?: Error; } diff --git a/DSL/tests/grammar/testGrammar.test.ts b/DSL/tests/grammar/testGrammar.test.ts index ebb8e5c34..a5648adfa 100644 --- a/DSL/tests/grammar/testGrammar.test.ts +++ b/DSL/tests/grammar/testGrammar.test.ts @@ -15,7 +15,7 @@ describe('grammar', () => { } // Get the actual syntax errors - const { diagnostics } = await validationHelper(services)(test.program); + const { diagnostics } = await validationHelper(services)(test.code); const syntaxErrors = diagnostics.filter( (d) => d.severity === 1 && (d.code === 'lexing-error' || d.code === 'parsing-error'), ); diff --git a/DSL/tests/helpers/location.test.ts b/DSL/tests/helpers/location.test.ts new file mode 100644 index 000000000..5755d31c9 --- /dev/null +++ b/DSL/tests/helpers/location.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { isLocationEqual, locationToString, positionToString, rangeToString } from './location'; + +describe('positionToString', () => { + it.each([ + { + position: { line: 0, character: 0 }, + expected: '1:1', + }, + { + position: { line: 1, character: 0 }, + expected: '2:1', + }, + ])('should convert position to string ($expected)', ({ position, expected }) => { + expect(positionToString(position)).toBe(expected); + }); +}); + +describe('rangeToString', () => { + it.each([ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + expected: '1:1 -> 1:1', + }, + { + range: { start: { line: 0, character: 0 }, end: { line: 1, character: 0 } }, + expected: '1:1 -> 2:1', + }, + ])('should convert range to string ($expected)', ({ range, expected }) => { + expect(rangeToString(range)).toBe(expected); + }); +}); + +describe('locationToString', () => { + it.each([ + { + location: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + expected: 'file:///test.sdstest:1:1 -> 1:1', + }, + { + location: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 1, character: 0 } }, + }, + expected: 'file:///test.sdstest:1:1 -> 2:1', + }, + ])(`should convert location to string ($expected)`, ({ location, expected }) => { + expect(locationToString(location)).toBe(expected); + }); +}); + +describe('isLocationEqual', () => { + it.each([ + { + location1: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + location2: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + expected: true, + id: 'same location', + }, + { + location1: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + location2: { + uri: 'file:///test2.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + expected: false, + id: 'different uri', + }, + { + location1: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + location2: { + uri: 'file:///test.sdstest', + range: { start: { line: 0, character: 0 }, end: { line: 1, character: 0 } }, + }, + expected: false, + id: 'different range', + }, + ])('should compare locations for equality ($id)', ({ location1, location2, expected }) => { + expect(isLocationEqual(location1, location2)).toBe(expected); + }); +}); diff --git a/DSL/tests/helpers/location.ts b/DSL/tests/helpers/location.ts new file mode 100644 index 000000000..1c31b483d --- /dev/null +++ b/DSL/tests/helpers/location.ts @@ -0,0 +1,43 @@ +import { Location, Position, Range } from 'vscode-languageserver'; +import { isRangeEqual } from 'langium/test'; + +/** + * Converts a position to a string. + * + * @param position The position to convert. + * @returns The string representation of the position. + */ +export const positionToString = (position: Position): string => { + return `${position.line + 1}:${position.character + 1}`; +}; + +/** + * Converts a range to a string. + * + * @param range The range to convert. + * @returns The string representation of the range. + */ +export const rangeToString = (range: Range): string => { + return `${positionToString(range.start)} -> ${positionToString(range.end)}`; +}; + +/** + * Converts a location to a string. + * + * @param location The location to convert. + * @returns The string representation of the location. + */ +export const locationToString = (location: Location) => { + return `${location.uri}:${rangeToString(location.range)}`; +}; + +/** + * Compare two locations for equality.ts. + * + * @param location1 The first location. + * @param location2 The second location. + * @returns True if the locations are equal, false otherwise. + */ +export const isLocationEqual = (location1: Location, location2: Location): boolean => { + return location1.uri === location2.uri && isRangeEqual(location1.range, location2.range); +}; diff --git a/DSL/tests/helpers/stringification.test.ts b/DSL/tests/helpers/stringification.test.ts deleted file mode 100644 index cf2bc9268..000000000 --- a/DSL/tests/helpers/stringification.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { locationToString, positionToString, rangeToString } from './stringification'; - -describe('positionToString', () => { - it.each([ - { - position: { line: 0, character: 0 }, - expected: '0:0', - id: '0:0', - }, - { - position: { line: 1, character: 0 }, - expected: '1:0', - id: '1:0', - }, - ])('should convert position to string ($id)', ({ position, expected }) => { - expect(positionToString(position)).toBe(expected); - }); -}); - -describe('rangeToString', () => { - it.each([ - { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, - expected: '0:0->0:0', - id: '0:0->0:0', - }, - { - range: { start: { line: 0, character: 0 }, end: { line: 1, character: 0 } }, - expected: '0:0->1:0', - id: '0:0->1:0', - }, - ])('should convert range to string ($id)', ({ range, expected }) => { - expect(rangeToString(range)).toBe(expected); - }); -}); - -describe('locationToString', () => { - it.each([ - { - location: { - uri: 'file:///test.sdstest', - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, - }, - expected: '[file:///test.sdstest] at 0:0->0:0', - }, - { - location: { - uri: 'file:///test.sdstest', - range: { start: { line: 0, character: 0 }, end: { line: 1, character: 0 } }, - }, - expected: '[file:///test.sdstest] at 0:0->1:0', - }, - ])(`should convert location to string`, ({ location, expected }) => { - expect(locationToString(location)).toBe(expected); - }); -}); diff --git a/DSL/tests/helpers/stringification.ts b/DSL/tests/helpers/stringification.ts deleted file mode 100644 index 5996dc980..000000000 --- a/DSL/tests/helpers/stringification.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Location, Position, Range } from 'vscode-languageserver'; - -/** - * Converts a position to a string. - */ -export const positionToString = (position: Position): string => { - return `${position.line}:${position.character}`; -}; - -/** - * Converts a range to a string. - */ -export const rangeToString = (range: Range): string => { - return `${positionToString(range.start)}->${positionToString(range.end)}`; -}; - -/** - * Converts a location to a string. - */ -export const locationToString = (location: Location) => { - return `[${location.uri}] at ${rangeToString(location.range)}`; -}; diff --git a/DSL/tests/helpers/testRanges.test.ts b/DSL/tests/helpers/testRanges.test.ts index 394575194..470c6603f 100644 --- a/DSL/tests/helpers/testRanges.test.ts +++ b/DSL/tests/helpers/testRanges.test.ts @@ -62,7 +62,7 @@ describe('findTestRanges', () => { const error = result.error; expect(error).toBeInstanceOf(CloseWithoutOpenError); expect((error as CloseWithoutOpenError).position).toStrictEqual(Position.create(1, 1)); - expect(error.message).toBe(`Found '${CLOSE}' without previous '${OPEN}' at 1:1.`); + expect(error.message).toBe(`Found '${CLOSE}' without previous '${OPEN}' at 2:2.`); } }); @@ -77,7 +77,7 @@ describe('findTestRanges', () => { Position.create(0, 0), Position.create(1, 0), ]); - expect(error.message).toBe(`Found '${OPEN}' without following '${CLOSE}' at 0:0, 1:0.`); + expect(error.message).toBe(`Found '${OPEN}' without following '${CLOSE}' at 1:1, 2:1.`); } }); }); diff --git a/DSL/tests/helpers/testRanges.ts b/DSL/tests/helpers/testRanges.ts index 7b1b56112..115c37376 100644 --- a/DSL/tests/helpers/testRanges.ts +++ b/DSL/tests/helpers/testRanges.ts @@ -1,7 +1,7 @@ import { Result } from 'true-myth'; import { Range, Position } from 'vscode-languageserver'; import { CLOSE, OPEN } from './testMarker'; -import { positionToString } from './stringification'; +import { positionToString } from './location'; /** * Finds test ranges, i.e. parts of the program delimited by opening and closing test markers. They are sorted by the @@ -85,7 +85,7 @@ export type FindTestRangesError = CloseWithoutOpenError | OpenWithoutCloseError; */ export class CloseWithoutOpenError extends Error { constructor(readonly position: Position) { - super(`Found '${CLOSE}' without previous '${OPEN}' at ${position.line}:${position.character}.`); + super(`Found '${CLOSE}' without previous '${OPEN}' at ${positionToString(position)}.`); } } diff --git a/DSL/tests/resources/scoping/annotation calls/test1/main.sdstest b/DSL/tests/resources/scoping/annotation calls/test1/main.sdstest new file mode 100644 index 000000000..03ab74e86 --- /dev/null +++ b/DSL/tests/resources/scoping/annotation calls/test1/main.sdstest @@ -0,0 +1,10 @@ +// $TEST$ target class_C +class »C« + +pipeline test { + // $TEST$ references class_C + »C«(); + + // $TEST$ unresolved + »D«; +} diff --git a/DSL/tests/resources/scoping/annotation calls/test1/resource1.sdstest b/DSL/tests/resources/scoping/annotation calls/test1/resource1.sdstest new file mode 100644 index 000000000..e69de29bb diff --git a/DSL/tests/scoping/creator.ts b/DSL/tests/scoping/creator.ts new file mode 100644 index 000000000..a089ee915 --- /dev/null +++ b/DSL/tests/scoping/creator.ts @@ -0,0 +1,213 @@ +import { listTestResources, resolvePathRelativeToResources } from '../helpers/testResources'; +import { group } from 'radash'; +import path from 'path'; +import fs from 'fs'; +import { findTestChecks } from '../helpers/testChecks'; +import { Location } from 'vscode-languageserver'; +import { URI } from 'vscode-uri'; + +export const createScopingTests = (): ScopingTest[] => { + const pathsRelativeToResources = listTestResources('scoping'); + const pathsRelativeToResourcesGroupedByDirname = group(pathsRelativeToResources, (pathRelativeToResources) => + path.dirname(pathRelativeToResources), + ) as Record; + + return Object.entries(pathsRelativeToResourcesGroupedByDirname).map(([dirname, paths]) => + createScopingTest(dirname, paths), + ); +}; + +const createScopingTest = (dirnameRelativeToResources: string, pathsRelativeToResources: string[]): ScopingTest => { + const uris: string[] = []; + const references: ExpectedReferenceWithTargetId[] = []; + const targets: Map = new Map(); + + for (const pathRelativeToResources of pathsRelativeToResources) { + const absolutePath = resolvePathRelativeToResources(path.join('scoping', pathRelativeToResources)); + const uri = URI.file(absolutePath).toString(); + uris.push(uri); + + const code = fs.readFileSync(absolutePath).toString(); + const checksResult = findTestChecks(code, uri, { failIfFewerRangesThanComments: true }); + + // Something went wrong when finding test checks + if (checksResult.isErr) { + return invalidTest(`INVALID TEST FILE [${pathRelativeToResources}]`, checksResult.error); + } + + for (const check of checksResult.value) { + // Expected unresolved reference + if (check.comment === 'unresolved') { + references.push({ + location: check.location!, + }); + continue; + } + + // Expected that reference is resolved and points to the target id + const referenceMatch = /references (?.*)/gu.exec(check.comment); + if (referenceMatch) { + references.push({ + location: check.location!, + targetId: referenceMatch.groups!.targetId!, + }); + continue; + } + + // Register a target with the given id + const targetMatch = /target (?.*)/gu.exec(check.comment); + if (targetMatch) { + const id = targetMatch.groups!.id!; + + if (targets.has(id)) { + return invalidTest( + `INVALID TEST SUITE [${dirnameRelativeToResources}]`, + new DuplicateTargetIdError(id), + ); + } else { + targets.set(id, { + id, + location: check.location!, + }); + } + continue; + } + + return invalidTest( + `INVALID TEST FILE [${pathRelativeToResources}]`, + new InvalidCommentError(check.comment), + ); + } + } + + // Check that all references point to a valid target and store the target location + for (const reference of references) { + if (reference.targetId) { + if (!targets.has(reference.targetId)) { + return invalidTest( + `INVALID TEST SUITE [${dirnameRelativeToResources}]`, + new MissingTargetError(reference.targetId), + ); + } + + reference.targetLocation = targets.get(reference.targetId)!.location; + } + } + + return { + testName: `[${dirnameRelativeToResources}] should be scoped correctly`, + uris, + expectedReferences: references, + }; +}; + +/** + * 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): ScopingTest => { + return { + testName, + uris: [], + expectedReferences: [], + error, + }; +}; + +/** + * A description of a scoping test. + */ +interface ScopingTest { + /** + * The name of the test. + */ + testName: string; + + /** + * The URIs of the files that should be loaded into the workspace. + */ + uris: string[]; + + /** + * The references we expect to find in the workspace. It is allowed to have additional references, which will not be + * checked. + */ + expectedReferences: ExpectedReference[]; + + /** + * An error that occurred while creating the test. If this is undefined, the test is valid. + */ + error?: Error; +} + +/** + * A reference that should point to some target or be unresolved. + */ +export interface ExpectedReference { + /** + * The location of the reference. + */ + location: Location; + + /** + * The location of the target that the reference should point to. If undefined, the reference should be unresolved. + */ + targetLocation?: Location; +} + +/** + * A reference that should point to some target or be unresolved. Used during the creation of scoping tests until all + * targets have been found. At this point the IDs are replaced with the locations of the targets. + */ +interface ExpectedReferenceWithTargetId extends ExpectedReference { + /** + * The ID of the target that the reference should point to. If undefined, the reference should be unresolved. + */ + targetId?: string; +} + +/** + * A name that can be referenced. + */ +interface Target { + /** + * The ID of the target. + */ + id: string; + + /** + * The location of the target. + */ + location: Location; +} + +/** + * A test comment did not match the expected format. + */ +class InvalidCommentError extends Error { + constructor(readonly comment: string) { + super( + `Invalid test comment (valid values are 'references ', 'unresolved', and 'target '): ${comment}`, + ); + } +} + +/** + * Several targets have the same ID. + */ +class DuplicateTargetIdError extends Error { + constructor(readonly id: string) { + super(`Target ID ${id} is used more than once`); + } +} + +/** + * A reference points to a target that does not exist. + */ +class MissingTargetError extends Error { + constructor(readonly targetId: string) { + super(`No target with ID ${targetId} exists`); + } +} diff --git a/DSL/tests/scoping/testScoping.test.ts b/DSL/tests/scoping/testScoping.test.ts new file mode 100644 index 000000000..758ecf12c --- /dev/null +++ b/DSL/tests/scoping/testScoping.test.ts @@ -0,0 +1,124 @@ +import { describe, it } from 'vitest'; +import { createSafeDsServices } from '../../src/language-server/safe-ds-module'; +import { URI } from 'vscode-uri'; +import { NodeFileSystem } from 'langium/node'; +import { isRangeEqual } from 'langium/test'; +import { AssertionError } from 'assert'; +import { isLocationEqual, locationToString } from '../helpers/location'; +import { createScopingTests, ExpectedReference } from './creator'; +import { LangiumDocument, Reference } from 'langium'; +import { Location } from 'vscode-languageserver'; + +const services = createSafeDsServices(NodeFileSystem).SafeDs; + +describe('scoping', () => { + it.each(createScopingTests())('$testName', async (test) => { + // Test is invalid + if (test.error) { + throw test.error; + } + + // Load all documents + const documents = test.uris.map((uri) => + services.shared.workspace.LangiumDocuments.getOrCreateDocument(URI.parse(uri)), + ); + await services.shared.workspace.DocumentBuilder.build(documents); + + // 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); + + // Expected reference to be resolved + if (expectedTargetLocation) { + if (!actualTargetLocation) { + throw new AssertionError({ + message: `Expected a resolved reference but it was unresolved.\n Reference Location: ${locationToString( + expectedReference.location, + )}\n Expected Target Location: ${locationToString(expectedTargetLocation)}`, + }); + } else if (!isLocationEqual(expectedTargetLocation, actualTargetLocation)) { + throw new AssertionError({ + message: `Expected a resolved reference but it points to the wrong declaration.\n Reference Location: ${locationToString( + expectedReference.location, + )}\n Expected Target Location: ${locationToString( + expectedTargetLocation, + )}\n Actual Target Location: ${locationToString(actualTargetLocation)}`, + expected: expectedTargetLocation, + actual: actualTargetLocation, + }); + } + } + + // Expected reference to be unresolved + else { + if (actualTargetLocation) { + throw new AssertionError({ + message: `Expected an unresolved reference but it was resolved.\n Reference Location: ${locationToString( + expectedReference.location, + )}\n Actual Target Location: ${locationToString(actualTargetLocation)}`, + }); + } + } + } + }); +}); + +/** + * 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 actualReference = findActualReference(document, expectedReference); + + const actualTarget = actualReference.$nodeDescription; + const actualTargetUri = actualTarget?.documentUri?.toString(); + const actualTargetRange = actualTarget?.nameSegment?.range; + + if (!actualTargetUri || !actualTargetRange) { + return undefined; + } + + return { + uri: actualTargetUri, + range: actualTargetRange, + }; +}; + +/** + * Find the reference in the given document that matches the expected reference. + * + * @param document The document to search in. + * @param expectedReference The expected reference. + * @returns The actual reference. + * @throws AssertionError If no reference was found. + */ +const findActualReference = (document: LangiumDocument, expectedReference: ExpectedReference): Reference => { + // Find actual reference + const actualReference = document.references.find((reference) => { + const actualReferenceRange = reference.$refNode?.range; + return actualReferenceRange && isRangeEqual(actualReferenceRange, expectedReference.location.range); + }); + + // Could not find a reference at the expected location + if (!actualReference) { + throw new AssertionError({ + message: `Expected a reference but found none.\n Reference Location: ${locationToString( + expectedReference.location, + )}`, + }); + } + return actualReference; +}; diff --git a/DSL/tsconfig.json b/DSL/tsconfig.json index 798a85ade..71ad6f7a4 100644 --- a/DSL/tsconfig.json +++ b/DSL/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES6", "module": "commonjs", - "lib": ["ESNext"], + "lib": ["ESNext", "dom"], "sourceMap": true, "outDir": "out", "strict": true, diff --git a/docs/development/formatting-testing.md b/docs/development/formatting-testing.md index cc5b72200..93a60b13f 100644 --- a/docs/development/formatting-testing.md +++ b/docs/development/formatting-testing.md @@ -1,6 +1,7 @@ # Formatting Testing -Formatting tests are data driven instead of being specified explicitly. This document explains how to add a new formatting test. +Formatting tests are data-driven instead of being specified explicitly. This document explains how to add a new +formatting test. ## Adding a formatting test diff --git a/docs/development/grammar-testing.md b/docs/development/grammar-testing.md index 2dcc1b603..2421c93a3 100644 --- a/docs/development/grammar-testing.md +++ b/docs/development/grammar-testing.md @@ -1,11 +1,12 @@ # Grammar Testing -Grammar tests are data driven instead of being specified explicitly. This document explains how to add a new grammar test. +Grammar tests are data-driven instead of being specified explicitly. This document explains how to add a new grammar +test. ## Adding a grammar test -1. Create a new file with extension `.sdstest` in the `DSL/tests/resources/grammar` directory or any - subdirectory. Give the file a descriptive name, since the file name becomes part of the test name. +1. Create a new file with extension `.sdstest` in the `DSL/tests/resources/grammar` directory or any subdirectory. Give + the file a descriptive name, since the file name becomes part of the test name. !!! note "Naming convention" diff --git a/docs/development/scoping-testing.md b/docs/development/scoping-testing.md new file mode 100644 index 000000000..e630a1efc --- /dev/null +++ b/docs/development/scoping-testing.md @@ -0,0 +1,35 @@ +# Scoping Testing + +Scoping tests are data-driven instead of being specified explicitly. This document explains how to add a new scoping +test. + +## Adding a scoping test + +1. Create a new **folder** (not just a file!) in the `DSL/tests/resources/scoping` 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 **the name** of any declaration that you want to reference with test markers, e.g. `class »C«`. Add a + comment in the preceding line with the following format (replace `` with some unique identifier): + ```ts + // $TEST$ target + ``` +5. Surround references you want to test with test markers, e.g. `»C«()`. If you want to assert that the reference should be resolved, + add a comment in the preceding line with the following format (replace `` with the identifier you previously + assigned to the referenced declaration): + + ```ts + // $TEST$ references + ``` + If you instead want to assert that the reference is unresolved, add the following comment to the preceding line: + ```ts + // $TEST$ unresolved + ``` +6. Run the tests. The test runner will automatically pick up the new test. diff --git a/mkdocs.yml b/mkdocs.yml index dcff1c708..b7ff013fe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - safeds.lang: stdlib/safeds_lang.md - Development: - Grammar Testing: development/grammar-testing.md + - Scoping Testing: development/scoping-testing.md - Formatting Testing: development/formatting-testing.md # Configuration of MkDocs & Material for MkDocs --------------------------------