Skip to content

Commit

Permalink
test: improve tests for formatter and grammar (#544)
Browse files Browse the repository at this point in the history
### Summary of Changes

* Add tests for the idempotence of formatter
* Clear documents between tests to avoid colliding URIs
* Move test creation code to a separate file, so it can be tested
* Bump dependencies
* Various bug fixes

---------

Co-authored-by: megalinter-bot <[email protected]>
lars-reimann and megalinter-bot authored Aug 8, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 9662469 commit c3c2aef
Showing 14 changed files with 846 additions and 639 deletions.
810 changes: 400 additions & 410 deletions DSL/package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions DSL/package.json
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
"description": "Statically checked Data Science programs.",
"version": "0.1.0",
"engines": {
"vscode": "^1.79.0"
"vscode": "^1.81.0"
},
"categories": [
"Programming Languages"
@@ -57,7 +57,7 @@
},
"dependencies": {
"chalk": "^5.3.0",
"chevrotain": "^11.0.1",
"chevrotain": "^11.0.2",
"commander": "^11.0.0",
"glob": "^10.3.3",
"langium": "^1.2.1",
@@ -69,12 +69,12 @@
"vscode-uri": "^3.0.7"
},
"devDependencies": {
"@types/node": "^18.16.18",
"@types/vscode": "^1.79.1",
"@vitest/coverage-v8": "^0.32.4",
"@vitest/ui": "^0.32.4",
"@types/node": "^18.17.3",
"@types/vscode": "^1.81.0",
"@vitest/coverage-v8": "^0.34.1",
"@vitest/ui": "^0.34.1",
"langium-cli": "^1.2.1",
"typescript": "^5.1.6",
"vitest": "^0.32.4"
"vitest": "^0.34.1"
}
}
2 changes: 1 addition & 1 deletion DSL/src/language-server/formatting/safe-ds-formatter.ts
Original file line number Diff line number Diff line change
@@ -905,7 +905,7 @@ export class SafeDSFormatter extends AbstractFormatter {
formatter.keyword('}').prepend(noSpace());
} else {
formatter.nodes(...columns).prepend(indent());
formatter.keywords(',').prepend(noSpace()).append(newLine());
formatter.keywords(',').prepend(noSpace());
formatter.keyword('}').prepend(newLine());
}
}
79 changes: 79 additions & 0 deletions DSL/tests/formatting/creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { listTestResources, resolvePathRelativeToResources } from '../helpers/testResources';
import path from 'path';
import fs from 'fs';
import { validationHelper } from 'langium/test';
import { Diagnostic } from 'vscode-languageserver-types';
import { createSafeDsServices } from '../../src/language-server/safe-ds-module';
import { EmptyFileSystem } from 'langium';

const services = createSafeDsServices(EmptyFileSystem).SafeDs;
const separator = '// -----------------------------------------------------------------------------';

export const createFormatterTests = async (): Promise<FormatterTest[]> => {
const testCases = listTestResources('formatting').map(async (pathRelativeToResources): Promise<FormatterTest> => {
const absolutePath = resolvePathRelativeToResources(path.join('formatting', pathRelativeToResources));
const program = fs.readFileSync(absolutePath).toString();
const parts = program.split(separator);

// Must contain exactly one separator
if (parts.length !== 2) {
return {
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
originalCode: '',
expectedFormattedCode: '',
error: new SeparatorError(parts.length - 1),
};
}

// Original code must not contain syntax errors
const originalCode = normalizeLineBreaks(parts[0]).trimEnd();
const expectedFormattedCode = normalizeLineBreaks(parts[1]).trim();

const validationResult = await validationHelper(services)(parts[0]);
const syntaxErrors = validationResult.diagnostics.filter(
(d) => d.severity === 1 && (d.code === 'lexing-error' || d.code === 'parsing-error'),
);

if (syntaxErrors.length > 0) {
return {
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
originalCode,
expectedFormattedCode,
error: new SyntaxErrorsInOriginalCodeError(syntaxErrors),
};
}

return {
testName: `${pathRelativeToResources} should be formatted correctly`,
originalCode,
expectedFormattedCode,
};
});

return Promise.all(testCases);
};

