Improve context for errors thrown by compiler

This commit introduces a custom error object to provide additional
context for errors throwing during parsing and compiling operations,
improving troubleshooting.

By integrating error context handling, the error messages become more
informative and user-friendly, providing sequence of trace with context
to aid in troubleshooting.

Changes include:

- Introduce custom error object that extends errors with contextual
  information. This replaces previous usages of `AggregateError` which
  is not displayed well by browsers when logged.
- Improve parsing functions to encapsulate error context with more
  details.
- Increase unit test coverage and refactor the related code to be more
  testable.
This commit is contained in:
undergroundwires
2024-05-25 13:55:30 +02:00
parent 7794846185
commit 4212c7b9e0
78 changed files with 3346 additions and 1268 deletions

View File

@@ -229,7 +229,11 @@ class ExpressionBuilder {
}
public build() {
return new Expression(this.position, this.evaluator, this.parameters);
return new Expression({
position: this.position,
evaluator: this.evaluator,
parameters: this.parameters,
});
}
private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`;

View File

@@ -1,22 +1,21 @@
import { describe, it, expect } from 'vitest';
import { createPositionFromRegexFullMatch } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('ExpressionPositionFactory', () => {
describe('createPositionFromRegexFullMatch', () => {
it(`creates ${ExpressionPosition.name} instance`, () => {
describe('it is a transient factory', () => {
// arrange
const expectedType = ExpressionPosition;
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: 5,
});
const fakeMatch = createRegexMatch();
// act
const position = createPositionFromRegexFullMatch(fakeMatch);
const create = () => createPositionFromRegexFullMatch(fakeMatch);
// assert
expect(position).to.be.instanceOf(expectedType);
itIsTransientFactory({
getter: create,
expectedType: ExpressionPosition,
});
});
it('creates a position with the correct start position', () => {
// arrange
const expectedStartPosition = 5;
@@ -63,10 +62,8 @@ describe('ExpressionPositionFactory', () => {
describe('invalid values', () => {
it('throws an error if match.index is undefined', () => {
// arrange
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: undefined,
});
const fakeMatch = createRegexMatch();
fakeMatch.index = undefined;
const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`;
// act
const act = () => createPositionFromRegexFullMatch(fakeMatch);
@@ -94,9 +91,9 @@ function createRegexMatch(options?: {
readonly capturingGroups?: readonly string[],
readonly matchIndex?: number,
}): RegExpMatchArray {
const fullMatch = options?.fullMatch ?? 'fake match';
const fullMatch = options?.fullMatch ?? 'default fake match';
const capturingGroups = options?.capturingGroups ?? [];
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
fakeMatch.index = options?.matchIndex;
fakeMatch.index = options?.matchIndex ?? 0;
return fakeMatch;
}

View File

