Refactor to unify scripts/categories as Executable
This commit consolidates scripts and categories under a unified 'Executable' concept. This simplifies the architecture and improves code readability. - Introduce subfolders within `src/domain` to segregate domain elements. - Update class and interface names by removing the 'I' prefix in alignment with new coding standards. - Replace 'Node' with 'Executable' to clarify usage; reserve 'Node' exclusively for the UI's tree component.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ISyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Syntax/ISyntaxFactory';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
|
||||
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
|
||||
import type { FunctionData } from '@/application/collections/';
|
||||
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
|
||||
import { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler';
|
||||
import { createCollectionUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
|
||||
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { createSyntaxFactoryStub } from '@tests/unit/shared/Stubs/SyntaxFactoryStub';
|
||||
|
||||
describe('CategoryCollectionSpecificUtilities', () => {
|
||||
describe('createCollectionUtilities', () => {
|
||||
describe('functionsData', () => {
|
||||
describe('can create with absent data', () => {
|
||||
itEachAbsentCollectionValue<FunctionData>((absentValue) => {
|
||||
// arrange
|
||||
const context = new TextContext()
|
||||
.withData(absentValue);
|
||||
// act
|
||||
const act = () => context.createCollectionUtilities();
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
}, { excludeNull: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('compiler', () => {
|
||||
it('constructed as expected', () => {
|
||||
// arrange
|
||||
const functionsData = [createFunctionDataWithCode()];
|
||||
const syntax = new LanguageSyntaxStub();
|
||||
const expected = new ScriptCompiler(functionsData, syntax);
|
||||
const language = ScriptingLanguage.shellscript;
|
||||
const factoryMock = createSyntaxFactoryStub(language, syntax);
|
||||
const definition = new ScriptingDefinitionStub()
|
||||
.withLanguage(language);
|
||||
const context = new TextContext()
|
||||
.withData(functionsData)
|
||||
.withScripting(definition)
|
||||
.withSyntaxFactory(factoryMock);
|
||||
// act
|
||||
const utilities = context.createCollectionUtilities();
|
||||
// assert
|
||||
const actual = utilities.compiler;
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('syntax', () => {
|
||||
it('set from syntax factory', () => {
|
||||
// arrange
|
||||
const language = ScriptingLanguage.shellscript;
|
||||
const expected = new LanguageSyntaxStub();
|
||||
const factoryMock = createSyntaxFactoryStub(language, expected);
|
||||
const definition = new ScriptingDefinitionStub()
|
||||
.withLanguage(language);
|
||||
const context = new TextContext()
|
||||
.withScripting(definition)
|
||||
.withSyntaxFactory(factoryMock);
|
||||
// act
|
||||
const utilities = context.createCollectionUtilities();
|
||||
// assert
|
||||
const actual = utilities.syntax;
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TextContext {
|
||||
private functionsData: readonly FunctionData[] | undefined = [createFunctionDataWithCode()];
|
||||
|
||||
private scripting: IScriptingDefinition = new ScriptingDefinitionStub();
|
||||
|
||||
private syntaxFactory: ISyntaxFactory = createSyntaxFactoryStub();
|
||||
|
||||
public withScripting(scripting: IScriptingDefinition): this {
|
||||
this.scripting = scripting;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withData(data: readonly FunctionData[] | undefined): this {
|
||||
this.functionsData = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSyntaxFactory(syntaxFactory: ISyntaxFactory): this {
|
||||
this.syntaxFactory = syntaxFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public createCollectionUtilities(): ReturnType<typeof createCollectionUtilities> {
|
||||
return createCollectionUtilities(
|
||||
this.functionsData,
|
||||
this.scripting,
|
||||
this.syntaxFactory,
|
||||
);
|
||||
}
|
||||
}
|
||||
462
tests/unit/application/Parser/Executable/CategoryParser.spec.ts
Normal file
462
tests/unit/application/Parser/Executable/CategoryParser.spec.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CategoryData, ExecutableData } from '@/application/collections/';
|
||||
import { type CategoryFactory, parseCategory } from '@/application/Parser/Executable/CategoryParser';
|
||||
import { type ScriptParser } from '@/application/Parser/Executable/Script/ScriptParser';
|
||||
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser';
|
||||
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
|
||||
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
||||
import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType';
|
||||
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
||||
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
|
||||
import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
|
||||
import { ExecutableValidatorStub, createExecutableValidatorFactoryStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
|
||||
import type { CategoryErrorContext, UnknownExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { createCategoryFactorySpy } from '@tests/unit/shared/Stubs/CategoryFactoryStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { indentText } from '@tests/shared/Text';
|
||||
import { itThrowsContextualError } from '../ContextualErrorTester';
|
||||
import { itValidatesName, itValidatesDefinedData, itAsserts } from './Validation/ExecutableValidationTester';
|
||||
import { generateDataValidationTestScenarios } from './Validation/DataValidationTestScenarioGenerator';
|
||||
|
||||
describe('CategoryParser', () => {
|
||||
describe('parseCategory', () => {
|
||||
describe('validation', () => {
|
||||
describe('validates for name', () => {
|
||||
// arrange
|
||||
const expectedName = 'expected category name to be validated';
|
||||
const category = new CategoryDataStub()
|
||||
.withName(expectedName);
|
||||
const expectedContext: CategoryErrorContext = {
|
||||
type: ExecutableType.Category,
|
||||
self: category,
|
||||
};
|
||||
itValidatesName((validatorFactory) => {
|
||||
// act
|
||||
new TestBuilder()
|
||||
.withData(category)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseCategory();
|
||||
// assert
|
||||
return {
|
||||
expectedNameToValidate: expectedName,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
});
|
||||
});
|
||||
describe('validates for defined data', () => {
|
||||
// arrange
|
||||
const category = new CategoryDataStub();
|
||||
const expectedContext: CategoryErrorContext = {
|
||||
type: ExecutableType.Category,
|
||||
self: category,
|
||||
};
|
||||
itValidatesDefinedData(
|
||||
(validatorFactory) => {
|
||||
// act
|
||||
new TestBuilder()
|
||||
.withData(category)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseCategory();
|
||||
// assert
|
||||
return {
|
||||
expectedDataToValidate: category,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('validates that category has some children', () => {
|
||||
const categoryName = 'test';
|
||||
const testScenarios = generateDataValidationTestScenarios<CategoryData>({
|
||||
expectFail: getAbsentCollectionTestCases<ExecutableData>().map(({
|
||||
valueName, absentValue: absentCollectionValue,
|
||||
}) => ({
|
||||
description: `with \`${valueName}\` value as children`,
|
||||
data: new CategoryDataStub()
|
||||
.withName(categoryName)
|
||||
.withChildren(absentCollectionValue as unknown as ExecutableData[]),
|
||||
})),
|
||||
expectPass: [{
|
||||
description: 'has single children',
|
||||
data: new CategoryDataStub()
|
||||
.withName(categoryName)
|
||||
.withChildren([createScriptDataWithCode()]),
|
||||
}],
|
||||
});
|
||||
testScenarios.forEach(({
|
||||
description, expectedPass, data: categoryData,
|
||||
}) => {
|
||||
describe(description, () => {
|
||||
itAsserts({
|
||||
expectedConditionResult: expectedPass,
|
||||
test: (validatorFactory) => {
|
||||
const expectedMessage = `"${categoryName}" has no children.`;
|
||||
const expectedContext: CategoryErrorContext = {
|
||||
type: ExecutableType.Category,
|
||||
self: categoryData,
|
||||
};
|
||||
// act
|
||||
try {
|
||||
new TestBuilder()
|
||||
.withData(categoryData)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseCategory();
|
||||
} catch { /* It may throw due to assertions not being evaluated */ }
|
||||
// assert
|
||||
return {
|
||||
expectedErrorMessage: expectedMessage,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('validates that a child is a category or a script', () => {
|
||||
// arrange
|
||||
const testScenarios = generateDataValidationTestScenarios<ExecutableData>({
|
||||
expectFail: [{
|
||||
description: 'child has incorrect properties',
|
||||
data: { property: 'non-empty-value' } as unknown as ExecutableData,
|
||||
}],
|
||||
expectPass: [
|
||||
{
|
||||
description: 'child is a category',
|
||||
data: new CategoryDataStub(),
|
||||
},
|
||||
{
|
||||
description: 'child is a script with call',
|
||||
data: createScriptDataWithCall(),
|
||||
},
|
||||
{
|
||||
description: 'child is a script with code',
|
||||
data: createScriptDataWithCode(),
|
||||
},
|
||||
],
|
||||
});
|
||||
testScenarios.forEach(({
|
||||
description, expectedPass, data: childData,
|
||||
}) => {
|
||||
describe(description, () => {
|
||||
itAsserts({
|
||||
expectedConditionResult: expectedPass,
|
||||
test: (validatorFactory) => {
|
||||
const expectedError = 'Executable is neither a category or a script.';
|
||||
const parent = new CategoryDataStub()
|
||||
.withName('parent')
|
||||
.withChildren([new CategoryDataStub().withName('valid child'), childData]);
|
||||
const expectedContext: UnknownExecutableErrorContext = {
|
||||
self: childData,
|
||||
parentCategory: parent,
|
||||
};
|
||||
// act
|
||||
new TestBuilder()
|
||||
.withData(parent)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseCategory();
|
||||
// assert
|
||||
return {
|
||||
expectedErrorMessage: expectedError,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('validates children recursively', () => {
|
||||
describe('validates (1th-level) child data', () => {
|
||||
// arrange
|
||||
const expectedName = 'child category';
|
||||
const child = new CategoryDataStub()
|
||||
.withName(expectedName);
|
||||
const parent = new CategoryDataStub()
|
||||
.withName('parent')
|
||||
.withChildren([child]);
|
||||
const expectedContext: UnknownExecutableErrorContext = {
|
||||
self: child,
|
||||
parentCategory: parent,
|
||||
};
|
||||
itValidatesDefinedData(
|
||||
(validatorFactory) => {
|
||||
// act
|
||||
new TestBuilder()
|
||||
.withData(parent)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseCategory();
|
||||
// assert
|
||||
return {
|
||||
expectedDataToValidate: child,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('validates that (2nd-level) child name', () => {
|
||||
// arrange
|
||||
const expectedName = 'grandchild category';
|
||||
const grandChild = new CategoryDataStub()
|
||||
.withName(expectedName);
|
||||
const child = new CategoryDataStub()
|
||||
.withChildren([grandChild])
|
||||
.withName('child category');
|
||||
const parent = new CategoryDataStub()
|
||||
.withName('parent')
|
||||
.withChildren([child]);
|
||||
const expectedContext: CategoryErrorContext = {
|
||||
type: ExecutableType.Category,
|
||||
self: grandChild,
|
||||
parentCategory: child,
|
||||
};
|
||||
itValidatesName((validatorFactory) => {
|
||||
// act
|
||||
new TestBuilder()
|
||||
.withData(parent)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseCategory();
|
||||
// assert
|
||||
return {
|
||||
expectedNameToValidate: expectedName,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('rethrows exception if category factory fails', () => {
|
||||
// arrange
|
||||
const givenData = new CategoryDataStub();
|
||||
const expectedContextMessage = 'Failed to parse category.';
|
||||
const expectedError = new Error();
|
||||
// act & assert
|
||||
itThrowsContextualError({
|
||||
throwingAction: (wrapError) => {
|
||||
const validatorStub = new ExecutableValidatorStub();
|
||||
validatorStub.createContextualErrorMessage = (message) => message;
|
||||
const factoryMock: CategoryFactory = () => {
|
||||
throw expectedError;
|
||||
};
|
||||
new TestBuilder()
|
||||
.withCategoryFactory(factoryMock)
|
||||
.withValidatorFactory(() => validatorStub)
|
||||
.withErrorWrapper(wrapError)
|
||||
.withData(givenData)
|
||||
.parseCategory();
|
||||
},
|
||||
expectedWrappedError: expectedError,
|
||||
expectedContextMessage,
|
||||
});
|
||||
});
|
||||
it('parses docs correctly', () => {
|
||||
// arrange
|
||||
const url = 'https://privacy.sexy';
|
||||
const categoryData = new CategoryDataStub()
|
||||
.withDocs(url);
|
||||
const parseDocs: DocsParser = (data) => {
|
||||
return [
|
||||
`parsed docs: ${JSON.stringify(data)}`,
|
||||
];
|
||||
};
|
||||
const expectedDocs = parseDocs(categoryData);
|
||||
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
|
||||
// act
|
||||
const actualCategory = new TestBuilder()
|
||||
.withData(categoryData)
|
||||
.withCategoryFactory(categoryFactorySpy)
|
||||
.withDocsParser(parseDocs)
|
||||
.parseCategory();
|
||||
// assert
|
||||
const actualDocs = getInitParameters(actualCategory)?.docs;
|
||||
expect(actualDocs).to.deep.equal(expectedDocs);
|
||||
});
|
||||
describe('parses expected subscript', () => {
|
||||
it('parses single script correctly', () => {
|
||||
// arrange
|
||||
const expectedScript = new ScriptStub('expected script');
|
||||
const scriptParser = new ScriptParserStub();
|
||||
const childScriptData = createScriptDataWithCode();
|
||||
const categoryData = new CategoryDataStub()
|
||||
.withChildren([childScriptData]);
|
||||
scriptParser.setupParsedResultForData(childScriptData, expectedScript);
|
||||
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
|
||||
// act
|
||||
const actualCategory = new TestBuilder()
|
||||
.withData(categoryData)
|
||||
.withScriptParser(scriptParser.get())
|
||||
.withCategoryFactory(categoryFactorySpy)
|
||||
.parseCategory();
|
||||
// assert
|
||||
const actualScripts = getInitParameters(actualCategory)?.scripts;
|
||||
expectExists(actualScripts);
|
||||
expect(actualScripts).to.have.lengthOf(1);
|
||||
const actualScript = actualScripts[0];
|
||||
expect(actualScript).to.equal(expectedScript);
|
||||
});
|
||||
it('parses multiple scripts correctly', () => {
|
||||
// arrange
|
||||
const expectedScripts = [
|
||||
new ScriptStub('expected-first-script'),
|
||||
new ScriptStub('expected-second-script'),
|
||||
];
|
||||
const childrenData = [
|
||||
createScriptDataWithCall(),
|
||||
createScriptDataWithCode(),
|
||||
];
|
||||
const scriptParser = new ScriptParserStub();
|
||||
childrenData.forEach((_, index) => {
|
||||
scriptParser.setupParsedResultForData(childrenData[index], expectedScripts[index]);
|
||||
});
|
||||
const categoryData = new CategoryDataStub()
|
||||
.withChildren(childrenData);
|
||||
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
|
||||
// act
|
||||
const actualCategory = new TestBuilder()
|
||||
.withScriptParser(scriptParser.get())
|
||||
.withData(categoryData)
|
||||
.withCategoryFactory(categoryFactorySpy)
|
||||
.parseCategory();
|
||||
// assert
|
||||
const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
|
||||
expectExists(actualParsedScripts);
|
||||
expect(actualParsedScripts.length).to.equal(expectedScripts.length);
|
||||
expect(actualParsedScripts).to.have.members(expectedScripts);
|
||||
});
|
||||
it('parses all scripts with correct utilities', () => {
|
||||
// arrange
|
||||
const expected = new CategoryCollectionSpecificUtilitiesStub();
|
||||
const scriptParser = new ScriptParserStub();
|
||||
const childrenData = [
|
||||
createScriptDataWithCode(),
|
||||
createScriptDataWithCode(),
|
||||
createScriptDataWithCode(),
|
||||
];
|
||||
const categoryData = new CategoryDataStub()
|
||||
.withChildren(childrenData);
|
||||
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
|
||||
// act
|
||||
const actualCategory = new TestBuilder()
|
||||
.withData(categoryData)
|
||||
.withCollectionUtilities(expected)
|
||||
.withScriptParser(scriptParser.get())
|
||||
.withCategoryFactory(categoryFactorySpy)
|
||||
.parseCategory();
|
||||
// assert
|
||||
const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
|
||||
expectExists(actualParsedScripts);
|
||||
const actualUtilities = actualParsedScripts.map(
|
||||
(s) => scriptParser.getParseParameters(s)[1],
|
||||
);
|
||||
expect(
|
||||
actualUtilities.every(
|
||||
(actual) => actual === expected,
|
||||
),
|
||||
formatAssertionMessage([
|
||||
`Expected all elements to be ${JSON.stringify(expected)}`,
|
||||
'All elements:',
|
||||
indentText(JSON.stringify(actualUtilities)),
|
||||
]),
|
||||
).to.equal(true);
|
||||
});
|
||||
});
|
||||
it('returns expected subcategories', () => {
|
||||
// arrange
|
||||
const expectedChildCategory = new CategoryStub(33);
|
||||
const childCategoryData = new CategoryDataStub()
|
||||
.withName('expected child category')
|
||||
.withChildren([createScriptDataWithCode()]);
|
||||
const categoryData = new CategoryDataStub()
|
||||
.withName('category name')
|
||||
.withChildren([childCategoryData]);
|
||||
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
|
||||
// act
|
||||
const actualCategory = new TestBuilder()
|
||||
.withData(categoryData)
|
||||
.withCategoryFactory((parameters) => {
|
||||
if (parameters.name === childCategoryData.category) {
|
||||
return expectedChildCategory;
|
||||
}
|
||||
return categoryFactorySpy(parameters);
|
||||
})
|
||||
.parseCategory();
|
||||
// assert
|
||||
const actualSubcategories = getInitParameters(actualCategory)?.subcategories;
|
||||
expectExists(actualSubcategories);
|
||||
expect(actualSubcategories).to.have.lengthOf(1);
|
||||
expect(actualSubcategories[0]).to.equal(expectedChildCategory);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestBuilder {
|
||||
private data: CategoryData = new CategoryDataStub();
|
||||
|
||||
private collectionUtilities:
|
||||
CategoryCollectionSpecificUtilitiesStub = new CategoryCollectionSpecificUtilitiesStub();
|
||||
|
||||
private categoryFactory: CategoryFactory = () => new CategoryStub(33);
|
||||
|
||||
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
|
||||
|
||||
private validatorFactory: ExecutableValidatorFactory = createExecutableValidatorFactoryStub;
|
||||
|
||||
private docsParser: DocsParser = () => ['docs'];
|
||||
|
||||
private scriptParser: ScriptParser = new ScriptParserStub().get();
|
||||
|
||||
public withData(data: CategoryData) {
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCollectionUtilities(
|
||||
collectionUtilities: CategoryCollectionSpecificUtilitiesStub,
|
||||
): this {
|
||||
this.collectionUtilities = collectionUtilities;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCategoryFactory(categoryFactory: CategoryFactory): this {
|
||||
this.categoryFactory = categoryFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withValidatorFactory(validatorFactory: ExecutableValidatorFactory): this {
|
||||
this.validatorFactory = validatorFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
|
||||
this.errorWrapper = errorWrapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withScriptParser(scriptParser: ScriptParser): this {
|
||||
this.scriptParser = scriptParser;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDocsParser(docsParser: DocsParser): this {
|
||||
this.docsParser = docsParser;
|
||||
return this;
|
||||
}
|
||||
|
||||
public parseCategory() {
|
||||
return parseCategory(
|
||||
this.data,
|
||||
this.collectionUtilities,
|
||||
{
|
||||
createCategory: this.categoryFactory,
|
||||
wrapError: this.errorWrapper,
|
||||
createValidator: this.validatorFactory,
|
||||
parseScript: this.scriptParser,
|
||||
parseDocs: this.docsParser,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentableData } from '@/application/collections/';
|
||||
import { parseDocs } from '@/application/Parser/Executable/DocumentationParser';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('DocumentationParser', () => {
|
||||
describe('parseDocs', () => {
|
||||
describe('throws when single documentation is missing', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing documentation';
|
||||
const data: DocumentableData = { docs: ['non empty doc 1', absentValue] };
|
||||
// act
|
||||
const act = () => parseDocs(data);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
describe('throws when type is unexpected', () => {
|
||||
// arrange
|
||||
const expectedTypeError = 'docs field (documentation) must be an array of strings';
|
||||
const wrongTypedValue = 22 as never;
|
||||
const testCases: ReadonlyArray<{
|
||||
readonly name: string;
|
||||
readonly data: DocumentableData;
|
||||
}> = [
|
||||
{
|
||||
name: 'given docs',
|
||||
data: { docs: wrongTypedValue },
|
||||
},
|
||||
{
|
||||
name: 'single doc',
|
||||
data: { docs: ['non empty doc 1', wrongTypedValue] },
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const act = () => parseDocs(testCase.data);
|
||||
// assert
|
||||
expect(act).to.throw(expectedTypeError);
|
||||
});
|
||||
}
|
||||
});
|
||||
it('returns empty when empty', () => {
|
||||
// arrange
|
||||
const empty: DocumentableData = { };
|
||||
// act
|
||||
const actual = parseDocs(empty);
|
||||
// assert
|
||||
expect(actual).to.have.lengthOf(0);
|
||||
});
|
||||
it('returns single item when string', () => {
|
||||
// arrange
|
||||
const url = 'https://privacy.sexy';
|
||||
const expected = [url];
|
||||
const sut: DocumentableData = { docs: url };
|
||||
// act
|
||||
const actual = parseDocs(sut);
|
||||
// assert
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
it('returns all when array', () => {
|
||||
// arrange
|
||||
const expected = ['https://privacy.sexy', 'https://github.com/undergroundwires/privacy.sexy'];
|
||||
const sut: DocumentableData = { docs: expected };
|
||||
// act
|
||||
const actual = parseDocs(sut);
|
||||
// assert
|
||||
expect(actual).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { type ExpressionEvaluator, Expression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/Expression';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
|
||||
import { FunctionCallArgumentStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentStub';
|
||||
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
|
||||
import type { IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { IExpressionEvaluationContext } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
|
||||
describe('Expression', () => {
|
||||
describe('ctor', () => {
|
||||
describe('position', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new ExpressionPosition(0, 5);
|
||||
// act
|
||||
const actual = new ExpressionBuilder()
|
||||
.withPosition(expected)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual.position).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('parameters', () => {
|
||||
describe('defaults to empty array if absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const parameters = absentValue;
|
||||
// act
|
||||
const actual = new ExpressionBuilder()
|
||||
.withParameters(parameters)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual.parameters);
|
||||
expect(actual.parameters.all);
|
||||
expect(actual.parameters.all.length).to.equal(0);
|
||||
}, { excludeNull: true });
|
||||
});
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionParameterCollectionStub()
|
||||
.withParameterName('firstParameterName')
|
||||
.withParameterName('secondParameterName');
|
||||
// act
|
||||
const actual = new ExpressionBuilder()
|
||||
.withParameters(expected)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual.parameters).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('evaluate', () => {
|
||||
describe('throws with invalid arguments', () => {
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
context: IExpressionEvaluationContext,
|
||||
expectedError: string,
|
||||
sutBuilder?: (builder: ExpressionBuilder) => ExpressionBuilder,
|
||||
}[] = [
|
||||
{
|
||||
name: 'throws when some of the required args are not provided',
|
||||
sutBuilder: (i: ExpressionBuilder) => i.withParameterNames(['a', 'b', 'c'], false),
|
||||
context: new ExpressionEvaluationContextStub()
|
||||
.withArgs(new FunctionCallArgumentCollectionStub().withArgument('b', 'provided')),
|
||||
expectedError: 'argument values are provided for required parameters: "a", "c"',
|
||||
},
|
||||
{
|
||||
name: 'throws when none of the required args are not provided',
|
||||
sutBuilder: (i: ExpressionBuilder) => i.withParameterNames(['a', 'b'], false),
|
||||
context: new ExpressionEvaluationContextStub()
|
||||
.withArgs(new FunctionCallArgumentCollectionStub().withArgument('c', 'unrelated')),
|
||||
expectedError: 'argument values are provided for required parameters: "a", "b"',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const sutBuilder = new ExpressionBuilder();
|
||||
if (testCase.sutBuilder) {
|
||||
testCase.sutBuilder(sutBuilder);
|
||||
}
|
||||
const sut = sutBuilder.build();
|
||||
// act
|
||||
const act = () => sut.evaluate(testCase.context);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
it('returns result from evaluator', () => {
|
||||
// arrange
|
||||
const evaluatorMock: ExpressionEvaluator = (c) => `"${c
|
||||
.args
|
||||
.getAllParameterNames()
|
||||
.map((name) => context.args.getArgument(name))
|
||||
.map((arg) => `${arg.parameterName}': '${arg.argumentValue}'`)
|
||||
.join('", "')}"`;
|
||||
const givenArguments = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameter1', 'value1')
|
||||
.withArgument('parameter2', 'value2');
|
||||
const expectedParameterNames = givenArguments.getAllParameterNames();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withArgs(givenArguments);
|
||||
const expected = evaluatorMock(context);
|
||||
const sut = new ExpressionBuilder()
|
||||
.withEvaluator(evaluatorMock)
|
||||
.withParameterNames(expectedParameterNames)
|
||||
.build();
|
||||
// arrange
|
||||
const actual = sut.evaluate(context);
|
||||
// assert
|
||||
expect(expected).to.equal(actual, formatAssertionMessage([
|
||||
`Given arguments: ${JSON.stringify(givenArguments)}`,
|
||||
`Expected parameter names: ${JSON.stringify(expectedParameterNames)}`,
|
||||
]));
|
||||
});
|
||||
it('sends pipeline compiler as it is', () => {
|
||||
// arrange
|
||||
const expected = new PipelineCompilerStub();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withPipelineCompiler(expected);
|
||||
let actual: IPipelineCompiler | undefined;
|
||||
const evaluatorMock: ExpressionEvaluator = (c) => {
|
||||
actual = c.pipelineCompiler;
|
||||
return '';
|
||||
};
|
||||
const sut = new ExpressionBuilder()
|
||||
.withEvaluator(evaluatorMock)
|
||||
.build();
|
||||
// arrange
|
||||
sut.evaluate(context);
|
||||
// assert
|
||||
expectExists(actual);
|
||||
expect(expected).to.equal(actual);
|
||||
});
|
||||
describe('filters unused parameters', () => {
|
||||
// arrange
|
||||
const testCases = [
|
||||
{
|
||||
name: 'with a provided argument',
|
||||
expressionParameters: new FunctionParameterCollectionStub()
|
||||
.withParameterName('parameterToHave', false),
|
||||
arguments: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameterToHave', 'value-to-have')
|
||||
.withArgument('parameterToIgnore', 'value-to-ignore'),
|
||||
expectedArguments: [
|
||||
new FunctionCallArgumentStub()
|
||||
.withParameterName('parameterToHave').withArgumentValue('value-to-have'),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'without a provided argument',
|
||||
expressionParameters: new FunctionParameterCollectionStub()
|
||||
.withParameterName('parameterToHave', false)
|
||||
.withParameterName('parameterToIgnore', true),
|
||||
arguments: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameterToHave', 'value-to-have'),
|
||||
expectedArguments: [
|
||||
new FunctionCallArgumentStub()
|
||||
.withParameterName('parameterToHave').withArgumentValue('value-to-have'),
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
let actual: IReadOnlyFunctionCallArgumentCollection | undefined;
|
||||
const evaluatorMock: ExpressionEvaluator = (c) => {
|
||||
actual = c.args;
|
||||
return '';
|
||||
};
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withArgs(testCase.arguments);
|
||||
const sut = new ExpressionBuilder()
|
||||
.withEvaluator(evaluatorMock)
|
||||
.withParameters(testCase.expressionParameters)
|
||||
.build();
|
||||
// act
|
||||
sut.evaluate(context);
|
||||
// assert
|
||||
expectExists(actual);
|
||||
const collection = actual;
|
||||
const actualArguments = collection.getAllParameterNames()
|
||||
.map((name) => collection.getArgument(name));
|
||||
expect(actualArguments).to.deep.equal(testCase.expectedArguments);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ExpressionBuilder {
|
||||
private position: ExpressionPosition = new ExpressionPosition(0, 5);
|
||||
|
||||
private parameters?: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub();
|
||||
|
||||
public withPosition(position: ExpressionPosition) {
|
||||
this.position = position;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withEvaluator(evaluator: ExpressionEvaluator) {
|
||||
this.evaluator = evaluator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParameters(parameters: IReadOnlyFunctionParameterCollection | undefined) {
|
||||
this.parameters = parameters;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParameterName(parameterName: string, isOptional = true) {
|
||||
const collection = new FunctionParameterCollectionStub()
|
||||
.withParameterName(parameterName, isOptional);
|
||||
return this.withParameters(collection);
|
||||
}
|
||||
|
||||
public withParameterNames(parameterNames: string[], isOptional = true) {
|
||||
const collection = new FunctionParameterCollectionStub()
|
||||
.withParameterNames(parameterNames, isOptional);
|
||||
return this.withParameters(collection);
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new Expression({
|
||||
position: this.position,
|
||||
evaluator: this.evaluator,
|
||||
parameters: this.parameters,
|
||||
});
|
||||
}
|
||||
|
||||
private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionEvaluationContext, type IExpressionEvaluationContext } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import type { IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
|
||||
describe('ExpressionEvaluationContext', () => {
|
||||
describe('ctor', () => {
|
||||
describe('args', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('expectedParameter', 'expectedValue');
|
||||
const builder = new ExpressionEvaluationContextBuilder()
|
||||
.withArgs(expected);
|
||||
// act
|
||||
const sut = builder.build();
|
||||
// assert
|
||||
const actual = sut.args;
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('pipelineCompiler', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new PipelineCompilerStub();
|
||||
const builder = new ExpressionEvaluationContextBuilder()
|
||||
.withPipelineCompiler(expected);
|
||||
// act
|
||||
const sut = builder.build();
|
||||
// assert
|
||||
expect(sut.pipelineCompiler).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ExpressionEvaluationContextBuilder {
|
||||
private args: IReadOnlyFunctionCallArgumentCollection = new FunctionCallArgumentCollectionStub();
|
||||
|
||||
private pipelineCompiler: IPipelineCompiler = new PipelineCompilerStub();
|
||||
|
||||
public withArgs(args: IReadOnlyFunctionCallArgumentCollection) {
|
||||
this.args = args;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withPipelineCompiler(pipelineCompiler: IPipelineCompiler) {
|
||||
this.pipelineCompiler = pipelineCompiler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): IExpressionEvaluationContext {
|
||||
return new ExpressionEvaluationContext(this.args, this.pipelineCompiler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
|
||||
describe('ExpressionPosition', () => {
|
||||
describe('ctor', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expectedStart = 0;
|
||||
const expectedEnd = 5;
|
||||
// act
|
||||
const sut = new ExpressionPosition(expectedStart, expectedEnd);
|
||||
// assert
|
||||
expect(sut.start).to.equal(expectedStart);
|
||||
expect(sut.end).to.equal(expectedEnd);
|
||||
});
|
||||
describe('throws when invalid', () => {
|
||||
// arrange
|
||||
const testCases = [
|
||||
{ start: 5, end: 5, error: 'no length (start = end = 5)' },
|
||||
{ start: 5, end: 3, error: 'start (5) after end (3)' },
|
||||
{ start: -1, end: 3, error: 'negative start position: -1' },
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.error, () => {
|
||||
// act
|
||||
const act = () => new ExpressionPosition(testCase.start, testCase.end);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('isInInsideOf', () => {
|
||||
// arrange
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
sut: ExpressionPosition,
|
||||
potentialParent: ExpressionPosition,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
{
|
||||
name: 'true; when other contains sut inside boundaries',
|
||||
sut: new ExpressionPosition(4, 8),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when other contains sut with same upper boundary',
|
||||
sut: new ExpressionPosition(4, 10),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when other contains sut with same lower boundary',
|
||||
sut: new ExpressionPosition(0, 8),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'false; when other is same as sut',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut contains other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(4, 8),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts and ends before other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(15, 25),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts before other but ends inside other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(5, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts inside other but ends after other',
|
||||
sut: new ExpressionPosition(5, 11),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts at same position but end after other',
|
||||
sut: new ExpressionPosition(0, 11),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut ends at same positions but start before other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(1, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = testCase.sut.isInInsideOf(testCase.potentialParent);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('isSame', () => {
|
||||
// arrange
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
sut: ExpressionPosition,
|
||||
other: ExpressionPosition,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
{
|
||||
name: 'true; when positions are same',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'false; when start position is different',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(1, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when end position is different',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(0, 11),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when both start and end positions are different',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(20, 30),
|
||||
expectedResult: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = testCase.sut.isSame(testCase.other);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('isIntersecting', () => {
|
||||
// arrange
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
first: ExpressionPosition,
|
||||
second: ExpressionPosition,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
{
|
||||
name: 'true; when one contains other inside boundaries',
|
||||
first: new ExpressionPosition(4, 8),
|
||||
second: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when one starts inside other\'s ending boundary without being contained',
|
||||
first: new ExpressionPosition(0, 10),
|
||||
second: new ExpressionPosition(9, 15),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when positions are the same',
|
||||
first: new ExpressionPosition(0, 5),
|
||||
second: new ExpressionPosition(0, 5),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when one starts inside other\'s starting boundary without being contained',
|
||||
first: new ExpressionPosition(5, 10),
|
||||
second: new ExpressionPosition(5, 11),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'false; when one starts directly after other',
|
||||
first: new ExpressionPosition(0, 10),
|
||||
second: new ExpressionPosition(10, 20),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when one starts after other with margin',
|
||||
first: new ExpressionPosition(0, 10),
|
||||
second: new ExpressionPosition(100, 200),
|
||||
expectedResult: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = testCase.first.isIntersecting(testCase.second);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
it(`reversed: ${testCase.name}`, () => {
|
||||
// act
|
||||
const actual = testCase.second.isIntersecting(testCase.first);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPositionFromRegexFullMatch } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
|
||||
|
||||
describe('ExpressionPositionFactory', () => {
|
||||
describe('createPositionFromRegexFullMatch', () => {
|
||||
describe('it is a transient factory', () => {
|
||||
// arrange
|
||||
const fakeMatch = createRegexMatch();
|
||||
// act
|
||||
const create = () => createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
itIsTransientFactory({
|
||||
getter: create,
|
||||
expectedType: ExpressionPosition,
|
||||
});
|
||||
});
|
||||
it('creates a position with the correct start position', () => {
|
||||
// arrange
|
||||
const expectedStartPosition = 5;
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: 'matched string',
|
||||
matchIndex: expectedStartPosition,
|
||||
});
|
||||
// act
|
||||
const position = createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(position.start).toBe(expectedStartPosition);
|
||||
});
|
||||
|
||||
it('creates a position with the correct end position', () => {
|
||||
// arrange
|
||||
const startPosition = 3;
|
||||
const matchedString = 'matched string';
|
||||
const expectedEndPosition = startPosition + matchedString.length;
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: matchedString,
|
||||
matchIndex: startPosition,
|
||||
});
|
||||
// act
|
||||
const position = createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(position.end).to.equal(expectedEndPosition);
|
||||
});
|
||||
|
||||
it('creates correct position with capturing groups', () => {
|
||||
// arrange
|
||||
const startPosition = 20;
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: 'matched string',
|
||||
capturingGroups: ['group1', 'group2'],
|
||||
matchIndex: startPosition,
|
||||
});
|
||||
// act
|
||||
const position = createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(position.start).toBe(startPosition);
|
||||
expect(position.end).toBe(startPosition + fakeMatch[0].length);
|
||||
});
|
||||
|
||||
describe('invalid values', () => {
|
||||
it('throws an error if match.index is undefined', () => {
|
||||
// arrange
|
||||
const fakeMatch = createRegexMatch();
|
||||
fakeMatch.index = undefined;
|
||||
const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`;
|
||||
// act
|
||||
const act = () => createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('throws an error for empty match', () => {
|
||||
// arrange
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: '',
|
||||
matchIndex: 0,
|
||||
});
|
||||
const expectedError = `Regex match is empty: ${JSON.stringify(fakeMatch)}`;
|
||||
// act
|
||||
const act = () => createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createRegexMatch(options?: {
|
||||
readonly fullMatch?: string,
|
||||
readonly capturingGroups?: readonly string[],
|
||||
readonly matchIndex?: number,
|
||||
}): RegExpMatchArray {
|
||||
const fullMatch = options?.fullMatch ?? 'default fake match';
|
||||
const capturingGroups = options?.capturingGroups ?? [];
|
||||
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
|
||||
fakeMatch.index = options?.matchIndex ?? 0;
|
||||
return fakeMatch;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/ExpressionsCompiler';
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
|
||||
import { ExpressionParserStub } from '@tests/unit/shared/Stubs/ExpressionParserStub';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression';
|
||||
|
||||
describe('ExpressionsCompiler', () => {
|
||||
describe('compileExpressions', () => {
|
||||
describe('returns empty string when code is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expected = '';
|
||||
const code = absentValue;
|
||||
const sut = new SystemUnderTest();
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const value = sut.compileExpressions(code, args);
|
||||
// assert
|
||||
expect(value).to.equal(expected);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
describe('can compile nested expressions', () => {
|
||||
it('when one expression is evaluated to a text that contains another expression', () => {
|
||||
// arrange
|
||||
const expectedResult = 'hello world!';
|
||||
const rawCode = 'hello {{ firstExpression }}!';
|
||||
const outerExpressionResult = '{{ secondExpression }}';
|
||||
const expectedCodeAfterFirstCompilationRound = 'hello {{ secondExpression }}!';
|
||||
const innerExpressionResult = 'world';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(rawCode, [
|
||||
new ExpressionStub()
|
||||
// {{ firstExpression }
|
||||
.withPosition(6, 27)
|
||||
// Parser would hit the outer expression
|
||||
.withEvaluatedResult(outerExpressionResult),
|
||||
])
|
||||
.withResult(expectedCodeAfterFirstCompilationRound, [
|
||||
new ExpressionStub()
|
||||
// {{ secondExpression }}
|
||||
.withPosition(6, 28)
|
||||
// once the outer expression parser, compiler now parses its evaluated result
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
]);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const actual = sut.compileExpressions(rawCode, args);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedResult);
|
||||
});
|
||||
describe('when one expression contains another hardcoded expression', () => {
|
||||
it('when hardcoded expression is does not contain the hardcoded expression', () => {
|
||||
// arrange
|
||||
const expectedResult = 'hi !';
|
||||
const rawCode = 'hi {{ outerExpressionStart }}delete {{ innerExpression }} me{{ outerExpressionEnd }}!';
|
||||
const outerExpressionResult = '';
|
||||
const innerExpressionResult = 'should not be there';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(rawCode, [
|
||||
new ExpressionStub()
|
||||
// {{ outerExpressionStart }}delete {{ innerExpression }} me{{ outerExpressionEnd }}
|
||||
.withPosition(3, 84)
|
||||
.withEvaluatedResult(outerExpressionResult),
|
||||
new ExpressionStub()
|
||||
// {{ innerExpression }}
|
||||
.withPosition(36, 57)
|
||||
// Parser would hit both expressions as one is hardcoded in other
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
])
|
||||
.withResult(expectedResult, []);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const actual = sut.compileExpressions(rawCode, args);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedResult);
|
||||
});
|
||||
it('when hardcoded expression contains the hardcoded expression', () => {
|
||||
// arrange
|
||||
const expectedResult = 'hi game of thrones!';
|
||||
const rawCode = 'hi {{ outerExpressionStart }} game {{ innerExpression }} {{ outerExpressionEnd }}!';
|
||||
const expectedCodeAfterFirstCompilationRound = 'hi game {{ innerExpression }}!'; // outer is compiled first
|
||||
const outerExpressionResult = 'game {{ innerExpression }}';
|
||||
const innerExpressionResult = 'of thrones';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(rawCode, [
|
||||
new ExpressionStub()
|
||||
// {{ outerExpressionStart }} game {{ innerExpression }} {{ outerExpressionEnd }}
|
||||
.withPosition(3, 81)
|
||||
// Parser would hit the outer expression
|
||||
.withEvaluatedResult(outerExpressionResult),
|
||||
new ExpressionStub()
|
||||
// {{ innerExpression }}
|
||||
.withPosition(35, 57)
|
||||
// Parser would hit both expressions as one is hardcoded in other
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
])
|
||||
.withResult(expectedCodeAfterFirstCompilationRound, [
|
||||
new ExpressionStub()
|
||||
// {{ innerExpression }}
|
||||
.withPosition(8, 29)
|
||||
// once the outer expression parser, compiler now parses its evaluated result
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
]);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const actual = sut.compileExpressions(rawCode, args);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('combines expressions as expected', () => {
|
||||
// arrange
|
||||
const code = 'part1 {{ a }} part2 {{ b }} part3';
|
||||
const testCases = [
|
||||
{
|
||||
name: 'with ordered expressions',
|
||||
expressions: [
|
||||
new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a'),
|
||||
new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b'),
|
||||
],
|
||||
expected: 'part1 a part2 b part3',
|
||||
},
|
||||
{
|
||||
name: 'unordered expressions',
|
||||
expressions: [
|
||||
new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b'),
|
||||
new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a'),
|
||||
],
|
||||
expected: 'part1 a part2 b part3',
|
||||
},
|
||||
{
|
||||
name: 'with an optional expected argument that is not provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a')
|
||||
.withParameterNames(['optionalParameter'], true),
|
||||
new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b')
|
||||
.withParameterNames(['optionalParameterTwo'], true),
|
||||
],
|
||||
expected: 'part1 a part2 b part3',
|
||||
},
|
||||
{
|
||||
name: 'with no expressions',
|
||||
expressions: [],
|
||||
expected: code,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, testCase.expressions);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
// act
|
||||
const actual = sut.compileExpressions(code, args);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('arguments', () => {
|
||||
it('passes arguments to expressions as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('test-arg', 'test-value');
|
||||
const code = 'longer than 6 characters';
|
||||
const expressions = [
|
||||
new ExpressionStub().withPosition(0, 3),
|
||||
new ExpressionStub().withPosition(3, 6),
|
||||
];
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, expressions);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
// act
|
||||
sut.compileExpressions(code, expected);
|
||||
// assert
|
||||
const actualArgs = expressions
|
||||
.flatMap((expression) => expression.callHistory)
|
||||
.map((context) => context.args);
|
||||
expect(
|
||||
actualArgs.every((arg) => arg === expected),
|
||||
`Expected: ${JSON.stringify(expected)}\n`
|
||||
+ `Actual: ${JSON.stringify(actualArgs)}\n`
|
||||
+ `Not equal: ${actualArgs.filter((arg) => arg !== expected)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('throws when expressions are invalid', () => {
|
||||
describe('throws when expected argument is not provided but used in code', () => {
|
||||
// arrange
|
||||
const testCases = [
|
||||
{
|
||||
name: 'empty parameters',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub(),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'unnecessary parameter is provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('unnecessaryParameter', 'unnecessaryValue'),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'multiple values are not provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter1'], false),
|
||||
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub(),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2", "parameter3" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'some values are provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter1'], false),
|
||||
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameter2', 'value'),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter3" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'parameter names are not repeated in error message',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter1', 'parameter1', 'parameter2', 'parameter2'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub(),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2" but used in code',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const code = 'non-important-code';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, testCase.expressions);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
// act
|
||||
const act = () => sut.compileExpressions(code, testCase.args);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('throws when expression positions are unexpected', () => {
|
||||
// arrange
|
||||
const code = 'c'.repeat(30);
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
expressions: readonly IExpression[],
|
||||
expectedError: string,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
(() => {
|
||||
const badExpression = new ExpressionStub().withPosition(0, code.length + 5);
|
||||
const goodExpression = new ExpressionStub().withPosition(0, code.length - 1);
|
||||
return {
|
||||
name: 'an expression has out-of-range position',
|
||||
expressions: [badExpression, goodExpression],
|
||||
expectedError: `Expressions out of range:\n${JSON.stringify([badExpression])}`,
|
||||
expectedResult: true,
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const duplicatedExpression = new ExpressionStub().withPosition(0, code.length - 1);
|
||||
const uniqueExpression = new ExpressionStub().withPosition(0, code.length - 2);
|
||||
return {
|
||||
name: 'two expressions at the same position',
|
||||
expressions: [duplicatedExpression, duplicatedExpression, uniqueExpression],
|
||||
expectedError: `Instructions at same position:\n${JSON.stringify([duplicatedExpression, duplicatedExpression])}`,
|
||||
expectedResult: true,
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const goodExpression = new ExpressionStub().withPosition(0, 5);
|
||||
const intersectingExpression = new ExpressionStub().withPosition(5, 10);
|
||||
const intersectingExpressionOther = new ExpressionStub().withPosition(7, 12);
|
||||
return {
|
||||
name: 'intersecting expressions',
|
||||
expressions: [goodExpression, intersectingExpression, intersectingExpressionOther],
|
||||
expectedError: `Instructions intersecting unexpectedly:\n${JSON.stringify([intersectingExpression, intersectingExpressionOther])}`,
|
||||
expectedResult: true,
|
||||
};
|
||||
})(),
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, testCase.expressions);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const act = () => sut.compileExpressions(code, args);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
it('calls parser with expected code', () => {
|
||||
// arrange
|
||||
const expected = 'expected-code';
|
||||
const expressionParserMock = new ExpressionParserStub();
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
sut.compileExpressions(expected, args);
|
||||
// assert
|
||||
expect(expressionParserMock.callHistory).to.have.lengthOf(1);
|
||||
expect(expressionParserMock.callHistory[0]).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class SystemUnderTest extends ExpressionsCompiler {
|
||||
constructor(extractor: IExpressionParser = new ExpressionParserStub()) {
|
||||
super(extractor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression';
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { CompositeExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/CompositeExpressionParser';
|
||||
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
|
||||
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('CompositeExpressionParser', () => {
|
||||
describe('ctor', () => {
|
||||
describe('throws when parsers are missing', () => {
|
||||
itEachAbsentCollectionValue<IExpressionParser>((absentCollection) => {
|
||||
// arrange
|
||||
const expectedError = 'missing leafs';
|
||||
const parsers = absentCollection;
|
||||
// act
|
||||
const act = () => new CompositeExpressionParser(parsers);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
});
|
||||
describe('findExpressions', () => {
|
||||
describe('returns result from parsers as expected', () => {
|
||||
// arrange
|
||||
const pool = [
|
||||
new ExpressionStub(), new ExpressionStub(), new ExpressionStub(),
|
||||
new ExpressionStub(), new ExpressionStub(),
|
||||
];
|
||||
const testCases = [
|
||||
{
|
||||
name: 'from single parsing none',
|
||||
parsers: [mockParser()],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: 'from single parsing single',
|
||||
parsers: [mockParser(pool[0])],
|
||||
expected: [pool[0]],
|
||||
},
|
||||
{
|
||||
name: 'from single parsing multiple',
|
||||
parsers: [mockParser(pool[0], pool[1])],
|
||||
expected: [pool[0], pool[1]],
|
||||
},
|
||||
{
|
||||
name: 'from multiple parsers with each parsing single',
|
||||
parsers: [
|
||||
mockParser(pool[0]),
|
||||
mockParser(pool[1]),
|
||||
mockParser(pool[2]),
|
||||
],
|
||||
expected: [pool[0], pool[1], pool[2]],
|
||||
},
|
||||
{
|
||||
name: 'from multiple parsers with each parsing multiple',
|
||||
parsers: [
|
||||
mockParser(pool[0], pool[1]),
|
||||
mockParser(pool[2], pool[3], pool[4])],
|
||||
expected: [pool[0], pool[1], pool[2], pool[3], pool[4]],
|
||||
},
|
||||
{
|
||||
name: 'from multiple parsers with only some parsing',
|
||||
parsers: [
|
||||
mockParser(pool[0], pool[1]),
|
||||
mockParser(),
|
||||
mockParser(pool[2]),
|
||||
mockParser(),
|
||||
],
|
||||
expected: [pool[0], pool[1], pool[2]],
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const sut = new CompositeExpressionParser(testCase.parsers);
|
||||
// act
|
||||
const result = sut.findExpressions('non-important-code');
|
||||
// expect
|
||||
expect(result).to.deep.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockParser(...result: IExpression[]): IExpressionParser {
|
||||
return {
|
||||
findExpressions: () => result,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionRegexBuilder } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder';
|
||||
|
||||
const AllWhitespaceCharacters = ' \t\n\r\v\f\u00A0';
|
||||
|
||||
describe('ExpressionRegexBuilder', () => {
|
||||
describe('expectCharacters', () => {
|
||||
describe('expectCharacters', () => {
|
||||
describe('escapes single character as expected', () => {
|
||||
const charactersToEscape = ['.', '$'];
|
||||
for (const character of charactersToEscape) {
|
||||
it(`escapes ${character} as expected`, () => expectMatch(
|
||||
character,
|
||||
(act) => act.expectCharacters(character),
|
||||
`${character}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
it('escapes multiple characters as expected', () => expectMatch(
|
||||
'.I have no $$.',
|
||||
(act) => act.expectCharacters('.I have no $$.'),
|
||||
'.I have no $$.',
|
||||
));
|
||||
it('adds characters as expected', () => expectMatch(
|
||||
'return as it is',
|
||||
(act) => act.expectCharacters('return as it is'),
|
||||
'return as it is',
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('expectOneOrMoreWhitespaces', () => {
|
||||
it('matches one whitespace', () => expectMatch(
|
||||
' ',
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
' ',
|
||||
));
|
||||
it('matches multiple whitespaces', () => expectMatch(
|
||||
AllWhitespaceCharacters,
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('matches whitespaces inside text', () => expectMatch(
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('does not match non-whitespace characters', () => expectNonMatch(
|
||||
'a',
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
));
|
||||
});
|
||||
describe('captureOptionalPipeline', () => {
|
||||
it('does not capture when no pipe is present', () => expectNonMatch(
|
||||
'noPipeHere',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
));
|
||||
it('captures when input starts with pipe', () => expectCapture(
|
||||
'| afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores without text before', () => expectCapture(
|
||||
'stuff before | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores without text before', () => expectCapture(
|
||||
'stuff before | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores whitespaces before the pipe', () => expectCapture(
|
||||
' | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores text after whitespace', () => expectCapture(
|
||||
'| first Pipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| first ',
|
||||
));
|
||||
describe('non-greedy matching', () => { // so the rest of the pattern can work
|
||||
it('non-letter character in pipe', () => expectCapture(
|
||||
'| firstPipe | sec0ndpipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| firstPipe ',
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('captureUntilWhitespaceOrPipe', () => {
|
||||
it('captures until first whitespace', () => expectCapture(
|
||||
// arrange
|
||||
'first ',
|
||||
// act
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'first',
|
||||
));
|
||||
it('captures until first pipe', () => expectCapture(
|
||||
// arrange
|
||||
'first|',
|
||||
// act
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'first',
|
||||
));
|
||||
it('captures all without whitespace or pipe', () => expectCapture(
|
||||
// arrange
|
||||
'all',
|
||||
// act
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'all',
|
||||
));
|
||||
});
|
||||
describe('captureMultilineAnythingExceptSurroundingWhitespaces', () => {
|
||||
describe('single line', () => {
|
||||
it('captures a line without surrounding whitespaces', () => expectCapture(
|
||||
// arrange
|
||||
'line',
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'line',
|
||||
));
|
||||
it('captures a line with internal whitespaces intact', () => expectCapture(
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
));
|
||||
it('excludes surrounding whitespaces', () => expectCapture(
|
||||
// arrange
|
||||
`${AllWhitespaceCharacters}single line\t`,
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'single line',
|
||||
));
|
||||
});
|
||||
describe('multiple lines', () => {
|
||||
it('captures text across multiple lines', () => expectCapture(
|
||||
// arrange
|
||||
'first line\nsecond line\r\nthird-line',
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line\r\nthird-line',
|
||||
));
|
||||
it('captures text with empty lines in between', () => expectCapture(
|
||||
'start\n\nend',
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
'start\n\nend',
|
||||
));
|
||||
it('excludes surrounding whitespaces from multiline text', () => expectCapture(
|
||||
// arrange
|
||||
` first line\nsecond line${AllWhitespaceCharacters}`,
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line',
|
||||
));
|
||||
});
|
||||
describe('edge cases', () => {
|
||||
it('does not capture for input with only whitespaces', () => expectNonCapture(
|
||||
AllWhitespaceCharacters,
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('expectExpressionStart', () => {
|
||||
it('matches expression start without trailing whitespaces', () => expectMatch(
|
||||
'{{expression',
|
||||
(act) => act.expectExpressionStart(),
|
||||
'{{',
|
||||
));
|
||||
it('matches expression start with trailing whitespaces', () => expectMatch(
|
||||
`{{${AllWhitespaceCharacters}expression`,
|
||||
(act) => act.expectExpressionStart(),
|
||||
`{{${AllWhitespaceCharacters}`,
|
||||
));
|
||||
it('does not match whitespaces not directly after expression start', () => expectMatch(
|
||||
' {{expression',
|
||||
(act) => act.expectExpressionStart(),
|
||||
'{{',
|
||||
));
|
||||
it('does not match if expression start is not present', () => expectNonMatch(
|
||||
'noExpressionStartHere',
|
||||
(act) => act.expectExpressionStart(),
|
||||
));
|
||||
});
|
||||
describe('expectExpressionEnd', () => {
|
||||
it('matches expression end without preceding whitespaces', () => expectMatch(
|
||||
'expression}}',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
'}}',
|
||||
));
|
||||
it('matches expression end with preceding whitespaces', () => expectMatch(
|
||||
`expression${AllWhitespaceCharacters}}}`,
|
||||
(act) => act.expectExpressionEnd(),
|
||||
`${AllWhitespaceCharacters}}}`,
|
||||
));
|
||||
it('does not capture whitespaces not directly before expression end', () => expectMatch(
|
||||
'expression}} ',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
'}}',
|
||||
));
|
||||
it('does not match if expression end is not present', () => expectNonMatch(
|
||||
'noExpressionEndHere',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
));
|
||||
});
|
||||
describe('expectOptionalWhitespaces', () => {
|
||||
describe('matching', () => {
|
||||
it('matches multiple Unix lines', () => expectMatch(
|
||||
// arrange
|
||||
'\n\n',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\n\n',
|
||||
));
|
||||
it('matches multiple Windows lines', () => expectMatch(
|
||||
// arrange
|
||||
'\r\n',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\r\n',
|
||||
));
|
||||
it('matches multiple spaces', () => expectMatch(
|
||||
// arrange
|
||||
' ',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
' ',
|
||||
));
|
||||
it('matches horizontal and vertical tabs', () => expectMatch(
|
||||
// arrange
|
||||
'\t\v',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\t\v',
|
||||
));
|
||||
it('matches form feed character', () => expectMatch(
|
||||
// arrange
|
||||
'\f',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\f',
|
||||
));
|
||||
it('matches a non-breaking space character', () => expectMatch(
|
||||
// arrange
|
||||
'\u00A0',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\u00A0',
|
||||
));
|
||||
it('matches a combination of whitespace characters', () => expectMatch(
|
||||
// arrange
|
||||
AllWhitespaceCharacters,
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('matches whitespace characters on different positions', () => expectMatch(
|
||||
// arrange
|
||||
'\ta\nb\rc\v',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\t\n\r\v',
|
||||
));
|
||||
});
|
||||
describe('non-matching', () => {
|
||||
it('a non-whitespace character', () => expectNonMatch(
|
||||
// arrange
|
||||
'a',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
));
|
||||
it('multiple non-whitespace characters', () => expectNonMatch(
|
||||
// arrange
|
||||
'abc',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('buildRegExp', () => {
|
||||
it('sets global flag', () => {
|
||||
// arrange
|
||||
const expected = 'g';
|
||||
const sut = new ExpressionRegexBuilder()
|
||||
.expectOneOrMoreWhitespaces();
|
||||
// act
|
||||
const actual = sut.buildRegExp().flags;
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
describe('can combine multiple parts', () => {
|
||||
it('combines character and whitespace expectations', () => expectMatch(
|
||||
'abc def',
|
||||
(act) => act
|
||||
.expectCharacters('abc')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('def'),
|
||||
'abc def',
|
||||
));
|
||||
it('captures optional pipeline and text after it', () => expectCapture(
|
||||
'abc | def',
|
||||
(act) => act
|
||||
.expectCharacters('abc ')
|
||||
.captureOptionalPipeline(),
|
||||
'| def',
|
||||
));
|
||||
it('combines multiline capture with optional whitespaces', () => expectCapture(
|
||||
'\n abc \n',
|
||||
(act) => act
|
||||
.expectOptionalWhitespaces()
|
||||
.captureMultilineAnythingExceptSurroundingWhitespaces()
|
||||
.expectOptionalWhitespaces(),
|
||||
'abc',
|
||||
));
|
||||
it('combines expression start, optional whitespaces, and character expectation', () => expectMatch(
|
||||
'{{ abc',
|
||||
(act) => act
|
||||
.expectExpressionStart()
|
||||
.expectOptionalWhitespaces()
|
||||
.expectCharacters('abc'),
|
||||
'{{ abc',
|
||||
));
|
||||
it('combines character expectation, optional whitespaces, and expression end', () => expectMatch(
|
||||
'abc }}',
|
||||
(act) => act
|
||||
.expectCharacters('abc')
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionEnd(),
|
||||
'abc }}',
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
enum MatchGroupIndex {
|
||||
FullMatch = 0,
|
||||
FirstCapturingGroup = 1,
|
||||
}
|
||||
|
||||
function expectCapture(
|
||||
input: string,
|
||||
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedCombinedCaptures: string | undefined,
|
||||
): void {
|
||||
// arrange
|
||||
const matchGroupIndex = MatchGroupIndex.FirstCapturingGroup;
|
||||
// act
|
||||
// assert
|
||||
expectMatch(input, act, expectedCombinedCaptures, matchGroupIndex);
|
||||
}
|
||||
|
||||
function expectNonMatch(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
matchGroupIndex = MatchGroupIndex.FullMatch,
|
||||
): void {
|
||||
expectMatch(input, act, undefined, matchGroupIndex);
|
||||
}
|
||||
|
||||
function expectNonCapture(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
): void {
|
||||
expectNonMatch(input, act, MatchGroupIndex.FirstCapturingGroup);
|
||||
}
|
||||
|
||||
function expectMatch(
|
||||
input: string,
|
||||
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedCombinedMatches: string | undefined,
|
||||
matchGroupIndex = MatchGroupIndex.FullMatch,
|
||||
): void {
|
||||
// arrange
|
||||
const regexBuilder = new ExpressionRegexBuilder();
|
||||
act(regexBuilder);
|
||||
const regex = regexBuilder.buildRegExp();
|
||||
// act
|
||||
const allMatchGroups = Array.from(input.matchAll(regex));
|
||||
// assert
|
||||
const actualMatches = allMatchGroups
|
||||
.filter((matches) => matches.length > matchGroupIndex)
|
||||
.map((matches) => matches[matchGroupIndex])
|
||||
.filter(Boolean) // matchAll returns `""` for full matches, `null` for capture groups
|
||||
.flat();
|
||||
const actualCombinedMatches = actualMatches.length ? actualMatches.join('') : undefined;
|
||||
expect(actualCombinedMatches).equal(
|
||||
expectedCombinedMatches,
|
||||
[
|
||||
'\n\n---',
|
||||
'Expected combined matches:',
|
||||
getTestDataText(expectedCombinedMatches),
|
||||
'Actual combined matches:',
|
||||
getTestDataText(actualCombinedMatches),
|
||||
'Input:',
|
||||
getTestDataText(input),
|
||||
'Regex:',
|
||||
getTestDataText(regex.toString()),
|
||||
'All match groups:',
|
||||
getTestDataText(JSON.stringify(allMatchGroups)),
|
||||
`Match index in group: ${matchGroupIndex}`,
|
||||
'---\n\n',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function getTestDataText(data: string | undefined): string {
|
||||
const outputPrefix = '\t> ';
|
||||
if (data === undefined) {
|
||||
return `${outputPrefix}undefined (no matches)`;
|
||||
}
|
||||
const getLiteralString = (text: string) => JSON.stringify(text).slice(1, -1);
|
||||
const text = `${outputPrefix}\`${getLiteralString(data)}\``;
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type {
|
||||
ExpressionEvaluator, ExpressionInitParameters,
|
||||
} from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/Expression';
|
||||
import {
|
||||
type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities,
|
||||
} from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/Regex/RegexParser';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
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/Executable/Script/Compiler/Expressions/Expression/IExpression';
|
||||
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
|
||||
import type { ExpressionPositionFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { indentText } from '@tests/shared/Text';
|
||||
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
|
||||
|
||||
describe('RegexParser', () => {
|
||||
describe('findExpressions', () => {
|
||||
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 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handles matched regex correctly', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly regex: RegExp;
|
||||
readonly code: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'non-matching regex',
|
||||
regex: /hello/g,
|
||||
code: 'world',
|
||||
},
|
||||
{
|
||||
description: 'single regex match',
|
||||
regex: /hello/g,
|
||||
code: 'hello world',
|
||||
},
|
||||
{
|
||||
description: 'multiple regex matches',
|
||||
regex: /l/g,
|
||||
code: 'hello world',
|
||||
},
|
||||
];
|
||||
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 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);
|
||||
const actualEvaluate = getInitParameters(expressions[0])?.evaluator;
|
||||
expect(actualEvaluate).to.equal(expectedEvaluate);
|
||||
});
|
||||
it('sets parameters correctly from expression', () => {
|
||||
// arrange
|
||||
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 = (): 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,
|
||||
},
|
||||
});
|
||||
// act
|
||||
const expressions = sut.findExpressions(code);
|
||||
// assert
|
||||
expect(expressions).to.have.lengthOf(1);
|
||||
const actualParameters = getInitParameters(expressions[0])?.parameters;
|
||||
expect(actualParameters).to.equal(parameterCollection);
|
||||
expect(actualParameters?.all).to.deep.equal(expectedParameters);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 createExpressionFactorySpy() {
|
||||
const createdExpressions = new Map<IExpression, ExpressionInitParameters>();
|
||||
const createExpression: ExpressionFactory = (parameters) => {
|
||||
const expression = new ExpressionStub();
|
||||
createdExpressions.set(expression, parameters);
|
||||
return expression;
|
||||
};
|
||||
return {
|
||||
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(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): PrimitiveExpression {
|
||||
return this.builder(match);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe } from 'vitest';
|
||||
import { EscapeDoubleQuotes } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { runPipeTests } from './PipeTestRunner';
|
||||
|
||||
describe('EscapeDoubleQuotes', () => {
|
||||
// arrange
|
||||
const sut = new EscapeDoubleQuotes();
|
||||
// act
|
||||
runPipeTests(sut, [
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: 'returns as it is when if input is missing',
|
||||
input: testCase.absentValue,
|
||||
expectedOutput: testCase.absentValue,
|
||||
})),
|
||||
{
|
||||
name: 'using "',
|
||||
input: 'hello "world"',
|
||||
expectedOutput: 'hello "^""world"^""',
|
||||
},
|
||||
{
|
||||
name: 'not using any double quotes',
|
||||
input: 'hello world',
|
||||
expectedOutput: 'hello world',
|
||||
},
|
||||
{
|
||||
name: 'consecutive double quotes',
|
||||
input: '""hello world""',
|
||||
expectedOutput: '"^"""^""hello world"^"""^""',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import { describe } from 'vitest';
|
||||
import { InlinePowerShell } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { type IPipeTestCase, runPipeTests } from './PipeTestRunner';
|
||||
|
||||
describe('InlinePowerShell', () => {
|
||||
// arrange
|
||||
const sut = new InlinePowerShell();
|
||||
// act
|
||||
runPipeTests(sut, [
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: 'returns as it is when if input is missing',
|
||||
input: testCase.absentValue,
|
||||
expectedOutput: '',
|
||||
})),
|
||||
...prefixTests('newline', getNewLineCases()),
|
||||
...prefixTests('comment', getCommentCases()),
|
||||
...prefixTests('here-string', hereStringCases()),
|
||||
...prefixTests('backtick', backTickCases()),
|
||||
]);
|
||||
});
|
||||
|
||||
function hereStringCases(): IPipeTestCase[] {
|
||||
const expectLinesInDoubleQuotes = (...lines: string[]) => lines.join('`r`n');
|
||||
const expectLinesInSingleQuotes = (...lines: string[]) => lines.join('\'+"`r`n"+\'');
|
||||
return [
|
||||
{
|
||||
name: 'adds newlines for double quotes',
|
||||
input: getWindowsLines(
|
||||
'@"',
|
||||
'Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: expectLinesInDoubleQuotes(
|
||||
'"Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet"',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adds newlines for single quotes',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'\'Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet\'',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not match with character after here string header',
|
||||
input: getWindowsLines(
|
||||
'@" invalid syntax',
|
||||
'I will not be processed as here-string',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'@" invalid syntax',
|
||||
'I will not be processed as here-string',
|
||||
'"@',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not match if there\'s character before here-string terminator',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'do not match here',
|
||||
' \'@',
|
||||
'character \'@',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'@\'',
|
||||
'do not match here',
|
||||
' \'@',
|
||||
'character \'@',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not match with different here-string header/terminator',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'lorem',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'@\'',
|
||||
'lorem',
|
||||
'"@',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'matches with inner single quoted here-string',
|
||||
input: getWindowsLines(
|
||||
'$hasInnerDoubleQuotedTerminator = @"',
|
||||
'inner text',
|
||||
'@\'',
|
||||
'inner terminator text',
|
||||
'\'@',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: expectLinesInDoubleQuotes(
|
||||
'$hasInnerDoubleQuotedTerminator = "inner text',
|
||||
'@\'',
|
||||
'inner terminator text',
|
||||
'\'@"',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'matches with inner double quoted string',
|
||||
input: getWindowsLines(
|
||||
'$hasInnerSingleQuotedTerminator = @\'',
|
||||
'inner text',
|
||||
'@"',
|
||||
'inner terminator text',
|
||||
'"@',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'$hasInnerSingleQuotedTerminator = \'inner text',
|
||||
'@"',
|
||||
'inner terminator text',
|
||||
'"@\'',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'matches if there\'s character after here-string terminator',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'lorem',
|
||||
'\'@ after',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'\'lorem\' after',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'escapes double quotes inside double quotes',
|
||||
input: getWindowsLines(
|
||||
'@"',
|
||||
'For help, type "get-help"',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: '"For help, type `"get-help`""',
|
||||
},
|
||||
{
|
||||
name: 'escapes single quotes inside single quotes',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'For help, type \'get-help\'',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: '\'For help, type \'\'get-help\'\'\'',
|
||||
},
|
||||
{
|
||||
name: 'converts when here-string header is not at line start',
|
||||
input: getWindowsLines(
|
||||
'$page = [XML] @"',
|
||||
'multi-lined',
|
||||
'and "quoted"',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: expectLinesInDoubleQuotes(
|
||||
'$page = [XML] "multi-lined',
|
||||
'and `"quoted`""',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trims after here-string header',
|
||||
input: getWindowsLines(
|
||||
'@" \t',
|
||||
'text with whitespaces at here-string start',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: '"text with whitespaces at here-string start"',
|
||||
},
|
||||
{
|
||||
name: 'preserves whitespaces in lines',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'\ttext with tabs around\t\t',
|
||||
' text with whitespaces around ',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'\'\ttext with tabs around\t\t',
|
||||
' text with whitespaces around \'',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function backTickCases(): IPipeTestCase[] {
|
||||
return [
|
||||
{
|
||||
name: 'wraps newlines with trailing backtick',
|
||||
input: getWindowsLines(
|
||||
'Get-Service * `',
|
||||
'| Format-Table -AutoSize',
|
||||
),
|
||||
expectedOutput: 'Get-Service * | Format-Table -AutoSize',
|
||||
},
|
||||
{
|
||||
name: 'wraps newlines with trailing backtick and different line endings',
|
||||
input: 'Get-Service `\n'
|
||||
+ '* `\r'
|
||||
+ '| Sort-Object StartType `\r\n'
|
||||
+ '| Format-Table -AutoSize',
|
||||
expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize',
|
||||
},
|
||||
{
|
||||
name: 'trims tabs and whitespaces on next lines when wrapping with trailing backtick',
|
||||
input: getWindowsLines(
|
||||
'Get-Service * `',
|
||||
'\t| Sort-Object StartType `',
|
||||
' | Format-Table -AutoSize',
|
||||
),
|
||||
expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize',
|
||||
},
|
||||
{
|
||||
name: 'does not wrap without whitespace before backtick',
|
||||
input: getWindowsLines(
|
||||
'Get-Service *`',
|
||||
'| Format-Table -AutoSize',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'Get-Service *`',
|
||||
'| Format-Table -AutoSize',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not wrap with characters after',
|
||||
input: getWindowsLines(
|
||||
'line start ` after',
|
||||
'should not be wrapped',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'line start ` after',
|
||||
'should not be wrapped',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getCommentCases(): IPipeTestCase[] {
|
||||
return [
|
||||
{
|
||||
name: 'converts hash comments in the line end',
|
||||
input: getWindowsLines(
|
||||
'$text = "Hello"\t# Comment after tab',
|
||||
'$text+= #Comment without space after hash',
|
||||
'Write-Host $text# Comment without space before hash',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$text = "Hello"\t<# Comment after tab #>',
|
||||
'$text+= <# Comment without space after hash #>',
|
||||
'Write-Host $text<# Comment without space before hash #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'converts hash comment line',
|
||||
input: getWindowsLines(
|
||||
'# Comment in first line',
|
||||
'Write-Host "Hello"',
|
||||
'# Comment in the middle',
|
||||
'Write-Host "World"',
|
||||
'# Consecutive comments',
|
||||
'# Last line comment without line ending in the end',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'<# Comment in first line #>',
|
||||
'Write-Host "Hello"',
|
||||
'<# Comment in the middle #>',
|
||||
'Write-Host "World"',
|
||||
'<# Consecutive comments #>',
|
||||
'<# Last line comment without line ending in the end #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'can convert comment with inline comment parts inside',
|
||||
input: getWindowsLines(
|
||||
'$text+= #Comment with < inside',
|
||||
'$text+= #Comment ending with >',
|
||||
'$text+= #Comment with <# inline comment #>',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$text+= <# Comment with < inside #>',
|
||||
'$text+= <# Comment ending with > #>',
|
||||
'$text+= <# Comment with <# inline comment #> #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'can convert comment with inline comment parts around', // Pretty uncommon
|
||||
input: getWindowsLines(
|
||||
'Write-Host "hi" # Comment ending line inline comment but not one #>',
|
||||
'Write-Host "hi" #>Comment starting like inline comment end but not one',
|
||||
// Following line does not compile as valid PowerShell due to missing #> for inline comment.
|
||||
'Write-Host "hi" <#Comment starting like inline comment start but not one',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'Write-Host "hi" <# Comment ending line inline comment but not one #> #>',
|
||||
'Write-Host "hi" <# >Comment starting like inline comment end but not one #>',
|
||||
'Write-Host "hi" <<# Comment starting like inline comment start but not one #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'converts empty hash comment',
|
||||
input: getWindowsLines(
|
||||
'Write-Host "Comment without text" #',
|
||||
'Write-Host "Non-empty line"',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'Write-Host "Comment without text" <##>',
|
||||
'Write-Host "Non-empty line"',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adds whitespaces around to match',
|
||||
input: getWindowsLines(
|
||||
'#Comment line with no whitespaces around',
|
||||
'Write-Host "Hello"#Comment in the end with no whitespaces around',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'<# Comment line with no whitespaces around #>',
|
||||
'Write-Host "Hello"<# Comment in the end with no whitespaces around #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trims whitespaces around comment',
|
||||
input: getWindowsLines(
|
||||
'# Comment with whitespaces around ',
|
||||
'#\tComment with tabs around\t\t',
|
||||
'#\t Comment with tabs and whitespaces around \t \t',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'<# Comment with whitespaces around #>',
|
||||
'<# Comment with tabs around #>',
|
||||
'<# Comment with tabs and whitespaces around #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not convert block comments',
|
||||
input: getWindowsLines(
|
||||
'$text = "Hello"\t<# block comment #> + "World"',
|
||||
'$text = "Hello"\t+<#comment#>"World"',
|
||||
'<# Block comment in a line #>',
|
||||
'Write-Host "Hello world <# Block comment in the end of line #>',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$text = "Hello"\t<# block comment #> + "World"',
|
||||
'$text = "Hello"\t+<#comment#>"World"',
|
||||
'<# Block comment in a line #>',
|
||||
'Write-Host "Hello world <# Block comment in the end of line #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not process if there are no multi lines',
|
||||
input: 'Write-Host "expected" # as it is!',
|
||||
expectedOutput: 'Write-Host "expected" # as it is!',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getNewLineCases(): IPipeTestCase[] {
|
||||
return [
|
||||
{
|
||||
name: 'no new line',
|
||||
input: 'Write-Host \'Hello, World!\'',
|
||||
expectedOutput: 'Write-Host \'Hello, World!\'',
|
||||
},
|
||||
{
|
||||
name: '\\n new line',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\nforeach ($thing in $things) {'
|
||||
+ '\nWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\n}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '\\n double empty lines are ignored',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\n\nforeach ($thing in $things) {'
|
||||
+ '\n\nWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\n\n\n}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '\\r new line',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\rforeach ($thing in $things) {'
|
||||
+ '\rWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\r}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '\\r and \\n newlines combined',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\r\nforeach ($thing in $things) {'
|
||||
+ '\n\rWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\n\r}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trims whitespaces on lines',
|
||||
input:
|
||||
' $things = Get-ChildItem C:\\Windows\\ '
|
||||
+ '\nforeach ($thing in $things) {'
|
||||
+ '\n\tWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\r \n}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function prefixTests(prefix: string, tests: IPipeTestCase[]): IPipeTestCase[] {
|
||||
return tests.map((test) => ({
|
||||
name: `[${prefix}] ${test.name}`,
|
||||
input: test.input,
|
||||
expectedOutput: test.expectedOutput,
|
||||
}));
|
||||
}
|
||||
|
||||
function getWindowsLines(...lines: string[]) {
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
function getSingleLinedOutput(...lines: string[]) {
|
||||
return lines.map((line) => line.trim()).join('; ');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import type { IPipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe';
|
||||
|
||||
export interface IPipeTestCase {
|
||||
readonly name: string;
|
||||
readonly input: string;
|
||||
readonly expectedOutput: string;
|
||||
}
|
||||
|
||||
export function runPipeTests(sut: IPipe, testCases: IPipeTestCase[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = sut.apply(testCase.input);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedOutput);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory';
|
||||
import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('PipeFactory', () => {
|
||||
describe('ctor', () => {
|
||||
it('throws when instances with same name is registered', () => {
|
||||
// arrange
|
||||
const duplicateName = 'duplicateName';
|
||||
const expectedError = `Pipe name must be unique: "${duplicateName}"`;
|
||||
const pipes = [
|
||||
new PipeStub().withName(duplicateName),
|
||||
new PipeStub().withName('uniqueName'),
|
||||
new PipeStub().withName(duplicateName),
|
||||
];
|
||||
// act
|
||||
const act = () => new PipeFactory(pipes);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
describe('throws when name is invalid', () => {
|
||||
// act
|
||||
const act = (invalidName: string) => new PipeFactory([new PipeStub().withName(invalidName)]);
|
||||
// assert
|
||||
testPipeNameValidation(act);
|
||||
});
|
||||
});
|
||||
describe('get', () => {
|
||||
describe('throws when name is invalid', () => {
|
||||
// arrange
|
||||
const sut = new PipeFactory();
|
||||
// act
|
||||
const act = (invalidName: string) => sut.get(invalidName);
|
||||
// assert
|
||||
testPipeNameValidation(act);
|
||||
});
|
||||
it('gets registered instance when it exists', () => {
|
||||
// arrange
|
||||
const expected = new PipeStub().withName('expectedName');
|
||||
const pipes = [expected, new PipeStub().withName('instanceToConfuse')];
|
||||
const sut = new PipeFactory(pipes);
|
||||
// act
|
||||
const actual = sut.get(expected.name);
|
||||
// expect
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
it('throws when instance does not exist', () => {
|
||||
// arrange
|
||||
const missingName = 'missingName';
|
||||
const expectedError = `Unknown pipe: "${missingName}"`;
|
||||
const pipes = [];
|
||||
const sut = new PipeFactory(pipes);
|
||||
// act
|
||||
const act = () => sut.get(missingName);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function testPipeNameValidation(testRunner: (invalidName: string) => void) {
|
||||
const testCases = [
|
||||
// Validate missing value
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `empty pipe name (${testCase.valueName})`,
|
||||
value: testCase.absentValue,
|
||||
expectedError: 'empty pipe name',
|
||||
})),
|
||||
// Validate camelCase
|
||||
...[
|
||||
'PascalCase',
|
||||
'snake-case',
|
||||
'includesNumb3rs',
|
||||
'includes Whitespace',
|
||||
'noSpec\'ial',
|
||||
].map((nonCamelCaseValue) => ({
|
||||
name: `non camel case value (${nonCamelCaseValue})`,
|
||||
value: nonCamelCaseValue,
|
||||
expectedError: `Pipe name should be camelCase: "${nonCamelCaseValue}"`,
|
||||
})),
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const invalidName = testCase.value;
|
||||
const { expectedError } = testCase;
|
||||
// act
|
||||
const act = () => testRunner(invalidName);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipelineCompiler';
|
||||
import { type IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
|
||||
import type { IPipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory';
|
||||
import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub';
|
||||
import { PipeFactoryStub } from '@tests/unit/shared/Stubs/PipeFactoryStub';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('PipelineCompiler', () => {
|
||||
describe('compile', () => {
|
||||
describe('throws for invalid arguments', () => {
|
||||
interface ITestCase {
|
||||
readonly name: string;
|
||||
readonly act: (test: PipelineTestRunner) => PipelineTestRunner;
|
||||
readonly expectedError: string;
|
||||
}
|
||||
const testCases: ITestCase[] = [
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `"value" is ${testCase.valueName}`,
|
||||
act: (test) => test.withValue(testCase.absentValue),
|
||||
expectedError: 'missing value',
|
||||
})),
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `"pipeline" is ${testCase.valueName}`,
|
||||
act: (test) => test.withPipeline(testCase.absentValue),
|
||||
expectedError: 'missing pipeline',
|
||||
})),
|
||||
{
|
||||
name: '"pipeline" does not start with pipe',
|
||||
act: (test) => test.withPipeline('pipeline |'),
|
||||
expectedError: 'pipeline does not start with pipe',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const runner = new PipelineTestRunner();
|
||||
testCase.act(runner);
|
||||
const act = () => runner.compile();
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('compiles pipeline as expected', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'compiles single pipe as expected',
|
||||
pipes: [
|
||||
new PipeStub().withName('doublePrint').withApplier((value) => `${value}-${value}`),
|
||||
],
|
||||
pipeline: '| doublePrint',
|
||||
value: 'value',
|
||||
expected: 'value-value',
|
||||
},
|
||||
{
|
||||
name: 'compiles multiple pipes as expected',
|
||||
pipes: [
|
||||
new PipeStub().withName('prependLetterA').withApplier((value) => `A-${value}`),
|
||||
new PipeStub().withName('prependLetterB').withApplier((value) => `B-${value}`),
|
||||
],
|
||||
pipeline: '| prependLetterA | prependLetterB',
|
||||
value: 'value',
|
||||
expected: 'B-A-value',
|
||||
},
|
||||
{
|
||||
name: 'compiles with relaxed whitespace placing',
|
||||
pipes: [
|
||||
new PipeStub().withName('appendNumberOne').withApplier((value) => `${value}1`),
|
||||
new PipeStub().withName('appendNumberTwo').withApplier((value) => `${value}2`),
|
||||
new PipeStub().withName('appendNumberThree').withApplier((value) => `${value}3`),
|
||||
],
|
||||
pipeline: ' | appendNumberOne|appendNumberTwo| appendNumberThree',
|
||||
value: 'value',
|
||||
expected: 'value123',
|
||||
},
|
||||
{
|
||||
name: 'can reuse same pipe',
|
||||
pipes: [
|
||||
new PipeStub().withName('removeFirstChar').withApplier((value) => `${value.slice(1)}`),
|
||||
],
|
||||
pipeline: ' | removeFirstChar | removeFirstChar | removeFirstChar',
|
||||
value: 'value',
|
||||
expected: 'ue',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const runner = new PipelineTestRunner()
|
||||
.withValue(testCase.value)
|
||||
.withPipeline(testCase.pipeline)
|
||||
.withFactory(new PipeFactoryStub().withPipes(testCase.pipes));
|
||||
// act
|
||||
const actual = runner.compile();
|
||||
// expect
|
||||
expect(actual).to.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class PipelineTestRunner implements IPipelineCompiler {
|
||||
private value = 'non-empty-value';
|
||||
|
||||
private pipeline = '| validPipeline';
|
||||
|
||||
private factory: IPipeFactory = new PipeFactoryStub();
|
||||
|
||||
public withValue(value: string) {
|
||||
this.value = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withPipeline(pipeline: string) {
|
||||
this.pipeline = pipeline;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFactory(factory: IPipeFactory) {
|
||||
this.factory = factory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public compile(): string {
|
||||
const sut = new PipelineCompiler(this.factory);
|
||||
return sut.compile(this.value, this.pipeline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe } from 'vitest';
|
||||
import { ParameterSubstitutionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
|
||||
|
||||
describe('ParameterSubstitutionParser', () => {
|
||||
const sut = new ParameterSubstitutionParser();
|
||||
const runner = new SyntaxParserTestsRunner(sut);
|
||||
describe('finds as expected', () => {
|
||||
runner.expectPosition(
|
||||
{
|
||||
name: 'single parameter',
|
||||
code: '{{ $parameter }}!',
|
||||
expected: [new ExpressionPosition(0, 16)],
|
||||
},
|
||||
{
|
||||
name: 'different parameters',
|
||||
code: 'He{{ $firstParameter }} {{ $secondParameter }}!!',
|
||||
expected: [new ExpressionPosition(2, 23), new ExpressionPosition(24, 46)],
|
||||
},
|
||||
{
|
||||
name: 'tolerates lack of spaces around brackets',
|
||||
code: 'He{{$firstParameter}}!!',
|
||||
expected: [new ExpressionPosition(2, 21)],
|
||||
},
|
||||
{
|
||||
name: 'does not tolerate space after dollar sign',
|
||||
code: 'He{{ $ firstParameter }}!!',
|
||||
expected: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('evaluates as expected', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'single parameter',
|
||||
code: '{{ $parameter }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world'],
|
||||
},
|
||||
{
|
||||
name: 'different parameters',
|
||||
code: '{{ $firstParameter }} {{ $secondParameter }}!',
|
||||
args: (args) => args
|
||||
.withArgument('firstParameter', 'Hello')
|
||||
.withArgument('secondParameter', 'World'),
|
||||
expected: ['Hello', 'World'],
|
||||
},
|
||||
{
|
||||
name: 'same parameters used twice',
|
||||
code: '{{ $letterH }}e{{ $letterL }}{{ $letterL }}o Wor{{ $letterL }}d!',
|
||||
args: (args) => args
|
||||
.withArgument('letterL', 'l')
|
||||
.withArgument('letterH', 'H'),
|
||||
expected: ['H', 'l', 'l', 'l'],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('compiles pipes as expected', () => {
|
||||
runner.expectPipeHits({
|
||||
codeBuilder: (pipeline) => `{{ $argument${pipeline}}}`,
|
||||
parameterName: 'argument',
|
||||
parameterValue: 'value',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
|
||||
export class SyntaxParserTestsRunner {
|
||||
constructor(private readonly sut: IExpressionParser) {
|
||||
}
|
||||
|
||||
public expectPosition(...testCases: ExpectPositionTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
const actual = expressions.map((e) => e.position);
|
||||
expect(scrambledEqual(actual, testCase.expected));
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectNoMatch(...testCases: NoMatchTestScenario[]) {
|
||||
this.expectPosition(...testCases.map((testCase) => ({
|
||||
name: testCase.name,
|
||||
code: testCase.code,
|
||||
expected: [],
|
||||
})));
|
||||
}
|
||||
|
||||
public expectResults(...testCases: ExpectResultTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const args = testCase.args(new FunctionCallArgumentCollectionStub());
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withArgs(args);
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
const actual = expressions.map((e) => e.evaluate(context));
|
||||
expect(actual).to.deep.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectThrows(...testCases: ExpectThrowsTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const { expectedError } = testCase;
|
||||
// act
|
||||
const act = () => this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectPipeHits(data: ExpectPipeHitTestScenario) {
|
||||
for (const validPipePart of PipeTestCases.ValidValues) {
|
||||
this.expectHitPipePart(validPipePart, data);
|
||||
}
|
||||
for (const invalidPipePart of PipeTestCases.InvalidValues) {
|
||||
this.expectMissPipePart(invalidPipePart, data);
|
||||
}
|
||||
}
|
||||
|
||||
private expectHitPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
|
||||
it(`"${pipeline}" hits`, () => {
|
||||
// arrange
|
||||
const expectedPipePart = pipeline.trim();
|
||||
const code = data.codeBuilder(pipeline);
|
||||
const args = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument(data.parameterName, data.parameterValue);
|
||||
const pipelineCompiler = new PipelineCompilerStub();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withPipelineCompiler(pipelineCompiler)
|
||||
.withArgs(args);
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(code);
|
||||
expressions[0].evaluate(context);
|
||||
// assert
|
||||
expect(expressions).has.lengthOf(1);
|
||||
expect(pipelineCompiler.compileHistory).has.lengthOf(1);
|
||||
const actualPipePart = pipelineCompiler.compileHistory[0].pipeline;
|
||||
const actualValue = pipelineCompiler.compileHistory[0].value;
|
||||
expect(actualPipePart).to.equal(expectedPipePart);
|
||||
expect(actualValue).to.equal(data.parameterValue);
|
||||
});
|
||||
}
|
||||
|
||||
private expectMissPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
|
||||
it(`"${pipeline}" misses`, () => {
|
||||
// arrange
|
||||
const args = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument(data.parameterName, data.parameterValue);
|
||||
const pipelineCompiler = new PipelineCompilerStub();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withPipelineCompiler(pipelineCompiler)
|
||||
.withArgs(args);
|
||||
const code = data.codeBuilder(pipeline);
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(code);
|
||||
expressions[0]?.evaluate(context); // Because an expression may include another with pipes
|
||||
// assert
|
||||
expect(pipelineCompiler.compileHistory).has.lengthOf(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpectResultTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly args: (
|
||||
builder: FunctionCallArgumentCollectionStub,
|
||||
) => FunctionCallArgumentCollectionStub;
|
||||
readonly expected: readonly string[];
|
||||
}
|
||||
|
||||
interface ExpectThrowsTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly expectedError: string;
|
||||
}
|
||||
|
||||
interface ExpectPositionTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly expected: readonly ExpressionPosition[];
|
||||
}
|
||||
|
||||
interface NoMatchTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
interface ExpectPipeHitTestScenario {
|
||||
readonly codeBuilder: (pipeline: string) => string;
|
||||
readonly parameterName: string;
|
||||
readonly parameterValue: string;
|
||||
}
|
||||
|
||||
const PipeTestCases = {
|
||||
ValidValues: [
|
||||
// Single pipe with different whitespace combinations
|
||||
' | pipe', ' |pipe', '|pipe', ' |pipe', ' | pipe',
|
||||
|
||||
// Double pipes with different whitespace combinations
|
||||
' | pipeFirst | pipeSecond', '| pipeFirst|pipeSecond', '|pipeFirst|pipeSecond', ' |pipeFirst |pipeSecond', '| pipeFirst | pipeSecond| pipeThird |pipeFourth',
|
||||
],
|
||||
InvalidValues: [
|
||||
' withoutPipeBefore |pipe', ' withoutPipeBefore',
|
||||
|
||||
// It's OK to match them (move to valid values if needed) to let compiler throw instead.
|
||||
'| pip€', '| pip{e} ', '| pipeWithNumber55', '| pipe with whitespace',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { WithParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/SyntaxParsers/WithParser';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
|
||||
|
||||
describe('WithParser', () => {
|
||||
const sut = new WithParser();
|
||||
const runner = new SyntaxParserTestsRunner(sut);
|
||||
describe('correctly identifies `with` syntax', () => {
|
||||
runner.expectPosition(
|
||||
{
|
||||
name: 'when no context variable is not used',
|
||||
code: 'hello {{ with $parameter }}no usage{{ end }} here',
|
||||
expected: [new ExpressionPosition(6, 44)],
|
||||
},
|
||||
{
|
||||
name: 'when context variable is used',
|
||||
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
|
||||
expected: [new ExpressionPosition(11, 53)],
|
||||
},
|
||||
{
|
||||
name: 'when used twice',
|
||||
code: 'first: {{ with $parameter }}value: {{ . }}{{ end }}, second: {{ with $parameter }}no usage{{ end }}',
|
||||
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)],
|
||||
},
|
||||
{
|
||||
name: 'when nested',
|
||||
code: 'outer: {{ with $outer }}outer value with context variable: {{ . }}, inner: {{ with $inner }}inner value{{ end }}.{{ end }}',
|
||||
expected: [
|
||||
/* outer: */ new ExpressionPosition(7, 122),
|
||||
/* inner: */ new ExpressionPosition(77, 112),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'whitespaces: tolerate lack of whitespaces',
|
||||
code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
|
||||
expected: [new ExpressionPosition(15, 55)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: match multiline text',
|
||||
code: 'non related line\n{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\n{{ end }}\nnon related line',
|
||||
expected: [new ExpressionPosition(17, 92)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: does not match newlines before',
|
||||
code: '\n{{ with $unimportant }}Text{{ end }}',
|
||||
expected: [new ExpressionPosition(1, 37)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: does not match newlines after',
|
||||
code: '{{ with $unimportant }}Text{{ end }}\n',
|
||||
expected: [new ExpressionPosition(0, 36)],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('throws with incorrect `with` syntax', () => {
|
||||
runner.expectThrows(
|
||||
{
|
||||
name: 'incorrect `with`: whitespace after dollar sign inside `with` statement',
|
||||
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `with`: whitespace before dollar sign inside `with` statement',
|
||||
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `with`: missing `with` statement',
|
||||
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `end`: missing `end` statement',
|
||||
code: '{{ with $parameter}}value: {{ . }}{{ fin }}',
|
||||
expectedError: 'Missing `end` statement, forgot `{{ end }}?',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `end`: used without `with`',
|
||||
code: 'Value {{ end }}',
|
||||
expectedError: 'Redundant `end` statement, missing `with`?',
|
||||
},
|
||||
{
|
||||
name: 'incorrect "context variable": used without `with`',
|
||||
code: 'Value: {{ . }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('ignores when syntax is wrong', () => {
|
||||
describe('does not render argument if substitution syntax is wrong', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'comma used instead of dot',
|
||||
code: '{{ with $parameter }}Hello {{ , }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'world!'),
|
||||
expected: ['Hello {{ , }}'],
|
||||
},
|
||||
{
|
||||
name: 'single brackets instead of double',
|
||||
code: '{{ with $parameter }}Hello { . }{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'world!'),
|
||||
expected: ['Hello { . }'],
|
||||
},
|
||||
{
|
||||
name: 'double dots instead of single',
|
||||
code: '{{ with $parameter }}Hello {{ .. }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'world!'),
|
||||
expected: ['Hello {{ .. }}'],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('scope rendering', () => {
|
||||
describe('conditional rendering based on argument value', () => {
|
||||
describe('does not render scope', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('renders scope', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
{
|
||||
name: 'renders scope even if value is not used',
|
||||
code: '{{ with $parameter }}Hello world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when it has value',
|
||||
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when whitespaces around brackets are missing',
|
||||
code: '{{ with $parameter }}{{.}} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value multiple times when it\'s used multiple times',
|
||||
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('letterL', 'l'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('whitespace handling inside scope', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'renders value in multi-lined text',
|
||||
code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('middleLine', 'value line'),
|
||||
expected: ['line before value\nvalue line\nline after value'],
|
||||
},
|
||||
{
|
||||
name: 'renders value around whitespaces in multi-lined text',
|
||||
code: '{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\t {{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('middleLine', 'value line'),
|
||||
expected: ['line before value\nvalue line\nline after value'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing whitespace after value',
|
||||
code: '{{ with $parameter }}{{ . }}! {{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing newline after value',
|
||||
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline before value',
|
||||
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading whitespaces before value',
|
||||
code: '{{ with $parameter }} {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline and whitespaces before value',
|
||||
code: '{{ with $parameter }}\r\n {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('nested with statements', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'renders nested with statements correctly',
|
||||
code: '{{ with $outer }}Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: {{ . }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('outer', 'OuterValue')
|
||||
.withArgument('inner', 'InnerValue'),
|
||||
expected: [
|
||||
'Inner: InnerValue',
|
||||
'Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: OuterValue',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'renders nested with statements with context variables',
|
||||
code: '{{ with $outer }}{{ with $inner }}{{ . }}{{ . }}{{ end }}{{ . }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('outer', 'O')
|
||||
.withArgument('inner', 'I'),
|
||||
expected: [
|
||||
'II',
|
||||
'{{ with $inner }}{{ . }}{{ . }}{{ end }}O',
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('pipe behavior', () => {
|
||||
runner.expectPipeHits({
|
||||
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
|
||||
parameterName: 'argument',
|
||||
parameterValue: 'value',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { FunctionCallArgument } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { testParameterName } from '../../../ParameterNameTestRunner';
|
||||
|
||||
describe('FunctionCallArgument', () => {
|
||||
describe('ctor', () => {
|
||||
describe('parameter name', () => {
|
||||
testParameterName(
|
||||
(parameterName) => new FunctionCallArgumentBuilder()
|
||||
.withParameterName(parameterName)
|
||||
.build()
|
||||
.parameterName,
|
||||
);
|
||||
});
|
||||
describe('throws if argument value is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const parameterName = 'paramName';
|
||||
const expectedError = `Missing argument value for the parameter "${parameterName}".`;
|
||||
const argumentValue = absentValue;
|
||||
// act
|
||||
const act = () => new FunctionCallArgumentBuilder()
|
||||
.withParameterName(parameterName)
|
||||
.withArgumentValue(argumentValue)
|
||||
.build();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class FunctionCallArgumentBuilder {
|
||||
private parameterName = 'default-parameter-name';
|
||||
|
||||
private argumentValue = 'default-argument-value';
|
||||
|
||||
public withParameterName(parameterName: string) {
|
||||
this.parameterName = parameterName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withArgumentValue(argumentValue: string) {
|
||||
this.argumentValue = argumentValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new FunctionCallArgument(this.parameterName, this.argumentValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
|
||||
import { FunctionCallArgumentStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { IFunctionCallArgument } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgument';
|
||||
|
||||
describe('FunctionCallArgumentCollection', () => {
|
||||
describe('addArgument', () => {
|
||||
it('throws if parameter value is already provided', () => {
|
||||
// arrange
|
||||
const duplicateParameterName = 'duplicateParam';
|
||||
const errorMessage = `argument value for parameter ${duplicateParameterName} is already provided`;
|
||||
const arg1 = new FunctionCallArgumentStub().withParameterName(duplicateParameterName);
|
||||
const arg2 = new FunctionCallArgumentStub().withParameterName(duplicateParameterName);
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
// act
|
||||
sut.addArgument(arg1);
|
||||
const act = () => sut.addArgument(arg2);
|
||||
// assert
|
||||
expect(act).to.throw(errorMessage);
|
||||
});
|
||||
});
|
||||
describe('getAllParameterNames', () => {
|
||||
describe('returns as expected', () => {
|
||||
// arrange
|
||||
const testCases: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly args: readonly IFunctionCallArgument[];
|
||||
readonly expectedParameterNames: string[];
|
||||
}> = [{
|
||||
description: 'no args',
|
||||
args: [],
|
||||
expectedParameterNames: [],
|
||||
}, {
|
||||
description: 'with some args',
|
||||
args: [
|
||||
new FunctionCallArgumentStub().withParameterName('a-param-name'),
|
||||
new FunctionCallArgumentStub().withParameterName('b-param-name')],
|
||||
expectedParameterNames: ['a-param-name', 'b-param-name'],
|
||||
}];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.description, () => {
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
// act
|
||||
for (const arg of testCase.args) {
|
||||
sut.addArgument(arg);
|
||||
}
|
||||
const actual = sut.getAllParameterNames();
|
||||
// assert
|
||||
expect(actual).to.deep.equal(testCase.expectedParameterNames);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('getArgument', () => {
|
||||
describe('throws if parameter name is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing parameter name';
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
const parameterName = absentValue;
|
||||
// act
|
||||
const act = () => sut.getArgument(parameterName);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
it('throws if argument does not exist', () => {
|
||||
// arrange
|
||||
const parameterName = 'nonExistingParam';
|
||||
const expectedError = `parameter does not exist: ${parameterName}`;
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
// act
|
||||
const act = () => sut.getArgument(parameterName);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('returns argument as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionCallArgumentStub()
|
||||
.withParameterName('expectedName')
|
||||
.withArgumentValue('expectedValue');
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
// act
|
||||
sut.addArgument(expected);
|
||||
const actual = sut.getArgument(expected.parameterName);
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('hasArgument', () => {
|
||||
describe('throws if parameter name is missing', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing parameter name';
|
||||
const parameterName = absentValue;
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
// act
|
||||
const act = () => sut.hasArgument(parameterName);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
describe('returns as expected', () => {
|
||||
// arrange
|
||||
const testCases = [{
|
||||
name: 'argument exists',
|
||||
parameter: 'existing-parameter-name',
|
||||
args: [
|
||||
new FunctionCallArgumentStub().withParameterName('existing-parameter-name'),
|
||||
new FunctionCallArgumentStub().withParameterName('unrelated-parameter-name'),
|
||||
],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'argument does not exist',
|
||||
parameter: 'not-existing-parameter-name',
|
||||
args: [
|
||||
new FunctionCallArgumentStub().withParameterName('unrelated-parameter-name-b'),
|
||||
new FunctionCallArgumentStub().withParameterName('unrelated-parameter-name-a'),
|
||||
],
|
||||
expected: false,
|
||||
}];
|
||||
for (const testCase of testCases) {
|
||||
it(`"${testCase.name}" returns "${testCase.expected}"`, () => {
|
||||
const sut = new FunctionCallArgumentCollection();
|
||||
// act
|
||||
for (const arg of testCase.args) {
|
||||
sut.addArgument(arg);
|
||||
}
|
||||
const actual = sut.hasArgument(testCase.parameter);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { NewlineCodeSegmentMerger } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/NewlineCodeSegmentMerger';
|
||||
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
|
||||
import { getAbsentStringTestCases, itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
|
||||
describe('NewlineCodeSegmentMerger', () => {
|
||||
describe('mergeCodeParts', () => {
|
||||
describe('throws given empty segments', () => {
|
||||
itEachAbsentCollectionValue<CompiledCode>((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing segments';
|
||||
const segments = absentValue;
|
||||
const merger = new NewlineCodeSegmentMerger();
|
||||
// act
|
||||
const act = () => merger.mergeCodeParts(segments);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
describe('merges correctly', () => {
|
||||
const testCases: ReadonlyArray<{
|
||||
readonly description: string,
|
||||
readonly segments: CompiledCodeStub[],
|
||||
readonly expected: {
|
||||
readonly code: string,
|
||||
readonly revertCode?: string,
|
||||
},
|
||||
}> = [
|
||||
{
|
||||
description: 'given `code` and `revertCode`',
|
||||
segments: [
|
||||
new CompiledCodeStub().withCode('code1').withRevertCode('revert1'),
|
||||
new CompiledCodeStub().withCode('code2').withRevertCode('revert2'),
|
||||
new CompiledCodeStub().withCode('code3').withRevertCode('revert3'),
|
||||
],
|
||||
expected: {
|
||||
code: 'code1\ncode2\ncode3',
|
||||
revertCode: 'revert1\nrevert2\nrevert3',
|
||||
},
|
||||
},
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((absentTestCase) => ({
|
||||
description: `filter out ${absentTestCase.valueName} \`revertCode\``,
|
||||
segments: [
|
||||
new CompiledCodeStub().withCode('code1').withRevertCode('revert1'),
|
||||
new CompiledCodeStub().withCode('code2').withRevertCode(absentTestCase.absentValue),
|
||||
new CompiledCodeStub().withCode('code3').withRevertCode('revert3'),
|
||||
],
|
||||
expected: {
|
||||
code: 'code1\ncode2\ncode3',
|
||||
revertCode: 'revert1\nrevert3',
|
||||
},
|
||||
})),
|
||||
...getAbsentStringTestCases({ excludeNull: true })
|
||||
.map((emptyRevertCode) => ({
|
||||
description: `given only \`code\` in segments with "${emptyRevertCode.valueName}" \`revertCode\``,
|
||||
segments: [
|
||||
new CompiledCodeStub().withCode('code1').withRevertCode(emptyRevertCode.absentValue),
|
||||
new CompiledCodeStub().withCode('code2').withRevertCode(emptyRevertCode.absentValue),
|
||||
],
|
||||
expected: {
|
||||
code: 'code1\ncode2',
|
||||
revertCode: '',
|
||||
},
|
||||
})),
|
||||
{
|
||||
description: 'given mix of segments with only `code` or `revertCode`',
|
||||
segments: [
|
||||
new CompiledCodeStub().withCode('code1').withRevertCode(''),
|
||||
new CompiledCodeStub().withCode('').withRevertCode('revert2'),
|
||||
new CompiledCodeStub().withCode('code3').withRevertCode(''),
|
||||
],
|
||||
expected: {
|
||||
code: 'code1\ncode3',
|
||||
revertCode: 'revert2',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'given only `revertCode` in segments',
|
||||
segments: [
|
||||
new CompiledCodeStub().withCode('').withRevertCode('revert1'),
|
||||
new CompiledCodeStub().withCode('').withRevertCode('revert2'),
|
||||
],
|
||||
expected: {
|
||||
code: '',
|
||||
revertCode: 'revert1\nrevert2',
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const { segments, expected, description } of testCases) {
|
||||
it(description, () => {
|
||||
// arrange
|
||||
const merger = new NewlineCodeSegmentMerger();
|
||||
// act
|
||||
const actual = merger.mergeCodeParts(segments);
|
||||
// assert
|
||||
expect(actual.code).to.equal(expected.code);
|
||||
expect(actual.revertCode).to.equal(expected.revertCode);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FunctionCallSequenceCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler';
|
||||
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
|
||||
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { SingleCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
|
||||
import type { CodeSegmentMerger } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/CodeSegmentMerger';
|
||||
import type { ISharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionCollection';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
|
||||
import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub';
|
||||
import { SingleCallCompilerStub } from '@tests/unit/shared/Stubs/SingleCallCompilerStub';
|
||||
import { CodeSegmentMergerStub } from '@tests/unit/shared/Stubs/CodeSegmentMergerStub';
|
||||
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
|
||||
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
|
||||
describe('FunctionCallSequenceCompiler', () => {
|
||||
describe('instance', () => {
|
||||
itIsSingletonFactory({
|
||||
getter: () => FunctionCallSequenceCompiler.instance,
|
||||
expectedType: FunctionCallSequenceCompiler,
|
||||
});
|
||||
});
|
||||
describe('compileFunctionCalls', () => {
|
||||
describe('parameter validation', () => {
|
||||
describe('calls', () => {
|
||||
describe('throws with missing call', () => {
|
||||
itEachAbsentCollectionValue<FunctionCall>((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing calls';
|
||||
const calls = absentValue;
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withCalls(calls);
|
||||
// act
|
||||
const act = () => builder.compileFunctionCalls();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('invokes single call compiler correctly', () => {
|
||||
describe('calls', () => {
|
||||
it('with expected call', () => {
|
||||
// arrange
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub();
|
||||
const expectedCall = new FunctionCallStub();
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withSingleCallCompiler(singleCallCompilerStub)
|
||||
.withCalls([expectedCall]);
|
||||
// act
|
||||
builder.compileFunctionCalls();
|
||||
// assert
|
||||
expect(singleCallCompilerStub.callHistory).to.have.lengthOf(1);
|
||||
const calledMethod = singleCallCompilerStub.callHistory.find((m) => m.methodName === 'compileSingleCall');
|
||||
expectExists(calledMethod);
|
||||
expect(calledMethod.args[0]).to.equal(expectedCall);
|
||||
});
|
||||
it('with every call', () => {
|
||||
// arrange
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub();
|
||||
const expectedCalls = [
|
||||
new FunctionCallStub(), new FunctionCallStub(), new FunctionCallStub(),
|
||||
];
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withSingleCallCompiler(singleCallCompilerStub)
|
||||
.withCalls(expectedCalls);
|
||||
// act
|
||||
builder.compileFunctionCalls();
|
||||
// assert
|
||||
const calledMethods = singleCallCompilerStub.callHistory.filter((m) => m.methodName === 'compileSingleCall');
|
||||
expect(calledMethods).to.have.lengthOf(expectedCalls.length);
|
||||
const callArguments = calledMethods.map((c) => c.args[0]);
|
||||
expect(expectedCalls).to.have.members(callArguments);
|
||||
});
|
||||
});
|
||||
describe('context', () => {
|
||||
it('with expected functions', () => {
|
||||
// arrange
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub();
|
||||
const expectedFunctions = new SharedFunctionCollectionStub();
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withSingleCallCompiler(singleCallCompilerStub)
|
||||
.withFunctions(expectedFunctions);
|
||||
// act
|
||||
builder.compileFunctionCalls();
|
||||
// assert
|
||||
expect(singleCallCompilerStub.callHistory).to.have.lengthOf(1);
|
||||
const calledMethod = singleCallCompilerStub.callHistory.find((m) => m.methodName === 'compileSingleCall');
|
||||
expectExists(calledMethod);
|
||||
const actualFunctions = calledMethod.args[1].allFunctions;
|
||||
expect(actualFunctions).to.equal(expectedFunctions);
|
||||
});
|
||||
it('with expected call sequence', () => {
|
||||
// arrange
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub();
|
||||
const expectedCallSequence = [new FunctionCallStub(), new FunctionCallStub()];
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withSingleCallCompiler(singleCallCompilerStub)
|
||||
.withCalls(expectedCallSequence);
|
||||
// act
|
||||
builder.compileFunctionCalls();
|
||||
// assert
|
||||
const calledMethods = singleCallCompilerStub.callHistory.filter((m) => m.methodName === 'compileSingleCall');
|
||||
expect(calledMethods).to.have.lengthOf(expectedCallSequence.length);
|
||||
const calledSequenceArgs = calledMethods.map((call) => call.args[1].rootCallSequence);
|
||||
expect(calledSequenceArgs.every((sequence) => sequence === expectedCallSequence));
|
||||
});
|
||||
it('with expected call compiler', () => {
|
||||
// arrange
|
||||
const expectedCompiler = new SingleCallCompilerStub();
|
||||
const rootCallSequence = [new FunctionCallStub(), new FunctionCallStub()];
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withCalls(rootCallSequence)
|
||||
.withSingleCallCompiler(expectedCompiler);
|
||||
// act
|
||||
builder.compileFunctionCalls();
|
||||
// assert
|
||||
const calledMethods = expectedCompiler.callHistory.filter((m) => m.methodName === 'compileSingleCall');
|
||||
expect(calledMethods).to.have.lengthOf(rootCallSequence.length);
|
||||
const compilerArgs = calledMethods.map((call) => call.args[1].singleCallCompiler);
|
||||
expect(compilerArgs.every((compiler) => compiler === expectedCompiler));
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('code segment merger', () => {
|
||||
it('invokes code segment merger correctly', () => {
|
||||
// arrange
|
||||
const singleCallCompilationScenario = new Map<FunctionCall, CompiledCode[]>([
|
||||
[new FunctionCallStub(), [new CompiledCodeStub()]],
|
||||
[new FunctionCallStub(), [new CompiledCodeStub(), new CompiledCodeStub()]],
|
||||
]);
|
||||
const expectedFlattenedSegments = [...singleCallCompilationScenario.values()].flat();
|
||||
const calls = [...singleCallCompilationScenario.keys()];
|
||||
const singleCallCompiler = new SingleCallCompilerStub()
|
||||
.withCallCompilationScenarios(singleCallCompilationScenario);
|
||||
const codeSegmentMergerStub = new CodeSegmentMergerStub();
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withCalls(calls)
|
||||
.withSingleCallCompiler(singleCallCompiler)
|
||||
.withCodeSegmentMerger(codeSegmentMergerStub);
|
||||
// act
|
||||
builder.compileFunctionCalls();
|
||||
// assert
|
||||
const calledMethod = codeSegmentMergerStub.callHistory.find((c) => c.methodName === 'mergeCodeParts');
|
||||
expectExists(calledMethod);
|
||||
const [actualSegments] = calledMethod.args;
|
||||
expect(expectedFlattenedSegments).to.have.lengthOf(actualSegments.length);
|
||||
expect(expectedFlattenedSegments).to.have.deep.members(actualSegments);
|
||||
});
|
||||
it('returns code segment merger result', () => {
|
||||
// arrange
|
||||
const expectedResult = new CompiledCodeStub();
|
||||
const codeSegmentMergerStub = new CodeSegmentMergerStub();
|
||||
codeSegmentMergerStub.mergeCodeParts = () => expectedResult;
|
||||
const builder = new FunctionCallSequenceCompilerBuilder()
|
||||
.withCodeSegmentMerger(codeSegmentMergerStub);
|
||||
// act
|
||||
const actualResult = builder.compileFunctionCalls();
|
||||
// assert
|
||||
expect(actualResult).to.equal(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class FunctionCallSequenceCompilerBuilder {
|
||||
private singleCallCompiler: SingleCallCompiler = new SingleCallCompilerStub();
|
||||
|
||||
private codeSegmentMerger: CodeSegmentMerger = new CodeSegmentMergerStub();
|
||||
|
||||
private functions: ISharedFunctionCollection = new SharedFunctionCollectionStub();
|
||||
|
||||
private calls: readonly FunctionCall[] = [
|
||||
new FunctionCallStub(),
|
||||
];
|
||||
|
||||
public withSingleCallCompiler(compiler: SingleCallCompiler): this {
|
||||
this.singleCallCompiler = compiler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCodeSegmentMerger(merger: CodeSegmentMerger): this {
|
||||
this.codeSegmentMerger = merger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCalls(calls: readonly FunctionCall[]): this {
|
||||
this.calls = calls;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFunctions(functions: ISharedFunctionCollection): this {
|
||||
this.functions = functions;
|
||||
return this;
|
||||
}
|
||||
|
||||
public compileFunctionCalls() {
|
||||
const compiler = new TestableFunctionCallSequenceCompiler({
|
||||
singleCallCompiler: this.singleCallCompiler,
|
||||
codeSegmentMerger: this.codeSegmentMerger,
|
||||
});
|
||||
return compiler.compileFunctionCalls(
|
||||
this.calls,
|
||||
this.functions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FunctionCallSequenceCompilerStubs {
|
||||
readonly singleCallCompiler?: SingleCallCompiler;
|
||||
readonly codeSegmentMerger: CodeSegmentMerger;
|
||||
}
|
||||
|
||||
class TestableFunctionCallSequenceCompiler extends FunctionCallSequenceCompiler {
|
||||
public constructor(options: FunctionCallSequenceCompilerStubs) {
|
||||
super(
|
||||
options.singleCallCompiler,
|
||||
options.codeSegmentMerger,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { createSharedFunctionStubWithCalls, createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub';
|
||||
import { NestedFunctionCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/NestedFunctionCallCompiler';
|
||||
import type { ArgumentCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/ArgumentCompiler';
|
||||
import { ArgumentCompilerStub } from '@tests/unit/shared/Stubs/ArgumentCompilerStub';
|
||||
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
|
||||
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
|
||||
import { SingleCallCompilerStub } from '@tests/unit/shared/Stubs/SingleCallCompilerStub';
|
||||
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
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', () => {
|
||||
it('returns `true` for code body function', () => {
|
||||
// arrange
|
||||
const expected = true;
|
||||
const func = createSharedFunctionStubWithCalls()
|
||||
.withSomeCalls();
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
const actual = compiler.canCompile(func);
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
it('returns `false` for non-code body function', () => {
|
||||
// arrange
|
||||
const expected = false;
|
||||
const func = createSharedFunctionStubWithCode();
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
const actual = compiler.canCompile(func);
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('compile', () => {
|
||||
describe('argument compilation', () => {
|
||||
it('uses correct context', () => {
|
||||
// arrange
|
||||
const argumentCompiler = new ArgumentCompilerStub();
|
||||
const expectedContext = new FunctionCallCompilationContextStub();
|
||||
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.withArgumentCompiler(argumentCompiler)
|
||||
.build();
|
||||
// act
|
||||
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
|
||||
// assert
|
||||
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
|
||||
expect(calls).have.lengthOf(1);
|
||||
const [,,actualContext] = calls[0].args;
|
||||
expect(actualContext).to.equal(expectedContext);
|
||||
});
|
||||
it('uses correct parent call', () => {
|
||||
// arrange
|
||||
const argumentCompiler = new ArgumentCompilerStub();
|
||||
const expectedContext = new FunctionCallCompilationContextStub();
|
||||
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
|
||||
const expectedParentCall = callToFrontFunc;
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.withArgumentCompiler(argumentCompiler)
|
||||
.build();
|
||||
// act
|
||||
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(expectedParentCall);
|
||||
});
|
||||
it('uses correct nested call', () => {
|
||||
// arrange
|
||||
const argumentCompiler = new ArgumentCompilerStub();
|
||||
const expectedContext = new FunctionCallCompilationContextStub();
|
||||
const {
|
||||
frontFunction, callToDeepFunc, callToFrontFunc,
|
||||
} = createSingleFuncCallingAnotherFunc();
|
||||
const expectedNestedCall = callToDeepFunc;
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.withArgumentCompiler(argumentCompiler)
|
||||
.build();
|
||||
// act
|
||||
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(expectedNestedCall);
|
||||
});
|
||||
});
|
||||
describe('re-compilation with compiled args', () => {
|
||||
it('uses correct context', () => {
|
||||
// arrange
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub();
|
||||
const expectedContext = new FunctionCallCompilationContextStub()
|
||||
.withSingleCallCompiler(singleCallCompilerStub);
|
||||
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
|
||||
// assert
|
||||
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
|
||||
expect(calls).have.lengthOf(1);
|
||||
const [,actualContext] = calls[0].args;
|
||||
expect(expectedContext).to.equal(actualContext);
|
||||
});
|
||||
it('uses compiled nested call', () => {
|
||||
// arrange
|
||||
const expectedCall = new FunctionCallStub();
|
||||
const argumentCompilerStub = new ArgumentCompilerStub();
|
||||
argumentCompilerStub.createCompiledNestedCall = () => expectedCall;
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub();
|
||||
const context = new FunctionCallCompilationContextStub()
|
||||
.withSingleCallCompiler(singleCallCompilerStub);
|
||||
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.withArgumentCompiler(argumentCompilerStub)
|
||||
.build();
|
||||
// act
|
||||
compiler.compileFunction(frontFunction, callToFrontFunc, context);
|
||||
// assert
|
||||
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
|
||||
expect(calls).have.lengthOf(1);
|
||||
const [actualNestedCall] = calls[0].args;
|
||||
expect(expectedCall).to.equal(actualNestedCall);
|
||||
});
|
||||
});
|
||||
it('flattens re-compiled functions', () => {
|
||||
// arrange
|
||||
const deepFunc1 = createSharedFunctionStubWithCode();
|
||||
const deepFunc2 = createSharedFunctionStubWithCalls();
|
||||
const callToDeepFunc1 = new FunctionCallStub().withFunctionName(deepFunc1.name);
|
||||
const callToDeepFunc2 = new FunctionCallStub().withFunctionName(deepFunc2.name);
|
||||
const singleCallCompilationScenario = new Map<FunctionCall, CompiledCode[]>([
|
||||
[callToDeepFunc1, [new CompiledCodeStub()]],
|
||||
[callToDeepFunc2, [new CompiledCodeStub(), new CompiledCodeStub()]],
|
||||
]);
|
||||
const argumentCompiler = new ArgumentCompilerStub()
|
||||
.withScenario({ givenNestedFunctionCall: callToDeepFunc1, result: callToDeepFunc1 })
|
||||
.withScenario({ givenNestedFunctionCall: callToDeepFunc2, result: callToDeepFunc2 });
|
||||
const expectedFlattenedCodes = [...singleCallCompilationScenario.values()].flat();
|
||||
const frontFunction = createSharedFunctionStubWithCalls()
|
||||
.withCalls(callToDeepFunc1, callToDeepFunc2);
|
||||
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
|
||||
const singleCallCompilerStub = new SingleCallCompilerStub()
|
||||
.withCallCompilationScenarios(singleCallCompilationScenario);
|
||||
const expectedContext = new FunctionCallCompilationContextStub()
|
||||
.withSingleCallCompiler(singleCallCompilerStub);
|
||||
const compiler = new NestedFunctionCallCompilerBuilder()
|
||||
.withArgumentCompiler(argumentCompiler)
|
||||
.build();
|
||||
// act
|
||||
const actualCodes = compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
|
||||
// assert
|
||||
expect(actualCodes).have.lengthOf(expectedFlattenedCodes.length);
|
||||
expect(actualCodes).to.have.members(expectedFlattenedCodes);
|
||||
});
|
||||
describe('error handling', () => {
|
||||
describe('rethrows error from argument compiler', () => {
|
||||
// arrange
|
||||
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 expectedInnerError;
|
||||
};
|
||||
const builder = new NestedFunctionCallCompilerBuilder()
|
||||
.withArgumentCompiler(argumentCompilerStub);
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
builder
|
||||
.withErrorWrapper(wrapError)
|
||||
.build()
|
||||
.compileFunction(
|
||||
frontFunction,
|
||||
callToFrontFunc,
|
||||
new FunctionCallCompilationContextStub(),
|
||||
);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
describe('rethrows error from single call compiler', () => {
|
||||
// arrange
|
||||
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 expectedInnerError;
|
||||
};
|
||||
const context = new FunctionCallCompilationContextStub()
|
||||
.withSingleCallCompiler(singleCallCompiler);
|
||||
const builder = new NestedFunctionCallCompilerBuilder();
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
builder
|
||||
.withErrorWrapper(wrapError)
|
||||
.build()
|
||||
.compileFunction(
|
||||
frontFunction,
|
||||
callToFrontFunc,
|
||||
context,
|
||||
);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
deepFunction,
|
||||
frontFunction,
|
||||
callToFrontFunc,
|
||||
callToDeepFunc,
|
||||
};
|
||||
}
|
||||
|
||||
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).`;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub';
|
||||
import type { FunctionCallParametersData } from '@/application/collections/';
|
||||
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
|
||||
import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub';
|
||||
import { AdaptiveFunctionCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/AdaptiveFunctionCallCompiler';
|
||||
import type { SingleCallCompilerStrategy } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompilerStrategy';
|
||||
import { SingleCallCompilerStrategyStub } from '@tests/unit/shared/Stubs/SingleCallCompilerStrategyStub';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
|
||||
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
|
||||
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
|
||||
import type { SingleCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
|
||||
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||
|
||||
describe('AdaptiveFunctionCallCompiler', () => {
|
||||
describe('compileSingleCall', () => {
|
||||
describe('throws if call parameters does not match function parameters', () => {
|
||||
// arrange
|
||||
const functionName = 'test-function-name';
|
||||
const testCases: Array<{
|
||||
readonly description: string,
|
||||
readonly functionParameters: string[],
|
||||
readonly callParameters: string[]
|
||||
readonly expectedError: string;
|
||||
}> = [
|
||||
{
|
||||
description: 'provided: single unexpected parameter, when: another expected',
|
||||
functionParameters: ['expected-parameter'],
|
||||
callParameters: ['unexpected-parameter'],
|
||||
expectedError:
|
||||
`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".`
|
||||
+ '\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".`
|
||||
+ '\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".`
|
||||
+ '\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".`
|
||||
+ '\nExpected parameter(s): "expected-parameter"',
|
||||
},
|
||||
];
|
||||
testCases.forEach(({
|
||||
description, functionParameters, callParameters, expectedError,
|
||||
}) => {
|
||||
it(description, () => {
|
||||
// arrange
|
||||
const func = createSharedFunctionStubWithCode()
|
||||
.withName('test-function-name')
|
||||
.withParameterNames(...functionParameters);
|
||||
const params = callParameters
|
||||
.reduce((result, parameter) => {
|
||||
return { ...result, [parameter]: 'defined-parameter-value' };
|
||||
}, {} as FunctionCallParametersData);
|
||||
const call = new FunctionCallStub()
|
||||
.withFunctionName(func.name)
|
||||
.withArguments(params);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withContext(new FunctionCallCompilationContextStub()
|
||||
.withAllFunctions(
|
||||
new SharedFunctionCollectionStub().withFunctions(func),
|
||||
))
|
||||
.withCall(call);
|
||||
// act
|
||||
const act = () => builder.compileSingleCall();
|
||||
// assert
|
||||
const errorMessage = collectExceptionMessage(act);
|
||||
expect(errorMessage).to.include(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('strategy selection', () => {
|
||||
it('uses the matching strategy among multiple', () => {
|
||||
// arrange
|
||||
const matchedStrategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(true);
|
||||
const unmatchedStrategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(false);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withStrategies([matchedStrategy, unmatchedStrategy]);
|
||||
// act
|
||||
builder.compileSingleCall();
|
||||
// assert
|
||||
expect(matchedStrategy.callHistory.filter((c) => c.methodName === 'compileFunction')).to.have.lengthOf(1);
|
||||
expect(unmatchedStrategy.callHistory.filter((c) => c.methodName === 'compileFunction')).to.have.lengthOf(0);
|
||||
});
|
||||
it('throws if multiple strategies can compile', () => {
|
||||
// arrange
|
||||
const expectedError = 'Multiple strategies found to compile the function call.';
|
||||
const matchedStrategy1 = new SingleCallCompilerStrategyStub().withCanCompileResult(true);
|
||||
const matchedStrategy2 = new SingleCallCompilerStrategyStub().withCanCompileResult(true);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder().withStrategies(
|
||||
[matchedStrategy1, matchedStrategy2],
|
||||
);
|
||||
// act
|
||||
const act = () => builder.compileSingleCall();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('throws if no strategy can compile', () => {
|
||||
// arrange
|
||||
const expectedError = 'No strategies found to compile the function call.';
|
||||
const unmatchedStrategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(false);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withStrategies([unmatchedStrategy]);
|
||||
// act
|
||||
const act = () => builder.compileSingleCall();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
describe('strategy invocation', () => {
|
||||
it('passes correct function for compilation ability check', () => {
|
||||
// arrange
|
||||
const expectedFunction = createSharedFunctionStubWithCode();
|
||||
const strategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(true);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withContext(new FunctionCallCompilationContextStub()
|
||||
.withAllFunctions(
|
||||
new SharedFunctionCollectionStub().withFunctions(expectedFunction),
|
||||
))
|
||||
.withCall(new FunctionCallStub().withFunctionName(expectedFunction.name))
|
||||
.withStrategies([strategy]);
|
||||
// act
|
||||
builder.compileSingleCall();
|
||||
// assert
|
||||
const call = strategy.callHistory.filter((c) => c.methodName === 'canCompile');
|
||||
expect(call).to.have.lengthOf(1);
|
||||
expect(call[0].args[0]).to.equal(expectedFunction);
|
||||
});
|
||||
describe('compilation arguments', () => {
|
||||
it('uses correct function', () => {
|
||||
// arrange
|
||||
const expectedFunction = createSharedFunctionStubWithCode();
|
||||
const strategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(true);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withContext(new FunctionCallCompilationContextStub()
|
||||
.withAllFunctions(
|
||||
new SharedFunctionCollectionStub().withFunctions(expectedFunction),
|
||||
))
|
||||
.withCall(new FunctionCallStub().withFunctionName(expectedFunction.name))
|
||||
.withStrategies([strategy]);
|
||||
// act
|
||||
builder.compileSingleCall();
|
||||
// assert
|
||||
const call = strategy.callHistory.filter((c) => c.methodName === 'compileFunction');
|
||||
expect(call).to.have.lengthOf(1);
|
||||
const [actualFunction] = call[0].args;
|
||||
expect(actualFunction).to.equal(expectedFunction);
|
||||
});
|
||||
it('uses correct call', () => {
|
||||
// arrange
|
||||
const expectedCall = new FunctionCallStub();
|
||||
const strategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(true);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withStrategies([strategy])
|
||||
.withCall(expectedCall);
|
||||
// act
|
||||
builder.compileSingleCall();
|
||||
// assert
|
||||
const call = strategy.callHistory.filter((c) => c.methodName === 'compileFunction');
|
||||
expect(call).to.have.lengthOf(1);
|
||||
const [,actualCall] = call[0].args;
|
||||
expect(actualCall).to.equal(expectedCall);
|
||||
});
|
||||
it('uses correct context', () => {
|
||||
// arrange
|
||||
const expectedContext = new FunctionCallCompilationContextStub();
|
||||
const strategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(true);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withStrategies([strategy])
|
||||
.withContext(expectedContext);
|
||||
// act
|
||||
builder.compileSingleCall();
|
||||
// assert
|
||||
const call = strategy.callHistory.filter((c) => c.methodName === 'compileFunction');
|
||||
expect(call).to.have.lengthOf(1);
|
||||
const [,,actualContext] = call[0].args;
|
||||
expect(actualContext).to.equal(expectedContext);
|
||||
});
|
||||
});
|
||||
});
|
||||
it('returns compiled code from strategy', () => {
|
||||
// arrange
|
||||
const expectedResult = [new CompiledCodeStub(), new CompiledCodeStub()];
|
||||
const strategy = new SingleCallCompilerStrategyStub()
|
||||
.withCanCompileResult(true)
|
||||
.withCompiledFunctionResult(expectedResult);
|
||||
const builder = new AdaptiveFunctionCallCompilerBuilder()
|
||||
.withStrategies([strategy]);
|
||||
// act
|
||||
const actualResult = builder.compileSingleCall();
|
||||
// assert
|
||||
expect(expectedResult).to.equal(actualResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class AdaptiveFunctionCallCompilerBuilder implements SingleCallCompiler {
|
||||
private strategies: SingleCallCompilerStrategy[] = [
|
||||
new SingleCallCompilerStrategyStub().withCanCompileResult(true),
|
||||
];
|
||||
|
||||
private call: FunctionCall = new FunctionCallStub();
|
||||
|
||||
private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub();
|
||||
|
||||
public withCall(call: FunctionCall): this {
|
||||
this.call = call;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withContext(context: FunctionCallCompilationContext): this {
|
||||
this.context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withStrategies(strategies: SingleCallCompilerStrategy[]): this {
|
||||
this.strategies = strategies;
|
||||
return this;
|
||||
}
|
||||
|
||||
public compileSingleCall() {
|
||||
const compiler = new AdaptiveFunctionCallCompiler(this.strategies);
|
||||
return compiler.compileSingleCall(
|
||||
this.call,
|
||||
this.context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import type { ArgumentCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/ArgumentCompiler';
|
||||
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { NestedFunctionArgumentCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler';
|
||||
import type { IExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/IExpressionsCompiler';
|
||||
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 { 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', () => {
|
||||
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 expressionsCompiler = new ExpressionsCompilerStub();
|
||||
expressionsCompiler.compileExpressions = () => { throw expectedInnerError; };
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withParentFunctionCall(parentCall)
|
||||
.withNestedFunctionCall(nestedCall)
|
||||
.withExpressionsCompiler(expressionsCompiler);
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
builder
|
||||
.withErrorWrapper(wrapError)
|
||||
.createCompiledNestedCall();
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
describe('compilation', () => {
|
||||
describe('without arguments', () => {
|
||||
it('matches nested call name', () => {
|
||||
// arrange
|
||||
const expectedCall = new FunctionCallStub()
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub().withEmptyArguments());
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withNestedFunctionCall(expectedCall);
|
||||
// act
|
||||
const actualCall = builder.createCompiledNestedCall();
|
||||
// assert
|
||||
expect(actualCall.functionName).to.equal(expectedCall.functionName);
|
||||
});
|
||||
it('has no arguments or parameters', () => {
|
||||
// arrange
|
||||
const expectedCall = new FunctionCallStub()
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub().withEmptyArguments());
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withNestedFunctionCall(expectedCall);
|
||||
// act
|
||||
const actualCall = builder.createCompiledNestedCall();
|
||||
// assert
|
||||
expect(actualCall.args.getAllParameterNames()).to.have.lengthOf(0);
|
||||
});
|
||||
it('does not compile expressions', () => {
|
||||
// arrange
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub();
|
||||
const call = new FunctionCallStub()
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub().withEmptyArguments());
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withNestedFunctionCall(call)
|
||||
.withExpressionsCompiler(expressionsCompilerStub);
|
||||
// act
|
||||
builder.createCompiledNestedCall();
|
||||
// assert
|
||||
expect(expressionsCompilerStub.callHistory).to.have.lengthOf(0);
|
||||
});
|
||||
});
|
||||
describe('with arguments', () => {
|
||||
it('matches nested call name', () => {
|
||||
// arrange
|
||||
const expectedName = 'expected-nested-function-call-name';
|
||||
const nestedCall = new FunctionCallStub()
|
||||
.withFunctionName(expectedName)
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub().withSomeArguments());
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withNestedFunctionCall(nestedCall);
|
||||
// act
|
||||
const call = builder.createCompiledNestedCall();
|
||||
// assert
|
||||
expect(call.functionName).to.equal(expectedName);
|
||||
});
|
||||
it('matches nested call parameters', () => {
|
||||
// arrange
|
||||
const expectedParameterNames = ['expectedFirstParameterName', 'expectedSecondParameterName'];
|
||||
const nestedCall = new FunctionCallStub()
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub()
|
||||
.withArguments(expectedParameterNames.reduce((acc, name) => ({ ...acc, ...{ [name]: 'unimportant-value' } }), {})));
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withNestedFunctionCall(nestedCall);
|
||||
// act
|
||||
const call = builder.createCompiledNestedCall();
|
||||
// assert
|
||||
const actualParameterNames = call.args.getAllParameterNames();
|
||||
expect(actualParameterNames.length).to.equal(expectedParameterNames.length);
|
||||
expect(actualParameterNames).to.have.members(expectedParameterNames);
|
||||
});
|
||||
it('compiles args using parent parameters', () => {
|
||||
// arrange
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub();
|
||||
const testParameterScenarios = [
|
||||
{
|
||||
parameterName: 'firstParameterName',
|
||||
rawArgumentValue: 'first-raw-argument-value',
|
||||
compiledArgumentValue: 'first-compiled-argument-value',
|
||||
},
|
||||
{
|
||||
parameterName: 'secondParameterName',
|
||||
rawArgumentValue: 'second-raw-argument-value',
|
||||
compiledArgumentValue: 'second-compiled-argument-value',
|
||||
},
|
||||
];
|
||||
const parentCall = new FunctionCallStub().withArgumentCollection(
|
||||
new FunctionCallArgumentCollectionStub().withSomeArguments(),
|
||||
);
|
||||
testParameterScenarios.forEach(({ rawArgumentValue }) => {
|
||||
expressionsCompilerStub.setup({
|
||||
givenCode: rawArgumentValue,
|
||||
givenArgs: parentCall.args,
|
||||
result: testParameterScenarios.find(
|
||||
(r) => r.rawArgumentValue === rawArgumentValue,
|
||||
)?.compiledArgumentValue ?? 'unexpected arguments',
|
||||
});
|
||||
});
|
||||
const nestedCallArgs = new FunctionCallArgumentCollectionStub()
|
||||
.withArguments(testParameterScenarios.reduce((
|
||||
acc,
|
||||
{ parameterName, rawArgumentValue },
|
||||
) => ({ ...acc, ...{ [parameterName]: rawArgumentValue } }), {}));
|
||||
const nestedCall = new FunctionCallStub()
|
||||
.withArgumentCollection(nestedCallArgs);
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withExpressionsCompiler(expressionsCompilerStub)
|
||||
.withParentFunctionCall(parentCall)
|
||||
.withNestedFunctionCall(nestedCall);
|
||||
// act
|
||||
const compiledCall = builder.createCompiledNestedCall();
|
||||
// assert
|
||||
const expectedParameterNames = testParameterScenarios.map((p) => p.parameterName);
|
||||
const actualParameterNames = compiledCall.args.getAllParameterNames();
|
||||
expect(expectedParameterNames.length).to.equal(actualParameterNames.length);
|
||||
expect(expectedParameterNames).to.have.members(actualParameterNames);
|
||||
const getActualArgumentValue = (parameterName: string) => compiledCall
|
||||
.args
|
||||
.getArgument(parameterName)
|
||||
.argumentValue;
|
||||
testParameterScenarios.forEach(({ parameterName, compiledArgumentValue }) => {
|
||||
expect(getActualArgumentValue(parameterName)).to.equal(compiledArgumentValue);
|
||||
});
|
||||
});
|
||||
describe('when expression compiler returns empty', () => {
|
||||
it('throws for required parameter', () => {
|
||||
// arrange
|
||||
const parameterName = 'requiredParameter';
|
||||
const initialValue = 'initial-value';
|
||||
const emptyCompiledExpression = '';
|
||||
const expectedError = `Compilation resulted in empty value for required parameter: "${parameterName}"`;
|
||||
const nestedCall = new FunctionCallStub()
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub()
|
||||
.withArgument(parameterName, initialValue));
|
||||
const parentCall = new FunctionCallStub().withArgumentCollection(
|
||||
new FunctionCallArgumentCollectionStub().withSomeArguments(),
|
||||
);
|
||||
const context = createContextWithParameter({
|
||||
existingFunctionName: nestedCall.functionName,
|
||||
existingParameterName: parameterName,
|
||||
isExistingParameterOptional: false,
|
||||
});
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub()
|
||||
.setup({
|
||||
givenCode: initialValue,
|
||||
givenArgs: parentCall.args,
|
||||
result: emptyCompiledExpression,
|
||||
});
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withExpressionsCompiler(expressionsCompilerStub)
|
||||
.withParentFunctionCall(parentCall)
|
||||
.withContext(context)
|
||||
.withNestedFunctionCall(nestedCall);
|
||||
// act
|
||||
const act = () => builder.createCompiledNestedCall();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('succeeds for optional parameter', () => {
|
||||
// arrange
|
||||
const parameterName = 'optionalParameter';
|
||||
const initialValue = 'initial-value';
|
||||
const emptyValue = '';
|
||||
const nestedCall = new FunctionCallStub()
|
||||
.withArgumentCollection(new FunctionCallArgumentCollectionStub()
|
||||
.withArgument(parameterName, initialValue));
|
||||
const parentCall = new FunctionCallStub().withArgumentCollection(
|
||||
new FunctionCallArgumentCollectionStub().withSomeArguments(),
|
||||
);
|
||||
const context = createContextWithParameter({
|
||||
existingFunctionName: nestedCall.functionName,
|
||||
existingParameterName: parameterName,
|
||||
isExistingParameterOptional: true,
|
||||
});
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub()
|
||||
.setup({
|
||||
givenCode: initialValue,
|
||||
givenArgs: parentCall.args,
|
||||
result: emptyValue,
|
||||
});
|
||||
const builder = new NestedFunctionArgumentCompilerBuilder()
|
||||
.withExpressionsCompiler(expressionsCompilerStub)
|
||||
.withParentFunctionCall(parentCall)
|
||||
.withContext(context)
|
||||
.withNestedFunctionCall(nestedCall);
|
||||
// act
|
||||
const compiledCall = builder.createCompiledNestedCall();
|
||||
// assert
|
||||
expect(compiledCall.args.hasArgument(parameterName)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createContextWithParameter(options: {
|
||||
readonly existingFunctionName: string,
|
||||
readonly existingParameterName: string,
|
||||
readonly isExistingParameterOptional: boolean,
|
||||
}): FunctionCallCompilationContext {
|
||||
const parameters = new FunctionParameterCollectionStub()
|
||||
.withParameterName(options.existingParameterName, options.isExistingParameterOptional);
|
||||
const func = createSharedFunctionStubWithCode()
|
||||
.withName(options.existingFunctionName)
|
||||
.withParameters(parameters);
|
||||
const functions = new SharedFunctionCollectionStub()
|
||||
.withFunctions(func);
|
||||
const context = new FunctionCallCompilationContextStub()
|
||||
.withAllFunctions(functions);
|
||||
return context;
|
||||
}
|
||||
|
||||
class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
|
||||
private expressionsCompiler: IExpressionsCompiler = new ExpressionsCompilerStub();
|
||||
|
||||
private nestedFunctionCall: FunctionCall = new FunctionCallStub();
|
||||
|
||||
private parentFunctionCall: FunctionCall = new FunctionCallStub();
|
||||
|
||||
private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub();
|
||||
|
||||
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
|
||||
|
||||
public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this {
|
||||
this.expressionsCompiler = expressionsCompiler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParentFunctionCall(parentFunctionCall: FunctionCall): this {
|
||||
this.parentFunctionCall = parentFunctionCall;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withNestedFunctionCall(nestedFunctionCall: FunctionCall): this {
|
||||
this.nestedFunctionCall = nestedFunctionCall;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withContext(context: FunctionCallCompilationContext): this {
|
||||
this.context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
|
||||
this.wrapError = wrapError;
|
||||
return this;
|
||||
}
|
||||
|
||||
public createCompiledNestedCall(): FunctionCall {
|
||||
const compiler = new NestedFunctionArgumentCompiler(
|
||||
this.expressionsCompiler,
|
||||
this.wrapError,
|
||||
);
|
||||
return compiler.createCompiledNestedCall(
|
||||
this.nestedFunctionCall,
|
||||
this.parentFunctionCall,
|
||||
this.context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { InlineFunctionCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/InlineFunctionCallCompiler';
|
||||
import { createSharedFunctionStubWithCode, createSharedFunctionStubWithCalls } from '@tests/unit/shared/Stubs/SharedFunctionStub';
|
||||
import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub';
|
||||
import type { IExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/IExpressionsCompiler';
|
||||
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
|
||||
describe('InlineFunctionCallCompiler', () => {
|
||||
describe('canCompile', () => {
|
||||
it('returns `true` if function has code body', () => {
|
||||
// arrange
|
||||
const expected = true;
|
||||
const func = createSharedFunctionStubWithCode();
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
const actual = compiler.canCompile(func);
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
it('returns `false` if function does not have code body', () => {
|
||||
// arrange
|
||||
const expected = false;
|
||||
const func = createSharedFunctionStubWithCalls();
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
const actual = compiler.canCompile(func);
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('compile', () => {
|
||||
it('throws if function body is not code', () => {
|
||||
// arrange
|
||||
const expectedError = 'Unexpected function body type.';
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => compiler.compileFunction(
|
||||
createSharedFunctionStubWithCalls(),
|
||||
new FunctionCallStub(),
|
||||
);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('compiles expressions with correct arguments', () => {
|
||||
// arrange
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub();
|
||||
const expectedArgs = new FunctionCallArgumentCollectionStub();
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.withExpressionsCompiler(expressionsCompilerStub)
|
||||
.build();
|
||||
// act
|
||||
compiler.compileFunction(
|
||||
createSharedFunctionStubWithCode(),
|
||||
new FunctionCallStub()
|
||||
.withArgumentCollection(expectedArgs),
|
||||
);
|
||||
// assert
|
||||
const actualArgs = expressionsCompilerStub.callHistory.map((call) => call.args[1]);
|
||||
expect(actualArgs.every((arg) => arg === expectedArgs));
|
||||
});
|
||||
describe('execute', () => {
|
||||
it('creates compiled code with compiled `execute`', () => {
|
||||
// arrange
|
||||
const func = createSharedFunctionStubWithCode();
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
const expectedCode = 'expected-code';
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub()
|
||||
.setup({
|
||||
givenCode: func.body.code.execute,
|
||||
givenArgs: args,
|
||||
result: expectedCode,
|
||||
});
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.withExpressionsCompiler(expressionsCompilerStub)
|
||||
.build();
|
||||
// act
|
||||
const compiledCodes = compiler
|
||||
.compileFunction(func, new FunctionCallStub().withArgumentCollection(args));
|
||||
// assert
|
||||
expect(compiledCodes).to.have.lengthOf(1);
|
||||
const actualCode = compiledCodes[0].code;
|
||||
expect(actualCode).to.equal(expectedCode);
|
||||
});
|
||||
});
|
||||
describe('revert', () => {
|
||||
it('compiles to `undefined` when given `undefined`', () => {
|
||||
// arrange
|
||||
const expected = undefined;
|
||||
const revertCode = undefined;
|
||||
const func = createSharedFunctionStubWithCode()
|
||||
.withRevertCode(revertCode);
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.build();
|
||||
// act
|
||||
const compiledCodes = compiler
|
||||
.compileFunction(func, new FunctionCallStub());
|
||||
// assert
|
||||
expect(compiledCodes).to.have.lengthOf(1);
|
||||
const actualRevertCode = compiledCodes[0].revertCode;
|
||||
expect(actualRevertCode).to.equal(expected);
|
||||
});
|
||||
it('creates compiled revert code with compiled `revert`', () => {
|
||||
// arrange
|
||||
const revertCode = 'revert-code-input';
|
||||
const func = createSharedFunctionStubWithCode()
|
||||
.withRevertCode(revertCode);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
const expectedRevertCode = 'expected-revert-code';
|
||||
const expressionsCompilerStub = new ExpressionsCompilerStub()
|
||||
.setup({
|
||||
givenCode: revertCode,
|
||||
givenArgs: args,
|
||||
result: expectedRevertCode,
|
||||
});
|
||||
const compiler = new InlineFunctionCallCompilerBuilder()
|
||||
.withExpressionsCompiler(expressionsCompilerStub)
|
||||
.build();
|
||||
// act
|
||||
const compiledCodes = compiler
|
||||
.compileFunction(func, new FunctionCallStub().withArgumentCollection(args));
|
||||
// assert
|
||||
expect(compiledCodes).to.have.lengthOf(1);
|
||||
const actualRevertCode = compiledCodes[0].revertCode;
|
||||
expect(actualRevertCode).to.equal(expectedRevertCode);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class InlineFunctionCallCompilerBuilder {
|
||||
private expressionsCompiler: IExpressionsCompiler = new ExpressionsCompilerStub();
|
||||
|
||||
public build(): InlineFunctionCallCompiler {
|
||||
return new InlineFunctionCallCompiler(this.expressionsCompiler);
|
||||
}
|
||||
|
||||
public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this {
|
||||
this.expressionsCompiler = expressionsCompiler;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFunctionCalls } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallParser';
|
||||
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('FunctionCallParser', () => {
|
||||
describe('parseFunctionCalls', () => {
|
||||
it('throws if call is not an object', () => {
|
||||
// arrange
|
||||
const expectedError = 'called function(s) must be an object';
|
||||
const invalidCalls = ['string', 33, false];
|
||||
invalidCalls.forEach((invalidCall) => {
|
||||
// act
|
||||
const act = () => parseFunctionCalls(invalidCall as never);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
describe('throws if call sequence has undefined function name', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing function name in function call';
|
||||
const data = [
|
||||
new FunctionCallDataStub().withName('function-name'),
|
||||
new FunctionCallDataStub().withName(absentValue),
|
||||
];
|
||||
// act
|
||||
const act = () => parseFunctionCalls(data);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
it('parses single call as expected', () => {
|
||||
// arrange
|
||||
const expectedFunctionName = 'functionName';
|
||||
const expectedParameterName = 'parameterName';
|
||||
const expectedArgumentValue = 'argumentValue';
|
||||
const data = new FunctionCallDataStub()
|
||||
.withName(expectedFunctionName)
|
||||
.withParameters({ [expectedParameterName]: expectedArgumentValue });
|
||||
// act
|
||||
const actual = parseFunctionCalls(data);
|
||||
// assert
|
||||
expect(actual).to.have.lengthOf(1);
|
||||
const call = actual[0];
|
||||
expect(call.functionName).to.equal(expectedFunctionName);
|
||||
const { args } = call;
|
||||
expect(args.getAllParameterNames()).to.have.lengthOf(1);
|
||||
expect(args.hasArgument(expectedParameterName)).to.equal(
|
||||
true,
|
||||
`Does not include expected parameter: "${expectedParameterName}"\n`
|
||||
+ `But includes: "${args.getAllParameterNames()}"`,
|
||||
);
|
||||
const argument = args.getArgument(expectedParameterName);
|
||||
expect(argument.parameterName).to.equal(expectedParameterName);
|
||||
expect(argument.argumentValue).to.equal(expectedArgumentValue);
|
||||
});
|
||||
it('parses multiple calls as expected', () => {
|
||||
// arrange
|
||||
const getFunctionName = (index: number) => `functionName${index}`;
|
||||
const getParameterName = (index: number) => `parameterName${index}`;
|
||||
const getArgumentValue = (index: number) => `argumentValue${index}`;
|
||||
const createCall = (index: number) => new FunctionCallDataStub()
|
||||
.withName(getFunctionName(index))
|
||||
.withParameters({ [getParameterName(index)]: getArgumentValue(index) });
|
||||
const calls = [createCall(0), createCall(1), createCall(2), createCall(3)];
|
||||
// act
|
||||
const actual = parseFunctionCalls(calls);
|
||||
// assert
|
||||
expect(actual).to.have.lengthOf(calls.length);
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = actual[i];
|
||||
const expectedParameterName = getParameterName(i);
|
||||
const expectedArgumentValue = getArgumentValue(i);
|
||||
expect(call.functionName).to.equal(getFunctionName(i));
|
||||
expect(call.args.getAllParameterNames()).to.have.lengthOf(1);
|
||||
expect(call.args.hasArgument(expectedParameterName)).to.equal(true);
|
||||
const argument = call.args.getArgument(expectedParameterName);
|
||||
expect(argument.parameterName).to.equal(expectedParameterName);
|
||||
expect(argument.argumentValue).to.equal(expectedArgumentValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ParsedFunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/ParsedFunctionCall';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('ParsedFunctionCall', () => {
|
||||
describe('ctor', () => {
|
||||
describe('args', () => {
|
||||
it('sets args as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('testParameter', 'testValue');
|
||||
// act
|
||||
const sut = new FunctionCallBuilder()
|
||||
.withArgs(expected)
|
||||
.build();
|
||||
// assert
|
||||
expect(sut.args).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('functionName', () => {
|
||||
describe('throws when function name is missing', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing function name in function call';
|
||||
const functionName = absentValue;
|
||||
// act
|
||||
const act = () => new FunctionCallBuilder()
|
||||
.withFunctionName(functionName)
|
||||
.build();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
it('sets function name as expected', () => {
|
||||
// arrange
|
||||
const expected = 'expectedFunctionName';
|
||||
// act
|
||||
const sut = new FunctionCallBuilder()
|
||||
.withFunctionName(expected)
|
||||
.build();
|
||||
// assert
|
||||
expect(sut.functionName).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class FunctionCallBuilder {
|
||||
private functionName = 'functionName';
|
||||
|
||||
private args: IReadOnlyFunctionCallArgumentCollection = new FunctionCallArgumentCollectionStub();
|
||||
|
||||
public withFunctionName(functionName: string) {
|
||||
this.functionName = functionName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withArgs(args: IReadOnlyFunctionCallArgumentCollection) {
|
||||
this.args = args;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new ParsedFunctionCall(this.functionName, this.args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
CallFunctionBody, CodeFunctionBody, SharedFunctionBody,
|
||||
} from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import { FunctionBodyType } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
|
||||
export function expectCodeFunctionBody(
|
||||
body: SharedFunctionBody,
|
||||
): asserts body is CodeFunctionBody {
|
||||
expectBodyType(body, FunctionBodyType.Code);
|
||||
}
|
||||
|
||||
export function expectCallsFunctionBody(
|
||||
body: SharedFunctionBody,
|
||||
): asserts body is CallFunctionBody {
|
||||
expectBodyType(body, FunctionBodyType.Calls);
|
||||
}
|
||||
|
||||
function expectBodyType(body: SharedFunctionBody, expectedType: FunctionBodyType) {
|
||||
const actualType = body.type;
|
||||
expect(actualType).to.equal(expectedType, formatAssertionMessage([
|
||||
`Actual: ${FunctionBodyType[actualType]}`,
|
||||
`Expected: ${FunctionBodyType[expectedType]}`,
|
||||
`Body: ${JSON.stringify(body)}`,
|
||||
]));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FunctionParameter } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameter';
|
||||
import { testParameterName } from '../../ParameterNameTestRunner';
|
||||
|
||||
describe('FunctionParameter', () => {
|
||||
describe('name', () => {
|
||||
testParameterName(
|
||||
(parameterName) => new FunctionParameterBuilder()
|
||||
.withName(parameterName)
|
||||
.build()
|
||||
.name,
|
||||
);
|
||||
});
|
||||
describe('isOptional', () => {
|
||||
describe('sets as expected', () => {
|
||||
// arrange
|
||||
const expectedValues = [true, false];
|
||||
for (const expected of expectedValues) {
|
||||
it(expected.toString(), () => {
|
||||
// act
|
||||
const sut = new FunctionParameterBuilder()
|
||||
.withIsOptional(expected)
|
||||
.build();
|
||||
// expect
|
||||
expect(sut.isOptional).to.equal(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class FunctionParameterBuilder {
|
||||
private name = 'parameterFromParameterBuilder';
|
||||
|
||||
private isOptional = false;
|
||||
|
||||
public withName(name: string) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withIsOptional(isOptional: boolean) {
|
||||
this.isOptional = isOptional;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new FunctionParameter(this.name, this.isOptional);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollection';
|
||||
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
|
||||
|
||||
describe('FunctionParameterCollection', () => {
|
||||
it('all returns added parameters as expected', () => {
|
||||
// arrange
|
||||
const expected = [
|
||||
new FunctionParameterStub().withName('1'),
|
||||
new FunctionParameterStub().withName('2').withOptional(true),
|
||||
new FunctionParameterStub().withName('3').withOptional(false),
|
||||
];
|
||||
const sut = new FunctionParameterCollection();
|
||||
for (const parameter of expected) {
|
||||
sut.addParameter(parameter);
|
||||
}
|
||||
// act
|
||||
const actual = sut.all;
|
||||
// assert
|
||||
expect(expected).to.deep.equal(actual);
|
||||
});
|
||||
it('throws when function parameters have same names', () => {
|
||||
// arrange
|
||||
const parameterName = 'duplicate-parameter';
|
||||
const expectedError = `duplicate parameter name: "${parameterName}"`;
|
||||
const sut = new FunctionParameterCollection();
|
||||
sut.addParameter(new FunctionParameterStub().withName(parameterName));
|
||||
// act
|
||||
const act = () => sut.addParameter(
|
||||
new FunctionParameterStub().withName(parameterName),
|
||||
);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollection';
|
||||
import { createFunctionParameterCollection } from '@/application/Parser/Executable/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
|
||||
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
|
||||
import { createCallerFunction, createFunctionWithInlineCode } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunction';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
|
||||
import { type CallFunctionBody, FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import {
|
||||
getAbsentStringTestCases, itEachAbsentCollectionValue,
|
||||
itEachAbsentStringValue,
|
||||
} from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';
|
||||
|
||||
describe('SharedFunction', () => {
|
||||
describe('SharedFunction', () => {
|
||||
describe('name', () => {
|
||||
runForEachFactoryMethod((build) => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = 'expected-function-name';
|
||||
const builder = new SharedFunctionBuilder()
|
||||
.withName(expected);
|
||||
// act
|
||||
const sut = build(builder);
|
||||
// assert
|
||||
expect(sut.name).equal(expected);
|
||||
});
|
||||
describe('throws when absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing function name';
|
||||
const builder = new SharedFunctionBuilder()
|
||||
.withName(absentValue);
|
||||
// act
|
||||
const act = () => build(builder);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('parameters', () => {
|
||||
runForEachFactoryMethod((build) => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionParameterCollectionStub()
|
||||
.withParameterName('test-parameter');
|
||||
const builder = new SharedFunctionBuilder()
|
||||
.withParameters(expected);
|
||||
// act
|
||||
const sut = build(builder);
|
||||
// assert
|
||||
expect(sut.parameters).equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('createFunctionWithInlineCode', () => {
|
||||
describe('code', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = 'expected-code';
|
||||
// act
|
||||
const sut = new SharedFunctionBuilder()
|
||||
.withCode(expected)
|
||||
.createFunctionWithInlineCode();
|
||||
// assert
|
||||
expectCodeFunctionBody(sut.body);
|
||||
expect(sut.body.code.execute).equal(expected);
|
||||
});
|
||||
describe('throws if absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const functionName = 'expected-function-name';
|
||||
const expectedError = `undefined code in function "${functionName}"`;
|
||||
const invalidValue = absentValue;
|
||||
// act
|
||||
const act = () => new SharedFunctionBuilder()
|
||||
.withName(functionName)
|
||||
.withCode(invalidValue)
|
||||
.createFunctionWithInlineCode();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
});
|
||||
describe('revertCode', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const revertCodeTestValues: readonly (string | undefined)[] = [
|
||||
'expected-revert-code',
|
||||
...getAbsentStringTestCases({
|
||||
excludeNull: true,
|
||||
}).map((testCase) => testCase.absentValue),
|
||||
];
|
||||
for (const revertCode of revertCodeTestValues) {
|
||||
// act
|
||||
const sut = new SharedFunctionBuilder()
|
||||
.withRevertCode(revertCode)
|
||||
.createFunctionWithInlineCode();
|
||||
// assert
|
||||
expectCodeFunctionBody(sut.body);
|
||||
expect(sut.body.code.revert).equal(revertCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
it('sets type as expected', () => {
|
||||
// arrange
|
||||
const expectedType = FunctionBodyType.Code;
|
||||
// act
|
||||
const sut = new SharedFunctionBuilder()
|
||||
.createFunctionWithInlineCode();
|
||||
// assert
|
||||
expect(sut.body.type).equal(expectedType);
|
||||
});
|
||||
it('calls are undefined', () => {
|
||||
// arrange
|
||||
const expectedCalls = undefined;
|
||||
// act
|
||||
const sut = new SharedFunctionBuilder()
|
||||
.createFunctionWithInlineCode();
|
||||
// assert
|
||||
expect((sut.body as CallFunctionBody).calls).equal(expectedCalls);
|
||||
});
|
||||
});
|
||||
describe('createCallerFunction', () => {
|
||||
describe('rootCallSequence', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = [
|
||||
new FunctionCallStub().withFunctionName('firstFunction'),
|
||||
new FunctionCallStub().withFunctionName('secondFunction'),
|
||||
];
|
||||
// act
|
||||
const sut = new SharedFunctionBuilder()
|
||||
.withRootCallSequence(expected)
|
||||
.createCallerFunction();
|
||||
// assert
|
||||
expectCallsFunctionBody(sut.body);
|
||||
expect(sut.body.calls).equal(expected);
|
||||
});
|
||||
describe('throws if missing', () => {
|
||||
itEachAbsentCollectionValue<FunctionCall>((absentValue) => {
|
||||
// arrange
|
||||
const functionName = 'invalidFunction';
|
||||
const rootCallSequence = absentValue;
|
||||
const expectedError = `missing call sequence in function "${functionName}"`;
|
||||
// act
|
||||
const act = () => new SharedFunctionBuilder()
|
||||
.withName(functionName)
|
||||
.withRootCallSequence(rootCallSequence)
|
||||
.createCallerFunction();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
});
|
||||
it('sets type as expected', () => {
|
||||
// arrange
|
||||
const expectedType = FunctionBodyType.Calls;
|
||||
// act
|
||||
const sut = new SharedFunctionBuilder()
|
||||
.createCallerFunction();
|
||||
// assert
|
||||
expect(sut.body.type).equal(expectedType);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function runForEachFactoryMethod(
|
||||
act: (action: (sut: SharedFunctionBuilder) => ISharedFunction) => void,
|
||||
): void {
|
||||
describe('createCallerFunction', () => {
|
||||
const action = (builder: SharedFunctionBuilder) => builder.createCallerFunction();
|
||||
act(action);
|
||||
});
|
||||
describe('createFunctionWithInlineCode', () => {
|
||||
const action = (builder: SharedFunctionBuilder) => builder.createFunctionWithInlineCode();
|
||||
act(action);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Using an abstraction here allows for easy refactorings in
|
||||
parameters or moving between functional and object-oriented
|
||||
solutions without refactorings all tests.
|
||||
*/
|
||||
class SharedFunctionBuilder {
|
||||
private name = 'name';
|
||||
|
||||
private parameters: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub();
|
||||
|
||||
private callSequence: readonly FunctionCall[] = [new FunctionCallStub()];
|
||||
|
||||
private code = `[${SharedFunctionBuilder.name}] code`;
|
||||
|
||||
private revertCode: string | undefined = `[${SharedFunctionBuilder.name}] revert-code`;
|
||||
|
||||
public createCallerFunction(): ISharedFunction {
|
||||
return createCallerFunction(
|
||||
this.name,
|
||||
this.parameters,
|
||||
this.callSequence,
|
||||
);
|
||||
}
|
||||
|
||||
public createFunctionWithInlineCode(): ISharedFunction {
|
||||
return createFunctionWithInlineCode(
|
||||
this.name,
|
||||
this.parameters,
|
||||
this.code,
|
||||
this.revertCode,
|
||||
);
|
||||
}
|
||||
|
||||
public withName(name: string) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParameters(parameters: IReadOnlyFunctionParameterCollection) {
|
||||
this.parameters = parameters;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCode(code: string) {
|
||||
this.code = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withRevertCode(revertCode: string | undefined) {
|
||||
this.revertCode = revertCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withRootCallSequence(callSequence: readonly FunctionCall[]) {
|
||||
this.callSequence = callSequence;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunctionCollection';
|
||||
import { createSharedFunctionStubWithCode, createSharedFunctionStubWithCalls } from '@tests/unit/shared/Stubs/SharedFunctionStub';
|
||||
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('SharedFunctionCollection', () => {
|
||||
describe('addFunction', () => {
|
||||
it('throws if function with same name already exists', () => {
|
||||
// arrange
|
||||
const functionName = 'duplicate-function';
|
||||
const expectedError = `function with name ${functionName} already exists`;
|
||||
const func = createSharedFunctionStubWithCode()
|
||||
.withName('duplicate-function');
|
||||
const sut = new SharedFunctionCollection();
|
||||
sut.addFunction(func);
|
||||
// act
|
||||
const act = () => sut.addFunction(func);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
describe('getFunctionByName', () => {
|
||||
describe('throws if name is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing function name';
|
||||
const sut = new SharedFunctionCollection();
|
||||
// act
|
||||
const act = () => sut.getFunctionByName(absentValue);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
it('throws if function does not exist', () => {
|
||||
// arrange
|
||||
const name = 'unique-name';
|
||||
const expectedError = `Called function is not defined: "${name}"`;
|
||||
const func = createSharedFunctionStubWithCode()
|
||||
.withName('unexpected-name');
|
||||
const sut = new SharedFunctionCollection();
|
||||
sut.addFunction(func);
|
||||
// act
|
||||
const act = () => sut.getFunctionByName(name);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
describe('returns existing function', () => {
|
||||
it('when function with inline code is added', () => {
|
||||
// arrange
|
||||
const expected = createSharedFunctionStubWithCode()
|
||||
.withName('expected-function-name');
|
||||
const sut = new SharedFunctionCollection();
|
||||
// act
|
||||
sut.addFunction(expected);
|
||||
const actual = sut.getFunctionByName(expected.name);
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
it('when calling function is added', () => {
|
||||
// arrange
|
||||
const callee = createSharedFunctionStubWithCode()
|
||||
.withName('calleeFunction');
|
||||
const caller = createSharedFunctionStubWithCalls()
|
||||
.withName('callerFunction')
|
||||
.withCalls(new FunctionCallStub().withFunctionName(callee.name));
|
||||
const sut = new SharedFunctionCollection();
|
||||
// act
|
||||
sut.addFunction(callee);
|
||||
sut.addFunction(caller);
|
||||
const actual = sut.getFunctionByName(caller.name);
|
||||
// assert
|
||||
expect(actual).to.equal(caller);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FunctionData, CodeInstruction } from '@/application/collections/';
|
||||
import type { ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import { SharedFunctionsParser, type FunctionParameterFactory } from '@/application/Parser/Executable/Script/Compiler/Function/SharedFunctionsParser';
|
||||
import { createFunctionDataWithCode, createFunctionDataWithoutCallOrCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
|
||||
import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub';
|
||||
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
|
||||
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
|
||||
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
|
||||
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
|
||||
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/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
|
||||
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
|
||||
import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';
|
||||
|
||||
describe('SharedFunctionsParser', () => {
|
||||
describe('instance', () => {
|
||||
itIsSingletonFactory({
|
||||
getter: () => SharedFunctionsParser.instance,
|
||||
expectedType: SharedFunctionsParser,
|
||||
});
|
||||
});
|
||||
describe('parseFunctions', () => {
|
||||
describe('validates functions', () => {
|
||||
it('throws when functions have no names', () => {
|
||||
// arrange
|
||||
const invalidFunctions = [
|
||||
createFunctionDataWithCode()
|
||||
.withCode('test function 1')
|
||||
.withName(' '), // Whitespace,
|
||||
createFunctionDataWithCode()
|
||||
.withCode('test function 2')
|
||||
.withName(undefined as unknown as string), // Undefined
|
||||
createFunctionDataWithCode()
|
||||
.withCode('test function 3')
|
||||
.withName(''), // Empty
|
||||
];
|
||||
const expectedError = `Some function(s) have no names:\n${invalidFunctions.map((f) => JSON.stringify(f)).join('\n')}`;
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions(invalidFunctions)
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('throws when functions have same names', () => {
|
||||
// arrange
|
||||
const name = 'same-func-name';
|
||||
const expectedError = `duplicate function name: "${name}"`;
|
||||
const functions = [
|
||||
createFunctionDataWithCode().withName(name),
|
||||
createFunctionDataWithCode().withName(name),
|
||||
];
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions(functions)
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
describe('throws when when function have duplicate code', () => {
|
||||
it('code', () => {
|
||||
// arrange
|
||||
const code = 'duplicate-code';
|
||||
const expectedError = `duplicate "code" in functions: "${code}"`;
|
||||
const functions = [
|
||||
createFunctionDataWithoutCallOrCode().withName('func-1').withCode(code),
|
||||
createFunctionDataWithoutCallOrCode().withName('func-2').withCode(code),
|
||||
];
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions(functions)
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('revertCode', () => {
|
||||
// arrange
|
||||
const revertCode = 'duplicate-revert-code';
|
||||
const expectedError = `duplicate "revertCode" in functions: "${revertCode}"`;
|
||||
const functions = [
|
||||
createFunctionDataWithoutCallOrCode()
|
||||
.withName('func-1').withCode('code-1').withRevertCode(revertCode),
|
||||
createFunctionDataWithoutCallOrCode()
|
||||
.withName('func-2').withCode('code-2').withRevertCode(revertCode),
|
||||
];
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions(functions)
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
describe('ensures either call or code is defined', () => {
|
||||
it('both code and call are defined', () => {
|
||||
// arrange
|
||||
const functionName = 'invalid-function';
|
||||
const expectedError = `both "code" and "call" are defined in "${functionName}"`;
|
||||
const invalidFunction = createFunctionDataWithoutCallOrCode()
|
||||
.withName(functionName)
|
||||
.withCode('code')
|
||||
.withMockCall();
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([invalidFunction])
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('neither code and call is defined', () => {
|
||||
// arrange
|
||||
const functionName = 'invalid-function';
|
||||
const expectedError = `neither "code" or "call" is defined in "${functionName}"`;
|
||||
const invalidFunction = createFunctionDataWithoutCallOrCode()
|
||||
.withName(functionName);
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([invalidFunction])
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
describe('throws when parameters type is not as expected', () => {
|
||||
const testScenarios = [
|
||||
{
|
||||
state: 'when not an array',
|
||||
invalidType: 5,
|
||||
},
|
||||
{
|
||||
state: 'when array but not of objects',
|
||||
invalidType: ['a', { a: 'b' }],
|
||||
},
|
||||
];
|
||||
for (const testCase of testScenarios) {
|
||||
it(testCase.state, () => {
|
||||
// arrange
|
||||
const func = createFunctionDataWithCode()
|
||||
.withParametersObject(testCase.invalidType as never);
|
||||
const expectedError = `parameters must be an array of objects in function(s) "${func.name}"`;
|
||||
// act
|
||||
const act = () => new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([func])
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
it('validates function code as expected when code is defined', () => {
|
||||
// arrange
|
||||
const expectedRules = [NoEmptyLines, NoDuplicatedLines];
|
||||
const functionData = createFunctionDataWithCode()
|
||||
.withCode('expected code to be validated')
|
||||
.withRevertCode('expected revert code to be validated');
|
||||
const validator = new CodeValidatorStub();
|
||||
// act
|
||||
new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([functionData])
|
||||
.withValidator(validator)
|
||||
.parseFunctions();
|
||||
// assert
|
||||
validator.assertHistory({
|
||||
validatedCodes: [functionData.code, functionData.revertCode],
|
||||
rules: expectedRules,
|
||||
});
|
||||
});
|
||||
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', () => {
|
||||
itEachAbsentCollectionValue<FunctionData>((absentValue) => {
|
||||
// act
|
||||
const actual = new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions(absentValue)
|
||||
.parseFunctions();
|
||||
// assert
|
||||
expect(actual).to.not.equal(undefined);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
describe('function with inline code', () => {
|
||||
it('parses single function with code as expected', () => {
|
||||
// arrange
|
||||
const name = 'function-name';
|
||||
const expected = createFunctionDataWithoutCallOrCode()
|
||||
.withName(name)
|
||||
.withCode('expected-code')
|
||||
.withRevertCode('expected-revert-code')
|
||||
.withParameters(
|
||||
new ParameterDefinitionDataStub().withName('expectedParameter').withOptionality(true),
|
||||
new ParameterDefinitionDataStub().withName('expectedParameter2').withOptionality(false),
|
||||
);
|
||||
// act
|
||||
const collection = new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([expected])
|
||||
.parseFunctions();
|
||||
// expect
|
||||
const actual = collection.getFunctionByName(name);
|
||||
expectEqualName(expected, actual);
|
||||
expectEqualParameters(expected, actual);
|
||||
expectEqualFunctionWithInlineCode(expected, actual);
|
||||
});
|
||||
});
|
||||
describe('function with calls', () => {
|
||||
it('parses single function with call as expected', () => {
|
||||
// arrange
|
||||
const call = new FunctionCallDataStub()
|
||||
.withName('calleeFunction')
|
||||
.withParameters({ test: 'value' });
|
||||
const data = createFunctionDataWithoutCallOrCode()
|
||||
.withName('caller-function')
|
||||
.withCall(call);
|
||||
// act
|
||||
const collection = new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([data])
|
||||
.parseFunctions();
|
||||
// expect
|
||||
const actual = collection.getFunctionByName(data.name);
|
||||
expectEqualName(data, actual);
|
||||
expectEqualParameters(data, actual);
|
||||
expectEqualCalls([call], actual);
|
||||
});
|
||||
it('parses multiple functions with call as expected', () => {
|
||||
// arrange
|
||||
const call1 = new FunctionCallDataStub()
|
||||
.withName('calleeFunction1')
|
||||
.withParameters({ param: 'value' });
|
||||
const call2 = new FunctionCallDataStub()
|
||||
.withName('calleeFunction2')
|
||||
.withParameters({ param2: 'value2' });
|
||||
const caller1 = createFunctionDataWithoutCallOrCode()
|
||||
.withName('caller-function')
|
||||
.withCall(call1);
|
||||
const caller2 = createFunctionDataWithoutCallOrCode()
|
||||
.withName('caller-function-2')
|
||||
.withCall([call1, call2]);
|
||||
// act
|
||||
const collection = new ParseFunctionsCallerWithDefaults()
|
||||
.withFunctions([caller1, caller2])
|
||||
.parseFunctions();
|
||||
// expect
|
||||
const compiledCaller1 = collection.getFunctionByName(caller1.name);
|
||||
expectEqualName(caller1, compiledCaller1);
|
||||
expectEqualParameters(caller1, compiledCaller1);
|
||||
expectEqualCalls([call1], compiledCaller1);
|
||||
const compiledCaller2 = collection.getFunctionByName(caller2.name);
|
||||
expectEqualName(caller2, compiledCaller2);
|
||||
expectEqualParameters(caller2, compiledCaller2);
|
||||
expectEqualCalls([call1, call2], compiledCaller2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ParseFunctionsCallerWithDefaults {
|
||||
private syntax: ILanguageSyntax = new LanguageSyntaxStub();
|
||||
|
||||
private codeValidator: ICodeValidator = new CodeValidatorStub();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public withValidator(codeValidator: ICodeValidator) {
|
||||
this.codeValidator = codeValidator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFunctions(functions: readonly FunctionData[]) {
|
||||
this.functions = functions;
|
||||
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(
|
||||
{
|
||||
codeValidator: this.codeValidator,
|
||||
wrapError: this.wrapError,
|
||||
createParameter: this.parameterFactory,
|
||||
createParameterCollection: this.parameterCollectionFactory,
|
||||
},
|
||||
);
|
||||
return sut.parseFunctions(this.functions, this.syntax);
|
||||
}
|
||||
}
|
||||
|
||||
function expectEqualName(expected: FunctionData, actual: ISharedFunction): void {
|
||||
expect(actual.name).to.equal(expected.name);
|
||||
}
|
||||
|
||||
function expectEqualParameters(expected: FunctionData, actual: ISharedFunction): void {
|
||||
const actualSimplifiedParameters = actual.parameters.all.map((parameter) => ({
|
||||
name: parameter.name,
|
||||
optional: parameter.isOptional,
|
||||
}));
|
||||
const expectedSimplifiedParameters = expected.parameters?.map((parameter) => ({
|
||||
name: parameter.name,
|
||||
optional: parameter.optional || false,
|
||||
})) || [];
|
||||
expect(expectedSimplifiedParameters).to.deep.equal(actualSimplifiedParameters, 'Unequal parameters');
|
||||
}
|
||||
|
||||
function expectEqualFunctionWithInlineCode(
|
||||
expected: CodeInstruction,
|
||||
actual: ISharedFunction,
|
||||
): void {
|
||||
expect(actual.body, `function "${actual.name}" has no body`);
|
||||
expectCodeFunctionBody(actual.body);
|
||||
expect(actual.body.code, `function "${actual.name}" has no code`);
|
||||
expect(actual.body.code.execute).to.equal(expected.code);
|
||||
expect(actual.body.code.revert).to.equal(expected.revertCode);
|
||||
}
|
||||
|
||||
function expectEqualCalls(
|
||||
expected: FunctionCallDataStub[],
|
||||
actual: ISharedFunction,
|
||||
) {
|
||||
expect(actual.body, `function "${actual.name}" has no body`);
|
||||
expectCallsFunctionBody(actual.body);
|
||||
expect(actual.body.calls, `function "${actual.name}" has no calls`);
|
||||
const actualSimplifiedCalls = actual.body.calls
|
||||
.map((call) => ({
|
||||
function: call.functionName,
|
||||
params: call.args.getAllParameterNames().map((name) => ({
|
||||
name, value: call.args.getArgument(name).argumentValue,
|
||||
})),
|
||||
}));
|
||||
const expectedSimplifiedCalls = expected
|
||||
.map((call) => ({
|
||||
function: call.function,
|
||||
params: Object.keys(call.parameters).map((key) => (
|
||||
{ name: key, value: call.parameters[key] }
|
||||
)),
|
||||
}));
|
||||
expect(actualSimplifiedCalls).to.deep.equal(expectedSimplifiedCalls, 'Unequal calls');
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
export function testParameterName(action: (parameterName: string) => string) {
|
||||
describe('name', () => {
|
||||
describe('sets as expected', () => {
|
||||
// arrange
|
||||
const expectedValues: readonly string[] = [
|
||||
'lowercase',
|
||||
'onlyLetters',
|
||||
'l3tt3rsW1thNumb3rs',
|
||||
];
|
||||
for (const expected of expectedValues) {
|
||||
it(expected, () => {
|
||||
// act
|
||||
const value = action(expected);
|
||||
// assert
|
||||
expect(value).to.equal(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('throws if invalid', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly value: string;
|
||||
readonly expectedError: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'empty Name',
|
||||
value: '',
|
||||
expectedError: 'missing parameter name',
|
||||
},
|
||||
{
|
||||
description: 'has @',
|
||||
value: 'b@d',
|
||||
expectedError: 'parameter name must be alphanumeric but it was "b@d"',
|
||||
},
|
||||
{
|
||||
description: 'has {',
|
||||
value: 'b{a}d',
|
||||
expectedError: 'parameter name must be alphanumeric but it was "b{a}d"',
|
||||
},
|
||||
];
|
||||
for (const { description, value, expectedError } of testScenarios) {
|
||||
it(description, () => {
|
||||
// act
|
||||
const act = () => action(value);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FunctionData } from '@/application/collections/';
|
||||
import { ScriptCompiler } from '@/application/Parser/Executable/Script/Compiler/ScriptCompiler';
|
||||
import type { ISharedFunctionsParser } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionsParser';
|
||||
import type { FunctionCallCompiler } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler';
|
||||
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
|
||||
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
|
||||
import { FunctionCallCompilerStub } from '@tests/unit/shared/Stubs/FunctionCallCompilerStub';
|
||||
import { SharedFunctionsParserStub } from '@tests/unit/shared/Stubs/SharedFunctionsParserStub';
|
||||
import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub';
|
||||
import { parseFunctionCalls } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallParser';
|
||||
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
|
||||
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/Executables/Script/Code/ScriptCodeFactory';
|
||||
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
|
||||
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
|
||||
|
||||
describe('ScriptCompiler', () => {
|
||||
describe('canCompile', () => {
|
||||
it('returns true if "call" is defined', () => {
|
||||
// arrange
|
||||
const sut = new ScriptCompilerBuilder()
|
||||
.withEmptyFunctions()
|
||||
.build();
|
||||
const script = createScriptDataWithCall();
|
||||
// act
|
||||
const actual = sut.canCompile(script);
|
||||
// assert
|
||||
expect(actual).to.equal(true);
|
||||
});
|
||||
it('returns false if "call" is undefined', () => {
|
||||
// arrange
|
||||
const sut = new ScriptCompilerBuilder()
|
||||
.withEmptyFunctions()
|
||||
.build();
|
||||
const script = createScriptDataWithCode();
|
||||
// act
|
||||
const actual = sut.canCompile(script);
|
||||
// assert
|
||||
expect(actual).to.equal(false);
|
||||
});
|
||||
});
|
||||
describe('compile', () => {
|
||||
it('throws if script does not have body', () => {
|
||||
// arrange
|
||||
const expectedError = 'Script does include any calls.';
|
||||
const scriptData = createScriptDataWithCode();
|
||||
const sut = new ScriptCompilerBuilder()
|
||||
.withSomeFunctions()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.compile(scriptData);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
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
|
||||
const expected: ILanguageSyntax = new LanguageSyntaxStub();
|
||||
const parser = new SharedFunctionsParserStub();
|
||||
const sut = new ScriptCompilerBuilder()
|
||||
.withSomeFunctions()
|
||||
.withSyntax(expected)
|
||||
.withSharedFunctionsParser(parser)
|
||||
.build();
|
||||
const scriptData = createScriptDataWithCall();
|
||||
// act
|
||||
sut.compile(scriptData);
|
||||
// assert
|
||||
expect(parser.callHistory.length).to.equal(1);
|
||||
expect(parser.callHistory[0].syntax).to.equal(expected);
|
||||
});
|
||||
it('parses given functions', () => {
|
||||
// arrange
|
||||
const expectedFunctions = [createFunctionDataWithCode().withName('existing-func')];
|
||||
const parser = new SharedFunctionsParserStub();
|
||||
const sut = new ScriptCompilerBuilder()
|
||||
.withFunctions(...expectedFunctions)
|
||||
.withSharedFunctionsParser(parser)
|
||||
.build();
|
||||
const scriptData = createScriptDataWithCall();
|
||||
// act
|
||||
sut.compile(scriptData);
|
||||
// assert
|
||||
expect(parser.callHistory.length).to.equal(1);
|
||||
expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions);
|
||||
});
|
||||
});
|
||||
describe('rethrows error with script name', () => {
|
||||
// arrange
|
||||
const scriptName = 'scriptName';
|
||||
const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
|
||||
const expectedInnerError = new Error();
|
||||
const callCompiler: FunctionCallCompiler = {
|
||||
compileFunctionCalls: () => { throw expectedInnerError; },
|
||||
};
|
||||
const scriptData = createScriptDataWithCall()
|
||||
.withName(scriptName);
|
||||
const builder = new ScriptCompilerBuilder()
|
||||
.withSomeFunctions()
|
||||
.withFunctionCallCompiler(callCompiler);
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
builder
|
||||
.withErrorWrapper(wrapError)
|
||||
.build()
|
||||
.compile(scriptData);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
describe('rethrows error from script code factory with script name', () => {
|
||||
// arrange
|
||||
const scriptName = 'scriptName';
|
||||
const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
|
||||
const expectedInnerError = new Error();
|
||||
const scriptCodeFactory: ScriptCodeFactory = () => {
|
||||
throw expectedInnerError;
|
||||
};
|
||||
const scriptData = createScriptDataWithCall()
|
||||
.withName(scriptName);
|
||||
const builder = new ScriptCompilerBuilder()
|
||||
.withSomeFunctions()
|
||||
.withScriptCodeFactory(scriptCodeFactory);
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
builder
|
||||
.withErrorWrapper(wrapError)
|
||||
.build()
|
||||
.compile(scriptData);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
it('validates compiled code as expected', () => {
|
||||
// arrange
|
||||
const expectedRules = [
|
||||
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
|
||||
sut.compile(scriptData);
|
||||
// assert
|
||||
validator.assertHistory({
|
||||
validatedCodes: [expectedExecuteCode, expectedRevertCode],
|
||||
rules: expectedRules,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ScriptCompilerBuilder {
|
||||
private static createFunctions(...names: string[]): FunctionData[] {
|
||||
return names.map((functionName) => {
|
||||
return createFunctionDataWithCode().withName(functionName);
|
||||
});
|
||||
}
|
||||
|
||||
private functions: FunctionData[] | undefined;
|
||||
|
||||
private syntax: ILanguageSyntax = new LanguageSyntaxStub();
|
||||
|
||||
private sharedFunctionsParser: ISharedFunctionsParser = new SharedFunctionsParserStub();
|
||||
|
||||
private callCompiler: FunctionCallCompiler = new FunctionCallCompilerStub();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public withSomeFunctions(): this {
|
||||
this.functions = ScriptCompilerBuilder.createFunctions('test-function');
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFunctionNames(...functionNames: string[]): this {
|
||||
this.functions = ScriptCompilerBuilder.createFunctions(...functionNames);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withEmptyFunctions(): this {
|
||||
this.functions = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSyntax(syntax: ILanguageSyntax): this {
|
||||
this.syntax = syntax;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSharedFunctionsParser(
|
||||
sharedFunctionsParser: ISharedFunctionsParser,
|
||||
): this {
|
||||
this.sharedFunctionsParser = sharedFunctionsParser;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCodeValidator(
|
||||
codeValidator: ICodeValidator,
|
||||
): this {
|
||||
this.codeValidator = codeValidator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFunctionCallCompiler(callCompiler: FunctionCallCompiler): this {
|
||||
this.callCompiler = callCompiler;
|
||||
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');
|
||||
}
|
||||
return new ScriptCompiler(
|
||||
this.functions,
|
||||
this.syntax,
|
||||
this.sharedFunctionsParser,
|
||||
this.callCompiler,
|
||||
this.codeValidator,
|
||||
this.wrapError,
|
||||
this.scriptCodeFactory,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ScriptData } from '@/application/collections/';
|
||||
import { parseScript, type ScriptFactory } from '@/application/Parser/Executable/Script/ScriptParser';
|
||||
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser';
|
||||
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
|
||||
import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub';
|
||||
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
|
||||
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
|
||||
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
|
||||
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
|
||||
import type { IEnumParser } from '@/application/Common/Enum';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
|
||||
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
|
||||
import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
|
||||
import { ExecutableValidatorStub, createExecutableValidatorFactoryStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
|
||||
import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType';
|
||||
import type { ScriptErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
|
||||
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/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 '@tests/unit/application/Parser/ContextualErrorTester';
|
||||
import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
|
||||
import { itAsserts, itValidatesDefinedData, itValidatesName } from '../Validation/ExecutableValidationTester';
|
||||
import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator';
|
||||
|
||||
describe('ScriptParser', () => {
|
||||
describe('parseScript', () => {
|
||||
it('parses name correctly', () => {
|
||||
// arrange
|
||||
const expected = 'test-expected-name';
|
||||
const scriptData = createScriptDataWithCode()
|
||||
.withName(expected);
|
||||
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
|
||||
// act
|
||||
const actualScript = new TestContext()
|
||||
.withData(scriptData)
|
||||
.withScriptFactory(scriptFactorySpy)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actualName = getInitParameters(actualScript)?.name;
|
||||
expect(actualName).to.equal(expected);
|
||||
});
|
||||
it('parses docs correctly', () => {
|
||||
// arrange
|
||||
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 actualScript = new TestContext()
|
||||
.withData(scriptData)
|
||||
.withScriptFactory(scriptFactorySpy)
|
||||
.withDocsParser(docsParser)
|
||||
.parseScript();
|
||||
// assert
|
||||
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('generated `undefined` level if given absent value', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedLevel = undefined;
|
||||
const scriptData = createScriptDataWithCode()
|
||||
.withRecommend(absentValue);
|
||||
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
|
||||
// act
|
||||
const actualScript = new TestContext()
|
||||
.withData(scriptData)
|
||||
.withScriptFactory(scriptFactorySpy)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actualLevel = getInitParameters(actualScript)?.level;
|
||||
expect(actualLevel).to.equal(expectedLevel);
|
||||
}, { excludeNull: true });
|
||||
});
|
||||
it('parses level as expected', () => {
|
||||
// arrange
|
||||
const expectedLevel = RecommendationLevel.Standard;
|
||||
const expectedName = 'level';
|
||||
const levelText = 'standard';
|
||||
const scriptData = createScriptDataWithCode()
|
||||
.withRecommend(levelText);
|
||||
const parserMock = new EnumParserStub<RecommendationLevel>()
|
||||
.setup(expectedName, levelText, expectedLevel);
|
||||
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
|
||||
// act
|
||||
const actualScript = new TestContext()
|
||||
.withData(scriptData)
|
||||
.withParser(parserMock)
|
||||
.withScriptFactory(scriptFactorySpy)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actualLevel = getInitParameters(actualScript)?.level;
|
||||
expect(actualLevel).to.equal(expectedLevel);
|
||||
});
|
||||
});
|
||||
describe('code', () => {
|
||||
it('creates from script code factory', () => {
|
||||
// arrange
|
||||
const expectedCode = new ScriptCodeStub();
|
||||
const scriptCodeFactory: ScriptCodeFactory = () => expectedCode;
|
||||
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
|
||||
// act
|
||||
const actualScript = new TestContext()
|
||||
.withScriptCodeFactory(scriptCodeFactory)
|
||||
.withScriptFactory(scriptFactorySpy)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actualCode = getInitParameters(actualScript)?.code;
|
||||
expect(expectedCode).to.equal(actualCode);
|
||||
});
|
||||
describe('parses code correctly', () => {
|
||||
it('parses "execute" as expected', () => {
|
||||
// arrange
|
||||
const expectedCode = 'expected-code';
|
||||
let actualCode: string | undefined;
|
||||
const scriptCodeFactory: ScriptCodeFactory = (code) => {
|
||||
actualCode = code;
|
||||
return new ScriptCodeStub();
|
||||
};
|
||||
const scriptData = createScriptDataWithCode()
|
||||
.withCode(expectedCode);
|
||||
// act
|
||||
new TestContext()
|
||||
.withData(scriptData)
|
||||
.withScriptCodeFactory(scriptCodeFactory)
|
||||
.parseScript();
|
||||
// assert
|
||||
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 collectionUtilities = new CategoryCollectionSpecificUtilitiesStub()
|
||||
.withCompiler(compiler);
|
||||
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
|
||||
// act
|
||||
const actualScript = new TestContext()
|
||||
.withData(script)
|
||||
.withCollectionUtilities(collectionUtilities)
|
||||
.withScriptFactory(scriptFactorySpy)
|
||||
.parseScript();
|
||||
// assert
|
||||
const actualCode = getInitParameters(actualScript)?.code;
|
||||
expect(actualCode).to.equal(expectedCode);
|
||||
});
|
||||
});
|
||||
describe('syntax', () => {
|
||||
it('set from the context', () => { // tests through script validation logic
|
||||
// arrange
|
||||
const commentDelimiter = 'should not throw';
|
||||
const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`;
|
||||
const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub()
|
||||
.withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter));
|
||||
const script = createScriptDataWithoutCallOrCodes()
|
||||
.withCode(duplicatedCode);
|
||||
// act
|
||||
const act = () => new TestContext()
|
||||
.withData(script)
|
||||
.withCollectionUtilities(collectionUtilities);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('validates a expected', () => {
|
||||
it('validates script with inline code (that is not compiled)', () => {
|
||||
// arrange
|
||||
const expectedRules = [
|
||||
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 scriptCodeFactory = createScriptCodeFactoryStub({
|
||||
scriptCode: new ScriptCodeStub()
|
||||
.withExecute(expectedCode)
|
||||
.withRevert(expectedRevertCode),
|
||||
});
|
||||
// act
|
||||
new TestContext()
|
||||
.withScriptCodeFactory(scriptCodeFactory)
|
||||
.withCodeValidator(validator)
|
||||
.parseScript();
|
||||
// assert
|
||||
validator.assertHistory({
|
||||
validatedCodes: expectedCodeCalls,
|
||||
rules: expectedRules,
|
||||
});
|
||||
});
|
||||
it('does not validate compiled code', () => {
|
||||
// arrange
|
||||
const expectedRules = [];
|
||||
const expectedCodeCalls = [];
|
||||
const validator = new CodeValidatorStub();
|
||||
const script = createScriptDataWithCall();
|
||||
const compiler = new ScriptCompilerStub()
|
||||
.withCompileAbility(script, new ScriptCodeStub());
|
||||
const collectionUtilities = new CategoryCollectionSpecificUtilitiesStub()
|
||||
.withCompiler(compiler);
|
||||
// act
|
||||
new TestContext()
|
||||
.withData(script)
|
||||
.withCodeValidator(validator)
|
||||
.withCollectionUtilities(collectionUtilities)
|
||||
.parseScript();
|
||||
// assert
|
||||
validator.assertHistory({
|
||||
validatedCodes: expectedCodeCalls,
|
||||
rules: expectedRules,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('validation', () => {
|
||||
describe('validates for name', () => {
|
||||
// arrange
|
||||
const expectedName = 'expected script name to be validated';
|
||||
const script = createScriptDataWithCall()
|
||||
.withName(expectedName);
|
||||
const expectedContext: ScriptErrorContext = {
|
||||
type: ExecutableType.Script,
|
||||
self: script,
|
||||
};
|
||||
itValidatesName((validatorFactory) => {
|
||||
// act
|
||||
new TestContext()
|
||||
.withData(script)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseScript();
|
||||
// assert
|
||||
return {
|
||||
expectedNameToValidate: expectedName,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
});
|
||||
});
|
||||
describe('validates for defined data', () => {
|
||||
// arrange
|
||||
const expectedScript = createScriptDataWithCall();
|
||||
const expectedContext: ScriptErrorContext = {
|
||||
type: ExecutableType.Script,
|
||||
self: expectedScript,
|
||||
};
|
||||
itValidatesDefinedData(
|
||||
(validatorFactory) => {
|
||||
// act
|
||||
new TestContext()
|
||||
.withData(expectedScript)
|
||||
.withValidatorFactory(validatorFactory)
|
||||
.parseScript();
|
||||
// assert
|
||||
return {
|
||||
expectedDataToValidate: expectedScript,
|
||||
expectedErrorContext: expectedContext,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
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: ScriptErrorContext = {
|
||||
type: ExecutableType.Script,
|
||||
self: 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: ExecutableValidatorFactory = () => {
|
||||
const validatorStub = new ExecutableValidatorStub();
|
||||
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 TestContext {
|
||||
private data: ScriptData = createScriptDataWithCode();
|
||||
|
||||
private collectionUtilities
|
||||
: CategoryCollectionSpecificUtilities = new CategoryCollectionSpecificUtilitiesStub();
|
||||
|
||||
private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
|
||||
.setupDefaultValue(RecommendationLevel.Standard);
|
||||
|
||||
private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy;
|
||||
|
||||
private codeValidator: ICodeValidator = new CodeValidatorStub();
|
||||
|
||||
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
|
||||
|
||||
private validatorFactory: ExecutableValidatorFactory = createExecutableValidatorFactoryStub;
|
||||
|
||||
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): this {
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCollectionUtilities(
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
): this {
|
||||
this.collectionUtilities = collectionUtilities;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParser(parser: IEnumParser<RecommendationLevel>): this {
|
||||
this.levelParser = parser;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withScriptFactory(scriptFactory: ScriptFactory): this {
|
||||
this.scriptFactory = scriptFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withValidatorFactory(validatorFactory: ExecutableValidatorFactory): 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(): ReturnType<typeof parseScript> {
|
||||
return parseScript(
|
||||
this.data,
|
||||
this.collectionUtilities,
|
||||
{
|
||||
levelParser: this.levelParser,
|
||||
createScript: this.scriptFactory,
|
||||
codeValidator: this.codeValidator,
|
||||
wrapError: this.errorWrapper,
|
||||
createValidator: this.validatorFactory,
|
||||
createCode: this.scriptCodeFactory,
|
||||
parseDocs: this.docsParser,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
|
||||
import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub';
|
||||
import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
|
||||
import type { ICodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeLine';
|
||||
import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule';
|
||||
|
||||
describe('CodeValidator', () => {
|
||||
describe('instance', () => {
|
||||
itIsSingletonFactory({
|
||||
getter: () => CodeValidator.instance,
|
||||
expectedType: CodeValidator,
|
||||
});
|
||||
});
|
||||
describe('throwIfInvalid', () => {
|
||||
describe('does not throw if code is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const code = absentValue;
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
const act = () => sut.throwIfInvalid(code, [new CodeValidationRuleStub()]);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
describe('throws if rules are empty', () => {
|
||||
itEachAbsentCollectionValue<ICodeValidationRule>((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing rules';
|
||||
const rules = absentValue;
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
const act = () => sut.throwIfInvalid('code', rules);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
describe('splits lines as expected', () => {
|
||||
it('supports all line separators', () => {
|
||||
// arrange
|
||||
const expectedLineTexts = ['line1', 'line2', 'line3', 'line4'];
|
||||
const code = 'line1\r\nline2\rline3\nline4';
|
||||
const spy = new CodeValidationRuleStub();
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
sut.throwIfInvalid(code, [spy]);
|
||||
// expect
|
||||
expect(spy.receivedLines).has.lengthOf(1);
|
||||
const actualLineTexts = spy.receivedLines[0].map((line) => line.text);
|
||||
expect(actualLineTexts).to.deep.equal(expectedLineTexts);
|
||||
});
|
||||
it('uses 1-indexed line numbering', () => {
|
||||
// arrange
|
||||
const expectedIndexes = [1, 2, 3];
|
||||
const code = ['line1', 'line2', 'line3'].join('\n');
|
||||
const spy = new CodeValidationRuleStub();
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
sut.throwIfInvalid(code, [spy]);
|
||||
// expect
|
||||
expect(spy.receivedLines).has.lengthOf(1);
|
||||
const actualLineIndexes = spy.receivedLines[0].map((line) => line.index);
|
||||
expect(actualLineIndexes).to.deep.equal(expectedIndexes);
|
||||
});
|
||||
it('matches texts with indexes as expected', () => {
|
||||
// arrange
|
||||
const expected: readonly ICodeLine[] = [
|
||||
{ index: 1, text: 'first' },
|
||||
{ index: 2, text: 'second' },
|
||||
];
|
||||
const code = expected.map((line) => line.text).join('\n');
|
||||
const spy = new CodeValidationRuleStub();
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
sut.throwIfInvalid(code, [spy]);
|
||||
// expect
|
||||
expect(spy.receivedLines).has.lengthOf(1);
|
||||
expect(spy.receivedLines[0]).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('throws invalid lines as expected', () => {
|
||||
it('throws with invalid line from single rule', () => {
|
||||
// arrange
|
||||
const errorText = 'error';
|
||||
const expectedError = new ExpectedErrorBuilder()
|
||||
.withOkLine('line1')
|
||||
.withErrorLine('line2', errorText)
|
||||
.withOkLine('line3')
|
||||
.withOkLine('line4')
|
||||
.buildError();
|
||||
const code = ['line1', 'line2', 'line3', 'line4'].join('\n');
|
||||
const invalidLines: readonly IInvalidCodeLine[] = [
|
||||
{ index: 2, error: errorText },
|
||||
];
|
||||
const rule = new CodeValidationRuleStub()
|
||||
.withReturnValue(invalidLines);
|
||||
const noopRule = new CodeValidationRuleStub()
|
||||
.withReturnValue([]);
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
const act = () => sut.throwIfInvalid(code, [rule, noopRule]);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('throws with combined invalid lines from multiple rules', () => {
|
||||
// arrange
|
||||
const firstError = 'firstError';
|
||||
const secondError = 'firstError';
|
||||
const expectedError = new ExpectedErrorBuilder()
|
||||
.withOkLine('line1')
|
||||
.withErrorLine('line2', firstError)
|
||||
.withOkLine('line3')
|
||||
.withErrorLine('line4', secondError)
|
||||
.buildError();
|
||||
const code = ['line1', 'line2', 'line3', 'line4'].join('\n');
|
||||
const firstRuleError: readonly IInvalidCodeLine[] = [
|
||||
{ index: 2, error: firstError },
|
||||
];
|
||||
const secondRuleError: readonly IInvalidCodeLine[] = [
|
||||
{ index: 4, error: secondError },
|
||||
];
|
||||
const firstRule = new CodeValidationRuleStub().withReturnValue(firstRuleError);
|
||||
const secondRule = new CodeValidationRuleStub().withReturnValue(secondRuleError);
|
||||
const sut = new CodeValidator();
|
||||
// act
|
||||
const act = () => sut.throwIfInvalid(code, [firstRule, secondRule]);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ExpectedErrorBuilder {
|
||||
private lineCount = 0;
|
||||
|
||||
private outputLines = new Array<string>();
|
||||
|
||||
public withOkLine(text: string) {
|
||||
return this.withNumberedLine(`✅ ${text}`);
|
||||
}
|
||||
|
||||
public withErrorLine(text: string, error: string) {
|
||||
return this
|
||||
.withNumberedLine(`❌ ${text}`)
|
||||
.withLine(`\t⟶ ${error}`);
|
||||
}
|
||||
|
||||
public buildError(): string {
|
||||
return [
|
||||
'Errors with the code.',
|
||||
...this.outputLines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private withLine(line: string) {
|
||||
this.outputLines.push(line);
|
||||
return this;
|
||||
}
|
||||
|
||||
private withNumberedLine(text: string) {
|
||||
this.lineCount += 1;
|
||||
const lineNumber = `[${this.lineCount}]`;
|
||||
return this.withLine(`${lineNumber} ${text}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule';
|
||||
import type { ICodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeLine';
|
||||
|
||||
interface ICodeValidationRuleTestCase {
|
||||
testName: string;
|
||||
codeLines: readonly string[];
|
||||
expected: readonly IInvalidCodeLine[];
|
||||
sut: ICodeValidationRule;
|
||||
}
|
||||
|
||||
export function testCodeValidationRule(testCases: readonly ICodeValidationRuleTestCase[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.testName, () => {
|
||||
// arrange
|
||||
const { sut } = testCase;
|
||||
const codeLines = createCodeLines(testCase.codeLines);
|
||||
// act
|
||||
const actual = sut.analyze(codeLines);
|
||||
// assert
|
||||
function sort(lines: readonly IInvalidCodeLine[]) { // To ignore order
|
||||
return Array.from(lines).sort((a, b) => a.index - b.index);
|
||||
}
|
||||
expect(sort(actual)).to.deep.equal(sort(testCase.expected));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createCodeLines(lines: readonly string[]): ICodeLine[] {
|
||||
return lines.map((lineText, index): ICodeLine => (
|
||||
{
|
||||
index: index + 1,
|
||||
text: lineText,
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe } from 'vitest';
|
||||
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
|
||||
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
|
||||
import type { IInvalidCodeLine } from '@/application/Parser/Executable/Script/Validation/ICodeValidationRule';
|
||||
import { testCodeValidationRule } from './CodeValidationRuleTestRunner';
|
||||
|
||||
describe('NoDuplicatedLines', () => {
|
||||
describe('analyze', () => {
|
||||
testCodeValidationRule([
|
||||
{
|
||||
testName: 'no results when code is valid',
|
||||
codeLines: ['unique1', 'unique2', 'unique3', 'unique4'],
|
||||
expected: [],
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()),
|
||||
},
|
||||
{
|
||||
testName: 'detects single duplicated line as expected',
|
||||
codeLines: ['duplicate', 'duplicate', 'unique', 'duplicate'],
|
||||
expected: expectInvalidCodeLines([1, 2, 4]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()),
|
||||
},
|
||||
{
|
||||
testName: 'detects multiple duplicated lines as expected',
|
||||
codeLines: ['duplicate1', 'duplicate2', 'unique', 'duplicate1', 'unique2', 'duplicate2'],
|
||||
expected: expectInvalidCodeLines([1, 4], [2, 6]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()),
|
||||
},
|
||||
{
|
||||
testName: 'common code parts: does not detect multiple common code part usages as duplicates',
|
||||
codeLines: ['good', 'good', 'bad', 'bad', 'good', 'also-good', 'also-good', 'unique'],
|
||||
expected: expectInvalidCodeLines([3, 4]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()
|
||||
.withCommonCodeParts('good', 'also-good')),
|
||||
},
|
||||
{
|
||||
testName: 'common code parts: does not detect multiple common code part used in same code line as duplicates',
|
||||
codeLines: ['bad', 'bad', 'good1 good2', 'good1 good2', 'good2 good1', 'good2 good1'],
|
||||
expected: expectInvalidCodeLines([1, 2]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()
|
||||
.withCommonCodeParts('good2', 'good1')),
|
||||
},
|
||||
{
|
||||
testName: 'common code parts: detects when common code parts used in conjunction with unique words',
|
||||
codeLines: [
|
||||
'common-part1', 'common-part1', 'common-part1 common-part2', 'common-part1 unique', 'common-part1 unique',
|
||||
'common-part2', 'common-part2 common-part1', 'unique common-part2', 'unique common-part2',
|
||||
],
|
||||
expected: expectInvalidCodeLines([4, 5], [8, 9]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()
|
||||
.withCommonCodeParts('common-part1', 'common-part2')),
|
||||
},
|
||||
{
|
||||
testName: 'comments: does not when lines start with comment',
|
||||
codeLines: ['#abc', '#abc', 'abc', 'unique', 'abc', '//abc', '//abc', '//unique', '#unique'],
|
||||
expected: expectInvalidCodeLines([3, 5]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()
|
||||
.withCommentDelimiters('#', '//')),
|
||||
},
|
||||
{
|
||||
testName: 'comments: does when comments come after lien start',
|
||||
codeLines: ['test #comment', 'test #comment', 'test2 # comment', 'test2 # comment'],
|
||||
expected: expectInvalidCodeLines([1, 2], [3, 4]),
|
||||
sut: new NoDuplicatedLines(new LanguageSyntaxStub()
|
||||
.withCommentDelimiters('#')),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function expectInvalidCodeLines(
|
||||
...lines: readonly ReadonlyArray<number>[]
|
||||
): IInvalidCodeLine[] {
|
||||
return lines.flatMap((occurrenceIndices): readonly IInvalidCodeLine[] => occurrenceIndices
|
||||
.map((index): IInvalidCodeLine => ({
|
||||
index,
|
||||
error: `Line is duplicated at line numbers ${occurrenceIndices.join(',')}.`,
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe } from 'vitest';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import { testCodeValidationRule } from './CodeValidationRuleTestRunner';
|
||||
|
||||
describe('NoEmptyLines', () => {
|
||||
describe('analyze', () => {
|
||||
testCodeValidationRule([
|
||||
{
|
||||
testName: 'no results when code is valid',
|
||||
codeLines: ['non-empty-line1', 'none-empty-line2'],
|
||||
expected: [],
|
||||
sut: new NoEmptyLines(),
|
||||
},
|
||||
{
|
||||
testName: 'shows error for empty line',
|
||||
codeLines: ['first line', '', 'third line'],
|
||||
expected: [{ index: 2, error: 'Empty line' }],
|
||||
sut: new NoEmptyLines(),
|
||||
},
|
||||
{
|
||||
testName: 'shows error for multiple empty lines',
|
||||
codeLines: ['first line', '', 'third line', ''],
|
||||
expected: [2, 4].map((index) => ({ index, error: 'Empty line' })),
|
||||
sut: new NoEmptyLines(),
|
||||
},
|
||||
{
|
||||
testName: 'shows error for whitespace-only lines',
|
||||
codeLines: ['first line', ' ', 'third line'],
|
||||
expected: [{ index: 2, error: 'Empty line: "{whitespace}{whitespace}"' }],
|
||||
sut: new NoEmptyLines(),
|
||||
},
|
||||
{
|
||||
testName: 'shows error for tab-only lines',
|
||||
codeLines: ['first line', '\t\t', 'third line'],
|
||||
expected: [{ index: 2, error: 'Empty line: "{tab}{tab}"' }],
|
||||
sut: new NoEmptyLines(),
|
||||
},
|
||||
{
|
||||
testName: 'shows error for lines that consists of whitespace and tabs',
|
||||
codeLines: ['first line', '\t \t', 'third line', ' \t '],
|
||||
expected: [{ index: 2, error: 'Empty line: "{tab}{whitespace}{tab}"' }, { index: 4, error: 'Empty line: "{whitespace}{tab}{whitespace}"' }],
|
||||
sut: new NoEmptyLines(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ShellScriptSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { BatchFileSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax';
|
||||
|
||||
function getSystemsUnderTest(): ILanguageSyntax[] {
|
||||
return [new BatchFileSyntax(), new ShellScriptSyntax()];
|
||||
}
|
||||
|
||||
describe('ConcreteSyntaxes', () => {
|
||||
describe('commentDelimiters', () => {
|
||||
for (const sut of getSystemsUnderTest()) {
|
||||
it(`${sut.constructor.name} returns defined value`, () => {
|
||||
// act
|
||||
const value = sut.commentDelimiters;
|
||||
// assert
|
||||
expect(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('commonCodeParts', () => {
|
||||
for (const sut of getSystemsUnderTest()) {
|
||||
it(`${sut.constructor.name} returns defined value`, () => {
|
||||
// act
|
||||
const value = sut.commonCodeParts;
|
||||
// assert
|
||||
expect(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe } from 'vitest';
|
||||
import { SyntaxFactory } from '@/application/Parser/Executable/Script/Validation/Syntax/SyntaxFactory';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { ShellScriptSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ShellScriptSyntax';
|
||||
import { ScriptingLanguageFactoryTestRunner } from '@tests/unit/application/Common/ScriptingLanguage/ScriptingLanguageFactoryTestRunner';
|
||||
import { BatchFileSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/BatchFileSyntax';
|
||||
|
||||
describe('SyntaxFactory', () => {
|
||||
const sut = new SyntaxFactory();
|
||||
const runner = new ScriptingLanguageFactoryTestRunner()
|
||||
.expectInstance(ScriptingLanguage.shellscript, ShellScriptSyntax)
|
||||
.expectInstance(ScriptingLanguage.batchfile, BatchFileSyntax);
|
||||
runner.testCreateMethod(sut);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface DataValidationTestScenario<T> {
|
||||
readonly description: string;
|
||||
readonly data: T;
|
||||
readonly expectedPass: boolean;
|
||||
readonly expectedMessage?: string;
|
||||
}
|
||||
|
||||
export function generateDataValidationTestScenarios<T>(
|
||||
...conditionBasedScenarios: DataValidationConditionBasedTestScenario<T>[]
|
||||
): DataValidationTestScenario<T>[] {
|
||||
return conditionBasedScenarios.flatMap((conditionScenario) => [
|
||||
conditionScenario.expectFail.map((failDefinition): DataValidationTestScenario<T> => ({
|
||||
description: `fails: "${failDefinition.description}"`,
|
||||
data: failDefinition.data,
|
||||
expectedPass: false,
|
||||
expectedMessage: conditionScenario.assertErrorMessage,
|
||||
})),
|
||||
conditionScenario.expectPass.map((passDefinition): DataValidationTestScenario<T> => ({
|
||||
description: `passes: "${passDefinition.description}"`,
|
||||
data: passDefinition.data,
|
||||
expectedPass: true,
|
||||
expectedMessage: conditionScenario.assertErrorMessage,
|
||||
})),
|
||||
].flat());
|
||||
}
|
||||
|
||||
interface DataValidationConditionBasedTestScenario<T> {
|
||||
readonly assertErrorMessage?: string;
|
||||
readonly expectPass: readonly DataValidationScenarioDefinition<T>[];
|
||||
readonly expectFail: readonly DataValidationScenarioDefinition<T>[];
|
||||
}
|
||||
|
||||
interface DataValidationScenarioDefinition<T> {
|
||||
readonly description: string;
|
||||
readonly data: T;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { it } from 'vitest';
|
||||
import type { ExecutableValidator, ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
|
||||
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
|
||||
import { ExecutableValidatorStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import type { ExecutableData } from '@/application/collections/';
|
||||
import type { FunctionKeys } from '@/TypeHelpers';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { indentText } from '@tests/shared/Text';
|
||||
|
||||
type ValidationTestFunction<TExpectation> = (
|
||||
factory: ExecutableValidatorFactory,
|
||||
) => TExpectation;
|
||||
|
||||
interface ValidNameExpectation {
|
||||
readonly expectedNameToValidate: string;
|
||||
readonly expectedErrorContext: ExecutableErrorContext;
|
||||
}
|
||||
|
||||
export function itValidatesName(
|
||||
test: ValidationTestFunction<ValidNameExpectation>,
|
||||
) {
|
||||
it('validates for name', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
test(factoryStub);
|
||||
// assert
|
||||
const call = validator.callHistory.find((c) => c.methodName === 'assertValidName');
|
||||
expectExists(call);
|
||||
});
|
||||
it('validates for name with correct name', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = test(factoryStub);
|
||||
// assert
|
||||
const expectedName = expectation.expectedNameToValidate;
|
||||
const names = validator.callHistory
|
||||
.filter((c) => c.methodName === 'assertValidName')
|
||||
.flatMap((c) => c.args[0]);
|
||||
expect(names).to.include(expectedName);
|
||||
});
|
||||
it('validates for name with correct context', () => {
|
||||
expectCorrectContextForFunctionCall({
|
||||
methodName: 'assertValidName',
|
||||
act: test,
|
||||
expectContext: (expectation) => expectation.expectedErrorContext,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface ValidDataExpectation {
|
||||
readonly expectedDataToValidate: ExecutableData;
|
||||
readonly expectedErrorContext: ExecutableErrorContext;
|
||||
}
|
||||
|
||||
export function itValidatesDefinedData(
|
||||
test: ValidationTestFunction<ValidDataExpectation>,
|
||||
) {
|
||||
it('validates data', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
test(factoryStub);
|
||||
// assert
|
||||
const call = validator.callHistory.find((c) => c.methodName === 'assertDefined');
|
||||
expectExists(call);
|
||||
});
|
||||
it('validates data with correct data', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = test(factoryStub);
|
||||
// assert
|
||||
const expectedData = expectation.expectedDataToValidate;
|
||||
const calls = validator.callHistory.filter((c) => c.methodName === 'assertDefined');
|
||||
const names = calls.flatMap((c) => c.args[0]);
|
||||
expect(names).to.include(expectedData);
|
||||
});
|
||||
it('validates data with correct context', () => {
|
||||
expectCorrectContextForFunctionCall({
|
||||
methodName: 'assertDefined',
|
||||
act: test,
|
||||
expectContext: (expectation) => expectation.expectedErrorContext,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface AssertionExpectation {
|
||||
readonly expectedErrorMessage: string;
|
||||
readonly expectedErrorContext: ExecutableErrorContext;
|
||||
}
|
||||
|
||||
export function itAsserts(
|
||||
testScenario: {
|
||||
readonly test: ValidationTestFunction<AssertionExpectation>,
|
||||
readonly expectedConditionResult: boolean;
|
||||
},
|
||||
) {
|
||||
it('asserts with correct message', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub()
|
||||
.withAssertThrowsOnFalseCondition(false);
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = testScenario.test(factoryStub);
|
||||
// assert
|
||||
const expectedError = expectation.expectedErrorMessage;
|
||||
const calls = validator.callHistory.filter((c) => c.methodName === 'assert');
|
||||
const actualMessages = calls.map((call) => {
|
||||
const [, message] = call.args;
|
||||
return message;
|
||||
});
|
||||
expect(actualMessages).to.include(expectedError, formatAssertionMessage([
|
||||
'Assertion failed: The expected error message was not triggered.',
|
||||
`Expected: "${expectedError}"`,
|
||||
'Actual messages (none match expected):',
|
||||
indentText(actualMessages.map((message) => `- ${message}`).join('\n')),
|
||||
]));
|
||||
});
|
||||
it('asserts with correct context', () => {
|
||||
expectCorrectContextForFunctionCall({
|
||||
methodName: 'assert',
|
||||
act: testScenario.test,
|
||||
expectContext: (expectation) => expectation.expectedErrorContext,
|
||||
});
|
||||
});
|
||||
it('asserts with correct condition result', () => {
|
||||
// arrange
|
||||
const expectedEvaluationResult = testScenario.expectedConditionResult;
|
||||
const validator = new ExecutableValidatorStub()
|
||||
.withAssertThrowsOnFalseCondition(false);
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = testScenario.test(factoryStub);
|
||||
// assert
|
||||
const assertCalls = validator.callHistory
|
||||
.filter((call) => call.methodName === 'assert');
|
||||
expect(assertCalls).to.have.length.greaterThan(0);
|
||||
const assertCallsWithMessage = assertCalls
|
||||
.filter((call) => {
|
||||
const [, message] = call.args;
|
||||
return message === expectation.expectedErrorMessage;
|
||||
});
|
||||
expect(assertCallsWithMessage).to.have.length.greaterThan(0);
|
||||
const evaluationResults = assertCallsWithMessage
|
||||
.map((call) => {
|
||||
const [predicate] = call.args;
|
||||
return predicate as (() => boolean);
|
||||
})
|
||||
.map((predicate) => predicate());
|
||||
expect(evaluationResults).to.include(expectedEvaluationResult);
|
||||
});
|
||||
}
|
||||
|
||||
function expectCorrectContextForFunctionCall<T>(testScenario: {
|
||||
methodName: FunctionKeys<ExecutableValidator>,
|
||||
act: ValidationTestFunction<T>,
|
||||
expectContext: (actionResult: T) => ExecutableErrorContext,
|
||||
}) {
|
||||
// arrange
|
||||
const { methodName } = testScenario;
|
||||
const createdValidators = new Array<{
|
||||
readonly validator: ExecutableValidatorStub;
|
||||
readonly context: ExecutableErrorContext;
|
||||
}>();
|
||||
const factoryStub: ExecutableValidatorFactory = (context) => {
|
||||
const validator = new ExecutableValidatorStub()
|
||||
.withAssertThrowsOnFalseCondition(false);
|
||||
createdValidators.push(({
|
||||
validator,
|
||||
context,
|
||||
}));
|
||||
return validator;
|
||||
};
|
||||
// act
|
||||
const actionResult = testScenario.act(factoryStub);
|
||||
// assert
|
||||
const expectedContext = testScenario.expectContext(actionResult);
|
||||
const providedContexts = createdValidators
|
||||
.filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName))
|
||||
.map((v) => v.context);
|
||||
expectDeepIncludes( // to.deep.include is not working
|
||||
providedContexts,
|
||||
expectedContext,
|
||||
formatAssertionMessage([
|
||||
'Error context mismatch.',
|
||||
'Provided contexts do not include the expected context.',
|
||||
'Expected context:',
|
||||
indentText(JSON.stringify(expectedContext, undefined, 2)),
|
||||
'Provided contexts:',
|
||||
indentText(JSON.stringify(providedContexts, undefined, 2)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function expectDeepIncludes<T>(
|
||||
array: readonly T[],
|
||||
item: T,
|
||||
message: string,
|
||||
) {
|
||||
const serializeItem = (c) => JSON.stringify(c);
|
||||
const serializedContexts = array.map((c) => serializeItem(c));
|
||||
const serializedExpectedContext = serializeItem(item);
|
||||
expect(serializedContexts).to.include(serializedExpectedContext, formatAssertionMessage([
|
||||
'Error context mismatch.',
|
||||
'Provided contexts do not include the expected context.',
|
||||
'Expected context:',
|
||||
indentText(JSON.stringify(message, undefined, 2)),
|
||||
'Provided contexts:',
|
||||
indentText(JSON.stringify(message, undefined, 2)),
|
||||
]));
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
||||
import type { ExecutableData } from '@/application/collections/';
|
||||
import { createExecutableErrorContextStub } from '@tests/unit/shared/Stubs/ExecutableErrorContextStub';
|
||||
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
|
||||
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||
import { ContextualExecutableValidator, createExecutableDataValidator, type ExecutableValidator } from '@/application/Parser/Executable/Validation/ExecutableValidator';
|
||||
import type { ExecutableContextErrorMessageCreator } from '@/application/Parser/Executable/Validation/ExecutableErrorContextMessage';
|
||||
import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('createExecutableDataValidator', () => {
|
||||
it(`returns an instance of ${ContextualExecutableValidator.name}`, () => {
|
||||
// arrange
|
||||
const context = createExecutableErrorContextStub();
|
||||
// act
|
||||
const validator = createExecutableDataValidator(context);
|
||||
// assert
|
||||
expect(validator).to.be.instanceOf(ContextualExecutableValidator);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextualExecutableValidator', () => {
|
||||
describe('assertValidName', () => {
|
||||
describe('throws when name is invalid', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly invalidName: unknown;
|
||||
readonly expectedMessage: string;
|
||||
}[] = [
|
||||
...getAbsentStringTestCases().map((testCase) => ({
|
||||
description: `missing name (${testCase.valueName})`,
|
||||
invalidName: testCase.absentValue,
|
||||
expectedMessage: 'missing name',
|
||||
})),
|
||||
{
|
||||
description: 'invalid type',
|
||||
invalidName: 33,
|
||||
expectedMessage: 'Name (33) is not a string but number.',
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({ description, invalidName, expectedMessage }) => {
|
||||
describe(`given "${description}"`, () => {
|
||||
itThrowsCorrectly({
|
||||
// act
|
||||
throwingAction: (sut) => {
|
||||
sut.assertValidName(invalidName as string);
|
||||
},
|
||||
// assert
|
||||
expectedMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('does not throw when name is valid', () => {
|
||||
// arrange
|
||||
const validName = 'validName';
|
||||
const sut = new ValidatorBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.assertValidName(validName);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('assertDefined', () => {
|
||||
describe('throws when data is missing', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly invalidData: unknown;
|
||||
}[] = [
|
||||
...getAbsentObjectTestCases().map((testCase) => ({
|
||||
description: `absent object (${testCase.valueName})`,
|
||||
invalidData: testCase.absentValue,
|
||||
})),
|
||||
{
|
||||
description: 'empty object',
|
||||
invalidData: {},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({ description, invalidData }) => {
|
||||
describe(`given "${description}"`, () => {
|
||||
const expectedMessage = 'missing executable data';
|
||||
itThrowsCorrectly({
|
||||
// act
|
||||
throwingAction: (sut: ExecutableValidator) => {
|
||||
sut.assertDefined(invalidData as ExecutableData);
|
||||
},
|
||||
// assert
|
||||
expectedMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('does not throw if data is defined', () => {
|
||||
// arrange
|
||||
const data = new CategoryDataStub();
|
||||
const sut = new ValidatorBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.assertDefined(data);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('assert', () => {
|
||||
describe('throws if validation fails', () => {
|
||||
const falsePredicate = () => false;
|
||||
const expectedErrorMessage = 'expected error';
|
||||
// assert
|
||||
itThrowsCorrectly({
|
||||
// act
|
||||
throwingAction: (sut: ExecutableValidator) => {
|
||||
sut.assert(falsePredicate, expectedErrorMessage);
|
||||
},
|
||||
// assert
|
||||
expectedMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
it('does not throw if validation succeeds', () => {
|
||||
// arrange
|
||||
const truePredicate = () => true;
|
||||
const sut = new ValidatorBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.assert(truePredicate, 'ignored error');
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('createContextualErrorMessage', () => {
|
||||
it('creates using the correct error message', () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'expected error';
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = (message) => message;
|
||||
const sut = new ValidatorBuilder()
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const actualErrorMessage = sut.createContextualErrorMessage(expectedErrorMessage);
|
||||
// assert
|
||||
expect(actualErrorMessage).to.equal(expectedErrorMessage);
|
||||
});
|
||||
it('creates using the correct context', () => {
|
||||
// arrange
|
||||
const expectedContext = createExecutableErrorContextStub();
|
||||
let actualContext: ExecutableErrorContext | undefined;
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = (_, context) => {
|
||||
actualContext = context;
|
||||
return '';
|
||||
};
|
||||
const sut = new ValidatorBuilder()
|
||||
.withContext(expectedContext)
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
sut.createContextualErrorMessage('unimportant');
|
||||
// assert
|
||||
expect(actualContext).to.equal(expectedContext);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ValidationThrowingFunction = (
|
||||
sut: ContextualExecutableValidator,
|
||||
) => void;
|
||||
|
||||
interface ValidationThrowingTestScenario {
|
||||
readonly throwingAction: ValidationThrowingFunction,
|
||||
readonly expectedMessage: string;
|
||||
}
|
||||
|
||||
function itThrowsCorrectly(
|
||||
testScenario: ValidationThrowingTestScenario,
|
||||
): void {
|
||||
it('throws an error', () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'Injected error message';
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = () => expectedErrorMessage;
|
||||
const sut = new ValidatorBuilder()
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const action = () => testScenario.throwingAction(sut);
|
||||
// assert
|
||||
expect(action).to.throw();
|
||||
});
|
||||
it('throws with the correct error message', () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = testScenario.expectedMessage;
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = (message) => message;
|
||||
const sut = new ValidatorBuilder()
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const action = () => testScenario.throwingAction(sut);
|
||||
// assert
|
||||
const actualErrorMessage = collectExceptionMessage(action);
|
||||
expect(actualErrorMessage).to.equal(expectedErrorMessage);
|
||||
});
|
||||
it('throws with the correct context', () => {
|
||||
// arrange
|
||||
const expectedContext = createExecutableErrorContextStub();
|
||||
const serializeContext = (context: ExecutableErrorContext) => JSON.stringify(context);
|
||||
const errorMessageBuilder:
|
||||
ExecutableContextErrorMessageCreator = (_, context) => serializeContext(context);
|
||||
const sut = new ValidatorBuilder()
|
||||
.withContext(expectedContext)
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const action = () => testScenario.throwingAction(sut);
|
||||
// assert
|
||||
const expectedSerializedContext = serializeContext(expectedContext);
|
||||
const actualSerializedContext = collectExceptionMessage(action);
|
||||
expect(expectedSerializedContext).to.equal(actualSerializedContext);
|
||||
});
|
||||
}
|
||||
|
||||
class ValidatorBuilder {
|
||||
private errorContext: ExecutableErrorContext = createExecutableErrorContextStub();
|
||||
|
||||
private errorMessageCreator: ExecutableContextErrorMessageCreator = () => `[${ValidatorBuilder.name}] stub error message`;
|
||||
|
||||
public withErrorMessageCreator(errorMessageCreator: ExecutableContextErrorMessageCreator): this {
|
||||
this.errorMessageCreator = errorMessageCreator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withContext(errorContext: ExecutableErrorContext): this {
|
||||
this.errorContext = errorContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ContextualExecutableValidator {
|
||||
return new ContextualExecutableValidator(
|
||||
this.errorContext,
|
||||
this.errorMessageCreator,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user