const normalizeLineBreaks = (code: string): string => {
return code.replace(/\r\n?/gu, '\n');
};

interface FormatterTest {
testName: string;
originalCode: string;
expectedFormattedCode: string;
error?: Error;
}

class SeparatorError extends Error {
constructor(readonly number_of_separators: number) {
super(`Expected exactly one separator but found ${number_of_separators}.`);
}
}

class SyntaxErrorsInOriginalCodeError extends Error {
constructor(readonly syntaxErrors: Diagnostic[]) {
const syntaxErrorsAsString = syntaxErrors.map((e) => `- ${e.message}`).join(`\n`);

super(`Original code has syntax errors:\n${syntaxErrorsAsString}`);
}
}
95 changes: 22 additions & 73 deletions DSL/tests/formatting/testFormatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,42 @@
import { createSafeDsServices } from '../../src/language-server/safe-ds-module';
import { expectFormatting, validationHelper } from 'langium/test';
import { clearDocuments, expectFormatting } from 'langium/test';
import { describe, it } from 'vitest';
import { EmptyFileSystem } from 'langium';
import { listTestResources, resolvePathRelativeToResources } from '../helpers/testResources';
import path from 'path';
import fs from 'fs';
import { Diagnostic } from 'vscode-languageserver-types';
import { createFormatterTests } from './creator';

const services = createSafeDsServices({ ...EmptyFileSystem }).SafeDs;
const separator = '// -----------------------------------------------------------------------------';
const services = createSafeDsServices(EmptyFileSystem).SafeDs;
const formatterTests = createFormatterTests();

describe('formatter', async () => {
it.each(await createFormatterTest())('$testName', async (test) => {
it.each(await formatterTests)('$testName', async (test) => {
// Test is invalid
if (test.error) {
throw test.error;
}

// Formatting original code must result in expected formatted code
await expectFormatting(services)({
before: test.originalCode,
after: test.expectedFormattedCode,
});
});
});

const createFormatterTest = async (): Promise<FormatterTest[]> => {
const testCases = listTestResources('formatting').map(async (pathRelativeToResources): Promise<FormatterTest> => {
const absolutePath = resolvePathRelativeToResources(path.join('formatting', pathRelativeToResources));
const program = fs.readFileSync(absolutePath).toString();
const parts = program.split(separator);
// Clear loaded documents to avoid colliding URIs (https://github.com/langium/langium/issues/1146)
await clearDocuments(services);
});

// Must contain exactly one separator
if (parts.length !== 2) {
return {
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
originalCode: '',
expectedFormattedCode: '',
error: new SeparatorError(parts.length - 1),
};
it.each(await formatterTests)('$testName (idempotence)', async (test) => {
// Test is invalid
if (test.error) {
throw test.error;
}

// Original code must not contain syntax errors
const originalCode = normalizeLineBreaks(parts[0]).trimEnd();
const expectedFormattedCode = normalizeLineBreaks(parts[1]).trim();

const validationResult = await validationHelper(services)(parts[0]);
const syntaxErrors = validationResult.diagnostics.filter(
(d) => d.severity === 1 && (d.code === 'lexing-error' || d.code === 'parsing-error'),
);

if (syntaxErrors.length > 0) {
return {
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
originalCode,
expectedFormattedCode,
error: new SyntaxErrorsInOriginalCodeError(syntaxErrors),
};
}
// Formatting must be idempotent
await expectFormatting(services)({
before: test.expectedFormattedCode,
after: test.expectedFormattedCode,
});

return {
testName: `${pathRelativeToResources} should be formatted correctly`,
originalCode,
expectedFormattedCode,
};
// Clear loaded documents to avoid colliding URIs (https://github.com/langium/langium/issues/1146)
await clearDocuments(services);
});

return Promise.all(testCases);
};

const normalizeLineBreaks = (code: string): string => {
return code.replace(/\r\n?/gu, '\n');
};

interface FormatterTest {
testName: string;
originalCode: string;
expectedFormattedCode: string;
error?: Error;
}

class SeparatorError extends Error {
constructor(readonly number_of_separators: number) {
super(`Expected exactly one separator but found ${number_of_separators}.`);
}
}

class SyntaxErrorsInOriginalCodeError extends Error {
constructor(readonly syntaxErrors: Diagnostic[]) {
const syntaxErrorsAsString = syntaxErrors.map((e) => `- ${e.message}`).join(`\n`);

super(`Original code has syntax errors:\n${syntaxErrorsAsString}`);
}
}
});
86 changes: 86 additions & 0 deletions DSL/tests/grammar/creator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { listTestResources, resolvePathRelativeToResources } from '../helpers/testResources';
import path from 'path';
import fs from 'fs';
import { findTestComments } from '../helpers/testComments';
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);

