Skip to content

Commit

Permalink
test: data-driven tests for code actions
Browse files Browse the repository at this point in the history
  • Loading branch information
lars-reimann committed Jan 8, 2025
1 parent c2c75f7 commit 25d7cfc
Show file tree
Hide file tree
Showing 9 changed files with 464 additions and 30 deletions.
28 changes: 28 additions & 0 deletions packages/safe-ds-lang/src/language/codeActions/factories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CodeAction, Diagnostic, TextEdit } from 'vscode-languageserver';
import { LangiumDocument } from 'langium';

export const createQuickfixFromTextEditsToSingleDocument = (
title: string,
diagnostic: Diagnostic,
document: LangiumDocument,
edits: TextEdit[],
isPreferred: boolean = false,
): CodeAction => {
return {
title,
kind: 'quickfix',
diagnostics: [diagnostic],
edit: {
documentChanges: [
{
textDocument: {
uri: document.textDocument.uri,
version: document.textDocument.version,
},
edits,
},
],
},
isPreferred,
};
};
Original file line number Diff line number Diff line change
@@ -1,40 +1,31 @@
import { Diagnostic, TextEdit } from 'vscode-languageserver';
import { AstUtils, LangiumDocument } from 'langium';
import { LangiumDocument } from 'langium';
import { SafeDsServices } from '../../safe-ds-module.js';
import { CodeActionAcceptor } from '../safe-ds-code-action-provider.js';
import { isSdsArgumentList, SdsArgument } from '../../generated/ast.js';
import { Argument, Parameter } from '../../helpers/nodeProperties.js';
import { SafeDsNodeMapper } from '../../helpers/safe-ds-node-mapper.js';
import { CodeActionAcceptor } from '../safe-ds-code-action-provider.js';
import { createQuickfixFromTextEditsToSingleDocument } from '../factories.js';