@@ -1,168 +1,438 @@
import { describe, it, expect } from 'vitest';
import type { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import { type IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import type {
ExpressionEvaluator, ExpressionInitParameters,
} from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import {
type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities,
} from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import type { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
import type { ExpressionPositionFactory } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
describe('RegexParser', () => {
describe('findExpressions', () => {
describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => {
describe('error handling', () => {
describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing code';
const sut = new RegexParserConcrete({
regex: /unimportant/,
});
// act
const act = () => sut.findExpressions(absentValue);
// assert
const errorMessage = collectExceptionMessage(act);
expect(errorMessage).to.include(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
describe('rethrows regex match errors', () => {
// arrange
const expectedError = 'missing code';
const sut = new RegexParserConcrete(/unimportant/);
// act
const act = () => sut.findExpressions(absentValue);
// assert
expect(act).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
const expectedMatchError = new TypeError('String.prototype.matchAll called with a non-global RegExp argument');
const expectedMessage = 'Failed to match regex.';
const expectedCodeInMessage = 'unimportant code content';
const expectedRegexInMessage = /failing-regex-because-it-is-non-global/;
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedContextMessage: expectedErrorMessage,
expectedWrappedError: expectedMatchError,
});
});
describe('rethrows expression building errors', () => {
// arrange
const expectedMessage = 'Failed to build expression.';
const expectedInnerError = new Error('Expected error from building expression');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionBuilder = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
builder: throwingExpressionBuilder,
utilities: {
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows position creation errors', () => {
// arrange
const expectedMessage = 'Failed to create position.';
const expectedInnerError = new Error('Expected error from position factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingPositionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createPosition: throwingPositionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows parameter creation errors', () => {
// arrange
const expectedMessage = 'Failed to create parameters.';
const expectedInnerError = new Error('Expected error from parameter collection factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingParameterCollectionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createParameterCollection: throwingParameterCollectionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows expression creation errors', () => {
// arrange
const expectedMessage = 'Failed to create expression.';
const expectedInnerError = new Error('Expected error from expression factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createExpression: throwingExpressionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
it('throws when position is invalid', () => {
describe('handles matched regex correctly', () => {
// arrange
const regexMatchingEmpty = /^/gm; /* expressions cannot be empty */
const code = 'unimportant';
const expectedErrorParts = [
`[${RegexParserConcrete.constructor.name}]`,
'invalid script position',
`Regex: ${regexMatchingEmpty}`,
`Code: ${code}`,
];
const sut = new RegexParserConcrete(regexMatchingEmpty);
// act
let errorMessage: string | undefined;
try {
sut.findExpressions(code);
} catch (err) {
errorMessage = err.message;
}
// assert
expectExists(errorMessage);
const error = errorMessage; // workaround for ts(18048): possibly 'undefined'
expect(
expectedErrorParts.every((part) => error.includes(part)),
`Expected parts: ${expectedErrorParts.join(', ')}`
+ `Actual error: ${errorMessage}`,
);
});
describe('matches regex as expected', () => {
// arrange
const testCases = [
const testScenarios: readonly {
readonly description: string;
readonly regex: RegExp;
readonly code: string;
}[] = [
{
name: 'returns no result when regex does not match',
description: 'non-matching regex',
regex: /hello/g,
code: 'world',
},
{
name: 'returns expected when regex matches single',
description: 'single regex match',
regex: /hello/g,
code: 'hello world',
},
{
name: 'returns expected when regex matches multiple',
description: 'multiple regex matches',
regex: /l/g,
code: 'hello world',
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
const expected = Array.from(testCase.code.matchAll(testCase.regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): IPrimitiveExpression => {
matches.push(m);
return mockPrimitiveExpression();
};
const sut = new RegexParserConcrete(testCase.regex, builder);
// act
const expressions = sut.findExpressions(testCase.code);
// assert
expect(expressions).to.have.lengthOf(matches.length);
expect(matches).to.deep.equal(expected);
testScenarios.forEach(({
description, code, regex,
}) => {
describe(description, () => {
it('generates expressions for all matches', () => {
// arrange
const expectedTotalExpressions = Array.from(code.matchAll(regex)).length;
const sut = new RegexParserConcrete({
regex,
});
// act
const expressions = sut.findExpressions(code);
// assert
const actualTotalExpressions = expressions.length;
expect(actualTotalExpressions).to.equal(
expectedTotalExpressions,
formatAssertionMessage([
`Expected ${actualTotalExpressions} expressions due to ${expectedTotalExpressions} matches`,
`Expressions:\n${indentText(JSON.stringify(expressions, undefined, 2))}`,
]),
);
});
it('builds primitive expressions for each match', () => {
const expected = Array.from(code.matchAll(regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): PrimitiveExpression => {
matches.push(m);
return createPrimitiveExpressionStub();
};
const sut = new RegexParserConcrete({
regex,
builder,
});
// act
sut.findExpressions(code);
// assert
expect(matches).to.deep.equal(expected);
});
it('sets positions correctly from matches', () => {
// arrange
const expectedMatches = [...code.matchAll(regex)];
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const serializeRegexMatch = (match: RegExpMatchArray) => `[startPos:${match?.index ?? 'none'},length:${match?.[0]?.length ?? 'none'}]`;
const positionsForMatches = new Map<string, ExpressionPosition>(expectedMatches.map(
(expectedMatch) => [serializeRegexMatch(expectedMatch), new ExpressionPosition(1, 4)],
));
const createPositionMock: ExpressionPositionFactory = (match) => {
const position = positionsForMatches.get(serializeRegexMatch(match));
return position ?? new ExpressionPosition(66, 666);
};
const sut = new RegexParserConcrete({
regex,
utilities: {
createExpression,
createPosition: createPositionMock,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
const expectedPositions = [...positionsForMatches.values()];
const actualPositions = expressions.map((e) => getInitParameters(e)?.position);
expect(actualPositions).to.deep.equal(expectedPositions, formatAssertionMessage([
'Actual positions do not match the expected positions.',
`Expected total positions: ${expectedPositions.length} (due to ${expectedMatches.length} regex matches)`,
`Actual total positions: ${actualPositions.length}`,
`Expected positions:\n${indentText(JSON.stringify(expectedPositions, undefined, 2))}`,
`Actual positions:\n${indentText(JSON.stringify(actualPositions, undefined, 2))}`,
]));
});
});
}
});
it('sets evaluator as expected', () => {
// arrange
const expected = getEvaluatorStub();
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: expected,
});
const sut = new RegexParserConcrete(regex, builder);
});
it('sets evaluator correctly from expression', () => {
// arrange
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const expectedEvaluate = createEvaluatorStub();
const { code, regex } = createCodeAndRegexMatchingOnce();
const builder = (): PrimitiveExpression => ({
evaluator: expectedEvaluate,
});
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].evaluate === expected);
const actualEvaluate = getInitParameters(expressions[0])?.evaluator;
expect(actualEvaluate).to.equal(expectedEvaluate);
});
it('sets parameters as expected', () => {
it('sets parameters correctly from expression', () => {
// arrange
const expected = [
new FunctionParameterStub().withName('parameter1').withOptionality(true),
new FunctionParameterStub().withName('parameter2').withOptionality(false),
const expectedParameters: IReadOnlyFunctionParameterCollection['all'] = [
new FunctionParameterStub().withName('parameter1').withOptional(true),
new FunctionParameterStub().withName('parameter2').withOptional(false),
];
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: getEvaluatorStub(),
parameters: expected,
const builder = (): PrimitiveExpression => ({
evaluator: createEvaluatorStub(),
parameters: expectedParameters,
});
const parameterCollection = new FunctionParameterCollectionStub();
const parameterCollectionFactoryStub
: FunctionParameterCollectionFactory = () => parameterCollection;
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
createParameterCollection: parameterCollectionFactoryStub,
},
});
const sut = new RegexParserConcrete(regex, builder);
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].parameters.all).to.deep.equal(expected);
});
it('sets expected position', () => {
// arrange
const code = 'mate date in state is fate';
const regex = /ate/g;
const expected = [
new ExpressionPosition(1, 4),
new ExpressionPosition(6, 9),
new ExpressionPosition(15, 18),
new ExpressionPosition(23, 26),
];
const sut = new RegexParserConcrete(regex);
// act
const expressions = sut.findExpressions(code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(expected);
const actualParameters = getInitParameters(expressions[0])?.parameters;
expect(actualParameters).to.equal(parameterCollection);
expect(actualParameters?.all).to.deep.equal(expectedParameters);
});
});
});
function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression {
return () => ({
evaluator: getEvaluatorStub(),
});
}
function getEvaluatorStub(): ExpressionEvaluator {
return () => `[${getEvaluatorStub.name}] evaluated code`;
function buildRethrowErrorMessage(
expectedContext: {
readonly message: string;
readonly regex: RegExp;
readonly code: string;
},
): string {
return [
expectedContext.message,
`Class name: ${RegexParserConcrete.name}`,
`Regex pattern used: ${expectedContext.regex}`,
`Code: ${expectedContext.code}`,
].join('\n');
}
function mockPrimitiveExpression(): IPrimitiveExpression {
function createExpressionFactorySpy() {
const createdExpressions = new Map<IExpression, ExpressionInitParameters>();
const createExpression: ExpressionFactory = (parameters) => {
const expression = new ExpressionStub();
createdExpressions.set(expression, parameters);
return expression;
};
return {
evaluator: getEvaluatorStub(),
createExpression,
getInitParameters: (expression) => createdExpressions.get(expression),
};
}
function createBuilderStub(): (match: RegExpMatchArray) => PrimitiveExpression {
return () => ({
evaluator: createEvaluatorStub(),
});
}
function createEvaluatorStub(): ExpressionEvaluator {
return () => `[${createEvaluatorStub.name}] evaluated code`;
}
function createPrimitiveExpressionStub(): PrimitiveExpression {
return {
evaluator: createEvaluatorStub(),
};
}
function createCodeAndRegexMatchingOnce() {
const code = 'expected code in context';
const regex = /code/g;
return { code, regex };
}
class RegexParserConcrete extends RegexParser {
private readonly builder: RegexParser['buildExpression'];
protected regex: RegExp;
public constructor(
regex: RegExp,
private readonly builder = mockBuilder(),
) {
super();
this.regex = regex;
public constructor(parameters?: {
regex?: RegExp,
builder?: RegexParser['buildExpression'],
utilities?: Partial<RegexParserUtilities>,
}) {
super({
wrapError: parameters?.utilities?.wrapError
?? (() => new Error(`[${RegexParserConcrete}] wrapped error`)),
createPosition: parameters?.utilities?.createPosition
?? (() => new ExpressionPosition(0, 5)),
createExpression: parameters?.utilities?.createExpression
?? (() => new ExpressionStub()),
createParameterCollection: parameters?.utilities?.createParameterCollection
?? (() => new FunctionParameterCollectionStub()),
});
this.builder = parameters?.builder ?? createBuilderStub();
this.regex = parameters?.regex ?? /unimportant/g;
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
return this.builder(match);
}
}

View File

@@ -17,7 +17,7 @@ describe('FunctionCallArgument', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const parameterName = 'paramName';
const expectedError = `missing argument value for "${parameterName}"`;
const expectedError = `Missing argument value for the parameter "${parameterName}".`;
const argumentValue = absentValue;
// act
const act = () => new FunctionCallArgumentBuilder()

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file */
import { describe, it, expect } from 'vitest';
import { FunctionCallSequenceCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
import type { CodeSegmentMerger } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/CodeSegmentMerger';
@@ -17,7 +17,7 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('FunctionCallSequenceCompiler', () => {
describe('instance', () => {
itIsSingleton({
itIsSingletonFactory({
getter: () => FunctionCallSequenceCompiler.instance,
expectedType: FunctionCallSequenceCompiler,
});

View File

@@ -9,7 +9,9 @@ import { SingleCallCompilerStub } from '@tests/unit/shared/Stubs/SingleCallCompi
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionCallCompiler', () => {
describe('canCompile', () => {
@@ -43,12 +45,12 @@ describe('NestedFunctionCallCompiler', () => {
// arrange
const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1);
@@ -59,33 +61,37 @@ describe('NestedFunctionCallCompiler', () => {
// arrange
const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedParentCall = callToFrontFunc;
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1);
const [,actualParentCall] = calls[0].args;
expect(actualParentCall).to.equal(callToFrontFunc);
expect(actualParentCall).to.equal(expectedParentCall);
});
it('uses correct nested call', () => {
// arrange
const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const {
frontFunction, callToDeepFunc, callToFrontFunc,
} = createSingleFuncCallingAnotherFunc();
const expectedNestedCall = callToDeepFunc;
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1);
const [actualNestedCall] = calls[0].args;
expect(actualNestedCall).to.deep.equal(callToFrontFunc);
expect(actualNestedCall).to.deep.equal(expectedNestedCall);
});
});
describe('re-compilation with compiled args', () => {
@@ -94,11 +100,11 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder()
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
expect(calls).have.lengthOf(1);
@@ -113,12 +119,12 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub();
const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, context);
compiler.compileFunction(frontFunction, callToFrontFunc, context);
// assert
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
expect(calls).have.lengthOf(1);
@@ -140,9 +146,9 @@ describe('NestedFunctionCallCompiler', () => {
.withScenario({ givenNestedFunctionCall: callToDeepFunc1, result: callToDeepFunc1 })
.withScenario({ givenNestedFunctionCall: callToDeepFunc2, result: callToDeepFunc2 });
const expectedFlattenedCodes = [...singleCallCompilationScenario.values()].flat();
const frontFunc = createSharedFunctionStubWithCalls()
const frontFunction = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc1, callToDeepFunc2);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
const singleCallCompilerStub = new SingleCallCompilerStub()
.withCallCompilationScenarios(singleCallCompilationScenario);
const expectedContext = new FunctionCallCompilationContextStub()
@@ -151,73 +157,105 @@ describe('NestedFunctionCallCompiler', () => {
.withArgumentCompiler(argumentCompiler)
.build();
// act
const actualCodes = compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
const actualCodes = compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
expect(actualCodes).have.lengthOf(expectedFlattenedCodes.length);
expect(actualCodes).to.have.members(expectedFlattenedCodes);
});
describe('error handling', () => {
it('handles argument compiler errors', () => {
describe('rethrows error from argument compiler', () => {
// arrange
const argumentCompilerError = new Error('Test error');
const expectedInnerError = new Error(`Expected error from ${ArgumentCompilerStub.name}`);
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const argumentCompilerStub = new ArgumentCompilerStub();
argumentCompilerStub.createCompiledNestedCall = () => {
throw argumentCompilerError;
throw expectedInnerError;
};
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedError = new AggregateError(
[argumentCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub)
.build();
// act
const act = () => compiler.compileFunction(
frontFunc,
callToFrontFunc,
new FunctionCallCompilationContextStub(),
);
// assert
expectDeepThrowsError(act, expectedError);
const builder = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc,
new FunctionCallCompilationContextStub(),
);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
it('handles single call compiler errors', () => {
describe('rethrows error from single call compiler', () => {
// arrange
const singleCallCompilerError = new Error('Test error');
const expectedInnerError = new Error(`Expected error from ${SingleCallCompilerStub.name}`);
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const singleCallCompiler = new SingleCallCompilerStub();
singleCallCompiler.compileSingleCall = () => {
throw singleCallCompilerError;
throw expectedInnerError;
};
const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompiler);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedError = new AggregateError(
[singleCallCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.build();
// act
const act = () => compiler.compileFunction(
frontFunc,
callToFrontFunc,
context,
);
// assert
expectDeepThrowsError(act, expectedError);
const builder = new NestedFunctionCallCompilerBuilder();
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc,
context,
);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
});
});
function createSingleFuncCallingAnotherFunc() {
const deepFunc = createSharedFunctionStubWithCode();
const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunc.name);
const frontFunc = createSharedFunctionStubWithCalls().withCalls(callToDeepFunc);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name);
function createSingleFuncCallingAnotherFunc(
functionNames?: {
readonly frontFunctionName?: string;
readonly deepFunctionName?: string;
},
) {
const deepFunction = createSharedFunctionStubWithCode()
.withName(functionNames?.deepFunctionName ?? 'deep-function (is called by front-function)');
const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunction.name);
const frontFunction = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc)
.withName(functionNames?.frontFunctionName ?? 'front-function (calls deep-function)');
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
return {
deepFunc,
frontFunc,
deepFunction,
frontFunction,
callToFrontFunc,
callToDeepFunc,
};
@@ -226,14 +264,31 @@ function createSingleFuncCallingAnotherFunc() {
class NestedFunctionCallCompilerBuilder {
private argumentCompiler: ArgumentCompiler = new ArgumentCompilerStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
public withArgumentCompiler(argumentCompiler: ArgumentCompiler): this {
this.argumentCompiler = argumentCompiler;
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public build(): NestedFunctionCallCompiler {
return new NestedFunctionCallCompiler(
this.argumentCompiler,
this.wrapError,
);
}
}
function buildRethrowErrorMessage(
functionNames: {
readonly caller: string;
readonly callee: string;
},
) {
return `Failed to call '${functionNames.callee}' (callee function) from '${functionNames.caller}' (caller function).`;
}

View File

@@ -11,6 +11,7 @@ import type { FunctionCallCompilationContext } from '@/application/Parser/Script
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('AdaptiveFunctionCallCompiler', () => {
describe('compileSingleCall', () => {
@@ -28,40 +29,40 @@ describe('AdaptiveFunctionCallCompiler', () => {
functionParameters: ['expected-parameter'],
callParameters: ['unexpected-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '. Expected parameter(s): "expected-parameter"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".`
+ '\nExpected parameter(s): "expected-parameter"',
},
{
description: 'provided: multiple unexpected parameters, when: different one is expected',
functionParameters: ['expected-parameter'],
callParameters: ['unexpected-parameter1', 'unexpected-parameter2'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2"`
+ '. Expected parameter(s): "expected-parameter"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2".`
+ '\nExpected parameter(s): "expected-parameter"',
},
{
description: 'provided: an unexpected parameter, when: multiple parameters are expected',
functionParameters: ['expected-parameter1', 'expected-parameter2'],
callParameters: ['expected-parameter1', 'expected-parameter2', 'unexpected-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '. Expected parameter(s): "expected-parameter1", "expected-parameter2"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".`
+ '\nExpected parameter(s): "expected-parameter1", "expected-parameter2"',
},
{
description: 'provided: an unexpected parameter, when: none required',
functionParameters: [],
callParameters: ['unexpected-call-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter"`
+ '. Expected parameter(s): none',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter".`
+ '\nExpected parameter(s): none',
},
{
description: 'provided: expected and unexpected parameter, when: one of them is expected',
functionParameters: ['expected-parameter'],
callParameters: ['expected-parameter', 'unexpected-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '. Expected parameter(s): "expected-parameter"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".`
+ '\nExpected parameter(s): "expected-parameter"',
},
];
testCases.forEach(({
@@ -88,7 +89,8 @@ describe('AdaptiveFunctionCallCompiler', () => {
// act
const act = () => builder.compileSingleCall();
// assert
expect(act).to.throw(expectedError);
const errorMessage = collectExceptionMessage(act);
expect(errorMessage).to.include(expectedError);
});
});
});

View File

@@ -7,38 +7,44 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub';
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
import { createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionArgumentCompiler', () => {
describe('createCompiledNestedCall', () => {
it('should handle error from expressions compiler', () => {
describe('rethrows error from expressions compiler', () => {
// arrange
const expectedInnerError = new Error('child-');
const parameterName = 'parameterName';
const expectedErrorMessage = `Error when compiling argument for "${parameterName}"`;
const nestedCall = new FunctionCallStub()
.withFunctionName('nested-function-call')
.withArgumentCollection(new FunctionCallArgumentCollectionStub()
.withArgument(parameterName, 'unimportant-value'));
const parentCall = new FunctionCallStub()
.withFunctionName('parent-function-call');
const expressionsCompilerError = new Error('child-');
const expectedError = new AggregateError(
[expressionsCompilerError],
`Error when compiling argument for "${parameterName}"`,
);
const expressionsCompiler = new ExpressionsCompilerStub();
expressionsCompiler.compileExpressions = () => { throw expressionsCompilerError; };
expressionsCompiler.compileExpressions = () => { throw expectedInnerError; };
const builder = new NestedFunctionArgumentCompilerBuilder()
.withParentFunctionCall(parentCall)
.withNestedFunctionCall(nestedCall)
.withExpressionsCompiler(expressionsCompiler);
// act
const act = () => builder.createCompiledNestedCall();
// assert
expectDeepThrowsError(act, expectedError);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.createCompiledNestedCall();
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('compilation', () => {
describe('without arguments', () => {
@@ -258,6 +264,8 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this {
this.expressionsCompiler = expressionsCompiler;
return this;
@@ -278,8 +286,16 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public createCompiledNestedCall(): FunctionCall {
const compiler = new NestedFunctionArgumentCompiler(this.expressionsCompiler);
const compiler = new NestedFunctionArgumentCompiler(
this.expressionsCompiler,
this.wrapError,
);
return compiler.createCompiledNestedCall(
this.nestedFunctionCall,
this.parentFunctionCall,

View File

@@ -7,8 +7,8 @@ describe('FunctionParameterCollection', () => {
// arrange
const expected = [
new FunctionParameterStub().withName('1'),
new FunctionParameterStub().withName('2').withOptionality(true),
new FunctionParameterStub().withName('3').withOptionality(false),
new FunctionParameterStub().withName('2').withOptional(true),
new FunctionParameterStub().withName('3').withOptional(false),
];
const sut = new FunctionParameterCollection();
for (const parameter of expected) {

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { createFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('FunctionParameterCollectionFactory', () => {
describe('createFunctionParameterCollection', () => {
describe('it is a transient factory', () => {
itIsTransientFactory({
getter: () => createFunctionParameterCollection(),
expectedType: FunctionParameterCollection,
});
});
it('returns an empty collection', () => {
// arrange
const expectedInitialParametersCount = 0;
// act
const collection = createFunctionParameterCollection();
// assert
expect(collection.all).to.have.lengthOf(expectedInitialParametersCount);
});
});
});

View File

@@ -35,7 +35,7 @@ describe('SharedFunctionCollection', () => {
it('throws if function does not exist', () => {
// arrange
const name = 'unique-name';
const expectedError = `called function is not defined "${name}"`;
const expectedError = `Called function is not defined: "${name}"`;
const func = createSharedFunctionStubWithCode()
.withName('unexpected-name');
const sut = new SharedFunctionCollection();

View File

@@ -1,25 +1,29 @@
import { describe, it, expect } from 'vitest';
import type { FunctionData, CodeInstruction } from '@/application/collections/';
import type { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import { SharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser';
import { SharedFunctionsParser, type FunctionParameterFactory } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser';
import { createFunctionDataWithCode, createFunctionDataWithoutCallOrCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';
describe('SharedFunctionsParser', () => {
describe('instance', () => {
itIsSingleton({
itIsSingletonFactory({
getter: () => SharedFunctionsParser.instance,
expectedType: SharedFunctionsParser,
});
@@ -127,7 +131,7 @@ describe('SharedFunctionsParser', () => {
});
});
describe('throws when parameters type is not as expected', () => {
const testCases = [
const testScenarios = [
{
state: 'when not an array',
invalidType: 5,
@@ -137,7 +141,7 @@ describe('SharedFunctionsParser', () => {
invalidType: ['a', { a: 'b' }],
},
];
for (const testCase of testCases) {
for (const testCase of testScenarios) {
it(testCase.state, () => {
// arrange
const func = createFunctionDataWithCode()
@@ -170,25 +174,33 @@ describe('SharedFunctionsParser', () => {
rules: expectedRules,
});
});
it('rethrows including function name when FunctionParameter throws', () => {
// arrange
const invalidParameterName = 'invalid function p@r4meter name';
const functionName = 'functionName';
const message = collectExceptionMessage(
() => new FunctionParameter(invalidParameterName, false),
);
const expectedError = `"${functionName}": ${message}`;
const functionData = createFunctionDataWithCode()
.withName(functionName)
.withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName));
// act
const act = () => new ParseFunctionsCallerWithDefaults()
.withFunctions([functionData])
.parseFunctions();
// assert
expect(act).to.throw(expectedError);
describe('parameter creation', () => {
describe('rethrows including function name when creating parameter throws', () => {
// arrange
const invalidParameterName = 'invalid-function-parameter-name';
const functionName = 'functionName';
const expectedErrorMessage = `Failed to create parameter: ${invalidParameterName} for function "${functionName}"`;
const expectedInnerError = new Error('injected error');
const parameterFactory: FunctionParameterFactory = () => {
throw expectedInnerError;
};
const functionData = createFunctionDataWithCode()
.withName(functionName)
.withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName));
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
new ParseFunctionsCallerWithDefaults()
.withFunctions([functionData])
.withFunctionParameterFactory(parameterFactory)
.withErrorWrapper(wrapError)
.parseFunctions();
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
});
describe('given empty functions, returns empty collection', () => {
@@ -282,6 +294,18 @@ class ParseFunctionsCallerWithDefaults {
private functions: readonly FunctionData[] = [createFunctionDataWithCode()];
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
private parameterFactory: FunctionParameterFactory = (
name: string,
isOptional: boolean,
) => new FunctionParameterStub()
.withName(name)
.withOptional(isOptional);
private parameterCollectionFactory
: FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub();
public withSyntax(syntax: ILanguageSyntax) {
this.syntax = syntax;
return this;
@@ -297,8 +321,32 @@ class ParseFunctionsCallerWithDefaults {
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public withFunctionParameterFactory(parameterFactory: FunctionParameterFactory): this {
this.parameterFactory = parameterFactory;
return this;
}
public withParameterCollectionFactory(
parameterCollectionFactory: FunctionParameterCollectionFactory,
): this {
this.parameterCollectionFactory = parameterCollectionFactory;
return this;
}
public parseFunctions() {
const sut = new SharedFunctionsParser(this.codeValidator);
const sut = new SharedFunctionsParser(
{
codeValidator: this.codeValidator,
wrapError: this.wrapError,
createParameter: this.parameterFactory,
createParameterCollection: this.parameterCollectionFactory,
},
);
return sut.parseFunctions(this.functions, this.syntax);
}
}

View File

@@ -1,9 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { FunctionData } from '@/application/collections/';
import { ScriptCode } from '@/domain/ScriptCode';
import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler';
import type { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
@@ -17,8 +15,13 @@ import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICod
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { itThrowsContextualError } from '../../ContextualErrorTester';
describe('ScriptCompiler', () => {
describe('canCompile', () => {
@@ -58,31 +61,59 @@ describe('ScriptCompiler', () => {
// assert
expect(act).to.throw(expectedError);
});
it('returns code as expected', () => {
// arrange
const expected: CompiledCode = {
code: 'expected-code',
revertCode: 'expected-revert-code',
};
const call = new FunctionCallDataStub();
const script = createScriptDataWithCall(call);
const functions = [createFunctionDataWithCode().withName('existing-func')];
const compiledFunctions = new SharedFunctionCollectionStub();
const functionParserMock = new SharedFunctionsParserStub();
functionParserMock.setup(functions, compiledFunctions);
const callCompilerMock = new FunctionCallCompilerStub();
callCompilerMock.setup(parseFunctionCalls(call), compiledFunctions, expected);
const sut = new ScriptCompilerBuilder()
.withFunctions(...functions)
.withSharedFunctionsParser(functionParserMock)
.withFunctionCallCompiler(callCompilerMock)
.build();
// act
const code = sut.compile(script);
// assert
expect(code.execute).to.equal(expected.code);
expect(code.revert).to.equal(expected.revertCode);
describe('code construction', () => {
it('returns code from the factory', () => {
// arrange
const expectedCode = new ScriptCodeStub();
const scriptCodeFactory = () => expectedCode;
const sut = new ScriptCompilerBuilder()
.withSomeFunctions()
.withScriptCodeFactory(scriptCodeFactory)
.build();
// act
const actualCode = sut.compile(createScriptDataWithCall());
// assert
expect(actualCode).to.equal(expectedCode);
});
it('creates code correctly', () => {
// arrange
const expectedCode = 'expected-code';
const expectedRevertCode = 'expected-revert-code';
let actualCode: string | undefined;
let actualRevertCode: string | undefined;
const scriptCodeFactory = (code: string, revertCode: string) => {
actualCode = code;
actualRevertCode = revertCode;
return new ScriptCodeStub();
};
const call = new FunctionCallDataStub();
const script = createScriptDataWithCall(call);
const functions = [createFunctionDataWithCode().withName('existing-func')];
const compiledFunctions = new SharedFunctionCollectionStub();
const functionParserMock = new SharedFunctionsParserStub();
functionParserMock.setup(functions, compiledFunctions);
const callCompilerMock = new FunctionCallCompilerStub();
callCompilerMock.setup(
parseFunctionCalls(call),
compiledFunctions,
new CompiledCodeStub()
.withCode(expectedCode)
.withRevertCode(expectedRevertCode),
);
const sut = new ScriptCompilerBuilder()
.withFunctions(...functions)
.withSharedFunctionsParser(functionParserMock)
.withFunctionCallCompiler(callCompilerMock)
.withScriptCodeFactory(scriptCodeFactory)
.build();
// act
sut.compile(script);
// assert
expect(actualCode).to.equal(expectedCode);
expect(actualRevertCode).to.equal(expectedRevertCode);
});
});
describe('parses functions as expected', () => {
it('parses functions with expected syntax', () => {
// arrange
@@ -116,49 +147,57 @@ describe('ScriptCompiler', () => {
expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions);
});
});
it('rethrows error with script name', () => {
describe('rethrows error with script name', () => {
// arrange
const scriptName = 'scriptName';
const innerError = 'innerError';
const expectedError = `Script "${scriptName}" ${innerError}`;
const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
const expectedInnerError = new Error();
const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => { throw new Error(innerError); },
compileFunctionCalls: () => { throw expectedInnerError; },
};
const scriptData = createScriptDataWithCall()
.withName(scriptName);
const sut = new ScriptCompilerBuilder()
const builder = new ScriptCompilerBuilder()
.withSomeFunctions()
.withFunctionCallCompiler(callCompiler)
.build();
// act
const act = () => sut.compile(scriptData);
// assert
expect(act).to.throw(expectedError);
.withFunctionCallCompiler(callCompiler);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
it('rethrows error from ScriptCode with script name', () => {
describe('rethrows error from script code factory with script name', () => {
// arrange
const scriptName = 'scriptName';
const syntax = new LanguageSyntaxStub();
const invalidCode = new CompiledCodeStub()
.withCode('' /* invalid code (empty string) */);
const realExceptionMessage = collectExceptionMessage(
() => new ScriptCode(invalidCode.code, invalidCode.revertCode),
);
const expectedError = `Script "${scriptName}" ${realExceptionMessage}`;
const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => invalidCode,
const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
const expectedInnerError = new Error();
const scriptCodeFactory: ScriptCodeFactory = () => {
throw expectedInnerError;
};
const scriptData = createScriptDataWithCall()
.withName(scriptName);
const sut = new ScriptCompilerBuilder()
const builder = new ScriptCompilerBuilder()
.withSomeFunctions()
.withFunctionCallCompiler(callCompiler)
.withSyntax(syntax)
.build();
// act
const act = () => sut.compile(scriptData);
// assert
expect(act).to.throw(expectedError);
.withScriptCodeFactory(scriptCodeFactory);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
it('validates compiled code as expected', () => {
// arrange
@@ -166,17 +205,27 @@ describe('ScriptCompiler', () => {
NoEmptyLines,
// Allow duplicated lines to enable calling same function multiple times
];
const expectedExecuteCode = 'execute code to be validated';
const expectedRevertCode = 'revert code to be validated';
const scriptData = createScriptDataWithCall();
const validator = new CodeValidatorStub();
const sut = new ScriptCompilerBuilder()
.withSomeFunctions()
.withCodeValidator(validator)
.withFunctionCallCompiler(
new FunctionCallCompilerStub()
.withDefaultCompiledCode(
new CompiledCodeStub()
.withCode(expectedExecuteCode)
.withRevertCode(expectedRevertCode),
),
)
.build();
// act
const compilationResult = sut.compile(scriptData);
sut.compile(scriptData);
// assert
validator.assertHistory({
validatedCodes: [compilationResult.execute, compilationResult.revert],
validatedCodes: [expectedExecuteCode, expectedRevertCode],
rules: expectedRules,
});
});
@@ -200,6 +249,12 @@ class ScriptCompilerBuilder {
private codeValidator: ICodeValidator = new CodeValidatorStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: ScriptCompilerBuilder.name,
});
public withFunctions(...functions: FunctionData[]): this {
this.functions = functions;
return this;
@@ -244,6 +299,16 @@ class ScriptCompilerBuilder {
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public build(): ScriptCompiler {
if (!this.functions) {
throw new Error('Function behavior not defined');
@@ -254,6 +319,8 @@ class ScriptCompilerBuilder {
this.sharedFunctionsParser,
this.callCompiler,
this.codeValidator,
this.wrapError,
this.scriptCodeFactory,
);
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { ScriptData } from '@/application/collections/';
import { parseScript, type ScriptFactoryType } from '@/application/Parser/Script/ScriptParser';
import { parseDocs } from '@/application/Parser/DocumentationParser';
import { parseScript, type ScriptFactory } from '@/application/Parser/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
@@ -11,54 +11,88 @@ import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
import { Script } from '@/domain/Script';
import type { IEnumParser } from '@/application/Common/Enum';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType';
import type { ScriptNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { itThrowsContextualError } from '../ContextualErrorTester';
import { itAsserts, itValidatesDefinedData, itValidatesName } from '../NodeDataValidationTester';
import { generateDataValidationTestScenarios } from '../DataValidationTestScenarioGenerator';
describe('ScriptParser', () => {
describe('parseScript', () => {
it('parses name as expected', () => {
it('parses name correctly', () => {
// arrange
const expected = 'test-expected-name';
const script = createScriptDataWithCode()
const scriptData = createScriptDataWithCode()
.withName(expected);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
expect(actual.name).to.equal(expected);
const actualName = getInitParameters(actualScript)?.name;
expect(actualName).to.equal(expected);
});
it('parses docs as expected', () => {
it('parses docs correctly', () => {
// arrange
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const script = createScriptDataWithCode()
.withDocs(docs);
const expected = parseDocs(script);
const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
const scriptData = createScriptDataWithCode()
.withDocs(expectedDocs);
const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs;
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.withDocsParser(docsParser)
.parseScript();
// assert
expect(actual.docs).to.deep.equal(expected);
const actualDocs = getInitParameters(actualScript)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
it('gets script from the factory', () => {
// arrange
const expectedScript = new ScriptStub('expected-script');
const scriptFactory: ScriptFactory = () => expectedScript;
// act
const actualScript = new TestContext()
.withScriptFactory(scriptFactory)
.parseScript();
// assert
expect(actualScript).to.equal(expectedScript);
});
describe('level', () => {
describe('accepts absent level', () => {
describe('generated `undefined` level if given absent value', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const script = createScriptDataWithCode()
const expectedLevel = undefined;
const scriptData = createScriptDataWithCode()
.withRecommend(absentValue);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
expect(actual.level).to.equal(undefined);
const actualLevel = getInitParameters(actualScript)?.level;
expect(actualLevel).to.equal(expectedLevel);
}, { excludeNull: true });
});
it('parses level as expected', () => {
@@ -66,63 +100,94 @@ describe('ScriptParser', () => {
const expectedLevel = RecommendationLevel.Standard;
const expectedName = 'level';
const levelText = 'standard';
const script = createScriptDataWithCode()
const scriptData = createScriptDataWithCode()
.withRecommend(levelText);
const parserMock = new EnumParserStub<RecommendationLevel>()
.setup(expectedName, levelText, expectedLevel);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withParser(parserMock)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
expect(actual.level).to.equal(expectedLevel);
const actualLevel = getInitParameters(actualScript)?.level;
expect(actualLevel).to.equal(expectedLevel);
});
});
describe('code', () => {
it('parses "execute" as expected', () => {
it('creates from script code factory', () => {
// arrange
const expected = 'expected-code';
const script = createScriptDataWithCode()
.withCode(expected);
const expectedCode = new ScriptCodeStub();
const scriptCodeFactory: ScriptCodeFactory = () => expectedCode;
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const parsed = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withScriptCodeFactory(scriptCodeFactory)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actual = parsed.code.execute;
expect(actual).to.equal(expected);
const actualCode = getInitParameters(actualScript)?.code;
expect(expectedCode).to.equal(actualCode);
});
it('parses "revert" as expected', () => {
// arrange
const expected = 'expected-revert-code';
const script = createScriptDataWithCode()
.withRevertCode(expected);
// act
const parsed = new TestBuilder()
.withData(script)
.parseScript();
// assert
const actual = parsed.code.revert;
expect(actual).to.equal(expected);
});
describe('compiler', () => {
it('gets code from compiler', () => {
describe('parses code correctly', () => {
it('parses "execute" as expected', () => {
// arrange
const expected = new ScriptCodeStub();
const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expected);
const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
const expectedCode = 'expected-code';
let actualCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (code) => {
actualCode = code;
return new ScriptCodeStub();
};
const scriptData = createScriptDataWithCode()
.withCode(expectedCode);
// act
const parsed = new TestBuilder()
.withData(script)
.withContext(parseContext)
new TestContext()
.withData(scriptData)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript();
// assert
const actual = parsed.code;
expect(actual).to.equal(expected);
expect(actualCode).to.equal(expectedCode);
});
it('parses "revert" as expected', () => {
// arrange
const expectedRevertCode = 'expected-revert-code';
const scriptData = createScriptDataWithCode()
.withRevertCode(expectedRevertCode);
let actualRevertCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (_, revertCode) => {
actualRevertCode = revertCode;
return new ScriptCodeStub();
};
// act
new TestContext()
.withData(scriptData)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript();
// assert
expect(actualRevertCode).to.equal(expectedRevertCode);
});
});
describe('compiler', () => {
it('compiles the code through the compiler', () => {
// arrange
const expectedCode = new ScriptCodeStub();
const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expectedCode);
const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actualScript = new TestContext()
.withData(script)
.withContext(parseContext)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actualCode = getInitParameters(actualScript)?.code;
expect(actualCode).to.equal(expectedCode);
});
});
describe('syntax', () => {
@@ -135,7 +200,7 @@ describe('ScriptParser', () => {
const script = createScriptDataWithoutCallOrCodes()
.withCode(duplicatedCode);
// act
const act = () => new TestBuilder()
const act = () => new TestContext()
.withData(script)
.withContext(parseContext);
// assert
@@ -149,18 +214,26 @@ describe('ScriptParser', () => {
NoEmptyLines,
NoDuplicatedLines,
];
const expectedCode = 'expected code to be validated';
const expectedRevertCode = 'expected revert code to be validated';
const expectedCodeCalls = [
expectedCode,
expectedRevertCode,
];
const validator = new CodeValidatorStub();
const script = createScriptDataWithCode()
.withCode('expected code to be validated')
.withRevertCode('expected revert code to be validated');
const scriptCodeFactory = createScriptCodeFactoryStub({
scriptCode: new ScriptCodeStub()
.withExecute(expectedCode)
.withRevert(expectedRevertCode),
});
// act
new TestBuilder()
.withData(script)
new TestContext()
.withScriptCodeFactory(scriptCodeFactory)
.withCodeValidator(validator)
.parseScript();
// assert
validator.assertHistory({
validatedCodes: [script.code, script.revertCode],
validatedCodes: expectedCodeCalls,
rules: expectedRules,
});
});
@@ -175,7 +248,7 @@ describe('ScriptParser', () => {
const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
// act
new TestBuilder()
new TestContext()
.withData(script)
.withCodeValidator(validator)
.withContext(parseContext)
@@ -188,111 +261,250 @@ describe('ScriptParser', () => {
});
});
});
describe('invalid script data', () => {
describe('validates script data', () => {
describe('validation', () => {
describe('validates for name', () => {
// arrange
const createTest = (script: ScriptData): ITestScenario => ({
act: () => new TestBuilder()
const expectedName = 'expected script name to be validated';
const script = createScriptDataWithCall()
.withName(expectedName);
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: script,
};
itValidatesName((validatorFactory) => {
// act
new TestContext()
.withData(script)
.parseScript(),
expectedContext: {
type: NodeType.Script,
selfNode: script,
},
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
// act and assert
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => {
return createTest(
createScriptDataWithCall().withName(invalidName),
);
})
.testMissingNodeData((node) => {
return createTest(node as ScriptData);
})
.runThrowingCase({
name: 'throws when both function call and code are defined',
scenario: createTest(
createScriptDataWithCall().withCode('code'),
),
expectedMessage: 'Both "call" and "code" are defined.',
})
.runThrowingCase({
name: 'throws when both function call and revertCode are defined',
scenario: createTest(
createScriptDataWithCall().withRevertCode('revert-code'),
),
expectedMessage: 'Both "call" and "revertCode" are defined.',
})
.runThrowingCase({
name: 'throws when neither call or revertCode are defined',
scenario: createTest(
createScriptDataWithoutCallOrCodes(),
),
expectedMessage: 'Neither "call" or "code" is defined.',
});
});
it(`rethrows exception if ${Script.name} cannot be constructed`, () => {
describe('validates for defined data', () => {
// arrange
const expectedError = 'script creation failed';
const factoryMock: ScriptFactoryType = () => { throw new Error(expectedError); };
const data = createScriptDataWithCode();
// act
const act = () => new TestBuilder()
.withData(data)
.withFactory(factoryMock)
.parseScript();
// expect
expectThrowsNodeError({
act,
expectedContext: {
type: NodeType.Script,
selfNode: data,
const expectedScript = createScriptDataWithCall();
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: expectedScript,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestContext()
.withData(expectedScript)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext,
};
},
}, expectedError);
);
});
describe('validates data', () => {
// arrange
const testScenarios = generateDataValidationTestScenarios<ScriptData>(
{
assertErrorMessage: 'Neither "call" or "code" is defined.',
expectFail: [{
description: 'with no call or code',
data: createScriptDataWithoutCallOrCodes(),
}],
expectPass: [
{
description: 'with call',
data: createScriptDataWithCall(),
},
{
description: 'with code',
data: createScriptDataWithCode(),
},
],
},
{
assertErrorMessage: 'Both "call" and "revertCode" are defined.',
expectFail: [{
description: 'with both call and revertCode',
data: createScriptDataWithCall()
.withRevertCode('revert-code'),
}],
expectPass: [
{
description: 'with call, without revertCode',
data: createScriptDataWithCall()
.withRevertCode(undefined),
},
{
description: 'with revertCode, without call',
data: createScriptDataWithCode()
.withRevertCode('revert code'),
},
],
},
{
assertErrorMessage: 'Both "call" and "code" are defined.',
expectFail: [{
description: 'with both call and code',
data: createScriptDataWithCall()
.withCode('code'),
}],
expectPass: [
{
description: 'with call, without code',
data: createScriptDataWithCall()
.withCode(''),
},
{
description: 'with code, without call',
data: createScriptDataWithCode()
.withCode('code'),
},
],
},
);
testScenarios.forEach(({
description, expectedPass, data: scriptData, expectedMessage,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: scriptData,
};
// act
new TestContext()
.withData(scriptData)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
expectExists(expectedMessage);
return {
expectedErrorMessage: expectedMessage,
expectedErrorContext: expectedContext,
};
},
});
});
});
});
});
describe('rethrows exception if script factory fails', () => {
// arrange
const givenData = createScriptDataWithCode();
const expectedContextMessage = 'Failed to parse script.';
const expectedError = new Error();
const validatorFactory: NodeDataValidatorFactory = () => {
const validatorStub = new NodeDataValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
return validatorStub;
};
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const factoryMock: ScriptFactory = () => {
throw expectedError;
};
new TestContext()
.withScriptFactory(factoryMock)
.withErrorWrapper(wrapError)
.withValidatorFactory(validatorFactory)
.withData(givenData)
.parseScript();
},
expectedWrappedError: expectedError,
expectedContextMessage,
});
});
});
});
class TestBuilder {
class TestContext {
private data: ScriptData = createScriptDataWithCode();
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
private parser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
.setupDefaultValue(RecommendationLevel.Standard);
private factory?: ScriptFactoryType = undefined;
private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy;
private codeValidator: ICodeValidator = new CodeValidatorStub();
public withCodeValidator(codeValidator: ICodeValidator) {
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub;
private docsParser: DocsParser = () => ['docs'];
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: TestContext.name,
});
public withCodeValidator(codeValidator: ICodeValidator): this {
this.codeValidator = codeValidator;
return this;
}
public withData(data: ScriptData) {
public withData(data: ScriptData): this {
this.data = data;
return this;
}
public withContext(context: ICategoryCollectionParseContext) {
public withContext(context: ICategoryCollectionParseContext): this {
this.context = context;
return this;
}
public withParser(parser: IEnumParser<RecommendationLevel>) {
this.parser = parser;
public withParser(parser: IEnumParser<RecommendationLevel>): this {
this.levelParser = parser;
return this;
}
public withFactory(factory: ScriptFactoryType) {
this.factory = factory;
public withScriptFactory(scriptFactory: ScriptFactory): this {
this.scriptFactory = scriptFactory;
return this;
}
public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this {
this.validatorFactory = validatorFactory;
return this;
}
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
this.errorWrapper = errorWrapper;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public withDocsParser(docsParser: DocsParser): this {
this.docsParser = docsParser;
return this;
}
public parseScript(): Script {
return parseScript(this.data, this.context, this.parser, this.factory, this.codeValidator);
return parseScript(
this.data,
this.context,
{
levelParser: this.levelParser,
createScript: this.scriptFactory,
codeValidator: this.codeValidator,
wrapError: this.errorWrapper,
createValidator: this.validatorFactory,
createCode: this.scriptCodeFactory,
parseDocs: this.docsParser,
},
);
}
}

View File

@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub';
import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import type { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule';
describe('CodeValidator', () => {
describe('instance', () => {
itIsSingleton({
itIsSingletonFactory({
getter: () => CodeValidator.instance,
expectedType: CodeValidator,
});