// Must contain at least one comment
if (comments.length === 0) {
return {
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
program,
expectedResult: 'invalid',
error: 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),
};
}

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),
};
}

let testName: string;
if (comment === 'syntax_error') {
testName = `[${pathRelativeToResources}] should have syntax errors`;
} else {
testName = `[${pathRelativeToResources}] should not have syntax errors`;
}

return {
testName,
program,
expectedResult: comment,
};
});
};

/**
* A description of a grammar test.
*/
interface GrammarTest {
testName: string;
program: string;
expectedResult: 'syntax_error' | 'no_syntax_error' | 'invalid';
error?: Error;
}

/**
* Found multiple test comments.
*/
class MultipleCommentsError extends Error {
constructor(readonly comments: string[]) {
super(`Found multiple test comments (grammar tests expect only one): ${comments}`);
}
}

/**
* Found one test comment but it was invalid.
*/
class InvalidCommentError extends Error {
constructor(readonly comment: string) {
super(`Invalid test comment (valid values are 'syntax_error' and 'no_syntax_error'): ${comment}`);
}
}
100 changes: 12 additions & 88 deletions DSL/tests/grammar/testGrammar.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,26 @@
import { listTestResources, resolvePathRelativeToResources } from '../helpers/testResources';
import { describe, it } from 'vitest';
import { NoCommentsError } from '../helpers/testChecks';
import fs from 'fs';
import { findTestComments } from '../helpers/testComments';
import path from 'path';
import { createSafeDsServices } from '../../src/language-server/safe-ds-module';
import { NodeFileSystem } from 'langium/node';
import { URI } from 'vscode-uri';
import { AssertionError } from 'assert';
import { NodeFileSystem } from 'langium/node';
import { createGrammarTests } from './creator';
import { clearDocuments, validationHelper } from 'langium/test';

const services = createSafeDsServices(NodeFileSystem).SafeDs;