export const makeArgumentsAssignedToOptionalParametersNamed = (services: SafeDsServices) => {
const locator = services.workspace.AstNodeLocator;
const nodeMapper = services.helpers.NodeMapper;

return (diagnostic: Diagnostic, document: LangiumDocument, acceptor: CodeActionAcceptor) => {
const node = locator.getAstNode(document.parseResult.value, diagnostic.data.path);
if (!node) {
return;
}

const argumentList = AstUtils.getContainerOfType(node, isSdsArgumentList);
if (!argumentList) {
/* c8 ignore next 2 */
if (!isSdsArgumentList(node)) {
return;
}

Check warning on line 18 in packages/safe-ds-lang/src/language/codeActions/quickfixes/arguments.ts

View check run for this annotation

Codecov / codecov/patch

packages/safe-ds-lang/src/language/codeActions/quickfixes/arguments.ts#L17-L18

Added lines #L17 - L18 were not covered by tests

const edits: TextEdit[] = argumentList.arguments.flatMap((it) => ensureArgumentIsNamed(nodeMapper, it));

acceptor({
title: 'Add names to arguments that are assigned to optional parameters.',
kind: 'quickfix',
diagnostics: [diagnostic],
edit: {
changes: {
[document.textDocument.uri]: edits,
},
},
isPreferred: true,
});
acceptor(
createQuickfixFromTextEditsToSingleDocument(
'Add names to arguments that are assigned to optional parameters.',
diagnostic,
document,
node.arguments.flatMap((it) => ensureArgumentIsNamed(nodeMapper, it)),
true,
),
);
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class SafeDsQuickfixProvider {
}

Check warning on line 20 in packages/safe-ds-lang/src/language/codeActions/quickfixes/safe-ds-quickfix-provider.ts

View check run for this annotation

Codecov / codecov/patch

packages/safe-ds-lang/src/language/codeActions/quickfixes/safe-ds-quickfix-provider.ts#L19-L20

Added lines #L19 - L20 were not covered by tests

const quickfixes = this.registry[diagnostic.code];

if (Array.isArray(quickfixes)) {
for (const quickfix of quickfixes) {
quickfix(diagnostic, document, acceptor);
Expand All @@ -31,7 +32,7 @@ export class SafeDsQuickfixProvider {
}

type QuickfixRegistry = {
[code: string | number]: Quickfix | Quickfix[];
[code: string | number]: QuickfixCreator | QuickfixCreator[];
};

type Quickfix = (diagnostic: Diagnostic, document: LangiumDocument, acceptor: CodeActionAcceptor) => void;
type QuickfixCreator = (diagnostic: Diagnostic, document: LangiumDocument, acceptor: CodeActionAcceptor) => void;
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { CodeActionProvider } from 'langium/lsp';
import { LangiumDocument, MaybePromise } from 'langium';
import { CancellationToken, CodeAction, CodeActionParams, Command } from 'vscode-languageserver';
import { CancellationToken, CodeAction, CodeActionParams } from 'vscode-languageserver';
import { SafeDsServices } from '../safe-ds-module.js';
import { SafeDsQuickfixProvider } from './quickfixes/safe-ds-quickfix-provider.js';
import { isEmpty } from '../../helpers/collections.js';

export class SafeDsCodeActionProvider implements CodeActionProvider {
private readonly quickfixProvider: SafeDsQuickfixProvider;
Expand All @@ -15,15 +16,15 @@ export class SafeDsCodeActionProvider implements CodeActionProvider {
document: LangiumDocument,
params: CodeActionParams,
_cancelToken?: CancellationToken,
): MaybePromise<Array<Command | CodeAction> | undefined> {
): MaybePromise<CodeAction[] | undefined> {
const result: CodeAction[] = [];
const acceptor = (action: CodeAction) => result.push(action);

for (const diagnostic of params.context.diagnostics) {
this.quickfixProvider.createQuickfixes(diagnostic, document, acceptor);
}

return result;
return isEmpty(result) ? undefined : result;
}
}

Expand Down
6 changes: 3 additions & 3 deletions packages/safe-ds-lang/tests/helpers/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ export class SyntaxErrorsInCodeError extends TestDescriptionError {
}

/**
* The code contains syntax errors.
* The code contains errors.
*/
export class ErrorsInCodeError extends TestDescriptionError {
constructor(
readonly errors: Diagnostic[],
uri: URI,
) {
const syntaxErrorsAsString = errors.map((e) => ` - ${e.message}`).join(`\n`);
const errorsAsString = errors.map((e) => ` - ${e.message}`).join(`\n`);

super(`Code has errors:\n${syntaxErrorsAsString}`, uri);
super(`Code has errors:\n${errorsAsString}`, uri);
}
}
156 changes: 156 additions & 0 deletions packages/safe-ds-lang/tests/language/codeActions/creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
listTestFilesWithExtensions,
listTestSafeDsFilesGroupedByParentDirectory,
loadDocuments,
uriToShortenedTestResourceName,
} from '../../helpers/testResources.js';
import path from 'path';
import { getSyntaxErrors, SyntaxErrorsInCodeError } from '../../helpers/diagnostics.js';
import { NodeFileSystem } from 'langium/node';
import { TestDescription, TestDescriptionError } from '../../helpers/testDescription.js';
import { URI } from 'langium';
import { createSafeDsServices } from '../../../src/language/index.js';
import { findTestComments } from '../../helpers/testComments.js';
import { SAFE_DS_FILE_EXTENSIONS } from '../../../src/language/helpers/fileExtensions.js';

const services = (await createSafeDsServices(NodeFileSystem)).SafeDs;
const langiumDocuments = services.shared.workspace.LangiumDocuments;

const rootResourceName = 'code actions';
const commentRegex = /\s*apply\s*(?<titleIsRegex>r)?"(?<title>.*)"/gu;

export const createCodeActionsTests = async (): Promise<CodeActionsTest[]> => {
const filesGroupedByParentDirectory = listTestSafeDsFilesGroupedByParentDirectory(rootResourceName);
const testCases = filesGroupedByParentDirectory.map((entry) => createCodeActionsTest(...entry));

return Promise.all(testCases);
};

const createCodeActionsTest = async (parentDirectory: URI, inputUris: URI[]): Promise<CodeActionsTest> => {
const outputRoot = URI.file(path.join(parentDirectory.fsPath, 'skip-output'));
const expectedOutputUris = listExpectedOutputFiles(outputRoot);
const inputs: CodeActionsInput[] = [];

// Load all files, so they get linked
await loadDocuments(services, inputUris, { validation: true });

for (const uri of inputUris) {
const document = langiumDocuments.getDocument(uri)!;
const code = document.textDocument.getText();

// File must not contain syntax errors
const syntaxErrors = await getSyntaxErrors(services, code);
if (syntaxErrors.length > 0) {
return invalidTest('FILE', new SyntaxErrorsInCodeError(syntaxErrors, uri));
}

const testComments = findTestComments(code);
const codeActionTitles: (string | RegExp)[] = [];

for (const comment of testComments) {
const match = commentRegex.exec(comment);

// Comment must match the expected format
if (!match) {
return invalidTest('FILE', new InvalidCommentError(comment, uri));
}

const title = match.groups!.title!;
const titleIsRegex = match.groups!.titleIsRegex === 'r';

codeActionTitles.push(titleIsRegex ? new RegExp(title, 'gu') : title);
}

inputs.push({ uri, codeActionTitles });
}

const shortenedResourceName = uriToShortenedTestResourceName(parentDirectory, rootResourceName);
return {
testName: `[${shortenedResourceName}]`,
inputs,
inputRoot: parentDirectory,
expectedOutputUris,
outputRoot,
};
};

/**
* List all expected output files.
*
* @param outputRoot The directory, where output files are located.
*/
const listExpectedOutputFiles = (outputRoot: URI): URI[] => {
return listTestFilesWithExtensions(uriToShortenedTestResourceName(outputRoot), SAFE_DS_FILE_EXTENSIONS);
};

/**
* Report a test that has errors.
*
* @param level Whether a test file or a test suite is invalid.
* @param error The error that occurred.
*/
const invalidTest = (level: 'FILE' | 'SUITE', error: TestDescriptionError): CodeActionsTest => {
const shortenedResourceName = uriToShortenedTestResourceName(error.uri, rootResourceName);
const testName = `INVALID TEST ${level} [${shortenedResourceName}]`;
return {
testName,
inputs: [],
inputRoot: URI.file(''),
expectedOutputUris: [],
outputRoot: URI.file(''),
error,
};
};

/**
* A description of a code actions test.
*/
interface CodeActionsTest extends TestDescription {
/**
* The original code.
*/
inputs: CodeActionsInput[];

/**
* The directory, where input files are located.
*/
inputRoot: URI;

/**
* The expected output files.
*/
expectedOutputUris: URI[];

/**
* The directory, where output files are located.
*/
outputRoot: URI;
}

/**
* A description of the input for code actions.
*/
interface CodeActionsInput {
/**
* The URI of the file.
*/
uri: URI;

/**
* The titles of the code actions that should be applied. Strings must match exactly, regular expressions must match
* the entire string.
*/
codeActionTitles: (string | RegExp)[];
}

/**
* A test comment did not match the expected format.
*/
class InvalidCommentError extends TestDescriptionError {
constructor(
readonly comment: string,
uri: URI,
) {
super(`Invalid test comment (refer to the documentation for guidance): ${comment}`, uri);
}
}
Loading

0 comments on commit 25d7cfc

Please sign in to comment.