From 646db9058541cebd0af437554de04fdc6bb63a6e Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Fri, 5 Mar 2021 15:52:49 +0100 Subject: [PATCH] refactor script compilation to make it easy to add new expressions #41 #53 --- CONTRIBUTING.md | 12 +- docs/application.md | 18 +- .../Parser/CategoryCollectionParser.ts | 5 +- .../Expressions/Expression/Expression.ts | 35 +++ .../Expression/ExpressionPosition.ts | 15 ++ .../Expressions/Expression/IExpression.ts | 12 + .../Expressions/ExpressionsCompiler.ts | 54 +++-- .../Script/Compiler/Expressions/ILCode.ts | 73 ------ .../Parser/CompositeExpressionParser.ts | 23 ++ .../Expressions/Parser/IExpressionParser.ts | 5 + .../Expressions/Parser/RegexParser.ts | 35 +++ .../ParameterSubstitutionParser.ts | 12 + .../Compiler/Function/FunctionCompiler.ts | 12 + .../FunctionCall/FunctionCallCompiler.ts | 9 +- .../ScriptingDefinition/CodeSubstituter.ts | 33 +++ .../ScriptingDefinition/ICodeSubstituter.ts | 5 + .../IScriptingDefinitionParser.ts | 0 .../ScriptingDefinitionParser.ts | 31 +++ .../Parser/ScriptingDefinitionParser.ts | 36 --- src/domain/ScriptCode.ts | 2 +- .../Parser/CategoryCollectionParser.spec.ts | 12 +- .../Expressions/Expression/Expression.spec.ts | 134 +++++++++++ .../Expression/ExpressionPosition.spec.ts | 34 +++ .../Expressions/ExpressionsCompiler.spec.ts | 136 ++++++++--- .../Compiler/Expressions/ILCode.spec.ts | 141 ------------ .../Parser/CompositeExpressionParser.spec.ts | 87 +++++++ .../Expressions/Parser/RegexParser.spec.ts | 122 ++++++++++ .../ParameterSubstitutionParser.spec.ts | 69 ++++++ .../Function/FunctionCompiler.spec.ts | 12 + .../FunctionCall/FunctionCallCompiler.spec.ts | 212 ++++++++++-------- .../Script/Compiler/ScriptCompiler.spec.ts | 22 +- .../Parser/Script/ScriptParser.spec.ts | 5 +- .../CodeSubstituter.spec.ts | 96 ++++++++ .../ScriptingDefinitionParser.spec.ts | 110 +++++++++ .../Parser/ScriptingDefinitionParser.spec.ts | 151 ------------- tests/unit/domain/ScriptCode.spec.ts | 2 +- tests/unit/stubs/CodeSubstituterStub.ts | 17 ++ tests/unit/stubs/EnumParserStub.ts | 33 ++- tests/unit/stubs/ExpressionParserStub.ts | 15 ++ tests/unit/stubs/ExpressionStub.ts | 26 +++ tests/unit/stubs/ExpressionsCompilerStub.ts | 2 + .../unit/stubs/ScriptingDefinitionDataStub.ts | 29 +++ 42 files changed, 1312 insertions(+), 582 deletions(-) create mode 100644 src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts delete mode 100644 src/application/Parser/Script/Compiler/Expressions/ILCode.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts create mode 100644 src/application/Parser/ScriptingDefinition/CodeSubstituter.ts create mode 100644 src/application/Parser/ScriptingDefinition/ICodeSubstituter.ts create mode 100644 src/application/Parser/ScriptingDefinition/IScriptingDefinitionParser.ts create mode 100644 src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts delete mode 100644 src/application/Parser/ScriptingDefinitionParser.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.spec.ts delete mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts create mode 100644 tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts create mode 100644 tests/unit/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.spec.ts delete mode 100644 tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts create mode 100644 tests/unit/stubs/CodeSubstituterStub.ts create mode 100644 tests/unit/stubs/ExpressionParserStub.ts create mode 100644 tests/unit/stubs/ExpressionStub.ts create mode 100644 tests/unit/stubs/ScriptingDefinitionDataStub.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5322e5c4..8eb6d2a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,9 @@ - Proposing new features - Becoming a maintainer -## Pull Request Process +## Pull request process -- [GitHub flow](https://guides.github.com/introduction/flow/index.html) is used +- [GitHub flow](https://guides.github.com/introduction/flow/index.html) with [GitOps](./img/architecture/gitops.png) is used - Your pull requests are actively welcomed. - The steps: 1. Fork the repo and create your branch from master. @@ -25,4 +25,10 @@ ## License -By contributing, you agree that your contributions will be licensed under its GNU General Public License v3.0. +By contributing, you agree that your contributions will be licensed under its [GNU General Public License v3.0](./LICENSE). + +## Read more + +- See [tests](./docs/tests.md) for testing +- See [extend script](./README.md#extend-scripts) for quick steps to extend scripts +- See [architecture overview](./README.md#architecture-overview) to deep dive into privacy.sexy codebase diff --git a/docs/application.md b/docs/application.md index 9f531142..ddd357ce 100644 --- a/docs/application.md +++ b/docs/application.md @@ -2,7 +2,7 @@ - It's mainly responsible for - creating and event based [application state](#application-state) - - parsing and compiling [application data](#application-data) + - [parsing](#parsing) and [compiling](#compiling) [application data](#application-data) ## Application state @@ -14,9 +14,23 @@ ## Application data -- Compiled to `Application` domain object. +- Compiled to [`Application`](./../src/domain/Application.ts) domain object. - The scripts are defined and controlled in different data files per OS - Enables [data-driven programming](https://en.wikipedia.org/wiki/Data-driven_programming) and easier contributions - Application data is defined in collection files and - 📖 See [Application data | Presentation layer](./presentation.md#application-data) to read how the application data is read by the presentation layer. - 📖 See [collection files documentation](./collection-files.md) to read more about how the data files are structured/defined and see [collection yaml files](./../src/application/collections/) to directly check the code. + +## Parsing + +- Application data is parsed to domain object [`Application.ts`](./../src/domain/Application.ts) +- Steps + 1. (Compile time) Load application data from [collection yaml files](./../src/application/collections/) using webpack loader + 2. (Runtime) Parse and compile application and make it available to presentation layer by [`ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts) + +### Compiling + +- Parsing the application files includes compiling scripts using [collection file defined functions](./collection-files.md#function) +- To extend the syntax: + 1. Add a new parser under [SyntaxParsers](./../src/application/Parser/Script/Compiler/Expressions/SyntaxParsers) where you can look at other parsers to understand more. + 2. Register your in [CompositeExpressionParser](./../src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts) diff --git a/src/application/Parser/CategoryCollectionParser.ts b/src/application/Parser/CategoryCollectionParser.ts index 373e871c..eaef5153 100644 --- a/src/application/Parser/CategoryCollectionParser.ts +++ b/src/application/Parser/CategoryCollectionParser.ts @@ -2,19 +2,20 @@ import { Category } from '@/domain/Category'; import { CollectionData } from 'js-yaml-loader!@/*'; import { parseCategory } from './CategoryParser'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { parseScriptingDefinition } from './ScriptingDefinitionParser'; import { createEnumParser } from '../Common/Enum'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { CategoryCollection } from '@/domain/CategoryCollection'; import { IProjectInformation } from '@/domain/IProjectInformation'; import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext'; +import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; export function parseCategoryCollection( content: CollectionData, info: IProjectInformation, osParser = createEnumParser(OperatingSystem)): ICategoryCollection { validate(content); - const scripting = parseScriptingDefinition(content.scripting, info); + const scripting = new ScriptingDefinitionParser() + .parse(content.scripting, info); const context = new CategoryCollectionParseContext(content.functions, scripting); const categories = new Array(); for (const action of content.actions) { diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts new file mode 100644 index 00000000..d538745c --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts @@ -0,0 +1,35 @@ +import { ExpressionPosition } from './ExpressionPosition'; +import { ExpressionArguments, IExpression } from './IExpression'; + +export type ExpressionEvaluator = (args?: ExpressionArguments) => string; +export class Expression implements IExpression { + constructor( + public readonly position: ExpressionPosition, + public readonly evaluator: ExpressionEvaluator, + public readonly parameters: readonly string[] = new Array()) { + if (!position) { + throw new Error('undefined position'); + } + if (!evaluator) { + throw new Error('undefined evaluator'); + } + } + public evaluate(args?: ExpressionArguments): string { + args = filterUnusedArguments(this.parameters, args); + return this.evaluator(args); + } +} + +function filterUnusedArguments( + parameters: readonly string[], args: ExpressionArguments): ExpressionArguments { + let result: ExpressionArguments = {}; + for (const parameter of Object.keys(args)) { + if (parameters.includes(parameter)) { + result = { + ...result, + [parameter]: args[parameter], + }; + } + } + return result; +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts new file mode 100644 index 00000000..a5cccb37 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts @@ -0,0 +1,15 @@ +export class ExpressionPosition { + constructor( + public readonly start: number, + public readonly end: number) { + if (start === end) { + throw new Error(`no length (start = end = ${start})`); + } + if (start > end) { + throw Error(`start (${start}) after end (${end})`); + } + if (start < 0) { + throw Error(`negative start position: ${start}`); + } + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts new file mode 100644 index 00000000..df5e2848 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts @@ -0,0 +1,12 @@ +import { ExpressionPosition } from './ExpressionPosition'; + +export interface IExpression { + readonly position: ExpressionPosition; + readonly parameters?: readonly string[]; + evaluate(args?: ExpressionArguments): string; +} + +export interface ExpressionArguments { + readonly [parameter: string]: string; +} + diff --git a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts index 90a2ae07..2102b6ec 100644 --- a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts +++ b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts @@ -1,31 +1,49 @@ import { IExpressionsCompiler, ParameterValueDictionary } from './IExpressionsCompiler'; -import { generateIlCode, IILCode } from './ILCode'; +import { IExpression } from './Expression/IExpression'; +import { IExpressionParser } from './Parser/IExpressionParser'; +import { CompositeExpressionParser } from './Parser/CompositeExpressionParser'; export class ExpressionsCompiler implements IExpressionsCompiler { - public static readonly instance: IExpressionsCompiler = new ExpressionsCompiler(); - protected constructor() { } + public constructor(private readonly extractor: IExpressionParser = new CompositeExpressionParser()) { } public compileExpressions(code: string, parameters?: ParameterValueDictionary): string { - let intermediateCode = generateIlCode(code); - intermediateCode = substituteParameters(intermediateCode, parameters); - return intermediateCode.compile(); + const expressions = this.extractor.findExpressions(code); + const requiredParameterNames = expressions.map((e) => e.parameters).filter((p) => p).flat(); + const uniqueParameterNames = Array.from(new Set(requiredParameterNames)); + ensureRequiredArgsProvided(uniqueParameterNames, parameters); + return compileExpressions(expressions, code, parameters); } } -function substituteParameters(intermediateCode: IILCode, parameters: ParameterValueDictionary): IILCode { - const parameterNames = intermediateCode.getUniqueParameterNames(); - ensureValuesProvided(parameterNames, parameters); - for (const parameterName of parameterNames) { - const parameterValue = parameters[parameterName]; - intermediateCode = intermediateCode.substituteParameter(parameterName, parameterValue); +function compileExpressions(expressions: IExpression[], code: string, parameters?: ParameterValueDictionary) { + let compiledCode = ''; + expressions = expressions + .slice() // copy the array to not mutate the parameter + .sort((a, b) => b.position.start - a.position.start); + let index = 0; + while (index !== code.length) { + const nextExpression = expressions.pop(); + if (nextExpression) { + compiledCode += code.substring(index, nextExpression.position.start); + const expressionCode = nextExpression.evaluate(parameters); + compiledCode += expressionCode; + index = nextExpression.position.end; + } else { + compiledCode += code.substring(index, code.length); + break; + } } - return intermediateCode; + return compiledCode; } -function ensureValuesProvided(names: string[], nameValues: ParameterValueDictionary) { - nameValues = nameValues || {}; - const notProvidedNames = names.filter((name) => !Boolean(nameValues[name])); - if (notProvidedNames.length) { - throw new Error(`parameter value(s) not provided for: ${printList(notProvidedNames)}`); +function ensureRequiredArgsProvided(parameters: readonly string[], args: ParameterValueDictionary) { + parameters = parameters || []; + args = args || {}; + if (!parameters.length) { + return; + } + const notProvidedParameters = parameters.filter((parameter) => !Boolean(args[parameter])); + if (notProvidedParameters.length) { + throw new Error(`parameter value(s) not provided for: ${printList(notProvidedParameters)}`); } } diff --git a/src/application/Parser/Script/Compiler/Expressions/ILCode.ts b/src/application/Parser/Script/Compiler/Expressions/ILCode.ts deleted file mode 100644 index 4b190d8a..00000000 --- a/src/application/Parser/Script/Compiler/Expressions/ILCode.ts +++ /dev/null @@ -1,73 +0,0 @@ -export interface IILCode { - compile(): string; - getUniqueParameterNames(): string[]; - substituteParameter(parameterName: string, parameterValue: string): IILCode; -} - -export function generateIlCode(rawText: string): IILCode { - const ilCode = generateIl(rawText); - return new ILCode(ilCode); -} - -class ILCode implements IILCode { - private readonly ilCode: string; - - constructor(ilCode: string) { - this.ilCode = ilCode; - } - - public substituteParameter(parameterName: string, parameterValue: string): IILCode { - const newCode = substituteParameter(this.ilCode, parameterName, parameterValue); - return new ILCode(newCode); - } - - public getUniqueParameterNames(): string[] { - return getUniqueParameterNames(this.ilCode); - } - - public compile(): string { - ensureNoExpressionLeft(this.ilCode); - return this.ilCode; - } -} - -// Trim each expression and put them inside "{{exp|}}" e.g. "{{ $hello }}" becomes "{{exp|$hello}}" -function generateIl(rawText: string): string { - return rawText.replace(/\{\{([\s]*[^;\s\{]+[\s]*)\}\}/g, (_, match) => { - return `\{\{exp|${match.trim()}\}\}`; - }); -} - -// finds all "{{exp|..}} left" -function ensureNoExpressionLeft(ilCode: string) { - const allSubstitutions = ilCode.matchAll(/\{\{exp\|(.*?)\}\}/g); - const allMatches = Array.from(allSubstitutions, (match) => match[1]); - const uniqueExpressions = getDistinctValues(allMatches); - if (uniqueExpressions.length > 0) { - throw new Error(`unknown expression: ${printList(uniqueExpressions)}`); - } -} - -// Parses all distinct usages of {{exp|$parameterName}} -function getUniqueParameterNames(ilCode: string) { - const allSubstitutions = ilCode.matchAll(/\{\{exp\|\$([^;\s\{]+[\s]*)\}\}/g); - const allParameters = Array.from(allSubstitutions, (match) => match[1]); - const uniqueParameterNames = getDistinctValues(allParameters); - return uniqueParameterNames; -} - -// substitutes {{exp|$parameterName}} to value of the parameter -function substituteParameter(ilCode: string, parameterName: string, parameterValue: string) { - const pattern = `{{exp|$${parameterName}}}`; - return ilCode.split(pattern).join(parameterValue); // as .replaceAll() is not yet supported by TS -} - -function getDistinctValues(values: readonly string[]): string[] { - return values.filter((value, index, self) => { - return self.indexOf(value) === index; - }); -} - -function printList(list: readonly string[]): string { - return `"${list.join('","')}"`; -} diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts new file mode 100644 index 00000000..928cfeae --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts @@ -0,0 +1,23 @@ +import { IExpression } from '../Expression/IExpression'; +import { IExpressionParser } from './IExpressionParser'; +import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser'; + +const parsers = [ + new ParameterSubstitutionParser(), +]; + +export class CompositeExpressionParser implements IExpressionParser { + public constructor(private readonly leafs: readonly IExpressionParser[] = parsers) { + if (leafs.some((leaf) => !leaf)) { throw new Error('undefined leaf'); } + } + public findExpressions(code: string): IExpression[] { + const expressions = new Array(); + for (const parser of this.leafs) { + const newExpressions = parser.findExpressions(code); + if (newExpressions && newExpressions.length) { + expressions.push(...newExpressions); + } + } + return expressions; + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser.ts new file mode 100644 index 00000000..f838cc26 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser.ts @@ -0,0 +1,5 @@ +import { IExpression } from '../Expression/IExpression'; + +export interface IExpressionParser { + findExpressions(code: string): IExpression[]; +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts new file mode 100644 index 00000000..4a1389ca --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts @@ -0,0 +1,35 @@ +import { IExpressionParser } from './IExpressionParser'; +import { ExpressionPosition } from '../Expression/ExpressionPosition'; +import { IExpression } from '../Expression/IExpression'; +import { Expression, ExpressionEvaluator } from '../Expression/Expression'; + +export abstract class RegexParser implements IExpressionParser { + protected abstract readonly regex: RegExp; + public findExpressions(code: string): IExpression[] { + return Array.from(this.findRegexExpressions(code)); + } + + protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression; + + private* findRegexExpressions(code: string): Iterable { + const matches = Array.from(code.matchAll(this.regex)); + for (const match of matches) { + const startPos = match.index; + const endPos = startPos + match[0].length; + let position: ExpressionPosition; + try { + position = new ExpressionPosition(startPos, endPos); + } catch (error) { + throw new Error(`[${this.constructor.name}] invalid script position: ${error.message}\nRegex ${this.regex}\nCode: ${code}`); + } + const primitiveExpression = this.buildExpression(match); + const expression = new Expression(position, primitiveExpression.evaluator, primitiveExpression.parameters); + yield expression; + } + } +} + +export interface IPrimitiveExpression { + evaluator: ExpressionEvaluator; + parameters?: readonly string[]; +} diff --git a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts new file mode 100644 index 00000000..19e0951d --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts @@ -0,0 +1,12 @@ +import { RegexParser, IPrimitiveExpression } from '../Parser/RegexParser'; + +export class ParameterSubstitutionParser extends RegexParser { + protected readonly regex = /{{\s*\$\s*([^}| ]+)\s*}}/g; + protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { + const parameterName = match[1]; + return { + parameters: [ parameterName ], + evaluator: (args) => args[parameterName], + }; + } +} diff --git a/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts b/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts index a145d2cc..be9d900d 100644 --- a/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts +++ b/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts @@ -49,6 +49,7 @@ function ensureValidFunctions(functions: readonly FunctionData[]) { ensureNoDuplicatesInParameterNames(functions); ensureNoDuplicateCode(functions); ensureEitherCallOrCodeIsDefined(functions); + ensureExpectedParameterNameTypes(functions); } function printList(list: readonly string[]): string { @@ -67,6 +68,17 @@ function ensureEitherCallOrCodeIsDefined(holders: readonly InstructionHolder[]) throw new Error(`neither "code" or "call" is defined in ${printNames(hasEitherCodeOrCall)}`); } } + +function ensureExpectedParameterNameTypes(functions: readonly FunctionData[]) { + const unexpectedFunctions = functions.filter((func) => func.parameters && !isArrayOfStrings(func.parameters)); + if (unexpectedFunctions.length) { + throw new Error(`unexpected parameter name type in ${printNames(unexpectedFunctions)}`); + } + function isArrayOfStrings(value: any): boolean { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); + } +} + function printNames(holders: readonly InstructionHolder[]) { return printList(holders.map((holder) => holder.name)); } diff --git a/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts b/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts index cf3ac2a7..33e78656 100644 --- a/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts +++ b/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts @@ -8,7 +8,7 @@ import { ExpressionsCompiler } from '../Expressions/ExpressionsCompiler'; export class FunctionCallCompiler implements IFunctionCallCompiler { public static readonly instance: IFunctionCallCompiler = new FunctionCallCompiler(); protected constructor( - private readonly expressionsCompiler: IExpressionsCompiler = ExpressionsCompiler.instance) { } + private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler()) { } public compileCall( call: ScriptFunctionCallData, functions: ISharedFunctionCollection): ICompiledCode { @@ -32,11 +32,12 @@ export class FunctionCallCompiler implements IFunctionCallCompiler { } function ensureExpectedParameters(func: FunctionData, call: FunctionCallData) { - if (!func.parameters && !call.parameters) { + const actual = Object.keys(call.parameters || {}); + const expected = func.parameters || []; + if (!actual.length && !expected.length) { return; } - const unexpectedParameters = Object.keys(call.parameters || {}) - .filter((callParam) => !func.parameters.includes(callParam)); + const unexpectedParameters = actual.filter((callParam) => !expected.includes(callParam)); if (unexpectedParameters.length) { throw new Error( `function "${func.name}" has unexpected parameter(s) provided: "${unexpectedParameters.join('", "')}"`); diff --git a/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts b/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts new file mode 100644 index 00000000..b3665df7 --- /dev/null +++ b/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts @@ -0,0 +1,33 @@ +import { IExpressionsCompiler, ParameterValueDictionary } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler'; +import { ParameterSubstitutionParser } from '@/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser'; +import { CompositeExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser'; +import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler'; +import { IProjectInformation } from '@/domain/IProjectInformation'; +import { ICodeSubstituter } from './ICodeSubstituter'; + +export class CodeSubstituter implements ICodeSubstituter { + constructor( + private readonly compiler: IExpressionsCompiler = createSubstituteCompiler(), + private readonly date = new Date(), + ) { + + } + public substitute(code: string, info: IProjectInformation): string { + if (!code) { throw new Error('undefined code'); } + if (!info) { throw new Error('undefined info'); } + const parameters: ParameterValueDictionary = { + homepage: info.homepage, + version: info.version, + date: this.date.toUTCString(), + }; + const compiledCode = this.compiler.compileExpressions(code, parameters); + return compiledCode; + } +} + +function createSubstituteCompiler(): IExpressionsCompiler { + const parsers = [ new ParameterSubstitutionParser() ]; + const parser = new CompositeExpressionParser(parsers); + const expressionCompiler = new ExpressionsCompiler(parser); + return expressionCompiler; +} diff --git a/src/application/Parser/ScriptingDefinition/ICodeSubstituter.ts b/src/application/Parser/ScriptingDefinition/ICodeSubstituter.ts new file mode 100644 index 00000000..11e9ee5b --- /dev/null +++ b/src/application/Parser/ScriptingDefinition/ICodeSubstituter.ts @@ -0,0 +1,5 @@ +import { IProjectInformation } from '@/domain/IProjectInformation'; + +export interface ICodeSubstituter { + substitute(code: string, info: IProjectInformation): string; +} diff --git a/src/application/Parser/ScriptingDefinition/IScriptingDefinitionParser.ts b/src/application/Parser/ScriptingDefinition/IScriptingDefinitionParser.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts b/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts new file mode 100644 index 00000000..743082dc --- /dev/null +++ b/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts @@ -0,0 +1,31 @@ +import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { ScriptingDefinitionData } from 'js-yaml-loader!@/*'; +import { ScriptingDefinition } from '@/domain/ScriptingDefinition'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { IProjectInformation } from '@/domain/IProjectInformation'; +import { createEnumParser } from '../../Common/Enum'; +import { ICodeSubstituter } from './ICodeSubstituter'; +import { CodeSubstituter } from './CodeSubstituter'; + +export class ScriptingDefinitionParser { + constructor( + private readonly languageParser = createEnumParser(ScriptingLanguage), + private readonly codeSubstituter: ICodeSubstituter = new CodeSubstituter(), + ) { + } + public parse( + definition: ScriptingDefinitionData, + info: IProjectInformation): IScriptingDefinition { + if (!info) { throw new Error('undefined info'); } + if (!definition) { throw new Error('undefined definition'); } + const language = this.languageParser.parseEnum(definition.language, 'language'); + const startCode = this.codeSubstituter.substitute(definition.startCode, info); + const endCode = this.codeSubstituter.substitute(definition.endCode, info); + return new ScriptingDefinition( + language, + startCode, + endCode, + ); + } +} + diff --git a/src/application/Parser/ScriptingDefinitionParser.ts b/src/application/Parser/ScriptingDefinitionParser.ts deleted file mode 100644 index 99e6a120..00000000 --- a/src/application/Parser/ScriptingDefinitionParser.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; -import { ScriptingDefinitionData } from 'js-yaml-loader!@/*'; -import { ScriptingDefinition } from '@/domain/ScriptingDefinition'; -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { IProjectInformation } from '@/domain/IProjectInformation'; -import { createEnumParser } from '../Common/Enum'; -import { generateIlCode } from './Script/Compiler/Expressions/ILCode'; - -export function parseScriptingDefinition( - definition: ScriptingDefinitionData, - info: IProjectInformation, - date = new Date(), - languageParser = createEnumParser(ScriptingLanguage)): IScriptingDefinition { - if (!info) { - throw new Error('undefined info'); - } - if (!definition) { - throw new Error('undefined definition'); - } - const language = languageParser.parseEnum(definition.language, 'language'); - const startCode = applySubstitutions(definition.startCode, info, date); - const endCode = applySubstitutions(definition.endCode, info, date); - return new ScriptingDefinition( - language, - startCode, - endCode, - ); -} - -function applySubstitutions(code: string, info: IProjectInformation, date: Date): string { - let ilCode = generateIlCode(code); - ilCode = ilCode.substituteParameter('homepage', info.homepage); - ilCode = ilCode.substituteParameter('version', info.version); - ilCode = ilCode.substituteParameter('date', date.toUTCString()); - return ilCode.compile(); -} diff --git a/src/domain/ScriptCode.ts b/src/domain/ScriptCode.ts index cd81b687..e5ff2b7f 100644 --- a/src/domain/ScriptCode.ts +++ b/src/domain/ScriptCode.ts @@ -47,7 +47,7 @@ function ensureCodeHasUniqueLines(code: string, syntax: ILanguageSyntax): void { } const duplicateLines = lines.filter((e, i, a) => a.indexOf(e) !== i); if (duplicateLines.length !== 0) { - throw Error(`Duplicates detected in script :\n ${duplicateLines.join('\n')}`); + throw Error(`Duplicates detected in script:\n${duplicateLines.map((line, index) => `(${index}) - ${line}`).join('\n')}`); } } diff --git a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts index fcb50c6a..a0c5df78 100644 --- a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts +++ b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts @@ -5,15 +5,15 @@ import { parseCategoryCollection } from '@/application/Parser/CategoryCollection import { parseCategory } from '@/application/Parser/CategoryParser'; import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { parseScriptingDefinition } from '@/application/Parser/ScriptingDefinitionParser'; -import { mockEnumParser } from '../../stubs/EnumParserStub'; +import { RecommendationLevel } from '@/domain/RecommendationLevel'; +import { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser'; +import { EnumParserStub } from '../../stubs/EnumParserStub'; import { ProjectInformationStub } from '../../stubs/ProjectInformationStub'; import { getCategoryStub, CollectionDataStub } from '../../stubs/CollectionDataStub'; import { CategoryCollectionParseContextStub } from '../../stubs/CategoryCollectionParseContextStub'; import { CategoryDataStub } from '../../stubs/CategoryDataStub'; import { ScriptDataStub } from '../../stubs/ScriptDataStub'; import { FunctionDataStub } from '../../stubs/FunctionDataStub'; -import { RecommendationLevel } from '../../../../src/domain/RecommendationLevel'; describe('CategoryCollectionParser', () => { describe('parseCategoryCollection', () => { @@ -74,7 +74,8 @@ describe('CategoryCollectionParser', () => { // arrange const collection = new CollectionDataStub(); const information = parseProjectInformation(process.env); - const expected = parseScriptingDefinition(collection.scripting, information); + const expected = new ScriptingDefinitionParser() + .parse(collection.scripting, information); // act const actual = parseCategoryCollection(collection, information).scripting; // assert @@ -89,7 +90,8 @@ describe('CategoryCollectionParser', () => { const expectedName = 'os'; const collection = new CollectionDataStub() .withOs(osText); - const parserMock = mockEnumParser(expectedName, osText, expectedOs); + const parserMock = new EnumParserStub() + .setup(expectedName, osText, expectedOs); const info = new ProjectInformationStub(); // act const actual = parseCategoryCollection(collection, info, parserMock); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts new file mode 100644 index 00000000..2b26bfce --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts @@ -0,0 +1,134 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; +import { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; +import { Expression } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; +import { ExpressionArguments } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; + +describe('Expression', () => { + describe('ctor', () => { + describe('position', () => { + it('throws if undefined', () => { + // arrange + const expectedError = 'undefined position'; + const position = undefined; + // act + const act = () => new ExpressionBuilder() + .withPosition(position) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + 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', () => { + it('defaults to empty array if undefined', () => { + // arrange + const parameters = undefined; + // act + const actual = new ExpressionBuilder() + .withParameters(parameters) + .build(); + // assert + expect(actual.parameters).to.have.lengthOf(0); + }); + it('sets as expected', () => { + // arrange + const expected = [ 'firstParameterName', 'secondParameterName' ]; + // act + const actual = new ExpressionBuilder() + .withParameters(expected) + .build(); + // assert + expect(actual.parameters).to.deep.equal(expected); + }); + }); + describe('evaluator', () => { + it('throws if undefined', () => { + // arrange + const expectedError = 'undefined evaluator'; + const evaluator = undefined; + // act + const act = () => new ExpressionBuilder() + .withEvaluator(evaluator) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + describe('evaluate', () => { + it('returns result from evaluator', () => { + // arrange + const evaluatorMock: ExpressionEvaluator = (args) => JSON.stringify(args); + const givenArguments = { parameter1: 'value1', parameter2: 'value2' }; + const expected = evaluatorMock(givenArguments); + const sut = new ExpressionBuilder() + .withEvaluator(evaluatorMock) + .withParameters(Object.keys(givenArguments)) + .build(); + // arrange + const actual = sut.evaluate(givenArguments); + // assert + expect(expected).to.equal(actual); + }); + it('filters unused arguments', () => { + // arrange + let actual: ExpressionArguments = {}; + const evaluatorMock: ExpressionEvaluator = (providedArgs) => { + Object.keys(providedArgs) + .forEach((name) => actual = {...actual, [name]: providedArgs[name] }); + return ''; + }; + const parameterNameToHave = 'parameterToHave'; + const parameterNameToIgnore = 'parameterToIgnore'; + const sut = new ExpressionBuilder() + .withEvaluator(evaluatorMock) + .withParameters([ parameterNameToHave ]) + .build(); + const args: ExpressionArguments = { + [parameterNameToHave]: 'value-to-have', + [parameterNameToIgnore]: 'value-to-ignore', + }; + const expected: ExpressionArguments = { + [parameterNameToHave]: args[parameterNameToHave], + }; + // arrange + sut.evaluate(args); + // assert + expect(expected).to.deep.equal(actual); + }); + }); +}); + +class ExpressionBuilder { + private position: ExpressionPosition = new ExpressionPosition(0, 5); + private parameters: readonly string[] = new Array(); + + public withPosition(position: ExpressionPosition) { + this.position = position; + return this; + } + public withEvaluator(evaluator: ExpressionEvaluator) { + this.evaluator = evaluator; + return this; + } + public withParameters(parameters: string[]) { + this.parameters = parameters; + return this; + } + public build() { + return new Expression(this.position, this.evaluator, this.parameters); + } + + private evaluator: ExpressionEvaluator = () => ''; +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.spec.ts new file mode 100644 index 00000000..7ea54641 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.spec.ts @@ -0,0 +1,34 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ExpressionPosition } from '@/application/Parser/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); + }); + } + }); + }); +}); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts index ccd49fc1..c879b25e 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts @@ -1,79 +1,127 @@ import 'mocha'; import { expect } from 'chai'; import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler'; +import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser'; +import { ExpressionStub } from '../../../../../stubs/ExpressionStub'; +import { ExpressionParserStub } from '../../../../../stubs/ExpressionParserStub'; describe('ExpressionsCompiler', () => { - describe('parameter substitution', () => { - describe('substitutes as expected', () => { + describe('compileExpressions', () => { + describe('combines expressions as expected', () => { // arrange - const testCases = [ { - name: 'with different parameters', - code: 'He{{ $firstParameter }} {{ $secondParameter }}!', - parameters: { - firstParameter: 'llo', - secondParameter: 'world', + 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', }, - expected: 'Hello world!', - }, { - name: 'with single parameter', - code: '{{ $parameter }}!', - parameters: { - parameter: 'Hodor', + { + name: 'unordered expressions', + expressions: [ + new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a'), + new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b'), + ], + expected: 'part1 a part2 b part3', }, - expected: 'Hodor!', - - }]; + { + name: 'with no expressions', + expressions: [], + expected: code, + }, + ]; for (const testCase of testCases) { it(testCase.name, () => { - const sut = new MockableExpressionsCompiler(); + const expressionParserMock = new ExpressionParserStub() + .withResult(testCase.expressions); + const sut = new MockableExpressionsCompiler(expressionParserMock); // act - const actual = sut.compileExpressions(testCase.code, testCase.parameters); + const actual = sut.compileExpressions(code); // assert expect(actual).to.equal(testCase.expected); }); } }); - describe('throws when expected value is not provided', () => { + it('passes arguments to expressions as expected', () => { + // arrange + const expected = { + parameter1: 'value1', + parameter2: 'value2', + }; + const code = 'non-important'; + const expressions = [ + new ExpressionStub(), + new ExpressionStub(), + ]; + const expressionParserMock = new ExpressionParserStub() + .withResult(expressions); + const sut = new MockableExpressionsCompiler(expressionParserMock); + // act + sut.compileExpressions(code, expected); + // assert + expect(expressions[0].callHistory).to.have.lengthOf(1); + expect(expressions[0].callHistory[0]).to.equal(expected); + expect(expressions[1].callHistory).to.have.lengthOf(1); + expect(expressions[1].callHistory[0]).to.equal(expected); + }); + describe('throws when expected argument is not provided', () => { // arrange const noParameterTestCases = [ { name: 'empty parameters', - code: '{{ $parameter }}!', - parameters: {}, + expressions: [ + new ExpressionStub().withParameters('parameter'), + ], + args: {}, expectedError: 'parameter value(s) not provided for: "parameter"', }, { name: 'undefined parameters', - code: '{{ $parameter }}!', - parameters: undefined, + expressions: [ + new ExpressionStub().withParameters('parameter'), + ], + args: undefined, expectedError: 'parameter value(s) not provided for: "parameter"', }, { name: 'unnecessary parameter provided', - code: '{{ $parameter }}!', - parameters: { + expressions: [ + new ExpressionStub().withParameters('parameter'), + ], + args: { unnecessaryParameter: 'unnecessaryValue', }, expectedError: 'parameter value(s) not provided for: "parameter"', }, { name: 'undefined value', - code: '{{ $parameter }}!', - parameters: { + expressions: [ + new ExpressionStub().withParameters('parameter'), + ], + args: { parameter: undefined, }, expectedError: 'parameter value(s) not provided for: "parameter"', }, { - name: 'multiple values are not', - code: '{{ $parameter1 }}, {{ $parameter2 }}, {{ $parameter3 }}', - parameters: {}, + name: 'multiple values are not provided', + expressions: [ + new ExpressionStub().withParameters('parameter1'), + new ExpressionStub().withParameters('parameter2', 'parameter3'), + ], + args: {}, expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2", "parameter3"', }, { name: 'some values are provided', - code: '{{ $parameter1 }}, {{ $parameter2 }}, {{ $parameter3 }}', - parameters: { + expressions: [ + new ExpressionStub().withParameters('parameter1'), + new ExpressionStub().withParameters('parameter2', 'parameter3'), + ], + args: { parameter2: 'value', }, expectedError: 'parameter value(s) not provided for: "parameter1", "parameter3"', @@ -81,19 +129,33 @@ describe('ExpressionsCompiler', () => { ]; for (const testCase of noParameterTestCases) { it(testCase.name, () => { - const sut = new MockableExpressionsCompiler(); + const code = 'non-important-code'; + const expressionParserMock = new ExpressionParserStub() + .withResult(testCase.expressions); + const sut = new MockableExpressionsCompiler(expressionParserMock); // act - const act = () => sut.compileExpressions(testCase.code, testCase.parameters); + const act = () => sut.compileExpressions(code, testCase.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 MockableExpressionsCompiler(expressionParserMock); + // act + sut.compileExpressions(expected); + // assert + expect(expressionParserMock.callHistory).to.have.lengthOf(1); + expect(expressionParserMock.callHistory[0]).to.equal(expected); + }); }); }); class MockableExpressionsCompiler extends ExpressionsCompiler { - constructor() { - super(); + constructor(extractor: IExpressionParser) { + super(extractor); } } diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts deleted file mode 100644 index c59cf15c..00000000 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import { generateIlCode } from '@/application/Parser/Script/Compiler/Expressions/ILCode'; - -describe('ILCode', () => { - describe('getUniqueParameterNames', () => { - // arrange - const testCases = [ - { - name: 'empty parameters: returns an empty array', - code: 'no expressions', - expected: [ ], - }, - { - name: 'single parameter: returns expected for single usage', - code: '{{ $single }}', - expected: [ 'single' ], - }, - { - name: 'single parameter: returns distinct values for repeating parameters', - code: '{{ $singleRepeating }}, {{ $singleRepeating }}', - expected: [ 'singleRepeating' ], - }, - { - name: 'multiple parameters: returns expected for single usage of each', - code: '{{ $firstParameter }}, {{ $secondParameter }}', - expected: [ 'firstParameter', 'secondParameter' ], - }, - { - name: 'multiple parameters: returns distinct values for repeating parameters', - code: '{{ $firstParameter }}, {{ $firstParameter }}, {{ $firstParameter }} {{ $secondParameter }}, {{ $secondParameter }}', - expected: [ 'firstParameter', 'secondParameter' ], - }, - ]; - for (const testCase of testCases) { - it(testCase.name, () => { - // act - const sut = generateIlCode(testCase.code); - const actual = sut.getUniqueParameterNames(); - // assert - expect(actual).to.deep.equal(testCase.expected); - }); - } - }); - describe('substituteParameter', () => { - describe('substitutes by ignoring white spaces inside mustaches', () => { - // arrange - const mustacheVariations = [ - 'Hello {{ $test }}!', - 'Hello {{$test }}!', - 'Hello {{ $test}}!', - 'Hello {{$test}}!']; - mustacheVariations.forEach((variation) => { - it(variation, () => { - // arrange - const ilCode = generateIlCode(variation); - const expected = 'Hello world!'; - // act - const actual = ilCode - .substituteParameter('test', 'world') - .compile(); - // assert - expect(actual).to.deep.equal(expected); - }); - }); - }); - describe('substitutes as expected', () => { - // arrange - const testCases = [ - { - name: 'single parameter', - code: 'Hello {{ $firstParameter }}!', - expected: 'Hello world!', - parameters: { - firstParameter: 'world', - }, - }, - { - name: 'single parameter repeated', - code: '{{ $firstParameter }} {{ $firstParameter }}!', - expected: 'hello hello!', - parameters: { - firstParameter: 'hello', - }, - }, - { - name: 'multiple parameters', - code: 'He{{ $firstParameter }} {{ $secondParameter }}!', - expected: 'Hello world!', - parameters: { - firstParameter: 'llo', - secondParameter: 'world', - }, - }, - { - name: 'multiple parameters repeated', - code: 'He{{ $firstParameter }} {{ $secondParameter }} and He{{ $firstParameter }} {{ $secondParameter }}!', - expected: 'Hello world and Hello world!', - parameters: { - firstParameter: 'llo', - secondParameter: 'world', - }, - }, - ]; - for (const testCase of testCases) { - it(testCase.name, () => { - // act - let ilCode = generateIlCode(testCase.code); - for (const parameterName of Object.keys(testCase.parameters)) { - const value = testCase.parameters[parameterName]; - ilCode = ilCode.substituteParameter(parameterName, value); - } - const actual = ilCode.compile(); - // assert - expect(actual).to.deep.equal(testCase.expected); - }); - } - }); - }); - describe('compile', () => { - it('throws if there are expressions left', () => { - // arrange - const expectedError = 'unknown expression: "each"'; - const code = '{{ each }}'; - // act - const ilCode = generateIlCode(code); - const act = () => ilCode.compile(); - // assert - expect(act).to.throw(expectedError); - }); - it('returns code as it is if there are no expressions', () => { - // arrange - const expected = 'I should be the same!'; - const ilCode = generateIlCode(expected); - // act - const actual = ilCode.compile(); - // assert - expect(actual).to.equal(expected); - }); - }); -}); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.spec.ts new file mode 100644 index 00000000..24cc2cd0 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.spec.ts @@ -0,0 +1,87 @@ +import 'mocha'; +import { expect } from 'chai'; +import { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; +import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser'; +import { CompositeExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser'; +import { ExpressionStub } from '../../../../../../stubs/ExpressionStub'; + +describe('CompositeExpressionParser', () => { + describe('ctor', () => { + it('throws if one of the parsers is undefined', () => { + // arrange + const expectedError = 'undefined leaf'; + const parsers: readonly IExpressionParser[] = [ undefined, mockParser() ]; + // act + const act = () => new CompositeExpressionParser(parsers); + // assert + expect(act).to.throw(expectedError); + }); + }); + 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, + }; +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts new file mode 100644 index 00000000..1793f7d9 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts @@ -0,0 +1,122 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; +import { IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/RegexParser'; +import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; + +describe('RegexParser', () => { + describe('findExpressions', () => { + describe('matches regex as expected', () => { + // arrange + const testCases = [ + { + name: 'returns no result when regex does not match', + regex: /hello/g, + code: 'world', + }, + { + name: 'returns expected when regex matches single', + regex: /hello/g, + code: 'hello world', + }, + { + name: 'returns expected when regex matches multiple', + regex: /l/g, + code: 'hello world', + }, + ]; + for (const testCase of testCases) { + it(testCase.name, () => { + const expected = Array.from(testCase.code.matchAll(testCase.regex)); + const matches = new Array(); + const builder = (m: RegExpMatchArray): IPrimitiveExpression => { + matches.push(m); + return mockPrimitiveExpression(); + }; + const sut = new RegexParserConcrete(testCase.regex, builder); + // act + const expressions = sut.findExpressions(testCase.code); + // assert + expect(expressions).to.have.lengthOf(matches.length); + expect(matches).to.deep.equal(expected); + }); + } + }); + it('sets evaluator as expected', () => { + // arrange + const expected = getEvaluatorStub(); + const regex = /hello/g; + const code = 'hello'; + const builder = (): IPrimitiveExpression => ({ + evaluator: expected, + }); + const sut = new RegexParserConcrete(regex, builder); + // act + const expressions = sut.findExpressions(code); + // assert + expect(expressions).to.have.lengthOf(1); + expect(expressions[0].evaluate === expected); + }); + it('sets parameters as expected', () => { + // arrange + const expected = [ 'parameter1', 'parameter2' ]; + const regex = /hello/g; + const code = 'hello'; + const builder = (): IPrimitiveExpression => ({ + evaluator: getEvaluatorStub(), + parameters: expected, + }); + const sut = new RegexParserConcrete(regex, builder); + // act + const expressions = sut.findExpressions(code); + // assert + expect(expressions).to.have.lengthOf(1); + expect(expressions[0].parameters).to.equal(expected); + }); + it('sets expected position', () => { + // arrange + const code = 'mate date in state is fate'; + const regex = /ate/g; + const expected = [ + new ExpressionPosition(1, 4), + new ExpressionPosition(6, 9), + new ExpressionPosition(15, 18), + new ExpressionPosition(23, 26), + ]; + const sut = new RegexParserConcrete(regex); + // act + const expressions = sut.findExpressions(code); + // assert + const actual = expressions.map((e) => e.position); + expect(actual).to.deep.equal(expected); + }); + }); +}); + +function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression { + return () => ({ + evaluator: getEvaluatorStub(), + }); +} +function getEvaluatorStub(): ExpressionEvaluator { + return () => undefined; +} + +function mockPrimitiveExpression(): IPrimitiveExpression { + return { + evaluator: getEvaluatorStub(), + }; +} + +class RegexParserConcrete extends RegexParser { + protected regex: RegExp; + public constructor( + regex: RegExp, + private readonly builder = mockBuilder()) { + super(); + this.regex = regex; + } + protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { + return this.builder(match); + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts new file mode 100644 index 00000000..f36b1104 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts @@ -0,0 +1,69 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ParameterSubstitutionParser } from '@/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser'; +import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; +import { ExpressionArguments } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; + +describe('ParameterSubstitutionParser', () => { + it('finds at expected positions', () => { + // arrange + const testCases = [ { + 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) ], + }]; + for (const testCase of testCases) { + it(testCase.name, () => { + const sut = new ParameterSubstitutionParser(); + // act + const expressions = sut.findExpressions(testCase.code); + // assert + const actual = expressions.map((e) => e.position); + expect(actual).to.deep.equal(testCase.expected); + }); + } + }); + it('evaluates as expected', () => { + const testCases = [ { + name: 'single parameter', + code: '{{ $parameter }}', + args: [ { + name: 'parameter', + value: 'Hello world', + }], + expected: [ 'Hello world' ], + }, + { + name: 'different parameters', + code: '{{ $firstParameter }} {{ $secondParameter }}!', + args: [ { + name: 'firstParameter', + value: 'Hello', + }, + { + name: 'firstParameter', + value: 'World', + }], + expected: [ 'Hello', 'World' ], + }]; + for (const testCase of testCases) { + it(testCase.name, () => { + const sut = new ParameterSubstitutionParser(); + let args: ExpressionArguments = {}; + for (const arg of testCase.args) { + args = {...args, [arg.name]: arg.value }; + } + // act + const expressions = sut.findExpressions(testCase.code); + // assert + const actual = expressions.map((e) => e.evaluate(args)); + expect(actual).to.deep.equal(testCase.expected); + }); + } + }); +}); + diff --git a/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts index c6960f14..c0e5a347 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts @@ -46,6 +46,18 @@ describe('FunctionsCompiler', () => { // assert expect(act).to.throw(expectedError); }); + it('throws when parameters is not an array of strings', () => { + // arrange + const parameterNameWithUnexpectedType = 5; + const func = FunctionDataStub.createWithCall() + .withParameters(parameterNameWithUnexpectedType as any); + const expectedError = `unexpected parameter name type in "${func.name}"`; + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions([ func ]); + // assert + expect(act).to.throw(expectedError); + }); describe('throws when when function have duplicate code', () => { it('code', () => { // arrange diff --git a/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts index 39c6acc5..6797266d 100644 --- a/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts @@ -10,103 +10,131 @@ import { SharedFunctionStub } from '../../../../../stubs/SharedFunctionStub'; describe('FunctionCallCompiler', () => { describe('compileCall', () => { - describe('call', () => { - it('throws with undefined call', () => { - // arrange - const expectedError = 'undefined call'; - const call = undefined; - const functions = new SharedFunctionCollectionStub(); - const sut = new MockableFunctionCallCompiler(); - // act - const act = () => sut.compileCall(call, functions); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if call is not an object', () => { - // arrange - const expectedError = 'called function(s) must be an object'; - const invalidCalls: readonly any[] = ['string', 33]; - const sut = new MockableFunctionCallCompiler(); - const functions = new SharedFunctionCollectionStub(); - invalidCalls.forEach((invalidCall) => { + describe('parameter validation', () => { + describe('call', () => { + it('throws with undefined call', () => { + // arrange + const expectedError = 'undefined call'; + const call = undefined; + const functions = new SharedFunctionCollectionStub(); + const sut = new MockableFunctionCallCompiler(); // act - const act = () => sut.compileCall(invalidCall, functions); + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call is not an object', () => { + // arrange + const expectedError = 'called function(s) must be an object'; + const invalidCalls: readonly any[] = ['string', 33]; + const sut = new MockableFunctionCallCompiler(); + const functions = new SharedFunctionCollectionStub(); + invalidCalls.forEach((invalidCall) => { + // act + const act = () => sut.compileCall(invalidCall, functions); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('throws if call sequence has undefined call', () => { + // arrange + const expectedError = 'undefined function call'; + const call: FunctionCallData[] = [ + { function: 'function-name' }, + undefined, + ]; + const functions = new SharedFunctionCollectionStub(); + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call sequence has undefined function name', () => { + // arrange + const expectedError = 'empty function name called'; + const call: FunctionCallData[] = [ + { function: 'function-name' }, + { function: undefined }, + ]; + const functions = new SharedFunctionCollectionStub(); + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call parameters does not match function parameters', () => { + // arrange + const functionName = 'test-function-name'; + const testCases = [ + { + name: 'an unexpected parameter instead', + functionParameters: [ 'another-parameter' ], + callParameters: [ 'unexpected-parameter' ], + expectedError: `function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`, + }, + { + name: 'an unexpected parameter when none required', + functionParameters: undefined, + callParameters: [ 'unexpected-parameter' ], + expectedError: `function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`, + }, + { + name: 'expected and unexpected parameter', + functionParameters: [ 'expected-parameter' ], + callParameters: [ 'expected-parameter', 'unexpected-parameter' ], + expectedError: `function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`, + }, + ]; + for (const testCase of testCases) { + it(testCase.name, () => { + const func = new SharedFunctionStub() + .withName('test-function-name') + .withParameters(...testCase.functionParameters); + let params: FunctionCallParametersData = {}; + for (const parameter of testCase.callParameters) { + params = {...params, [parameter]: 'defined-parameter-value '}; + } + const call: FunctionCallData = { function: func.name, parameters: params }; + const functions = new SharedFunctionCollectionStub().withFunction(func); + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(testCase.expectedError); + }); + } + }); + }); + describe('functions', () => { + it('throws with undefined functions', () => { + // arrange + const expectedError = 'undefined functions'; + const call: FunctionCallData = { function: 'function-call' }; + const functions = undefined; + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if function does not exist', () => { + // arrange + const expectedError = 'function does not exist'; + const call: FunctionCallData = { function: 'function-call' }; + const functions: ISharedFunctionCollection = { + getFunctionByName: () => { throw new Error(expectedError); }, + }; + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); // assert expect(act).to.throw(expectedError); }); }); - it('throws if call sequence has undefined call', () => { - // arrange - const expectedError = 'undefined function call'; - const call: FunctionCallData[] = [ - { function: 'function-name' }, - undefined, - ]; - const functions = new SharedFunctionCollectionStub(); - const sut = new MockableFunctionCallCompiler(); - // act - const act = () => sut.compileCall(call, functions); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if call sequence has undefined function name', () => { - // arrange - const expectedError = 'empty function name called'; - const call: FunctionCallData[] = [ - { function: 'function-name' }, - { function: undefined }, - ]; - const functions = new SharedFunctionCollectionStub(); - const sut = new MockableFunctionCallCompiler(); - // act - const act = () => sut.compileCall(call, functions); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if call parameters does not match function parameters', () => { - // arrange - const unexpectedCallParameterName = 'unexpected-parameter-name'; - const func = new SharedFunctionStub() - .withName('test-function-name') - .withParameters('another-parameter'); - const expectedError = `function "${func.name}" has unexpected parameter(s) provided: "${unexpectedCallParameterName}"`; - const sut = new MockableFunctionCallCompiler(); - const params: FunctionCallParametersData = { - [`${unexpectedCallParameterName}`]: 'unexpected-parameter-value', - }; - const call: FunctionCallData = { function: func.name, parameters: params }; - const functions = new SharedFunctionCollectionStub().withFunction(func); - // act - const act = () => sut.compileCall(call, functions); - // assert - expect(act).to.throw(expectedError); - }); - }); - describe('functions', () => { - it('throws with undefined functions', () => { - // arrange - const expectedError = 'undefined functions'; - const call: FunctionCallData = { function: 'function-call' }; - const functions = undefined; - const sut = new MockableFunctionCallCompiler(); - // act - const act = () => sut.compileCall(call, functions); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if function does not exist', () => { - // arrange - const expectedError = 'function does not exist'; - const call: FunctionCallData = { function: 'function-call' }; - const functions: ISharedFunctionCollection = { - getFunctionByName: () => { throw new Error(expectedError); }, - }; - const sut = new MockableFunctionCallCompiler(); - // act - const act = () => sut.compileCall(call, functions); - // assert - expect(act).to.throw(expectedError); - }); + + }); describe('builds code as expected', () => { describe('builds single call as expected', () => { diff --git a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts index 1631b890..1b252634 100644 --- a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts @@ -123,9 +123,9 @@ describe('ScriptCompiler', () => { // assert expect(isUsed).to.equal(true); }); - it('rethrows error from ScriptCode with script name', () => { + it('rethrows error with script name', () => { // arrange - const scriptName = 'scriptName'; // // arrange + const scriptName = 'scriptName'; const innerError = 'innerError'; const expectedError = `Script "${scriptName}" ${innerError}`; const callCompiler: IFunctionCallCompiler = { @@ -142,6 +142,24 @@ describe('ScriptCompiler', () => { // assert expect(act).to.throw(expectedError); }); + it('rethrows error from ScriptCode with script name', () => { + // arrange + const scriptName = 'scriptName'; + const expectedError = `Script "${scriptName}" code is empty or undefined`; + const callCompiler: IFunctionCallCompiler = { + compileCall: () => ({ code: undefined, revertCode: undefined }), + }; + const scriptData = ScriptDataStub.createWithCall() + .withName(scriptName); + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .withFunctionCallCompiler(callCompiler) + .build(); + // act + const act = () => sut.compile(scriptData); + // assert + expect(act).to.throw(expectedError); + }); }); }); diff --git a/tests/unit/application/Parser/Script/ScriptParser.spec.ts b/tests/unit/application/Parser/Script/ScriptParser.spec.ts index 04146e4c..b22fba6e 100644 --- a/tests/unit/application/Parser/Script/ScriptParser.spec.ts +++ b/tests/unit/application/Parser/Script/ScriptParser.spec.ts @@ -6,7 +6,7 @@ import { RecommendationLevel } from '@/domain/RecommendationLevel'; import { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; import { ScriptCompilerStub } from '../../../stubs/ScriptCompilerStub'; import { ScriptDataStub } from '../../../stubs/ScriptDataStub'; -import { mockEnumParser } from '../../../stubs/EnumParserStub'; +import { EnumParserStub } from '../../../stubs/EnumParserStub'; import { ScriptCodeStub } from '../../../stubs/ScriptCodeStub'; import { CategoryCollectionParseContextStub } from '../../../stubs/CategoryCollectionParseContextStub'; import { LanguageSyntaxStub } from '../../../stubs/LanguageSyntaxStub'; @@ -104,7 +104,8 @@ describe('ScriptParser', () => { const script = ScriptDataStub.createWithCode() .withRecommend(levelText); const parseContext = new CategoryCollectionParseContextStub(); - const parserMock = mockEnumParser(expectedName, levelText, expectedLevel); + const parserMock = new EnumParserStub() + .setup(expectedName, levelText, expectedLevel); // act const actual = parseScript(script, parseContext, parserMock); // assert diff --git a/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts b/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts new file mode 100644 index 00000000..f583d7c9 --- /dev/null +++ b/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts @@ -0,0 +1,96 @@ +import 'mocha'; +import { expect } from 'chai'; +import { CodeSubstituter } from '@/application/Parser/ScriptingDefinition/CodeSubstituter'; +import { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler'; +import { ProjectInformationStub } from '../../../stubs/ProjectInformationStub'; +import { ExpressionsCompilerStub } from '../../../stubs/ExpressionsCompilerStub'; + +describe('CodeSubstituter', () => { + describe('throws with invalid parameters', () => { + // arrange + const testCases = [{ + expectedError: 'undefined code', + parameters: { + code: undefined, + info: new ProjectInformationStub(), + }}, + { + expectedError: 'undefined info', + parameters: { + code: 'non empty code', + info: undefined, + }, + }]; + for (const testCase of testCases) { + it(`throws "${testCase.expectedError}" as expected`, () => { + const sut = new CodeSubstituterBuilder().build(); + // act + const act = () => sut.substitute(testCase.parameters.code, testCase.parameters.info); + // assert + expect(act).to.throw(testCase.expectedError); + }); + } + }); + describe('substitutes parameters as expected values', () => { + // arrange + const info = new ProjectInformationStub(); + const date = new Date(); + const testCases = [ + { + parameter: 'homepage', + argument: info.homepage, + }, + { + parameter: 'version', + argument: info.version, + }, + { + parameter: 'date', + argument: date.toUTCString(), + }, + ]; + for (const testCase of testCases) { + it(`substitutes ${testCase.parameter} as expected`, () => { + const compilerStub = new ExpressionsCompilerStub(); + const sut = new CodeSubstituterBuilder() + .withCompiler(compilerStub) + .withDate(date) + .build(); + // act + sut.substitute('non empty code', info); + // assert + expect(compilerStub.callHistory).to.have.lengthOf(1); + expect(compilerStub.callHistory[0].parameters[testCase.parameter]).to.equal(testCase.argument); + }); + } + }); + it('returns code as it is', () => { + // arrange + const expected = 'expected-code'; + const compilerStub = new ExpressionsCompilerStub(); + const sut = new CodeSubstituterBuilder() + .withCompiler(compilerStub) + .build(); + // act + sut.substitute(expected, new ProjectInformationStub()); + // assert + expect(compilerStub.callHistory).to.have.lengthOf(1); + expect(compilerStub.callHistory[0].code).to.equal(expected); + }); +}); + +class CodeSubstituterBuilder { + private compiler: IExpressionsCompiler = new ExpressionsCompilerStub(); + private date = new Date(); + public withCompiler(compiler: IExpressionsCompiler) { + this.compiler = compiler; + return this; + } + public withDate(date: Date) { + this.date = date; + return this; + } + public build() { + return new CodeSubstituter(this.compiler, this.date); + } +} diff --git a/tests/unit/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.spec.ts b/tests/unit/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.spec.ts new file mode 100644 index 00000000..2118fb1e --- /dev/null +++ b/tests/unit/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.spec.ts @@ -0,0 +1,110 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; +import { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser'; +import { IEnumParser } from '@/application/Common/Enum'; +import { ICodeSubstituter } from '@/application/Parser/ScriptingDefinition/ICodeSubstituter'; +import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { ProjectInformationStub } from '../../../stubs/ProjectInformationStub'; +import { EnumParserStub } from '../../../stubs/EnumParserStub'; +import { ScriptingDefinitionDataStub } from '../../../stubs/ScriptingDefinitionDataStub'; +import { CodeSubstituterStub } from '../../../stubs/CodeSubstituterStub'; + +describe('ScriptingDefinitionParser', () => { + describe('parseScriptingDefinition', () => { + it('throws when info is undefined', () => { + // arrange + const info = undefined; + const definition = new ScriptingDefinitionDataStub(); + const sut = new ScriptingDefinitionParserBuilder() + .build(); + // act + const act = () => sut.parse(definition, info); + // assert + expect(act).to.throw('undefined info'); + }); + it('throws when definition is undefined', () => { + // arrange + const info = new ProjectInformationStub(); + const definition = undefined; + const sut = new ScriptingDefinitionParserBuilder() + .build(); + // act + const act = () => sut.parse(definition, info); + // assert + expect(act).to.throw('undefined definition'); + }); + describe('language', () => { + it('parses as expected', () => { + // arrange + const expectedLanguage = ScriptingLanguage.batchfile; + const languageText = 'batchfile'; + const expectedName = 'language'; + const info = new ProjectInformationStub(); + const definition = new ScriptingDefinitionDataStub() + .withLanguage(languageText); + const parserMock = new EnumParserStub() + .setup(expectedName, languageText, expectedLanguage); + const sut = new ScriptingDefinitionParserBuilder() + .withParser(parserMock) + .build(); + // act + const actual = sut.parse(definition, info); + // assert + expect(actual.language).to.equal(expectedLanguage); + }); + }); + describe('substitutes code as expected', () => { + // arrange + const code = 'hello'; + const expected = 'substituted'; + const testCases = [ + { + name: 'startCode', + getActualValue: (result: IScriptingDefinition) => result.startCode, + data: new ScriptingDefinitionDataStub() + .withStartCode(code), + }, + { + name: 'endCode', + getActualValue: (result: IScriptingDefinition) => result.endCode, + data: new ScriptingDefinitionDataStub() + .withEndCode(code), + }, + ]; + for (const testCase of testCases) { + it(testCase.name, () => { + const info = new ProjectInformationStub(); + const substituterMock = new CodeSubstituterStub() + .setup(code, info, expected); + const sut = new ScriptingDefinitionParserBuilder() + .withSubstituter(substituterMock) + .build(); + // act + const definition = sut.parse(testCase.data, info); + // assert + const actual = testCase.getActualValue(definition); + expect(actual).to.equal(expected); + }); + } + }); + }); +}); + +class ScriptingDefinitionParserBuilder { + private languageParser: IEnumParser = new EnumParserStub() + .setupDefaultValue(ScriptingLanguage.shellscript); + private codeSubstituter: ICodeSubstituter = new CodeSubstituterStub(); + + public withParser(parser: IEnumParser) { + this.languageParser = parser; + return this; + } + public withSubstituter(substituter: ICodeSubstituter) { + this.codeSubstituter = substituter; + return this; + } + public build() { + return new ScriptingDefinitionParser(this.languageParser, this.codeSubstituter); + } +} diff --git a/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts b/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts deleted file mode 100644 index 0a1664e0..00000000 --- a/tests/unit/application/Parser/ScriptingDefinitionParser.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import 'mocha'; -import { expect } from 'chai'; -import { ScriptingDefinitionData } from 'js-yaml-loader!@/*'; -import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; -import { parseScriptingDefinition } from '@/application/Parser/ScriptingDefinitionParser'; -import { ProjectInformationStub } from './../../stubs/ProjectInformationStub'; -import { mockEnumParser } from '../../stubs/EnumParserStub'; - -describe('ScriptingDefinitionParser', () => { - describe('parseScriptingDefinition', () => { - it('throws when info is undefined', () => { - // arrange - const info = undefined; - const definition = new ScriptingDefinitionBuilder().construct(); - // act - const act = () => parseScriptingDefinition(definition, info); - // assert - expect(act).to.throw('undefined info'); - }); - it('throws when definition is undefined', () => { - // arrange - const info = new ProjectInformationStub(); - const definition = undefined; - // act - const act = () => parseScriptingDefinition(definition, info); - // assert - expect(act).to.throw('undefined definition'); - }); - describe('language', () => { - it('parses as expected', () => { - // arrange - const expectedLanguage = ScriptingLanguage.batchfile; - const languageText = 'batchfile'; - const expectedName = 'language'; - const info = new ProjectInformationStub(); - const definition = new ScriptingDefinitionBuilder() - .withLanguage(languageText) - .construct(); - const parserMock = mockEnumParser(expectedName, languageText, expectedLanguage); - // act - const actual = parseScriptingDefinition(definition, info, new Date(), parserMock); - // assert - expect(actual.language).to.equal(expectedLanguage); - }); - }); - describe('fileExtension', () => { - it('parses as expected', () => { - // arrange - const expected = 'bat'; - const info = new ProjectInformationStub(); - const file = new ScriptingDefinitionBuilder() - .withExtension(expected).construct(); - // act - const definition = parseScriptingDefinition(file, info); - // assert - const actual = definition.fileExtension; - expect(actual).to.equal(expected); - }); - }); - describe('startCode', () => { - it('sets as it is', () => { - // arrange - const expected = 'expected-start-code'; - const info = new ProjectInformationStub(); - const file = new ScriptingDefinitionBuilder().withStartCode(expected).construct(); - // act - const definition = parseScriptingDefinition(file, info); - // assert - expect(definition.startCode).to.equal(expected); - }); - it('substitutes as expected', () => { - // arrange - const code = 'homepage: {{ $homepage }}, version: {{ $version }}, date: {{ $date }}'; - const homepage = 'https://cloudarchitecture.io'; - const version = '1.0.2'; - const date = new Date(); - const expected = `homepage: ${homepage}, version: ${version}, date: ${date.toUTCString()}`; - const info = new ProjectInformationStub().withHomepageUrl(homepage).withVersion(version); - const file = new ScriptingDefinitionBuilder().withStartCode(code).construct(); - // act - const definition = parseScriptingDefinition(file, info, date); - // assert - const actual = definition.startCode; - expect(actual).to.equal(expected); - }); - }); - describe('endCode', () => { - it('sets as it is', () => { - // arrange - const expected = 'expected-end-code'; - const info = new ProjectInformationStub(); - const file = new ScriptingDefinitionBuilder().withEndCode(expected).construct(); - // act - const definition = parseScriptingDefinition(file, info); - // assert - expect(definition.endCode).to.equal(expected); - }); - it('substitutes as expected', () => { - // arrange - const code = 'homepage: {{ $homepage }}, version: {{ $version }}, date: {{ $date }}'; - const homepage = 'https://cloudarchitecture.io'; - const version = '1.0.2'; - const date = new Date(); - const expected = `homepage: ${homepage}, version: ${version}, date: ${date.toUTCString()}`; - const info = new ProjectInformationStub().withHomepageUrl(homepage).withVersion(version); - const file = new ScriptingDefinitionBuilder().withEndCode(code).construct(); - // act - const definition = parseScriptingDefinition(file, info, date); - // assert - const actual = definition.endCode; - expect(actual).to.equal(expected); - }); - }); - }); -}); - -class ScriptingDefinitionBuilder { - private language = ScriptingLanguage[ScriptingLanguage.batchfile]; - private fileExtension = 'bat'; - private startCode = 'startCode'; - private endCode = 'endCode'; - - public withLanguage(language: string): ScriptingDefinitionBuilder { - this.language = language; - return this; - } - - public withStartCode(startCode: string): ScriptingDefinitionBuilder { - this.startCode = startCode; - return this; - } - - public withEndCode(endCode: string): ScriptingDefinitionBuilder { - this.endCode = endCode; - return this; - } - - public withExtension(extension: string): ScriptingDefinitionBuilder { - this.fileExtension = extension; - return this; - } - - public construct(): ScriptingDefinitionData { - return { - language: this.language, - fileExtension: this.fileExtension, - startCode: this.startCode, - endCode: this.endCode, - }; - } -} diff --git a/tests/unit/domain/ScriptCode.spec.ts b/tests/unit/domain/ScriptCode.spec.ts index 769e926f..6e07da74 100644 --- a/tests/unit/domain/ScriptCode.spec.ts +++ b/tests/unit/domain/ScriptCode.spec.ts @@ -53,7 +53,7 @@ describe('ScriptCode', () => { { testName: 'cannot construct with duplicate lines', code: 'duplicate\nduplicate\ntest\nduplicate', - expectedMessage: 'Duplicates detected in script :\n duplicate\nduplicate', + expectedMessage: 'Duplicates detected in script:\n(0) - duplicate\n(1) - duplicate', }, { testName: 'cannot construct with empty lines', diff --git a/tests/unit/stubs/CodeSubstituterStub.ts b/tests/unit/stubs/CodeSubstituterStub.ts new file mode 100644 index 00000000..6388a235 --- /dev/null +++ b/tests/unit/stubs/CodeSubstituterStub.ts @@ -0,0 +1,17 @@ +import { IProjectInformation } from '@/domain/IProjectInformation'; +import { ICodeSubstituter } from '@/application/Parser/ScriptingDefinition/ICodeSubstituter'; + +export class CodeSubstituterStub implements ICodeSubstituter { + private readonly scenarios = new Array<{ code: string, info: IProjectInformation, result: string}>(); + public substitute(code: string, info: IProjectInformation): string { + const scenario = this.scenarios.find((s) => s.code === code && s.info === info); + if (scenario) { + return scenario.result; + } + return `[CodeSubstituterStub] - code: ${code}`; + } + public setup( code: string, info: IProjectInformation, result: string) { + this.scenarios.push({ code, info, result}); + return this; + } +} diff --git a/tests/unit/stubs/EnumParserStub.ts b/tests/unit/stubs/EnumParserStub.ts index 27d5b90c..3b44d9ae 100644 --- a/tests/unit/stubs/EnumParserStub.ts +++ b/tests/unit/stubs/EnumParserStub.ts @@ -1,15 +1,24 @@ import { IEnumParser } from '@/application/Common/Enum'; -export function mockEnumParser(inputName: string, inputValue: string, outputValue: T): IEnumParser { - return { - parseEnum: (value, name) => { - if (name !== inputName) { - throw new Error(`Unexpected name: "${name}"`); - } - if (value !== inputValue) { - throw new Error(`Unexpected value: "${value}"`); - } - return outputValue; - }, - }; +export class EnumParserStub implements IEnumParser { + private readonly scenarios = new Array<{ inputName: string, inputValue: string, outputValue: T }>(); + private defaultValue: T; + public setup(inputName: string, inputValue: string, outputValue: T) { + this.scenarios.push({inputName, inputValue, outputValue}); + return this; + } + public setupDefaultValue(outputValue: T) { + this.defaultValue = outputValue; + return this; + } + public parseEnum(value: string, propertyName: string): T { + const scenario = this.scenarios.find((s) => s.inputName === propertyName && s.inputValue === value); + if (scenario) { + return scenario.outputValue; + } + if (this.defaultValue) { + return this.defaultValue; + } + throw new Error('enum parser is not set up'); + } } diff --git a/tests/unit/stubs/ExpressionParserStub.ts b/tests/unit/stubs/ExpressionParserStub.ts new file mode 100644 index 00000000..ddc96638 --- /dev/null +++ b/tests/unit/stubs/ExpressionParserStub.ts @@ -0,0 +1,15 @@ +import { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; +import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser'; + +export class ExpressionParserStub implements IExpressionParser { + public callHistory = new Array(); + private result: IExpression[] = []; + public withResult(result: IExpression[]) { + this.result = result; + return this; + } + public findExpressions(code: string): IExpression[] { + this.callHistory.push(code); + return this.result; + } +} diff --git a/tests/unit/stubs/ExpressionStub.ts b/tests/unit/stubs/ExpressionStub.ts new file mode 100644 index 00000000..e729e0f6 --- /dev/null +++ b/tests/unit/stubs/ExpressionStub.ts @@ -0,0 +1,26 @@ +import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; +import { ExpressionArguments, IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; + +export class ExpressionStub implements IExpression { + public callHistory = new Array(); + public position = new ExpressionPosition(0, 5); + public parameters = []; + private result: string; + public withParameters(...parameters: string[]) { + this.parameters = parameters; + return this; + } + public withPosition(start: number, end: number) { + this.position = new ExpressionPosition(start, end); + return this; + } + public withEvaluatedResult(result: string) { + this.result = result; + return this; + } + public evaluate(args?: ExpressionArguments): string { + this.callHistory.push(args); + const result = this.result || `[expression-stub] args: ${args ? Object.keys(args).map((key) => `${key}: ${args[key]}`).join('", "') : 'none'}`; + return result; + } +} diff --git a/tests/unit/stubs/ExpressionsCompilerStub.ts b/tests/unit/stubs/ExpressionsCompilerStub.ts index d3fee408..aa2b4a49 100644 --- a/tests/unit/stubs/ExpressionsCompilerStub.ts +++ b/tests/unit/stubs/ExpressionsCompilerStub.ts @@ -3,12 +3,14 @@ import { IExpressionsCompiler, ParameterValueDictionary } from '@/application/Pa interface Scenario { code: string; parameters: ParameterValueDictionary; result: string; } export class ExpressionsCompilerStub implements IExpressionsCompiler { + public readonly callHistory = new Array<{code: string, parameters?: ParameterValueDictionary}>(); private readonly scenarios = new Array(); public setup(code: string, parameters: ParameterValueDictionary, result: string) { this.scenarios.push({ code, parameters, result }); return this; } public compileExpressions(code: string, parameters?: ParameterValueDictionary): string { + this.callHistory.push({ code, parameters}); const scenario = this.scenarios.find((s) => s.code === code && deepEqual(s.parameters, parameters)); if (scenario) { return scenario.result; diff --git a/tests/unit/stubs/ScriptingDefinitionDataStub.ts b/tests/unit/stubs/ScriptingDefinitionDataStub.ts new file mode 100644 index 00000000..3a8ab3e9 --- /dev/null +++ b/tests/unit/stubs/ScriptingDefinitionDataStub.ts @@ -0,0 +1,29 @@ +import { ScriptingDefinitionData } from 'js-yaml-loader!@/*'; +import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; + +export class ScriptingDefinitionDataStub implements ScriptingDefinitionData { + public language = ScriptingLanguage[ScriptingLanguage.batchfile]; + public fileExtension = 'bat'; + public startCode = 'startCode'; + public endCode = 'endCode'; + + public withLanguage(language: string): ScriptingDefinitionDataStub { + this.language = language; + return this; + } + + public withStartCode(startCode: string): ScriptingDefinitionDataStub { + this.startCode = startCode; + return this; + } + + public withEndCode(endCode: string): ScriptingDefinitionDataStub { + this.endCode = endCode; + return this; + } + + public withExtension(extension: string): ScriptingDefinitionDataStub { + this.fileExtension = extension; + return this; + } +}