describe('grammar', () => {
it.each(createGrammarTests())('$testName', async (test) => {
// Test is invalid
if (test.error) {
throw test.error;
}

const document = services.shared.workspace.LangiumDocuments.getOrCreateDocument(URI.file(test.absolutePath));
await services.shared.workspace.DocumentBuilder.build([document], { validationChecks: 'all' });

const diagnostics = document.diagnostics;
if (!diagnostics) {
throw new Error('No diagnostics found');
}

// Get the actual syntax errors
const { diagnostics } = await validationHelper(services)(test.program);
const syntaxErrors = diagnostics.filter(
(d) => d.severity === 1 && (d.code === 'lexing-error' || d.code === 'parsing-error'),
);

// Expected syntax errors
if (test.expectedResult === 'syntax_error') {
if (syntaxErrors.length === 0) {
throw new AssertionError({
@@ -39,7 +31,8 @@ describe('grammar', () => {
}
}

if (test.expectedResult === 'no_syntax_error') {
// Expected no syntax errors
else if (test.expectedResult === 'no_syntax_error') {
if (syntaxErrors.length > 0) {
throw new AssertionError({
message: 'Expected no syntax errors but found some.',
@@ -48,77 +41,8 @@ describe('grammar', () => {
});
}
}
});
});

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);

// Must contain at least one comment
if (comments.length === 0) {
return {
absolutePath,
expectedResult: 'invalid',
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
error: new NoCommentsError(),
};
}

// Must contain no more than one comment
if (comments.length > 1) {
return {
absolutePath,
expectedResult: 'invalid',
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
error: new MultipleCommentsError(comments),
};
}

const comment = comments[0];

// Must contain a valid comment
if (comment !== 'syntax_error' && comment !== 'no_syntax_error') {
return {
absolutePath,
expectedResult: 'invalid',
testName: `INVALID TEST FILE [${pathRelativeToResources}]`,
error: new InvalidCommentError(comment),
};
}

let testName: string;
if (comment === 'syntax_error') {
testName = `[${pathRelativeToResources}] should have syntax errors`;
} else {
testName = `[${pathRelativeToResources}] should not have syntax errors`;
}

return {
absolutePath,
expectedResult: comment,
testName,
};
// Clear loaded documents to avoid colliding URIs (https://github.com/langium/langium/issues/1146)
await clearDocuments(services);
});
};

interface GrammarTest {
absolutePath: string;
expectedResult: 'syntax_error' | 'no_syntax_error' | 'invalid';
testName: string;
error?: Error;
}

class MultipleCommentsError extends Error {
constructor(readonly comments: string[]) {
super(`Found multiple test comments (grammar tests expect only one): ${comments}`);
}
}

class InvalidCommentError extends Error {
constructor(readonly comment: string) {
super(`Invalid test comment (valid values are 'syntax_error' and 'no_syntax_error'): ${comment}`);
}
}
});
57 changes: 57 additions & 0 deletions DSL/tests/helpers/stringification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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);
});
});
22 changes: 22 additions & 0 deletions DSL/tests/helpers/stringification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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)}`;
};
92 changes: 70 additions & 22 deletions DSL/tests/helpers/testChecks.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import { describe, expect, it } from 'vitest';
import { CloseWithoutOpenError, OpenWithoutCloseError } from './testRanges';
import { CLOSE, OPEN } from './testMarker';
import { findTestChecks, MoreRangesThanCommentsError, NoCommentsError } from './testChecks';
import {
FewerRangesThanCommentsError,
findTestChecks,
MoreRangesThanCommentsError,
NoCommentsError,
} from './testChecks';
import { Range } from 'vscode-languageserver';

const uri = 'file:///test.sdstest';

describe('findTestChecks', () => {
it.each([
{
program: '',
expected: [],
id: 'no comments, no ranges',
},
{
program: `
// $TEST$ no_syntax_error
`,
expected: [
{
comment: 'no_syntax_error',
range: undefined,
location: {
uri,
range: undefined,
},
},
],
id: 'one comment, no range',
@@ -26,7 +41,10 @@ ${OPEN}${CLOSE}
expected: [
{
comment: 'no_syntax_error',
range: Range.create(3, 2, 3, 2),
location: {
uri,
range: Range.create(2, 1, 2, 1),
},
},
],
id: 'one comment, one range',
@@ -41,66 +59,96 @@ ${OPEN}${CLOSE}
expected: [
{
comment: 'no_syntax_error',
range: Range.create(3, 2, 3, 2),
location: {
uri,
range: Range.create(2, 1, 2, 1),
},
},
{
comment: 'syntax_error',
range: Range.create(5, 2, 5, 2),
location: {
uri,
range: Range.create(4, 1, 4, 1),
},
},
],
id: 'two comments, two ranges',
},
])('should associated comments and ranges ($id)', ({ program, expected }) => {
const result = findTestChecks(program);
const result = findTestChecks(program, uri);
expect(result.isOk).toBeTruthy();

if (result.isOk) {
expect(result.value).toStrictEqual(expected);
}
});

it('should report if no test comments are found', () => {
const result = findTestChecks('');
it('should report closing test markers without matching opening test marker', () => {
const result = findTestChecks(
`
// $TEST$ no_syntax_error
${OPEN}\n${CLOSE}${CLOSE}
`,
uri,
);
expect(result.isErr).toBeTruthy();

if (result.isErr) {
expect(result.error).toBeInstanceOf(NoCommentsError);
expect(result.error).toBeInstanceOf(CloseWithoutOpenError);
}
});

it('should report opening test markers without matching closing test marker', () => {
const result = findTestChecks(
`
// $TEST$ no_syntax_error
${OPEN}\n${OPEN}${OPEN}${CLOSE}
`,
uri,
);
expect(result.isErr).toBeTruthy();

if (result.isErr) {
expect(result.error).toBeInstanceOf(OpenWithoutCloseError);
}
});

it('should report if more ranges than comments are found', () => {
const result = findTestChecks(`
const result = findTestChecks(
`
// $TEST$ no_syntax_error
${OPEN}\n${CLOSE}${OPEN}\n${CLOSE}
`);
`,
uri,
);
expect(result.isErr).toBeTruthy();

if (result.isErr) {
expect(result.error).toBeInstanceOf(MoreRangesThanCommentsError);
}
});

it('should report closing test markers without matching opening test marker', () => {
const result = findTestChecks(`
// $TEST$ no_syntax_error
${OPEN}\n${CLOSE}${CLOSE}
`);
it('should report if no test comments are found if corresponding check is enabled', () => {
const result = findTestChecks('', uri, { failIfNoComments: true });
expect(result.isErr).toBeTruthy();

if (result.isErr) {
expect(result.error).toBeInstanceOf(CloseWithoutOpenError);
expect(result.error).toBeInstanceOf(NoCommentsError);
}
});

it('should report opening test markers without matching closing test marker', () => {
const result = findTestChecks(`
it('should report if fewer ranges than comments are found if corresponding check is enabled', () => {
const result = findTestChecks(
`
// $TEST$ no_syntax_error
${OPEN}\n${OPEN}${OPEN}${CLOSE}
`);
`,
uri,
{ failIfFewerRangesThanComments: true },
);
expect(result.isErr).toBeTruthy();

if (result.isErr) {
expect(result.error).toBeInstanceOf(OpenWithoutCloseError);
expect(result.error).toBeInstanceOf(FewerRangesThanCommentsError);
}
});
});
87 changes: 68 additions & 19 deletions DSL/tests/helpers/testChecks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Range } from 'vscode-languageserver';
import { Location, Range } from 'vscode-languageserver';
import { findTestComments } from './testComments';
import { findTestRanges, FindTestRangesError } from './testRanges';
import { Result } from 'true-myth';
@@ -7,42 +7,88 @@ import { Result } from 'true-myth';
* Finds all test checks, i.e. test comments and their corresponding test ranges.
*
* @param program The program with test comments and test markers.
* @param uri The URI of the program.
* @param options Options for the function.
*/
export const findTestChecks = (program: string): Result<TestCheck[], FindTestChecksError> => {
export const findTestChecks = (
program: string,
uri: string,
options: FindTestChecksOptions = {},
): Result<TestCheck[], FindTestChecksError> => {
const { failIfNoComments = false, failIfFewerRangesThanComments = false } = options;

const comments = findTestComments(program);
const ranges = findTestRanges(program);
const rangesResult = findTestRanges(program);

// Must contain at least one comment
if (comments.length === 0) {
return Result.err(new NoCommentsError());
// Opening and closing test markers must match
if (rangesResult.isErr) {
return Result.err(rangesResult.error);
}
const ranges = rangesResult.value;

// Opening and closing test markers must match
if (ranges.isErr) {
return Result.err(ranges.error);
// Must never contain more ranges than comments
if (ranges.length > comments.length) {
return Result.err(new MoreRangesThanCommentsError(comments, ranges));
}

// Must contain at least one comment, if corresponding check is enabled
if (failIfNoComments && comments.length === 0) {
return Result.err(new NoCommentsError());
}

// Must not contain more locations markers than severities
if (ranges.value.length > comments.length) {
return Result.err(new MoreRangesThanCommentsError(comments, ranges.value));
// Must not contain fewer ranges than comments, if corresponding check is enabled
if (failIfFewerRangesThanComments && ranges.length < comments.length) {
return Result.err(new FewerRangesThanCommentsError(comments, ranges));
}

return Result.ok(comments.map((comment, index) => ({ comment, range: ranges.value[index] })));
return Result.ok(comments.map((comment, index) => ({ comment, location: { uri, range: ranges[index] } })));
};

/**
* Options for the `findTestChecks` function.
*/
export interface FindTestChecksOptions {
/**
* If this option is set to `true`, an error is returned if there are no comments.
*/
failIfNoComments?: boolean;

/**
* It is never permissible to have *more* ranges than comments. If this option is set to `true`, the number of
* ranges must be *equal to* the number of comments.
*/
failIfFewerRangesThanComments?: boolean;
}

/**
* A test check, i.e. a test comment and its corresponding test range. The range is optional. If it is omitted, the test
* comment is associated with the whole program.
*/
export interface TestCheck {
comment: string;
range?: Range;
location?: Location;
}

/**
* Something went wrong while finding test checks.
*/
export type FindTestChecksError = NoCommentsError | MoreRangesThanCommentsError | FindTestRangesError;
export type FindTestChecksError =
| FindTestRangesError
| MoreRangesThanCommentsError
| NoCommentsError
| FewerRangesThanCommentsError;

/**
* Found more test ranges than test comments.
*/
export class MoreRangesThanCommentsError extends Error {
constructor(
readonly comments: string[],
readonly ranges: Range[],
) {
super(`Found more test ranges (${ranges.length}) than test comments (${comments.length}).`);
}
}

/**
* Did not find any test comments.
@@ -54,10 +100,13 @@ export class NoCommentsError extends Error {
}

/**
* Found more test ranges than test comments.
* Found fewer test ranges than test comments.
*/
export class MoreRangesThanCommentsError extends Error {
constructor(readonly comments: string[], readonly ranges: Range[]) {
super(`Found more test ranges (${ranges.length}) than test comments (${comments.length}).`);
export class FewerRangesThanCommentsError extends Error {
constructor(
readonly comments: string[],
readonly ranges: Range[],
) {
super(`Found fewer test ranges (${ranges.length}) than test comments (${comments.length}).`);
}
}
6 changes: 6 additions & 0 deletions DSL/tests/helpers/testComments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
const pattern = /\/\/\s*\$TEST\$(?<comment>[^\n]*)/gu;

/**
* Finds all test comments (`// $TEST$ ...`) in the given program.
*
* @param program The program with test comments.
* @return The list of test comments.
*/
export const findTestComments = (program: string): string[] => {
const comments: string[] = [];
for (const match of program.matchAll(pattern)) {
20 changes: 10 additions & 10 deletions DSL/tests/helpers/testRanges.test.ts
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ describe('findTestRanges', () => {

if (result.isOk) {
const ranges = result.value;
expect(ranges).toStrictEqual([Range.create(1, 6, 1, 10), Range.create(2, 2, 2, 6)]);
expect(ranges).toStrictEqual([Range.create(0, 5, 0, 9), Range.create(1, 1, 1, 5)]);
}
});

@@ -20,7 +20,7 @@ describe('findTestRanges', () => {

if (result.isOk) {
const ranges = result.value;
expect(ranges).toStrictEqual([Range.create(1, 2, 3, 1), Range.create(2, 6, 2, 6)]);
expect(ranges).toStrictEqual([Range.create(0, 1, 2, 0), Range.create(1, 5, 1, 5)]);
}
});

@@ -30,7 +30,7 @@ describe('findTestRanges', () => {

if (result.isOk) {
const ranges = result.value;
expect(ranges).toStrictEqual([Range.create(2, 2, 3, 1)]);
expect(ranges).toStrictEqual([Range.create(1, 1, 2, 0)]);
}
});

@@ -40,7 +40,7 @@ describe('findTestRanges', () => {

if (result.isOk) {
const ranges = result.value;
expect(ranges).toStrictEqual([Range.create(2, 2, 3, 1)]);
expect(ranges).toStrictEqual([Range.create(1, 1, 2, 0)]);
}
});

@@ -50,7 +50,7 @@ describe('findTestRanges', () => {

if (result.isOk) {
const ranges = result.value;
expect(ranges).toStrictEqual([Range.create(2, 2, 3, 1)]);
expect(ranges).toStrictEqual([Range.create(1, 1, 2, 0)]);
}
});

@@ -61,8 +61,8 @@ describe('findTestRanges', () => {
if (result.isErr) {
const error = result.error;
expect(error).toBeInstanceOf(CloseWithoutOpenError);
expect((error as CloseWithoutOpenError).position).toStrictEqual(Position.create(2, 2));
expect(error.message).toBe(`Found '${CLOSE}' without previous '${OPEN}' at 2:2.`);
expect((error as CloseWithoutOpenError).position).toStrictEqual(Position.create(1, 1));
expect(error.message).toBe(`Found '${CLOSE}' without previous '${OPEN}' at 1:1.`);
}
});

@@ -74,10 +74,10 @@ describe('findTestRanges', () => {
const error = result.error;
expect(error).toBeInstanceOf(OpenWithoutCloseError);
expect((error as OpenWithoutCloseError).positions).toStrictEqual([
Position.create(1, 1),
Position.create(2, 1),
Position.create(0, 0),
Position.create(1, 0),
]);
expect(error.message).toBe(`Found '${OPEN}' without following '${CLOSE}' at 1:1, 2:1.`);
expect(error.message).toBe(`Found '${OPEN}' without following '${CLOSE}' at 0:0, 1:0.`);
}
});
});
15 changes: 6 additions & 9 deletions DSL/tests/helpers/testRanges.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Result } from 'true-myth';
import { Range, Position } from 'vscode-languageserver';
import { CLOSE, OPEN } from './testMarker';
import { positionToString } from './stringification';

