diff --git a/docs/templating.md b/docs/templating.md index 96373fad..b6d33c64 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -10,10 +10,19 @@ - Expressions start and end with mustaches (double brackets, `{{` and `}}`). - E.g. `Hello {{ $name }} !` -- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) that has inspired this templating language. +- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same. - Functions enables usage of expressions. - In script definition parts of a function, see [`Function`](./collection-files.md#Function). - When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function). +- Expressions inside expressions (nested templates) are supported. + - An expression can output another expression that will also be compiled. + - E.g. following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output. + + ```go + {{ with $condition }} + echo {{ $text }} + {{ end }} + ``` ### Parameter substitution @@ -74,6 +83,14 @@ It supports multiline text inside the block. You can have something like: {{ end }} ``` +You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution): + +```go + {{ with $condition }} + This is a different parameter: {{ $text }} + {{ end }} +``` + 💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`. Example: diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts index d6f14f54..e43b0239 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.ts @@ -13,4 +13,22 @@ export class ExpressionPosition { throw Error(`negative start position: ${start}`); } } + + public isInInsideOf(potentialParent: ExpressionPosition): boolean { + if (this.isSame(potentialParent)) { + return false; + } + return potentialParent.start <= this.start + && potentialParent.end >= this.end; + } + + public isSame(other: ExpressionPosition): boolean { + return other.start === this.start + && other.end === this.end; + } + + public isIntersecting(other: ExpressionPosition): boolean { + return (other.start < this.end && other.end > this.start) + || (this.end > other.start && other.start >= this.start); + } } diff --git a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts index 9177a77e..ec81a581 100644 --- a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts +++ b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts @@ -20,21 +20,59 @@ export class ExpressionsCompiler implements IExpressionsCompiler { if (!code) { return code; } - const expressions = this.extractor.findExpressions(code); - ensureParamsUsedInCodeHasArgsProvided(expressions, args); const context = new ExpressionEvaluationContext(args); - const compiledCode = compileExpressions(expressions, code, context); + const compiledCode = compileRecursively(code, context, this.extractor); return compiledCode; } } +function compileRecursively( + code: string, + context: IExpressionEvaluationContext, + extractor: IExpressionParser, +): string { + /* + Instead of compiling code at once and returning we compile expressions from the code. + And recompile expressions from resulting code recursively. + This allows using expressions inside expressions blocks. E.g.: + ``` + {{ with $condition }} + echo '{{ $text }}' + {{ end }} + ``` + Without recursing parameter substitution for '{{ $text }}' is skipped once the outer + {{ with $condition }} is rendered. + A more optimized alternative to recursion would be to a parse an expression tree + instead of linear expression lists. + */ + if (!code) { + return code; + } + const expressions = extractor.findExpressions(code); + if (expressions.length === 0) { + return code; + } + const compiledCode = compileExpressions(expressions, code, context); + return compileRecursively(compiledCode, context, extractor); +} + function compileExpressions( expressions: readonly IExpression[], code: string, context: IExpressionEvaluationContext, ) { + ensureValidExpressions(expressions, code, context); let compiledCode = ''; - const sortedExpressions = expressions + const outerExpressions = expressions.filter( + (expression) => expressions + .filter((otherExpression) => otherExpression !== expression) + .every((otherExpression) => !expression.position.isInInsideOf(otherExpression.position)), + ); + /* + This logic will only compile outer expressions if there were nested expressions. + So the output of this compilation may result in new uncompiled expressions. + */ + const sortedExpressions = outerExpressions .slice() // copy the array to not mutate the parameter .sort((a, b) => b.position.start - a.position.start); let index = 0; @@ -65,6 +103,43 @@ function extractRequiredParameterNames( .filter((name, index, array) => array.indexOf(name) === index); // Remove duplicates } +function printList(list: readonly string[]): string { + return `"${list.join('", "')}"`; +} + +function ensureValidExpressions( + expressions: readonly IExpression[], + code: string, + context: IExpressionEvaluationContext, +) { + ensureParamsUsedInCodeHasArgsProvided(expressions, context.args); + ensureExpressionsDoesNotExtendCodeLength(expressions, code); + ensureNoExpressionsAtSamePosition(expressions); + ensureNoInvalidIntersections(expressions); +} + +function ensureExpressionsDoesNotExtendCodeLength( + expressions: readonly IExpression[], + code: string, +) { + const expectedMax = code.length; + const expressionsOutOfRange = expressions + .filter((expression) => expression.position.end > expectedMax); + if (expressionsOutOfRange.length > 0) { + throw new Error(`Expressions out of range:\n${JSON.stringify(expressionsOutOfRange)}`); + } +} + +function ensureNoExpressionsAtSamePosition(expressions: readonly IExpression[]) { + const instructionsAtSamePosition = expressions.filter( + (expression) => expressions + .filter((other) => expression.position.isSame(other.position)).length > 1, + ); + if (instructionsAtSamePosition.length > 0) { + throw new Error(`Instructions at same position:\n${JSON.stringify(instructionsAtSamePosition)}`); + } +} + function ensureParamsUsedInCodeHasArgsProvided( expressions: readonly IExpression[], providedArgs: IReadOnlyFunctionCallArgumentCollection, @@ -80,6 +155,16 @@ function ensureParamsUsedInCodeHasArgsProvided( } } -function printList(list: readonly string[]): string { - return `"${list.join('", "')}"`; +function ensureNoInvalidIntersections(expressions: readonly IExpression[]) { + const intersectingInstructions = expressions.filter( + (expression) => expressions + .filter((other) => expression.position.isIntersecting(other.position)) + .filter((other) => !expression.position.isSame(other.position)) + .filter((other) => !expression.position.isInInsideOf(other.position)) + .filter((other) => !other.position.isInInsideOf(expression.position)) + .length > 0, + ); + if (intersectingInstructions.length > 0) { + throw new Error(`Instructions intersecting unexpectedly:\n${JSON.stringify(intersectingInstructions)}`); + } } 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 index baf6aeb9..5033023e 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition.spec.ts @@ -31,4 +31,184 @@ describe('ExpressionPosition', () => { } }); }); + 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); + }); + } + }); }); 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 a7630ede..cfc00c19 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts @@ -6,10 +6,11 @@ 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 { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; describe('ExpressionsCompiler', () => { describe('compileExpressions', () => { - describe('returns code when it is absent', () => { + describe('returns code when code is absent', () => { itEachAbsentStringValue((absentValue) => { // arrange const expected = absentValue; @@ -21,6 +22,99 @@ describe('ExpressionsCompiler', () => { expect(value).to.equal(expected); }); }); + 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'; @@ -60,7 +154,7 @@ describe('ExpressionsCompiler', () => { for (const testCase of testCases) { it(testCase.name, () => { const expressionParserMock = new ExpressionParserStub() - .withResult(testCase.expressions); + .withResult(code, testCase.expressions); const args = new FunctionCallArgumentCollectionStub(); const sut = new SystemUnderTest(expressionParserMock); // act @@ -75,21 +169,26 @@ describe('ExpressionsCompiler', () => { // arrange const expected = new FunctionCallArgumentCollectionStub() .withArgument('test-arg', 'test-value'); - const code = 'non-important'; + const code = 'longer than 6 characters'; const expressions = [ - new ExpressionStub(), - new ExpressionStub(), + new ExpressionStub().withPosition(0, 3), + new ExpressionStub().withPosition(3, 6), ]; const expressionParserMock = new ExpressionParserStub() - .withResult(expressions); + .withResult(code, expressions); const sut = new SystemUnderTest(expressionParserMock); // act sut.compileExpressions(code, expected); // assert - expect(expressions[0].callHistory).to.have.lengthOf(1); - expect(expressions[0].callHistory[0].args).to.equal(expected); - expect(expressions[1].callHistory).to.have.lengthOf(1); - expect(expressions[1].callHistory[0].args).to.equal(expected); + 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 if arguments is missing', () => { itEachAbsentObjectValue((absentValue) => { @@ -105,66 +204,122 @@ describe('ExpressionsCompiler', () => { }); }); }); - 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(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 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 diff --git a/tests/unit/shared/Stubs/ExpressionParserStub.ts b/tests/unit/shared/Stubs/ExpressionParserStub.ts index 00965df5..8a4a1df8 100644 --- a/tests/unit/shared/Stubs/ExpressionParserStub.ts +++ b/tests/unit/shared/Stubs/ExpressionParserStub.ts @@ -4,15 +4,25 @@ import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressi export class ExpressionParserStub implements IExpressionParser { public callHistory = new Array(); - private result: IExpression[] = []; + private results = new Map(); - public withResult(result: IExpression[]) { - this.result = result; + public withResult(code: string, result: readonly IExpression[]) { + if (this.results.has(code)) { + throw new Error( + 'Result for code is already registered.' + + `\nCode: ${code}` + + `\nResult: ${JSON.stringify(result)}`, + ); + } + this.results.set(code, result); return this; } public findExpressions(code: string): IExpression[] { this.callHistory.push(code); - return this.result; + if (this.results.has(code)) { + return [...this.results.get(code)]; + } + return []; } } diff --git a/tests/unit/shared/Stubs/ExpressionStub.ts b/tests/unit/shared/Stubs/ExpressionStub.ts index 2e5e678a..1579fdf1 100644 --- a/tests/unit/shared/Stubs/ExpressionStub.ts +++ b/tests/unit/shared/Stubs/ExpressionStub.ts @@ -11,7 +11,7 @@ export class ExpressionStub implements IExpression { public parameters: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub(); - private result: string; + private result: string = undefined; public withParameters(parameters: IReadOnlyFunctionParameterCollection) { this.parameters = parameters; @@ -35,9 +35,11 @@ export class ExpressionStub implements IExpression { } public evaluate(context: IExpressionEvaluationContext): string { - const { args } = context; this.callHistory.push(context); - const result = this.result || `[expression-stub] args: ${args ? Object.keys(args).map((key) => `${key}: ${args[key]}`).join('", "') : 'none'}`; - return result; + if (this.result === undefined /* not empty string */) { + const { args } = context; + return `[expression-stub] args: ${args ? Object.keys(args).map((key) => `${key}: ${args[key]}`).join('", "') : 'none'}`; + } + return this.result; } }