/**
* Finds test ranges, i.e. parts of the program delimited by opening and closing test markers. They are sorted by the
@@ -14,8 +15,8 @@ import { CLOSE, OPEN } from './testMarker';
* @see OPEN
*/
export const findTestRanges = (program: string): Result<Range[], FindTestRangesError> => {
let currentLine = 1;
let currentColumn = 1;
let currentLine = 0;
let currentColumn = 0;
let previousChar: string | null = null;

const testRangeStarts: Position[] = [];
@@ -42,12 +43,12 @@ export const findTestRanges = (program: string): Result<Range[], FindTestRangesE
break;
case '\r':
currentLine++;
currentColumn = 1;
currentColumn = 0;
break;
case '\n':
if (previousChar !== '\r') {
currentLine++;
currentColumn = 1;
currentColumn = 0;
}
break;
default:
@@ -93,10 +94,6 @@ export class CloseWithoutOpenError extends Error {
*/
export class OpenWithoutCloseError extends Error {
constructor(readonly positions: Position[]) {
super(
`Found '${OPEN}' without following '${CLOSE}' at ${positions
.map((position) => `${position.line}:${position.character}`)
.join(', ')}.`,
);
super(`Found '${OPEN}' without following '${CLOSE}' at ${positions.map(positionToString).join(', ')}.`);
}
}

0 comments on commit c3c2aef

Please sign in to comment.