Refactor to unify scripts/categories as Executable
This commit consolidates scripts and categories under a unified 'Executable' concept. This simplifies the architecture and improves code readability. - Introduce subfolders within `src/domain` to segregate domain elements. - Update class and interface names by removing the 'I' prefix in alignment with new coding standards. - Replace 'Node' with 'Executable' to clarify usage; reserve 'Node' exclusively for the UI's tree component.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { type ExpressionEvaluator, Expression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/Expression';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
|
||||
import { FunctionCallArgumentStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentStub';
|
||||
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
|
||||
import type { IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { IExpressionEvaluationContext } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
|
||||
describe('Expression', () => {
|
||||
describe('ctor', () => {
|
||||
describe('position', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new ExpressionPosition(0, 5);
|
||||
// act
|
||||
const actual = new ExpressionBuilder()
|
||||
.withPosition(expected)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual.position).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('parameters', () => {
|
||||
describe('defaults to empty array if absent', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const parameters = absentValue;
|
||||
// act
|
||||
const actual = new ExpressionBuilder()
|
||||
.withParameters(parameters)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual.parameters);
|
||||
expect(actual.parameters.all);
|
||||
expect(actual.parameters.all.length).to.equal(0);
|
||||
}, { excludeNull: true });
|
||||
});
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionParameterCollectionStub()
|
||||
.withParameterName('firstParameterName')
|
||||
.withParameterName('secondParameterName');
|
||||
// act
|
||||
const actual = new ExpressionBuilder()
|
||||
.withParameters(expected)
|
||||
.build();
|
||||
// assert
|
||||
expect(actual.parameters).to.deep.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('evaluate', () => {
|
||||
describe('throws with invalid arguments', () => {
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
context: IExpressionEvaluationContext,
|
||||
expectedError: string,
|
||||
sutBuilder?: (builder: ExpressionBuilder) => ExpressionBuilder,
|
||||
}[] = [
|
||||
{
|
||||
name: 'throws when some of the required args are not provided',
|
||||
sutBuilder: (i: ExpressionBuilder) => i.withParameterNames(['a', 'b', 'c'], false),
|
||||
context: new ExpressionEvaluationContextStub()
|
||||
.withArgs(new FunctionCallArgumentCollectionStub().withArgument('b', 'provided')),
|
||||
expectedError: 'argument values are provided for required parameters: "a", "c"',
|
||||
},
|
||||
{
|
||||
name: 'throws when none of the required args are not provided',
|
||||
sutBuilder: (i: ExpressionBuilder) => i.withParameterNames(['a', 'b'], false),
|
||||
context: new ExpressionEvaluationContextStub()
|
||||
.withArgs(new FunctionCallArgumentCollectionStub().withArgument('c', 'unrelated')),
|
||||
expectedError: 'argument values are provided for required parameters: "a", "b"',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const sutBuilder = new ExpressionBuilder();
|
||||
if (testCase.sutBuilder) {
|
||||
testCase.sutBuilder(sutBuilder);
|
||||
}
|
||||
const sut = sutBuilder.build();
|
||||
// act
|
||||
const act = () => sut.evaluate(testCase.context);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
it('returns result from evaluator', () => {
|
||||
// arrange
|
||||
const evaluatorMock: ExpressionEvaluator = (c) => `"${c
|
||||
.args
|
||||
.getAllParameterNames()
|
||||
.map((name) => context.args.getArgument(name))
|
||||
.map((arg) => `${arg.parameterName}': '${arg.argumentValue}'`)
|
||||
.join('", "')}"`;
|
||||
const givenArguments = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameter1', 'value1')
|
||||
.withArgument('parameter2', 'value2');
|
||||
const expectedParameterNames = givenArguments.getAllParameterNames();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withArgs(givenArguments);
|
||||
const expected = evaluatorMock(context);
|
||||
const sut = new ExpressionBuilder()
|
||||
.withEvaluator(evaluatorMock)
|
||||
.withParameterNames(expectedParameterNames)
|
||||
.build();
|
||||
// arrange
|
||||
const actual = sut.evaluate(context);
|
||||
// assert
|
||||
expect(expected).to.equal(actual, formatAssertionMessage([
|
||||
`Given arguments: ${JSON.stringify(givenArguments)}`,
|
||||
`Expected parameter names: ${JSON.stringify(expectedParameterNames)}`,
|
||||
]));
|
||||
});
|
||||
it('sends pipeline compiler as it is', () => {
|
||||
// arrange
|
||||
const expected = new PipelineCompilerStub();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withPipelineCompiler(expected);
|
||||
let actual: IPipelineCompiler | undefined;
|
||||
const evaluatorMock: ExpressionEvaluator = (c) => {
|
||||
actual = c.pipelineCompiler;
|
||||
return '';
|
||||
};
|
||||
const sut = new ExpressionBuilder()
|
||||
.withEvaluator(evaluatorMock)
|
||||
.build();
|
||||
// arrange
|
||||
sut.evaluate(context);
|
||||
// assert
|
||||
expectExists(actual);
|
||||
expect(expected).to.equal(actual);
|
||||
});
|
||||
describe('filters unused parameters', () => {
|
||||
// arrange
|
||||
const testCases = [
|
||||
{
|
||||
name: 'with a provided argument',
|
||||
expressionParameters: new FunctionParameterCollectionStub()
|
||||
.withParameterName('parameterToHave', false),
|
||||
arguments: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameterToHave', 'value-to-have')
|
||||
.withArgument('parameterToIgnore', 'value-to-ignore'),
|
||||
expectedArguments: [
|
||||
new FunctionCallArgumentStub()
|
||||
.withParameterName('parameterToHave').withArgumentValue('value-to-have'),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'without a provided argument',
|
||||
expressionParameters: new FunctionParameterCollectionStub()
|
||||
.withParameterName('parameterToHave', false)
|
||||
.withParameterName('parameterToIgnore', true),
|
||||
arguments: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameterToHave', 'value-to-have'),
|
||||
expectedArguments: [
|
||||
new FunctionCallArgumentStub()
|
||||
.withParameterName('parameterToHave').withArgumentValue('value-to-have'),
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
let actual: IReadOnlyFunctionCallArgumentCollection | undefined;
|
||||
const evaluatorMock: ExpressionEvaluator = (c) => {
|
||||
actual = c.args;
|
||||
return '';
|
||||
};
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withArgs(testCase.arguments);
|
||||
const sut = new ExpressionBuilder()
|
||||
.withEvaluator(evaluatorMock)
|
||||
.withParameters(testCase.expressionParameters)
|
||||
.build();
|
||||
// act
|
||||
sut.evaluate(context);
|
||||
// assert
|
||||
expectExists(actual);
|
||||
const collection = actual;
|
||||
const actualArguments = collection.getAllParameterNames()
|
||||
.map((name) => collection.getArgument(name));
|
||||
expect(actualArguments).to.deep.equal(testCase.expectedArguments);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ExpressionBuilder {
|
||||
private position: ExpressionPosition = new ExpressionPosition(0, 5);
|
||||
|
||||
private parameters?: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub();
|
||||
|
||||
public withPosition(position: ExpressionPosition) {
|
||||
this.position = position;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withEvaluator(evaluator: ExpressionEvaluator) {
|
||||
this.evaluator = evaluator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParameters(parameters: IReadOnlyFunctionParameterCollection | undefined) {
|
||||
this.parameters = parameters;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withParameterName(parameterName: string, isOptional = true) {
|
||||
const collection = new FunctionParameterCollectionStub()
|
||||
.withParameterName(parameterName, isOptional);
|
||||
return this.withParameters(collection);
|
||||
}
|
||||
|
||||
public withParameterNames(parameterNames: string[], isOptional = true) {
|
||||
const collection = new FunctionParameterCollectionStub()
|
||||
.withParameterNames(parameterNames, isOptional);
|
||||
return this.withParameters(collection);
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new Expression({
|
||||
position: this.position,
|
||||
evaluator: this.evaluator,
|
||||
parameters: this.parameters,
|
||||
});
|
||||
}
|
||||
|
||||
private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionEvaluationContext, type IExpressionEvaluationContext } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import type { IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
|
||||
describe('ExpressionEvaluationContext', () => {
|
||||
describe('ctor', () => {
|
||||
describe('args', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('expectedParameter', 'expectedValue');
|
||||
const builder = new ExpressionEvaluationContextBuilder()
|
||||
.withArgs(expected);
|
||||
// act
|
||||
const sut = builder.build();
|
||||
// assert
|
||||
const actual = sut.args;
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
});
|
||||
describe('pipelineCompiler', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expected = new PipelineCompilerStub();
|
||||
const builder = new ExpressionEvaluationContextBuilder()
|
||||
.withPipelineCompiler(expected);
|
||||
// act
|
||||
const sut = builder.build();
|
||||
// assert
|
||||
expect(sut.pipelineCompiler).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class ExpressionEvaluationContextBuilder {
|
||||
private args: IReadOnlyFunctionCallArgumentCollection = new FunctionCallArgumentCollectionStub();
|
||||
|
||||
private pipelineCompiler: IPipelineCompiler = new PipelineCompilerStub();
|
||||
|
||||
public withArgs(args: IReadOnlyFunctionCallArgumentCollection) {
|
||||
this.args = args;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withPipelineCompiler(pipelineCompiler: IPipelineCompiler) {
|
||||
this.pipelineCompiler = pipelineCompiler;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): IExpressionEvaluationContext {
|
||||
return new ExpressionEvaluationContext(this.args, this.pipelineCompiler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
|
||||
describe('ExpressionPosition', () => {
|
||||
describe('ctor', () => {
|
||||
it('sets as expected', () => {
|
||||
// arrange
|
||||
const expectedStart = 0;
|
||||
const expectedEnd = 5;
|
||||
// act
|
||||
const sut = new ExpressionPosition(expectedStart, expectedEnd);
|
||||
// assert
|
||||
expect(sut.start).to.equal(expectedStart);
|
||||
expect(sut.end).to.equal(expectedEnd);
|
||||
});
|
||||
describe('throws when invalid', () => {
|
||||
// arrange
|
||||
const testCases = [
|
||||
{ start: 5, end: 5, error: 'no length (start = end = 5)' },
|
||||
{ start: 5, end: 3, error: 'start (5) after end (3)' },
|
||||
{ start: -1, end: 3, error: 'negative start position: -1' },
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.error, () => {
|
||||
// act
|
||||
const act = () => new ExpressionPosition(testCase.start, testCase.end);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('isInInsideOf', () => {
|
||||
// arrange
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
sut: ExpressionPosition,
|
||||
potentialParent: ExpressionPosition,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
{
|
||||
name: 'true; when other contains sut inside boundaries',
|
||||
sut: new ExpressionPosition(4, 8),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when other contains sut with same upper boundary',
|
||||
sut: new ExpressionPosition(4, 10),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when other contains sut with same lower boundary',
|
||||
sut: new ExpressionPosition(0, 8),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'false; when other is same as sut',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut contains other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(4, 8),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts and ends before other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(15, 25),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts before other but ends inside other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(5, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts inside other but ends after other',
|
||||
sut: new ExpressionPosition(5, 11),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut starts at same position but end after other',
|
||||
sut: new ExpressionPosition(0, 11),
|
||||
potentialParent: new ExpressionPosition(0, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when sut ends at same positions but start before other',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
potentialParent: new ExpressionPosition(1, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = testCase.sut.isInInsideOf(testCase.potentialParent);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('isSame', () => {
|
||||
// arrange
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
sut: ExpressionPosition,
|
||||
other: ExpressionPosition,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
{
|
||||
name: 'true; when positions are same',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'false; when start position is different',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(1, 10),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when end position is different',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(0, 11),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when both start and end positions are different',
|
||||
sut: new ExpressionPosition(0, 10),
|
||||
other: new ExpressionPosition(20, 30),
|
||||
expectedResult: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = testCase.sut.isSame(testCase.other);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('isIntersecting', () => {
|
||||
// arrange
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
first: ExpressionPosition,
|
||||
second: ExpressionPosition,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
{
|
||||
name: 'true; when one contains other inside boundaries',
|
||||
first: new ExpressionPosition(4, 8),
|
||||
second: new ExpressionPosition(0, 10),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when one starts inside other\'s ending boundary without being contained',
|
||||
first: new ExpressionPosition(0, 10),
|
||||
second: new ExpressionPosition(9, 15),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when positions are the same',
|
||||
first: new ExpressionPosition(0, 5),
|
||||
second: new ExpressionPosition(0, 5),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'true; when one starts inside other\'s starting boundary without being contained',
|
||||
first: new ExpressionPosition(5, 10),
|
||||
second: new ExpressionPosition(5, 11),
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: 'false; when one starts directly after other',
|
||||
first: new ExpressionPosition(0, 10),
|
||||
second: new ExpressionPosition(10, 20),
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: 'false; when one starts after other with margin',
|
||||
first: new ExpressionPosition(0, 10),
|
||||
second: new ExpressionPosition(100, 200),
|
||||
expectedResult: false,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = testCase.first.isIntersecting(testCase.second);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
it(`reversed: ${testCase.name}`, () => {
|
||||
// act
|
||||
const actual = testCase.second.isIntersecting(testCase.first);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedResult);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPositionFromRegexFullMatch } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
|
||||
|
||||
describe('ExpressionPositionFactory', () => {
|
||||
describe('createPositionFromRegexFullMatch', () => {
|
||||
describe('it is a transient factory', () => {
|
||||
// arrange
|
||||
const fakeMatch = createRegexMatch();
|
||||
// act
|
||||
const create = () => createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
itIsTransientFactory({
|
||||
getter: create,
|
||||
expectedType: ExpressionPosition,
|
||||
});
|
||||
});
|
||||
it('creates a position with the correct start position', () => {
|
||||
// arrange
|
||||
const expectedStartPosition = 5;
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: 'matched string',
|
||||
matchIndex: expectedStartPosition,
|
||||
});
|
||||
// act
|
||||
const position = createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(position.start).toBe(expectedStartPosition);
|
||||
});
|
||||
|
||||
it('creates a position with the correct end position', () => {
|
||||
// arrange
|
||||
const startPosition = 3;
|
||||
const matchedString = 'matched string';
|
||||
const expectedEndPosition = startPosition + matchedString.length;
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: matchedString,
|
||||
matchIndex: startPosition,
|
||||
});
|
||||
// act
|
||||
const position = createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(position.end).to.equal(expectedEndPosition);
|
||||
});
|
||||
|
||||
it('creates correct position with capturing groups', () => {
|
||||
// arrange
|
||||
const startPosition = 20;
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: 'matched string',
|
||||
capturingGroups: ['group1', 'group2'],
|
||||
matchIndex: startPosition,
|
||||
});
|
||||
// act
|
||||
const position = createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(position.start).toBe(startPosition);
|
||||
expect(position.end).toBe(startPosition + fakeMatch[0].length);
|
||||
});
|
||||
|
||||
describe('invalid values', () => {
|
||||
it('throws an error if match.index is undefined', () => {
|
||||
// arrange
|
||||
const fakeMatch = createRegexMatch();
|
||||
fakeMatch.index = undefined;
|
||||
const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`;
|
||||
// act
|
||||
const act = () => createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
it('throws an error for empty match', () => {
|
||||
// arrange
|
||||
const fakeMatch = createRegexMatch({
|
||||
fullMatch: '',
|
||||
matchIndex: 0,
|
||||
});
|
||||
const expectedError = `Regex match is empty: ${JSON.stringify(fakeMatch)}`;
|
||||
// act
|
||||
const act = () => createPositionFromRegexFullMatch(fakeMatch);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createRegexMatch(options?: {
|
||||
readonly fullMatch?: string,
|
||||
readonly capturingGroups?: readonly string[],
|
||||
readonly matchIndex?: number,
|
||||
}): RegExpMatchArray {
|
||||
const fullMatch = options?.fullMatch ?? 'default fake match';
|
||||
const capturingGroups = options?.capturingGroups ?? [];
|
||||
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
|
||||
fakeMatch.index = options?.matchIndex ?? 0;
|
||||
return fakeMatch;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/ExpressionsCompiler';
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
|
||||
import { ExpressionParserStub } from '@tests/unit/shared/Stubs/ExpressionParserStub';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression';
|
||||
|
||||
describe('ExpressionsCompiler', () => {
|
||||
describe('compileExpressions', () => {
|
||||
describe('returns empty string when code is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expected = '';
|
||||
const code = absentValue;
|
||||
const sut = new SystemUnderTest();
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const value = sut.compileExpressions(code, args);
|
||||
// assert
|
||||
expect(value).to.equal(expected);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
describe('can compile nested expressions', () => {
|
||||
it('when one expression is evaluated to a text that contains another expression', () => {
|
||||
// arrange
|
||||
const expectedResult = 'hello world!';
|
||||
const rawCode = 'hello {{ firstExpression }}!';
|
||||
const outerExpressionResult = '{{ secondExpression }}';
|
||||
const expectedCodeAfterFirstCompilationRound = 'hello {{ secondExpression }}!';
|
||||
const innerExpressionResult = 'world';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(rawCode, [
|
||||
new ExpressionStub()
|
||||
// {{ firstExpression }
|
||||
.withPosition(6, 27)
|
||||
// Parser would hit the outer expression
|
||||
.withEvaluatedResult(outerExpressionResult),
|
||||
])
|
||||
.withResult(expectedCodeAfterFirstCompilationRound, [
|
||||
new ExpressionStub()
|
||||
// {{ secondExpression }}
|
||||
.withPosition(6, 28)
|
||||
// once the outer expression parser, compiler now parses its evaluated result
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
]);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const actual = sut.compileExpressions(rawCode, args);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedResult);
|
||||
});
|
||||
describe('when one expression contains another hardcoded expression', () => {
|
||||
it('when hardcoded expression is does not contain the hardcoded expression', () => {
|
||||
// arrange
|
||||
const expectedResult = 'hi !';
|
||||
const rawCode = 'hi {{ outerExpressionStart }}delete {{ innerExpression }} me{{ outerExpressionEnd }}!';
|
||||
const outerExpressionResult = '';
|
||||
const innerExpressionResult = 'should not be there';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(rawCode, [
|
||||
new ExpressionStub()
|
||||
// {{ outerExpressionStart }}delete {{ innerExpression }} me{{ outerExpressionEnd }}
|
||||
.withPosition(3, 84)
|
||||
.withEvaluatedResult(outerExpressionResult),
|
||||
new ExpressionStub()
|
||||
// {{ innerExpression }}
|
||||
.withPosition(36, 57)
|
||||
// Parser would hit both expressions as one is hardcoded in other
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
])
|
||||
.withResult(expectedResult, []);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const actual = sut.compileExpressions(rawCode, args);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedResult);
|
||||
});
|
||||
it('when hardcoded expression contains the hardcoded expression', () => {
|
||||
// arrange
|
||||
const expectedResult = 'hi game of thrones!';
|
||||
const rawCode = 'hi {{ outerExpressionStart }} game {{ innerExpression }} {{ outerExpressionEnd }}!';
|
||||
const expectedCodeAfterFirstCompilationRound = 'hi game {{ innerExpression }}!'; // outer is compiled first
|
||||
const outerExpressionResult = 'game {{ innerExpression }}';
|
||||
const innerExpressionResult = 'of thrones';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(rawCode, [
|
||||
new ExpressionStub()
|
||||
// {{ outerExpressionStart }} game {{ innerExpression }} {{ outerExpressionEnd }}
|
||||
.withPosition(3, 81)
|
||||
// Parser would hit the outer expression
|
||||
.withEvaluatedResult(outerExpressionResult),
|
||||
new ExpressionStub()
|
||||
// {{ innerExpression }}
|
||||
.withPosition(35, 57)
|
||||
// Parser would hit both expressions as one is hardcoded in other
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
])
|
||||
.withResult(expectedCodeAfterFirstCompilationRound, [
|
||||
new ExpressionStub()
|
||||
// {{ innerExpression }}
|
||||
.withPosition(8, 29)
|
||||
// once the outer expression parser, compiler now parses its evaluated result
|
||||
.withEvaluatedResult(innerExpressionResult),
|
||||
]);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const actual = sut.compileExpressions(rawCode, args);
|
||||
// assert
|
||||
expect(actual).to.equal(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('combines expressions as expected', () => {
|
||||
// arrange
|
||||
const code = 'part1 {{ a }} part2 {{ b }} part3';
|
||||
const testCases = [
|
||||
{
|
||||
name: 'with ordered expressions',
|
||||
expressions: [
|
||||
new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a'),
|
||||
new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b'),
|
||||
],
|
||||
expected: 'part1 a part2 b part3',
|
||||
},
|
||||
{
|
||||
name: 'unordered expressions',
|
||||
expressions: [
|
||||
new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b'),
|
||||
new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a'),
|
||||
],
|
||||
expected: 'part1 a part2 b part3',
|
||||
},
|
||||
{
|
||||
name: 'with an optional expected argument that is not provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withPosition(6, 13).withEvaluatedResult('a')
|
||||
.withParameterNames(['optionalParameter'], true),
|
||||
new ExpressionStub().withPosition(20, 27).withEvaluatedResult('b')
|
||||
.withParameterNames(['optionalParameterTwo'], true),
|
||||
],
|
||||
expected: 'part1 a part2 b part3',
|
||||
},
|
||||
{
|
||||
name: 'with no expressions',
|
||||
expressions: [],
|
||||
expected: code,
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, testCase.expressions);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
// act
|
||||
const actual = sut.compileExpressions(code, args);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('arguments', () => {
|
||||
it('passes arguments to expressions as expected', () => {
|
||||
// arrange
|
||||
const expected = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('test-arg', 'test-value');
|
||||
const code = 'longer than 6 characters';
|
||||
const expressions = [
|
||||
new ExpressionStub().withPosition(0, 3),
|
||||
new ExpressionStub().withPosition(3, 6),
|
||||
];
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, expressions);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
// act
|
||||
sut.compileExpressions(code, expected);
|
||||
// assert
|
||||
const actualArgs = expressions
|
||||
.flatMap((expression) => expression.callHistory)
|
||||
.map((context) => context.args);
|
||||
expect(
|
||||
actualArgs.every((arg) => arg === expected),
|
||||
`Expected: ${JSON.stringify(expected)}\n`
|
||||
+ `Actual: ${JSON.stringify(actualArgs)}\n`
|
||||
+ `Not equal: ${actualArgs.filter((arg) => arg !== expected)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('throws when expressions are invalid', () => {
|
||||
describe('throws when expected argument is not provided but used in code', () => {
|
||||
// arrange
|
||||
const testCases = [
|
||||
{
|
||||
name: 'empty parameters',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub(),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'unnecessary parameter is provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('unnecessaryParameter', 'unnecessaryValue'),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'multiple values are not provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter1'], false),
|
||||
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub(),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2", "parameter3" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'some values are provided',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter1'], false),
|
||||
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub()
|
||||
.withArgument('parameter2', 'value'),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter3" but used in code',
|
||||
},
|
||||
{
|
||||
name: 'parameter names are not repeated in error message',
|
||||
expressions: [
|
||||
new ExpressionStub().withParameterNames(['parameter1', 'parameter1', 'parameter2', 'parameter2'], false),
|
||||
],
|
||||
args: new FunctionCallArgumentCollectionStub(),
|
||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2" but used in code',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const code = 'non-important-code';
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, testCase.expressions);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
// act
|
||||
const act = () => sut.compileExpressions(code, testCase.args);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('throws when expression positions are unexpected', () => {
|
||||
// arrange
|
||||
const code = 'c'.repeat(30);
|
||||
const testCases: readonly {
|
||||
name: string,
|
||||
expressions: readonly IExpression[],
|
||||
expectedError: string,
|
||||
expectedResult: boolean,
|
||||
}[] = [
|
||||
(() => {
|
||||
const badExpression = new ExpressionStub().withPosition(0, code.length + 5);
|
||||
const goodExpression = new ExpressionStub().withPosition(0, code.length - 1);
|
||||
return {
|
||||
name: 'an expression has out-of-range position',
|
||||
expressions: [badExpression, goodExpression],
|
||||
expectedError: `Expressions out of range:\n${JSON.stringify([badExpression])}`,
|
||||
expectedResult: true,
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const duplicatedExpression = new ExpressionStub().withPosition(0, code.length - 1);
|
||||
const uniqueExpression = new ExpressionStub().withPosition(0, code.length - 2);
|
||||
return {
|
||||
name: 'two expressions at the same position',
|
||||
expressions: [duplicatedExpression, duplicatedExpression, uniqueExpression],
|
||||
expectedError: `Instructions at same position:\n${JSON.stringify([duplicatedExpression, duplicatedExpression])}`,
|
||||
expectedResult: true,
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const goodExpression = new ExpressionStub().withPosition(0, 5);
|
||||
const intersectingExpression = new ExpressionStub().withPosition(5, 10);
|
||||
const intersectingExpressionOther = new ExpressionStub().withPosition(7, 12);
|
||||
return {
|
||||
name: 'intersecting expressions',
|
||||
expressions: [goodExpression, intersectingExpression, intersectingExpressionOther],
|
||||
expectedError: `Instructions intersecting unexpectedly:\n${JSON.stringify([intersectingExpression, intersectingExpressionOther])}`,
|
||||
expectedResult: true,
|
||||
};
|
||||
})(),
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const expressionParserMock = new ExpressionParserStub()
|
||||
.withResult(code, testCase.expressions);
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
const act = () => sut.compileExpressions(code, args);
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
it('calls parser with expected code', () => {
|
||||
// arrange
|
||||
const expected = 'expected-code';
|
||||
const expressionParserMock = new ExpressionParserStub();
|
||||
const sut = new SystemUnderTest(expressionParserMock);
|
||||
const args = new FunctionCallArgumentCollectionStub();
|
||||
// act
|
||||
sut.compileExpressions(expected, args);
|
||||
// assert
|
||||
expect(expressionParserMock.callHistory).to.have.lengthOf(1);
|
||||
expect(expressionParserMock.callHistory[0]).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class SystemUnderTest extends ExpressionsCompiler {
|
||||
constructor(extractor: IExpressionParser = new ExpressionParserStub()) {
|
||||
super(extractor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression';
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { CompositeExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/CompositeExpressionParser';
|
||||
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
|
||||
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('CompositeExpressionParser', () => {
|
||||
describe('ctor', () => {
|
||||
describe('throws when parsers are missing', () => {
|
||||
itEachAbsentCollectionValue<IExpressionParser>((absentCollection) => {
|
||||
// arrange
|
||||
const expectedError = 'missing leafs';
|
||||
const parsers = absentCollection;
|
||||
// act
|
||||
const act = () => new CompositeExpressionParser(parsers);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
}, { excludeUndefined: true, excludeNull: true });
|
||||
});
|
||||
});
|
||||
describe('findExpressions', () => {
|
||||
describe('returns result from parsers as expected', () => {
|
||||
// arrange
|
||||
const pool = [
|
||||
new ExpressionStub(), new ExpressionStub(), new ExpressionStub(),
|
||||
new ExpressionStub(), new ExpressionStub(),
|
||||
];
|
||||
const testCases = [
|
||||
{
|
||||
name: 'from single parsing none',
|
||||
parsers: [mockParser()],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: 'from single parsing single',
|
||||
parsers: [mockParser(pool[0])],
|
||||
expected: [pool[0]],
|
||||
},
|
||||
{
|
||||
name: 'from single parsing multiple',
|
||||
parsers: [mockParser(pool[0], pool[1])],
|
||||
expected: [pool[0], pool[1]],
|
||||
},
|
||||
{
|
||||
name: 'from multiple parsers with each parsing single',
|
||||
parsers: [
|
||||
mockParser(pool[0]),
|
||||
mockParser(pool[1]),
|
||||
mockParser(pool[2]),
|
||||
],
|
||||
expected: [pool[0], pool[1], pool[2]],
|
||||
},
|
||||
{
|
||||
name: 'from multiple parsers with each parsing multiple',
|
||||
parsers: [
|
||||
mockParser(pool[0], pool[1]),
|
||||
mockParser(pool[2], pool[3], pool[4])],
|
||||
expected: [pool[0], pool[1], pool[2], pool[3], pool[4]],
|
||||
},
|
||||
{
|
||||
name: 'from multiple parsers with only some parsing',
|
||||
parsers: [
|
||||
mockParser(pool[0], pool[1]),
|
||||
mockParser(),
|
||||
mockParser(pool[2]),
|
||||
mockParser(),
|
||||
],
|
||||
expected: [pool[0], pool[1], pool[2]],
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const sut = new CompositeExpressionParser(testCase.parsers);
|
||||
// act
|
||||
const result = sut.findExpressions('non-important-code');
|
||||
// expect
|
||||
expect(result).to.deep.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockParser(...result: IExpression[]): IExpressionParser {
|
||||
return {
|
||||
findExpressions: () => result,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionRegexBuilder } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder';
|
||||
|
||||
const AllWhitespaceCharacters = ' \t\n\r\v\f\u00A0';
|
||||
|
||||
describe('ExpressionRegexBuilder', () => {
|
||||
describe('expectCharacters', () => {
|
||||
describe('expectCharacters', () => {
|
||||
describe('escapes single character as expected', () => {
|
||||
const charactersToEscape = ['.', '$'];
|
||||
for (const character of charactersToEscape) {
|
||||
it(`escapes ${character} as expected`, () => expectMatch(
|
||||
character,
|
||||
(act) => act.expectCharacters(character),
|
||||
`${character}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
it('escapes multiple characters as expected', () => expectMatch(
|
||||
'.I have no $$.',
|
||||
(act) => act.expectCharacters('.I have no $$.'),
|
||||
'.I have no $$.',
|
||||
));
|
||||
it('adds characters as expected', () => expectMatch(
|
||||
'return as it is',
|
||||
(act) => act.expectCharacters('return as it is'),
|
||||
'return as it is',
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('expectOneOrMoreWhitespaces', () => {
|
||||
it('matches one whitespace', () => expectMatch(
|
||||
' ',
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
' ',
|
||||
));
|
||||
it('matches multiple whitespaces', () => expectMatch(
|
||||
AllWhitespaceCharacters,
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('matches whitespaces inside text', () => expectMatch(
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('does not match non-whitespace characters', () => expectNonMatch(
|
||||
'a',
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
));
|
||||
});
|
||||
describe('captureOptionalPipeline', () => {
|
||||
it('does not capture when no pipe is present', () => expectNonMatch(
|
||||
'noPipeHere',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
));
|
||||
it('captures when input starts with pipe', () => expectCapture(
|
||||
'| afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores without text before', () => expectCapture(
|
||||
'stuff before | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores without text before', () => expectCapture(
|
||||
'stuff before | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores whitespaces before the pipe', () => expectCapture(
|
||||
' | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores text after whitespace', () => expectCapture(
|
||||
'| first Pipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| first ',
|
||||
));
|
||||
describe('non-greedy matching', () => { // so the rest of the pattern can work
|
||||
it('non-letter character in pipe', () => expectCapture(
|
||||
'| firstPipe | sec0ndpipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| firstPipe ',
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('captureUntilWhitespaceOrPipe', () => {
|
||||
it('captures until first whitespace', () => expectCapture(
|
||||
// arrange
|
||||
'first ',
|
||||
// act
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'first',
|
||||
));
|
||||
it('captures until first pipe', () => expectCapture(
|
||||
// arrange
|
||||
'first|',
|
||||
// act
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'first',
|
||||
));
|
||||
it('captures all without whitespace or pipe', () => expectCapture(
|
||||
// arrange
|
||||
'all',
|
||||
// act
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'all',
|
||||
));
|
||||
});
|
||||
describe('captureMultilineAnythingExceptSurroundingWhitespaces', () => {
|
||||
describe('single line', () => {
|
||||
it('captures a line without surrounding whitespaces', () => expectCapture(
|
||||
// arrange
|
||||
'line',
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'line',
|
||||
));
|
||||
it('captures a line with internal whitespaces intact', () => expectCapture(
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
));
|
||||
it('excludes surrounding whitespaces', () => expectCapture(
|
||||
// arrange
|
||||
`${AllWhitespaceCharacters}single line\t`,
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'single line',
|
||||
));
|
||||
});
|
||||
describe('multiple lines', () => {
|
||||
it('captures text across multiple lines', () => expectCapture(
|
||||
// arrange
|
||||
'first line\nsecond line\r\nthird-line',
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line\r\nthird-line',
|
||||
));
|
||||
it('captures text with empty lines in between', () => expectCapture(
|
||||
'start\n\nend',
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
'start\n\nend',
|
||||
));
|
||||
it('excludes surrounding whitespaces from multiline text', () => expectCapture(
|
||||
// arrange
|
||||
` first line\nsecond line${AllWhitespaceCharacters}`,
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line',
|
||||
));
|
||||
});
|
||||
describe('edge cases', () => {
|
||||
it('does not capture for input with only whitespaces', () => expectNonCapture(
|
||||
AllWhitespaceCharacters,
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('expectExpressionStart', () => {
|
||||
it('matches expression start without trailing whitespaces', () => expectMatch(
|
||||
'{{expression',
|
||||
(act) => act.expectExpressionStart(),
|
||||
'{{',
|
||||
));
|
||||
it('matches expression start with trailing whitespaces', () => expectMatch(
|
||||
`{{${AllWhitespaceCharacters}expression`,
|
||||
(act) => act.expectExpressionStart(),
|
||||
`{{${AllWhitespaceCharacters}`,
|
||||
));
|
||||
it('does not match whitespaces not directly after expression start', () => expectMatch(
|
||||
' {{expression',
|
||||
(act) => act.expectExpressionStart(),
|
||||
'{{',
|
||||
));
|
||||
it('does not match if expression start is not present', () => expectNonMatch(
|
||||
'noExpressionStartHere',
|
||||
(act) => act.expectExpressionStart(),
|
||||
));
|
||||
});
|
||||
describe('expectExpressionEnd', () => {
|
||||
it('matches expression end without preceding whitespaces', () => expectMatch(
|
||||
'expression}}',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
'}}',
|
||||
));
|
||||
it('matches expression end with preceding whitespaces', () => expectMatch(
|
||||
`expression${AllWhitespaceCharacters}}}`,
|
||||
(act) => act.expectExpressionEnd(),
|
||||
`${AllWhitespaceCharacters}}}`,
|
||||
));
|
||||
it('does not capture whitespaces not directly before expression end', () => expectMatch(
|
||||
'expression}} ',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
'}}',
|
||||
));
|
||||
it('does not match if expression end is not present', () => expectNonMatch(
|
||||
'noExpressionEndHere',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
));
|
||||
});
|
||||
describe('expectOptionalWhitespaces', () => {
|
||||
describe('matching', () => {
|
||||
it('matches multiple Unix lines', () => expectMatch(
|
||||
// arrange
|
||||
'\n\n',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\n\n',
|
||||
));
|
||||
it('matches multiple Windows lines', () => expectMatch(
|
||||
// arrange
|
||||
'\r\n',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\r\n',
|
||||
));
|
||||
it('matches multiple spaces', () => expectMatch(
|
||||
// arrange
|
||||
' ',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
' ',
|
||||
));
|
||||
it('matches horizontal and vertical tabs', () => expectMatch(
|
||||
// arrange
|
||||
'\t\v',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\t\v',
|
||||
));
|
||||
it('matches form feed character', () => expectMatch(
|
||||
// arrange
|
||||
'\f',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\f',
|
||||
));
|
||||
it('matches a non-breaking space character', () => expectMatch(
|
||||
// arrange
|
||||
'\u00A0',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\u00A0',
|
||||
));
|
||||
it('matches a combination of whitespace characters', () => expectMatch(
|
||||
// arrange
|
||||
AllWhitespaceCharacters,
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('matches whitespace characters on different positions', () => expectMatch(
|
||||
// arrange
|
||||
'\ta\nb\rc\v',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\t\n\r\v',
|
||||
));
|
||||
});
|
||||
describe('non-matching', () => {
|
||||
it('a non-whitespace character', () => expectNonMatch(
|
||||
// arrange
|
||||
'a',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
));
|
||||
it('multiple non-whitespace characters', () => expectNonMatch(
|
||||
// arrange
|
||||
'abc',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('buildRegExp', () => {
|
||||
it('sets global flag', () => {
|
||||
// arrange
|
||||
const expected = 'g';
|
||||
const sut = new ExpressionRegexBuilder()
|
||||
.expectOneOrMoreWhitespaces();
|
||||
// act
|
||||
const actual = sut.buildRegExp().flags;
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
describe('can combine multiple parts', () => {
|
||||
it('combines character and whitespace expectations', () => expectMatch(
|
||||
'abc def',
|
||||
(act) => act
|
||||
.expectCharacters('abc')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('def'),
|
||||
'abc def',
|
||||
));
|
||||
it('captures optional pipeline and text after it', () => expectCapture(
|
||||
'abc | def',
|
||||
(act) => act
|
||||
.expectCharacters('abc ')
|
||||
.captureOptionalPipeline(),
|
||||
'| def',
|
||||
));
|
||||
it('combines multiline capture with optional whitespaces', () => expectCapture(
|
||||
'\n abc \n',
|
||||
(act) => act
|
||||
.expectOptionalWhitespaces()
|
||||
.captureMultilineAnythingExceptSurroundingWhitespaces()
|
||||
.expectOptionalWhitespaces(),
|
||||
'abc',
|
||||
));
|
||||
it('combines expression start, optional whitespaces, and character expectation', () => expectMatch(
|
||||
'{{ abc',
|
||||
(act) => act
|
||||
.expectExpressionStart()
|
||||
.expectOptionalWhitespaces()
|
||||
.expectCharacters('abc'),
|
||||
'{{ abc',
|
||||
));
|
||||
it('combines character expectation, optional whitespaces, and expression end', () => expectMatch(
|
||||
'abc }}',
|
||||
(act) => act
|
||||
.expectCharacters('abc')
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionEnd(),
|
||||
'abc }}',
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
enum MatchGroupIndex {
|
||||
FullMatch = 0,
|
||||
FirstCapturingGroup = 1,
|
||||
}
|
||||
|
||||
function expectCapture(
|
||||
input: string,
|
||||
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedCombinedCaptures: string | undefined,
|
||||
): void {
|
||||
// arrange
|
||||
const matchGroupIndex = MatchGroupIndex.FirstCapturingGroup;
|
||||
// act
|
||||
// assert
|
||||
expectMatch(input, act, expectedCombinedCaptures, matchGroupIndex);
|
||||
}
|
||||
|
||||
function expectNonMatch(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
matchGroupIndex = MatchGroupIndex.FullMatch,
|
||||
): void {
|
||||
expectMatch(input, act, undefined, matchGroupIndex);
|
||||
}
|
||||
|
||||
function expectNonCapture(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
): void {
|
||||
expectNonMatch(input, act, MatchGroupIndex.FirstCapturingGroup);
|
||||
}
|
||||
|
||||
function expectMatch(
|
||||
input: string,
|
||||
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedCombinedMatches: string | undefined,
|
||||
matchGroupIndex = MatchGroupIndex.FullMatch,
|
||||
): void {
|
||||
// arrange
|
||||
const regexBuilder = new ExpressionRegexBuilder();
|
||||
act(regexBuilder);
|
||||
const regex = regexBuilder.buildRegExp();
|
||||
// act
|
||||
const allMatchGroups = Array.from(input.matchAll(regex));
|
||||
// assert
|
||||
const actualMatches = allMatchGroups
|
||||
.filter((matches) => matches.length > matchGroupIndex)
|
||||
.map((matches) => matches[matchGroupIndex])
|
||||
.filter(Boolean) // matchAll returns `""` for full matches, `null` for capture groups
|
||||
.flat();
|
||||
const actualCombinedMatches = actualMatches.length ? actualMatches.join('') : undefined;
|
||||
expect(actualCombinedMatches).equal(
|
||||
expectedCombinedMatches,
|
||||
[
|
||||
'\n\n---',
|
||||
'Expected combined matches:',
|
||||
getTestDataText(expectedCombinedMatches),
|
||||
'Actual combined matches:',
|
||||
getTestDataText(actualCombinedMatches),
|
||||
'Input:',
|
||||
getTestDataText(input),
|
||||
'Regex:',
|
||||
getTestDataText(regex.toString()),
|
||||
'All match groups:',
|
||||
getTestDataText(JSON.stringify(allMatchGroups)),
|
||||
`Match index in group: ${matchGroupIndex}`,
|
||||
'---\n\n',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function getTestDataText(data: string | undefined): string {
|
||||
const outputPrefix = '\t> ';
|
||||
if (data === undefined) {
|
||||
return `${outputPrefix}undefined (no matches)`;
|
||||
}
|
||||
const getLiteralString = (text: string) => JSON.stringify(text).slice(1, -1);
|
||||
const text = `${outputPrefix}\`${getLiteralString(data)}\``;
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type {
|
||||
ExpressionEvaluator, ExpressionInitParameters,
|
||||
} from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/Expression';
|
||||
import {
|
||||
type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities,
|
||||
} from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/Regex/RegexParser';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
|
||||
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
|
||||
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
|
||||
import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression';
|
||||
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
|
||||
import type { ExpressionPositionFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { indentText } from '@tests/shared/Text';
|
||||
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
|
||||
|
||||
describe('RegexParser', () => {
|
||||
describe('findExpressions', () => {
|
||||
describe('error handling', () => {
|
||||
describe('throws when code is absent', () => {
|
||||
itEachAbsentStringValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing code';
|
||||
const sut = new RegexParserConcrete({
|
||||
regex: /unimportant/,
|
||||
});
|
||||
// act
|
||||
const act = () => sut.findExpressions(absentValue);
|
||||
// assert
|
||||
const errorMessage = collectExceptionMessage(act);
|
||||
expect(errorMessage).to.include(expectedError);
|
||||
}, { excludeNull: true, excludeUndefined: true });
|
||||
});
|
||||
describe('rethrows regex match errors', () => {
|
||||
// arrange
|
||||
const expectedMatchError = new TypeError('String.prototype.matchAll called with a non-global RegExp argument');
|
||||
const expectedMessage = 'Failed to match regex.';
|
||||
const expectedCodeInMessage = 'unimportant code content';
|
||||
const expectedRegexInMessage = /failing-regex-because-it-is-non-global/;
|
||||
const expectedErrorMessage = buildRethrowErrorMessage({
|
||||
message: expectedMessage,
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
});
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
const sut = new RegexParserConcrete(
|
||||
{
|
||||
regex: expectedRegexInMessage,
|
||||
utilities: {
|
||||
wrapError,
|
||||
},
|
||||
},
|
||||
);
|
||||
sut.findExpressions(expectedCodeInMessage);
|
||||
},
|
||||
// assert
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
expectedWrappedError: expectedMatchError,
|
||||
});
|
||||
});
|
||||
describe('rethrows expression building errors', () => {
|
||||
// arrange
|
||||
const expectedMessage = 'Failed to build expression.';
|
||||
const expectedInnerError = new Error('Expected error from building expression');
|
||||
const {
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
} = createCodeAndRegexMatchingOnce();
|
||||
const throwingExpressionBuilder = () => {
|
||||
throw expectedInnerError;
|
||||
};
|
||||
const expectedErrorMessage = buildRethrowErrorMessage({
|
||||
message: expectedMessage,
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
});
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
const sut = new RegexParserConcrete(
|
||||
{
|
||||
regex: expectedRegexInMessage,
|
||||
builder: throwingExpressionBuilder,
|
||||
utilities: {
|
||||
wrapError,
|
||||
},
|
||||
},
|
||||
);
|
||||
sut.findExpressions(expectedCodeInMessage);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
describe('rethrows position creation errors', () => {
|
||||
// arrange
|
||||
const expectedMessage = 'Failed to create position.';
|
||||
const expectedInnerError = new Error('Expected error from position factory');
|
||||
const {
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
} = createCodeAndRegexMatchingOnce();
|
||||
const throwingPositionFactory = () => {
|
||||
throw expectedInnerError;
|
||||
};
|
||||
const expectedErrorMessage = buildRethrowErrorMessage({
|
||||
message: expectedMessage,
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
});
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
const sut = new RegexParserConcrete(
|
||||
{
|
||||
regex: expectedRegexInMessage,
|
||||
utilities: {
|
||||
createPosition: throwingPositionFactory,
|
||||
wrapError,
|
||||
},
|
||||
},
|
||||
);
|
||||
sut.findExpressions(expectedCodeInMessage);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
describe('rethrows parameter creation errors', () => {
|
||||
// arrange
|
||||
const expectedMessage = 'Failed to create parameters.';
|
||||
const expectedInnerError = new Error('Expected error from parameter collection factory');
|
||||
const {
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
} = createCodeAndRegexMatchingOnce();
|
||||
const throwingParameterCollectionFactory = () => {
|
||||
throw expectedInnerError;
|
||||
};
|
||||
const expectedErrorMessage = buildRethrowErrorMessage({
|
||||
message: expectedMessage,
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
});
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
const sut = new RegexParserConcrete(
|
||||
{
|
||||
regex: expectedRegexInMessage,
|
||||
utilities: {
|
||||
createParameterCollection: throwingParameterCollectionFactory,
|
||||
wrapError,
|
||||
},
|
||||
},
|
||||
);
|
||||
sut.findExpressions(expectedCodeInMessage);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
describe('rethrows expression creation errors', () => {
|
||||
// arrange
|
||||
const expectedMessage = 'Failed to create expression.';
|
||||
const expectedInnerError = new Error('Expected error from expression factory');
|
||||
const {
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
} = createCodeAndRegexMatchingOnce();
|
||||
const throwingExpressionFactory = () => {
|
||||
throw expectedInnerError;
|
||||
};
|
||||
const expectedErrorMessage = buildRethrowErrorMessage({
|
||||
message: expectedMessage,
|
||||
code: expectedCodeInMessage,
|
||||
regex: expectedRegexInMessage,
|
||||
});
|
||||
itThrowsContextualError({
|
||||
// act
|
||||
throwingAction: (wrapError) => {
|
||||
const sut = new RegexParserConcrete(
|
||||
{
|
||||
regex: expectedRegexInMessage,
|
||||
utilities: {
|
||||
createExpression: throwingExpressionFactory,
|
||||
wrapError,
|
||||
},
|
||||
},
|
||||
);
|
||||
sut.findExpressions(expectedCodeInMessage);
|
||||
},
|
||||
// assert
|
||||
expectedWrappedError: expectedInnerError,
|
||||
expectedContextMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handles matched regex correctly', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly regex: RegExp;
|
||||
readonly code: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'non-matching regex',
|
||||
regex: /hello/g,
|
||||
code: 'world',
|
||||
},
|
||||
{
|
||||
description: 'single regex match',
|
||||
regex: /hello/g,
|
||||
code: 'hello world',
|
||||
},
|
||||
{
|
||||
description: 'multiple regex matches',
|
||||
regex: /l/g,
|
||||
code: 'hello world',
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, code, regex,
|
||||
}) => {
|
||||
describe(description, () => {
|
||||
it('generates expressions for all matches', () => {
|
||||
// arrange
|
||||
const expectedTotalExpressions = Array.from(code.matchAll(regex)).length;
|
||||
const sut = new RegexParserConcrete({
|
||||
regex,
|
||||
});
|
||||
// act
|
||||
const expressions = sut.findExpressions(code);
|
||||
// assert
|
||||
const actualTotalExpressions = expressions.length;
|
||||
expect(actualTotalExpressions).to.equal(
|
||||
expectedTotalExpressions,
|
||||
formatAssertionMessage([
|
||||
`Expected ${actualTotalExpressions} expressions due to ${expectedTotalExpressions} matches`,
|
||||
`Expressions:\n${indentText(JSON.stringify(expressions, undefined, 2))}`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
it('builds primitive expressions for each match', () => {
|
||||
const expected = Array.from(code.matchAll(regex));
|
||||
const matches = new Array<RegExpMatchArray>();
|
||||
const builder = (m: RegExpMatchArray): PrimitiveExpression => {
|
||||
matches.push(m);
|
||||
return createPrimitiveExpressionStub();
|
||||
};
|
||||
const sut = new RegexParserConcrete({
|
||||
regex,
|
||||
builder,
|
||||
});
|
||||
// act
|
||||
sut.findExpressions(code);
|
||||
// assert
|
||||
expect(matches).to.deep.equal(expected);
|
||||
});
|
||||
it('sets positions correctly from matches', () => {
|
||||
// arrange
|
||||
const expectedMatches = [...code.matchAll(regex)];
|
||||
const { createExpression, getInitParameters } = createExpressionFactorySpy();
|
||||
const serializeRegexMatch = (match: RegExpMatchArray) => `[startPos:${match?.index ?? 'none'},length:${match?.[0]?.length ?? 'none'}]`;
|
||||
const positionsForMatches = new Map<string, ExpressionPosition>(expectedMatches.map(
|
||||
(expectedMatch) => [serializeRegexMatch(expectedMatch), new ExpressionPosition(1, 4)],
|
||||
));
|
||||
const createPositionMock: ExpressionPositionFactory = (match) => {
|
||||
const position = positionsForMatches.get(serializeRegexMatch(match));
|
||||
return position ?? new ExpressionPosition(66, 666);
|
||||
};
|
||||
const sut = new RegexParserConcrete({
|
||||
regex,
|
||||
utilities: {
|
||||
createExpression,
|
||||
createPosition: createPositionMock,
|
||||
},
|
||||
});
|
||||
// act
|
||||
const expressions = sut.findExpressions(code);
|
||||
// assert
|
||||
const expectedPositions = [...positionsForMatches.values()];
|
||||
const actualPositions = expressions.map((e) => getInitParameters(e)?.position);
|
||||
expect(actualPositions).to.deep.equal(expectedPositions, formatAssertionMessage([
|
||||
'Actual positions do not match the expected positions.',
|
||||
`Expected total positions: ${expectedPositions.length} (due to ${expectedMatches.length} regex matches)`,
|
||||
`Actual total positions: ${actualPositions.length}`,
|
||||
`Expected positions:\n${indentText(JSON.stringify(expectedPositions, undefined, 2))}`,
|
||||
`Actual positions:\n${indentText(JSON.stringify(actualPositions, undefined, 2))}`,
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('sets evaluator correctly from expression', () => {
|
||||
// arrange
|
||||
const { createExpression, getInitParameters } = createExpressionFactorySpy();
|
||||
const expectedEvaluate = createEvaluatorStub();
|
||||
const { code, regex } = createCodeAndRegexMatchingOnce();
|
||||
const builder = (): PrimitiveExpression => ({
|
||||
evaluator: expectedEvaluate,
|
||||
});
|
||||
const sut = new RegexParserConcrete({
|
||||
regex,
|
||||
builder,
|
||||
utilities: {
|
||||
createExpression,
|
||||
},
|
||||
});
|
||||
// act
|
||||
const expressions = sut.findExpressions(code);
|
||||
// assert
|
||||
expect(expressions).to.have.lengthOf(1);
|
||||
const actualEvaluate = getInitParameters(expressions[0])?.evaluator;
|
||||
expect(actualEvaluate).to.equal(expectedEvaluate);
|
||||
});
|
||||
it('sets parameters correctly from expression', () => {
|
||||
// arrange
|
||||
const expectedParameters: IReadOnlyFunctionParameterCollection['all'] = [
|
||||
new FunctionParameterStub().withName('parameter1').withOptional(true),
|
||||
new FunctionParameterStub().withName('parameter2').withOptional(false),
|
||||
];
|
||||
const regex = /hello/g;
|
||||
const code = 'hello';
|
||||
const builder = (): PrimitiveExpression => ({
|
||||
evaluator: createEvaluatorStub(),
|
||||
parameters: expectedParameters,
|
||||
});
|
||||
const parameterCollection = new FunctionParameterCollectionStub();
|
||||
const parameterCollectionFactoryStub
|
||||
: FunctionParameterCollectionFactory = () => parameterCollection;
|
||||
const { createExpression, getInitParameters } = createExpressionFactorySpy();
|
||||
const sut = new RegexParserConcrete({
|
||||
regex,
|
||||
builder,
|
||||
utilities: {
|
||||
createExpression,
|
||||
createParameterCollection: parameterCollectionFactoryStub,
|
||||
},
|
||||
});
|
||||
// act
|
||||
const expressions = sut.findExpressions(code);
|
||||
// assert
|
||||
expect(expressions).to.have.lengthOf(1);
|
||||
const actualParameters = getInitParameters(expressions[0])?.parameters;
|
||||
expect(actualParameters).to.equal(parameterCollection);
|
||||
expect(actualParameters?.all).to.deep.equal(expectedParameters);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildRethrowErrorMessage(
|
||||
expectedContext: {
|
||||
readonly message: string;
|
||||
readonly regex: RegExp;
|
||||
readonly code: string;
|
||||
},
|
||||
): string {
|
||||
return [
|
||||
expectedContext.message,
|
||||
`Class name: ${RegexParserConcrete.name}`,
|
||||
`Regex pattern used: ${expectedContext.regex}`,
|
||||
`Code: ${expectedContext.code}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function createExpressionFactorySpy() {
|
||||
const createdExpressions = new Map<IExpression, ExpressionInitParameters>();
|
||||
const createExpression: ExpressionFactory = (parameters) => {
|
||||
const expression = new ExpressionStub();
|
||||
createdExpressions.set(expression, parameters);
|
||||
return expression;
|
||||
};
|
||||
return {
|
||||
createExpression,
|
||||
getInitParameters: (expression) => createdExpressions.get(expression),
|
||||
};
|
||||
}
|
||||
|
||||
function createBuilderStub(): (match: RegExpMatchArray) => PrimitiveExpression {
|
||||
return () => ({
|
||||
evaluator: createEvaluatorStub(),
|
||||
});
|
||||
}
|
||||
function createEvaluatorStub(): ExpressionEvaluator {
|
||||
return () => `[${createEvaluatorStub.name}] evaluated code`;
|
||||
}
|
||||
|
||||
function createPrimitiveExpressionStub(): PrimitiveExpression {
|
||||
return {
|
||||
evaluator: createEvaluatorStub(),
|
||||
};
|
||||
}
|
||||
|
||||
function createCodeAndRegexMatchingOnce() {
|
||||
const code = 'expected code in context';
|
||||
const regex = /code/g;
|
||||
return { code, regex };
|
||||
}
|
||||
|
||||
class RegexParserConcrete extends RegexParser {
|
||||
private readonly builder: RegexParser['buildExpression'];
|
||||
|
||||
protected regex: RegExp;
|
||||
|
||||
public constructor(parameters?: {
|
||||
regex?: RegExp,
|
||||
builder?: RegexParser['buildExpression'],
|
||||
utilities?: Partial<RegexParserUtilities>,
|
||||
}) {
|
||||
super({
|
||||
wrapError: parameters?.utilities?.wrapError
|
||||
?? (() => new Error(`[${RegexParserConcrete}] wrapped error`)),
|
||||
createPosition: parameters?.utilities?.createPosition
|
||||
?? (() => new ExpressionPosition(0, 5)),
|
||||
createExpression: parameters?.utilities?.createExpression
|
||||
?? (() => new ExpressionStub()),
|
||||
createParameterCollection: parameters?.utilities?.createParameterCollection
|
||||
?? (() => new FunctionParameterCollectionStub()),
|
||||
});
|
||||
this.builder = parameters?.builder ?? createBuilderStub();
|
||||
this.regex = parameters?.regex ?? /unimportant/g;
|
||||
}
|
||||
|
||||
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
|
||||
return this.builder(match);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe } from 'vitest';
|
||||
import { EscapeDoubleQuotes } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { runPipeTests } from './PipeTestRunner';
|
||||
|
||||
describe('EscapeDoubleQuotes', () => {
|
||||
// arrange
|
||||
const sut = new EscapeDoubleQuotes();
|
||||
// act
|
||||
runPipeTests(sut, [
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: 'returns as it is when if input is missing',
|
||||
input: testCase.absentValue,
|
||||
expectedOutput: testCase.absentValue,
|
||||
})),
|
||||
{
|
||||
name: 'using "',
|
||||
input: 'hello "world"',
|
||||
expectedOutput: 'hello "^""world"^""',
|
||||
},
|
||||
{
|
||||
name: 'not using any double quotes',
|
||||
input: 'hello world',
|
||||
expectedOutput: 'hello world',
|
||||
},
|
||||
{
|
||||
name: 'consecutive double quotes',
|
||||
input: '""hello world""',
|
||||
expectedOutput: '"^"""^""hello world"^"""^""',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import { describe } from 'vitest';
|
||||
import { InlinePowerShell } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { type IPipeTestCase, runPipeTests } from './PipeTestRunner';
|
||||
|
||||
describe('InlinePowerShell', () => {
|
||||
// arrange
|
||||
const sut = new InlinePowerShell();
|
||||
// act
|
||||
runPipeTests(sut, [
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: 'returns as it is when if input is missing',
|
||||
input: testCase.absentValue,
|
||||
expectedOutput: '',
|
||||
})),
|
||||
...prefixTests('newline', getNewLineCases()),
|
||||
...prefixTests('comment', getCommentCases()),
|
||||
...prefixTests('here-string', hereStringCases()),
|
||||
...prefixTests('backtick', backTickCases()),
|
||||
]);
|
||||
});
|
||||
|
||||
function hereStringCases(): IPipeTestCase[] {
|
||||
const expectLinesInDoubleQuotes = (...lines: string[]) => lines.join('`r`n');
|
||||
const expectLinesInSingleQuotes = (...lines: string[]) => lines.join('\'+"`r`n"+\'');
|
||||
return [
|
||||
{
|
||||
name: 'adds newlines for double quotes',
|
||||
input: getWindowsLines(
|
||||
'@"',
|
||||
'Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: expectLinesInDoubleQuotes(
|
||||
'"Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet"',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adds newlines for single quotes',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'\'Lorem',
|
||||
'ipsum',
|
||||
'dolor sit amet\'',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not match with character after here string header',
|
||||
input: getWindowsLines(
|
||||
'@" invalid syntax',
|
||||
'I will not be processed as here-string',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'@" invalid syntax',
|
||||
'I will not be processed as here-string',
|
||||
'"@',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not match if there\'s character before here-string terminator',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'do not match here',
|
||||
' \'@',
|
||||
'character \'@',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'@\'',
|
||||
'do not match here',
|
||||
' \'@',
|
||||
'character \'@',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not match with different here-string header/terminator',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'lorem',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'@\'',
|
||||
'lorem',
|
||||
'"@',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'matches with inner single quoted here-string',
|
||||
input: getWindowsLines(
|
||||
'$hasInnerDoubleQuotedTerminator = @"',
|
||||
'inner text',
|
||||
'@\'',
|
||||
'inner terminator text',
|
||||
'\'@',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: expectLinesInDoubleQuotes(
|
||||
'$hasInnerDoubleQuotedTerminator = "inner text',
|
||||
'@\'',
|
||||
'inner terminator text',
|
||||
'\'@"',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'matches with inner double quoted string',
|
||||
input: getWindowsLines(
|
||||
'$hasInnerSingleQuotedTerminator = @\'',
|
||||
'inner text',
|
||||
'@"',
|
||||
'inner terminator text',
|
||||
'"@',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'$hasInnerSingleQuotedTerminator = \'inner text',
|
||||
'@"',
|
||||
'inner terminator text',
|
||||
'"@\'',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'matches if there\'s character after here-string terminator',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'lorem',
|
||||
'\'@ after',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'\'lorem\' after',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'escapes double quotes inside double quotes',
|
||||
input: getWindowsLines(
|
||||
'@"',
|
||||
'For help, type "get-help"',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: '"For help, type `"get-help`""',
|
||||
},
|
||||
{
|
||||
name: 'escapes single quotes inside single quotes',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'For help, type \'get-help\'',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: '\'For help, type \'\'get-help\'\'\'',
|
||||
},
|
||||
{
|
||||
name: 'converts when here-string header is not at line start',
|
||||
input: getWindowsLines(
|
||||
'$page = [XML] @"',
|
||||
'multi-lined',
|
||||
'and "quoted"',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: expectLinesInDoubleQuotes(
|
||||
'$page = [XML] "multi-lined',
|
||||
'and `"quoted`""',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trims after here-string header',
|
||||
input: getWindowsLines(
|
||||
'@" \t',
|
||||
'text with whitespaces at here-string start',
|
||||
'"@',
|
||||
),
|
||||
expectedOutput: '"text with whitespaces at here-string start"',
|
||||
},
|
||||
{
|
||||
name: 'preserves whitespaces in lines',
|
||||
input: getWindowsLines(
|
||||
'@\'',
|
||||
'\ttext with tabs around\t\t',
|
||||
' text with whitespaces around ',
|
||||
'\'@',
|
||||
),
|
||||
expectedOutput: expectLinesInSingleQuotes(
|
||||
'\'\ttext with tabs around\t\t',
|
||||
' text with whitespaces around \'',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function backTickCases(): IPipeTestCase[] {
|
||||
return [
|
||||
{
|
||||
name: 'wraps newlines with trailing backtick',
|
||||
input: getWindowsLines(
|
||||
'Get-Service * `',
|
||||
'| Format-Table -AutoSize',
|
||||
),
|
||||
expectedOutput: 'Get-Service * | Format-Table -AutoSize',
|
||||
},
|
||||
{
|
||||
name: 'wraps newlines with trailing backtick and different line endings',
|
||||
input: 'Get-Service `\n'
|
||||
+ '* `\r'
|
||||
+ '| Sort-Object StartType `\r\n'
|
||||
+ '| Format-Table -AutoSize',
|
||||
expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize',
|
||||
},
|
||||
{
|
||||
name: 'trims tabs and whitespaces on next lines when wrapping with trailing backtick',
|
||||
input: getWindowsLines(
|
||||
'Get-Service * `',
|
||||
'\t| Sort-Object StartType `',
|
||||
' | Format-Table -AutoSize',
|
||||
),
|
||||
expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize',
|
||||
},
|
||||
{
|
||||
name: 'does not wrap without whitespace before backtick',
|
||||
input: getWindowsLines(
|
||||
'Get-Service *`',
|
||||
'| Format-Table -AutoSize',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'Get-Service *`',
|
||||
'| Format-Table -AutoSize',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not wrap with characters after',
|
||||
input: getWindowsLines(
|
||||
'line start ` after',
|
||||
'should not be wrapped',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'line start ` after',
|
||||
'should not be wrapped',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getCommentCases(): IPipeTestCase[] {
|
||||
return [
|
||||
{
|
||||
name: 'converts hash comments in the line end',
|
||||
input: getWindowsLines(
|
||||
'$text = "Hello"\t# Comment after tab',
|
||||
'$text+= #Comment without space after hash',
|
||||
'Write-Host $text# Comment without space before hash',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$text = "Hello"\t<# Comment after tab #>',
|
||||
'$text+= <# Comment without space after hash #>',
|
||||
'Write-Host $text<# Comment without space before hash #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'converts hash comment line',
|
||||
input: getWindowsLines(
|
||||
'# Comment in first line',
|
||||
'Write-Host "Hello"',
|
||||
'# Comment in the middle',
|
||||
'Write-Host "World"',
|
||||
'# Consecutive comments',
|
||||
'# Last line comment without line ending in the end',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'<# Comment in first line #>',
|
||||
'Write-Host "Hello"',
|
||||
'<# Comment in the middle #>',
|
||||
'Write-Host "World"',
|
||||
'<# Consecutive comments #>',
|
||||
'<# Last line comment without line ending in the end #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'can convert comment with inline comment parts inside',
|
||||
input: getWindowsLines(
|
||||
'$text+= #Comment with < inside',
|
||||
'$text+= #Comment ending with >',
|
||||
'$text+= #Comment with <# inline comment #>',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$text+= <# Comment with < inside #>',
|
||||
'$text+= <# Comment ending with > #>',
|
||||
'$text+= <# Comment with <# inline comment #> #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'can convert comment with inline comment parts around', // Pretty uncommon
|
||||
input: getWindowsLines(
|
||||
'Write-Host "hi" # Comment ending line inline comment but not one #>',
|
||||
'Write-Host "hi" #>Comment starting like inline comment end but not one',
|
||||
// Following line does not compile as valid PowerShell due to missing #> for inline comment.
|
||||
'Write-Host "hi" <#Comment starting like inline comment start but not one',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'Write-Host "hi" <# Comment ending line inline comment but not one #> #>',
|
||||
'Write-Host "hi" <# >Comment starting like inline comment end but not one #>',
|
||||
'Write-Host "hi" <<# Comment starting like inline comment start but not one #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'converts empty hash comment',
|
||||
input: getWindowsLines(
|
||||
'Write-Host "Comment without text" #',
|
||||
'Write-Host "Non-empty line"',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'Write-Host "Comment without text" <##>',
|
||||
'Write-Host "Non-empty line"',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'adds whitespaces around to match',
|
||||
input: getWindowsLines(
|
||||
'#Comment line with no whitespaces around',
|
||||
'Write-Host "Hello"#Comment in the end with no whitespaces around',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'<# Comment line with no whitespaces around #>',
|
||||
'Write-Host "Hello"<# Comment in the end with no whitespaces around #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trims whitespaces around comment',
|
||||
input: getWindowsLines(
|
||||
'# Comment with whitespaces around ',
|
||||
'#\tComment with tabs around\t\t',
|
||||
'#\t Comment with tabs and whitespaces around \t \t',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'<# Comment with whitespaces around #>',
|
||||
'<# Comment with tabs around #>',
|
||||
'<# Comment with tabs and whitespaces around #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not convert block comments',
|
||||
input: getWindowsLines(
|
||||
'$text = "Hello"\t<# block comment #> + "World"',
|
||||
'$text = "Hello"\t+<#comment#>"World"',
|
||||
'<# Block comment in a line #>',
|
||||
'Write-Host "Hello world <# Block comment in the end of line #>',
|
||||
),
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$text = "Hello"\t<# block comment #> + "World"',
|
||||
'$text = "Hello"\t+<#comment#>"World"',
|
||||
'<# Block comment in a line #>',
|
||||
'Write-Host "Hello world <# Block comment in the end of line #>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'does not process if there are no multi lines',
|
||||
input: 'Write-Host "expected" # as it is!',
|
||||
expectedOutput: 'Write-Host "expected" # as it is!',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getNewLineCases(): IPipeTestCase[] {
|
||||
return [
|
||||
{
|
||||
name: 'no new line',
|
||||
input: 'Write-Host \'Hello, World!\'',
|
||||
expectedOutput: 'Write-Host \'Hello, World!\'',
|
||||
},
|
||||
{
|
||||
name: '\\n new line',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\nforeach ($thing in $things) {'
|
||||
+ '\nWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\n}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '\\n double empty lines are ignored',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\n\nforeach ($thing in $things) {'
|
||||
+ '\n\nWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\n\n\n}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '\\r new line',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\rforeach ($thing in $things) {'
|
||||
+ '\rWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\r}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '\\r and \\n newlines combined',
|
||||
input:
|
||||
'$things = Get-ChildItem C:\\Windows\\'
|
||||
+ '\r\nforeach ($thing in $things) {'
|
||||
+ '\n\rWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\n\r}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trims whitespaces on lines',
|
||||
input:
|
||||
' $things = Get-ChildItem C:\\Windows\\ '
|
||||
+ '\nforeach ($thing in $things) {'
|
||||
+ '\n\tWrite-Host $thing.Name -ForegroundColor Magenta'
|
||||
+ '\r \n}',
|
||||
expectedOutput: getSingleLinedOutput(
|
||||
'$things = Get-ChildItem C:\\Windows\\',
|
||||
'foreach ($thing in $things) {',
|
||||
'Write-Host $thing.Name -ForegroundColor Magenta',
|
||||
'}',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function prefixTests(prefix: string, tests: IPipeTestCase[]): IPipeTestCase[] {
|
||||
return tests.map((test) => ({
|
||||
name: `[${prefix}] ${test.name}`,
|
||||
input: test.input,
|
||||
expectedOutput: test.expectedOutput,
|
||||
}));
|
||||
}
|
||||
|
||||
function getWindowsLines(...lines: string[]) {
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
function getSingleLinedOutput(...lines: string[]) {
|
||||
return lines.map((line) => line.trim()).join('; ');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import type { IPipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe';
|
||||
|
||||
export interface IPipeTestCase {
|
||||
readonly name: string;
|
||||
readonly input: string;
|
||||
readonly expectedOutput: string;
|
||||
}
|
||||
|
||||
export function runPipeTests(sut: IPipe, testCases: IPipeTestCase[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const actual = sut.apply(testCase.input);
|
||||
// assert
|
||||
expect(actual).to.equal(testCase.expectedOutput);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory';
|
||||
import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('PipeFactory', () => {
|
||||
describe('ctor', () => {
|
||||
it('throws when instances with same name is registered', () => {
|
||||
// arrange
|
||||
const duplicateName = 'duplicateName';
|
||||
const expectedError = `Pipe name must be unique: "${duplicateName}"`;
|
||||
const pipes = [
|
||||
new PipeStub().withName(duplicateName),
|
||||
new PipeStub().withName('uniqueName'),
|
||||
new PipeStub().withName(duplicateName),
|
||||
];
|
||||
// act
|
||||
const act = () => new PipeFactory(pipes);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
describe('throws when name is invalid', () => {
|
||||
// act
|
||||
const act = (invalidName: string) => new PipeFactory([new PipeStub().withName(invalidName)]);
|
||||
// assert
|
||||
testPipeNameValidation(act);
|
||||
});
|
||||
});
|
||||
describe('get', () => {
|
||||
describe('throws when name is invalid', () => {
|
||||
// arrange
|
||||
const sut = new PipeFactory();
|
||||
// act
|
||||
const act = (invalidName: string) => sut.get(invalidName);
|
||||
// assert
|
||||
testPipeNameValidation(act);
|
||||
});
|
||||
it('gets registered instance when it exists', () => {
|
||||
// arrange
|
||||
const expected = new PipeStub().withName('expectedName');
|
||||
const pipes = [expected, new PipeStub().withName('instanceToConfuse')];
|
||||
const sut = new PipeFactory(pipes);
|
||||
// act
|
||||
const actual = sut.get(expected.name);
|
||||
// expect
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
it('throws when instance does not exist', () => {
|
||||
// arrange
|
||||
const missingName = 'missingName';
|
||||
const expectedError = `Unknown pipe: "${missingName}"`;
|
||||
const pipes = [];
|
||||
const sut = new PipeFactory(pipes);
|
||||
// act
|
||||
const act = () => sut.get(missingName);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function testPipeNameValidation(testRunner: (invalidName: string) => void) {
|
||||
const testCases = [
|
||||
// Validate missing value
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `empty pipe name (${testCase.valueName})`,
|
||||
value: testCase.absentValue,
|
||||
expectedError: 'empty pipe name',
|
||||
})),
|
||||
// Validate camelCase
|
||||
...[
|
||||
'PascalCase',
|
||||
'snake-case',
|
||||
'includesNumb3rs',
|
||||
'includes Whitespace',
|
||||
'noSpec\'ial',
|
||||
].map((nonCamelCaseValue) => ({
|
||||
name: `non camel case value (${nonCamelCaseValue})`,
|
||||
value: nonCamelCaseValue,
|
||||
expectedError: `Pipe name should be camelCase: "${nonCamelCaseValue}"`,
|
||||
})),
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const invalidName = testCase.value;
|
||||
const { expectedError } = testCase;
|
||||
// act
|
||||
const act = () => testRunner(invalidName);
|
||||
// expect
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipelineCompiler';
|
||||
import { type IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
|
||||
import type { IPipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory';
|
||||
import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub';
|
||||
import { PipeFactoryStub } from '@tests/unit/shared/Stubs/PipeFactoryStub';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('PipelineCompiler', () => {
|
||||
describe('compile', () => {
|
||||
describe('throws for invalid arguments', () => {
|
||||
interface ITestCase {
|
||||
readonly name: string;
|
||||
readonly act: (test: PipelineTestRunner) => PipelineTestRunner;
|
||||
readonly expectedError: string;
|
||||
}
|
||||
const testCases: ITestCase[] = [
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `"value" is ${testCase.valueName}`,
|
||||
act: (test) => test.withValue(testCase.absentValue),
|
||||
expectedError: 'missing value',
|
||||
})),
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `"pipeline" is ${testCase.valueName}`,
|
||||
act: (test) => test.withPipeline(testCase.absentValue),
|
||||
expectedError: 'missing pipeline',
|
||||
})),
|
||||
{
|
||||
name: '"pipeline" does not start with pipe',
|
||||
act: (test) => test.withPipeline('pipeline |'),
|
||||
expectedError: 'pipeline does not start with pipe',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const runner = new PipelineTestRunner();
|
||||
testCase.act(runner);
|
||||
const act = () => runner.compile();
|
||||
// assert
|
||||
expect(act).to.throw(testCase.expectedError);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('compiles pipeline as expected', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'compiles single pipe as expected',
|
||||
pipes: [
|
||||
new PipeStub().withName('doublePrint').withApplier((value) => `${value}-${value}`),
|
||||
],
|
||||
pipeline: '| doublePrint',
|
||||
value: 'value',
|
||||
expected: 'value-value',
|
||||
},
|
||||
{
|
||||
name: 'compiles multiple pipes as expected',
|
||||
pipes: [
|
||||
new PipeStub().withName('prependLetterA').withApplier((value) => `A-${value}`),
|
||||
new PipeStub().withName('prependLetterB').withApplier((value) => `B-${value}`),
|
||||
],
|
||||
pipeline: '| prependLetterA | prependLetterB',
|
||||
value: 'value',
|
||||
expected: 'B-A-value',
|
||||
},
|
||||
{
|
||||
name: 'compiles with relaxed whitespace placing',
|
||||
pipes: [
|
||||
new PipeStub().withName('appendNumberOne').withApplier((value) => `${value}1`),
|
||||
new PipeStub().withName('appendNumberTwo').withApplier((value) => `${value}2`),
|
||||
new PipeStub().withName('appendNumberThree').withApplier((value) => `${value}3`),
|
||||
],
|
||||
pipeline: ' | appendNumberOne|appendNumberTwo| appendNumberThree',
|
||||
value: 'value',
|
||||
expected: 'value123',
|
||||
},
|
||||
{
|
||||
name: 'can reuse same pipe',
|
||||
pipes: [
|
||||
new PipeStub().withName('removeFirstChar').withApplier((value) => `${value.slice(1)}`),
|
||||
],
|
||||
pipeline: ' | removeFirstChar | removeFirstChar | removeFirstChar',
|
||||
value: 'value',
|
||||
expected: 'ue',
|
||||
},
|
||||
];
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const runner = new PipelineTestRunner()
|
||||
.withValue(testCase.value)
|
||||
.withPipeline(testCase.pipeline)
|
||||
.withFactory(new PipeFactoryStub().withPipes(testCase.pipes));
|
||||
// act
|
||||
const actual = runner.compile();
|
||||
// expect
|
||||
expect(actual).to.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class PipelineTestRunner implements IPipelineCompiler {
|
||||
private value = 'non-empty-value';
|
||||
|
||||
private pipeline = '| validPipeline';
|
||||
|
||||
private factory: IPipeFactory = new PipeFactoryStub();
|
||||
|
||||
public withValue(value: string) {
|
||||
this.value = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withPipeline(pipeline: string) {
|
||||
this.pipeline = pipeline;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFactory(factory: IPipeFactory) {
|
||||
this.factory = factory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public compile(): string {
|
||||
const sut = new PipelineCompiler(this.factory);
|
||||
return sut.compile(this.value, this.pipeline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe } from 'vitest';
|
||||
import { ParameterSubstitutionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
|
||||
|
||||
describe('ParameterSubstitutionParser', () => {
|
||||
const sut = new ParameterSubstitutionParser();
|
||||
const runner = new SyntaxParserTestsRunner(sut);
|
||||
describe('finds as expected', () => {
|
||||
runner.expectPosition(
|
||||
{
|
||||
name: 'single parameter',
|
||||
code: '{{ $parameter }}!',
|
||||
expected: [new ExpressionPosition(0, 16)],
|
||||
},
|
||||
{
|
||||
name: 'different parameters',
|
||||
code: 'He{{ $firstParameter }} {{ $secondParameter }}!!',
|
||||
expected: [new ExpressionPosition(2, 23), new ExpressionPosition(24, 46)],
|
||||
},
|
||||
{
|
||||
name: 'tolerates lack of spaces around brackets',
|
||||
code: 'He{{$firstParameter}}!!',
|
||||
expected: [new ExpressionPosition(2, 21)],
|
||||
},
|
||||
{
|
||||
name: 'does not tolerate space after dollar sign',
|
||||
code: 'He{{ $ firstParameter }}!!',
|
||||
expected: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('evaluates as expected', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'single parameter',
|
||||
code: '{{ $parameter }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world'],
|
||||
},
|
||||
{
|
||||
name: 'different parameters',
|
||||
code: '{{ $firstParameter }} {{ $secondParameter }}!',
|
||||
args: (args) => args
|
||||
.withArgument('firstParameter', 'Hello')
|
||||
.withArgument('secondParameter', 'World'),
|
||||
expected: ['Hello', 'World'],
|
||||
},
|
||||
{
|
||||
name: 'same parameters used twice',
|
||||
code: '{{ $letterH }}e{{ $letterL }}{{ $letterL }}o Wor{{ $letterL }}d!',
|
||||
args: (args) => args
|
||||
.withArgument('letterL', 'l')
|
||||
.withArgument('letterH', 'H'),
|
||||
expected: ['H', 'l', 'l', 'l'],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('compiles pipes as expected', () => {
|
||||
runner.expectPipeHits({
|
||||
codeBuilder: (pipeline) => `{{ $argument${pipeline}}}`,
|
||||
parameterName: 'argument',
|
||||
parameterValue: 'value',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
|
||||
export class SyntaxParserTestsRunner {
|
||||
constructor(private readonly sut: IExpressionParser) {
|
||||
}
|
||||
|
||||
public expectPosition(...testCases: ExpectPositionTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
const actual = expressions.map((e) => e.position);
|
||||
expect(scrambledEqual(actual, testCase.expected));
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectNoMatch(...testCases: NoMatchTestScenario[]) {
|
||||
this.expectPosition(...testCases.map((testCase) => ({
|
||||
name: testCase.name,
|
||||
code: testCase.code,
|
||||
expected: [],
|
||||
})));
|
||||
}
|
||||
|
||||
public expectResults(...testCases: ExpectResultTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const args = testCase.args(new FunctionCallArgumentCollectionStub());
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withArgs(args);
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
const actual = expressions.map((e) => e.evaluate(context));
|
||||
expect(actual).to.deep.equal(testCase.expected);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectThrows(...testCases: ExpectThrowsTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const { expectedError } = testCase;
|
||||
// act
|
||||
const act = () => this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectPipeHits(data: ExpectPipeHitTestScenario) {
|
||||
for (const validPipePart of PipeTestCases.ValidValues) {
|
||||
this.expectHitPipePart(validPipePart, data);
|
||||
}
|
||||
for (const invalidPipePart of PipeTestCases.InvalidValues) {
|
||||
this.expectMissPipePart(invalidPipePart, data);
|
||||
}
|
||||
}
|
||||
|
||||
private expectHitPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
|
||||
it(`"${pipeline}" hits`, () => {
|
||||
// arrange
|
||||
const expectedPipePart = pipeline.trim();
|
||||
const code = data.codeBuilder(pipeline);
|
||||
const args = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument(data.parameterName, data.parameterValue);
|
||||
const pipelineCompiler = new PipelineCompilerStub();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withPipelineCompiler(pipelineCompiler)
|
||||
.withArgs(args);
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(code);
|
||||
expressions[0].evaluate(context);
|
||||
// assert
|
||||
expect(expressions).has.lengthOf(1);
|
||||
expect(pipelineCompiler.compileHistory).has.lengthOf(1);
|
||||
const actualPipePart = pipelineCompiler.compileHistory[0].pipeline;
|
||||
const actualValue = pipelineCompiler.compileHistory[0].value;
|
||||
expect(actualPipePart).to.equal(expectedPipePart);
|
||||
expect(actualValue).to.equal(data.parameterValue);
|
||||
});
|
||||
}
|
||||
|
||||
private expectMissPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
|
||||
it(`"${pipeline}" misses`, () => {
|
||||
// arrange
|
||||
const args = new FunctionCallArgumentCollectionStub()
|
||||
.withArgument(data.parameterName, data.parameterValue);
|
||||
const pipelineCompiler = new PipelineCompilerStub();
|
||||
const context = new ExpressionEvaluationContextStub()
|
||||
.withPipelineCompiler(pipelineCompiler)
|
||||
.withArgs(args);
|
||||
const code = data.codeBuilder(pipeline);
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(code);
|
||||
expressions[0]?.evaluate(context); // Because an expression may include another with pipes
|
||||
// assert
|
||||
expect(pipelineCompiler.compileHistory).has.lengthOf(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpectResultTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly args: (
|
||||
builder: FunctionCallArgumentCollectionStub,
|
||||
) => FunctionCallArgumentCollectionStub;
|
||||
readonly expected: readonly string[];
|
||||
}
|
||||
|
||||
interface ExpectThrowsTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly expectedError: string;
|
||||
}
|
||||
|
||||
interface ExpectPositionTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly expected: readonly ExpressionPosition[];
|
||||
}
|
||||
|
||||
interface NoMatchTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
interface ExpectPipeHitTestScenario {
|
||||
readonly codeBuilder: (pipeline: string) => string;
|
||||
readonly parameterName: string;
|
||||
readonly parameterValue: string;
|
||||
}
|
||||
|
||||
const PipeTestCases = {
|
||||
ValidValues: [
|
||||
// Single pipe with different whitespace combinations
|
||||
' | pipe', ' |pipe', '|pipe', ' |pipe', ' | pipe',
|
||||
|
||||
// Double pipes with different whitespace combinations
|
||||
' | pipeFirst | pipeSecond', '| pipeFirst|pipeSecond', '|pipeFirst|pipeSecond', ' |pipeFirst |pipeSecond', '| pipeFirst | pipeSecond| pipeThird |pipeFourth',
|
||||
],
|
||||
InvalidValues: [
|
||||
' withoutPipeBefore |pipe', ' withoutPipeBefore',
|
||||
|
||||
// It's OK to match them (move to valid values if needed) to let compiler throw instead.
|
||||
'| pip€', '| pip{e} ', '| pipeWithNumber55', '| pipe with whitespace',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe } from 'vitest';
|
||||
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
|
||||
import { WithParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/SyntaxParsers/WithParser';
|
||||
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
|
||||
|
||||
describe('WithParser', () => {
|
||||
const sut = new WithParser();
|
||||
const runner = new SyntaxParserTestsRunner(sut);
|
||||
describe('correctly identifies `with` syntax', () => {
|
||||
runner.expectPosition(
|
||||
{
|
||||
name: 'when no context variable is not used',
|
||||
code: 'hello {{ with $parameter }}no usage{{ end }} here',
|
||||
expected: [new ExpressionPosition(6, 44)],
|
||||
},
|
||||
{
|
||||
name: 'when context variable is used',
|
||||
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
|
||||
expected: [new ExpressionPosition(11, 53)],
|
||||
},
|
||||
{
|
||||
name: 'when used twice',
|
||||
code: 'first: {{ with $parameter }}value: {{ . }}{{ end }}, second: {{ with $parameter }}no usage{{ end }}',
|
||||
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)],
|
||||
},
|
||||
{
|
||||
name: 'when nested',
|
||||
code: 'outer: {{ with $outer }}outer value with context variable: {{ . }}, inner: {{ with $inner }}inner value{{ end }}.{{ end }}',
|
||||
expected: [
|
||||
/* outer: */ new ExpressionPosition(7, 122),
|
||||
/* inner: */ new ExpressionPosition(77, 112),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'whitespaces: tolerate lack of whitespaces',
|
||||
code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
|
||||
expected: [new ExpressionPosition(15, 55)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: match multiline text',
|
||||
code: 'non related line\n{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\n{{ end }}\nnon related line',
|
||||
expected: [new ExpressionPosition(17, 92)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: does not match newlines before',
|
||||
code: '\n{{ with $unimportant }}Text{{ end }}',
|
||||
expected: [new ExpressionPosition(1, 37)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: does not match newlines after',
|
||||
code: '{{ with $unimportant }}Text{{ end }}\n',
|
||||
expected: [new ExpressionPosition(0, 36)],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('throws with incorrect `with` syntax', () => {
|
||||
runner.expectThrows(
|
||||
{
|
||||
name: 'incorrect `with`: whitespace after dollar sign inside `with` statement',
|
||||
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `with`: whitespace before dollar sign inside `with` statement',
|
||||
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `with`: missing `with` statement',
|
||||
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `end`: missing `end` statement',
|
||||
code: '{{ with $parameter}}value: {{ . }}{{ fin }}',
|
||||
expectedError: 'Missing `end` statement, forgot `{{ end }}?',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `end`: used without `with`',
|
||||
code: 'Value {{ end }}',
|
||||
expectedError: 'Redundant `end` statement, missing `with`?',
|
||||
},
|
||||
{
|
||||
name: 'incorrect "context variable": used without `with`',
|
||||
code: 'Value: {{ . }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('ignores when syntax is wrong', () => {
|
||||
describe('does not render argument if substitution syntax is wrong', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'comma used instead of dot',
|
||||
code: '{{ with $parameter }}Hello {{ , }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'world!'),
|
||||
expected: ['Hello {{ , }}'],
|
||||
},
|
||||
{
|
||||
name: 'single brackets instead of double',
|
||||
code: '{{ with $parameter }}Hello { . }{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'world!'),
|
||||
expected: ['Hello { . }'],
|
||||
},
|
||||
{
|
||||
name: 'double dots instead of single',
|
||||
code: '{{ with $parameter }}Hello {{ .. }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'world!'),
|
||||
expected: ['Hello {{ .. }}'],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('scope rendering', () => {
|
||||
describe('conditional rendering based on argument value', () => {
|
||||
describe('does not render scope', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('renders scope', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
|
||||
.map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
{
|
||||
name: 'renders scope even if value is not used',
|
||||
code: '{{ with $parameter }}Hello world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when it has value',
|
||||
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when whitespaces around brackets are missing',
|
||||
code: '{{ with $parameter }}{{.}} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value multiple times when it\'s used multiple times',
|
||||
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('letterL', 'l'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('whitespace handling inside scope', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'renders value in multi-lined text',
|
||||
code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('middleLine', 'value line'),
|
||||
expected: ['line before value\nvalue line\nline after value'],
|
||||
},
|
||||
{
|
||||
name: 'renders value around whitespaces in multi-lined text',
|
||||
code: '{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\t {{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('middleLine', 'value line'),
|
||||
expected: ['line before value\nvalue line\nline after value'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing whitespace after value',
|
||||
code: '{{ with $parameter }}{{ . }}! {{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing newline after value',
|
||||
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline before value',
|
||||
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading whitespaces before value',
|
||||
code: '{{ with $parameter }} {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline and whitespaces before value',
|
||||
code: '{{ with $parameter }}\r\n {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('nested with statements', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'renders nested with statements correctly',
|
||||
code: '{{ with $outer }}Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: {{ . }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('outer', 'OuterValue')
|
||||
.withArgument('inner', 'InnerValue'),
|
||||
expected: [
|
||||
'Inner: InnerValue',
|
||||
'Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: OuterValue',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'renders nested with statements with context variables',
|
||||
code: '{{ with $outer }}{{ with $inner }}{{ . }}{{ . }}{{ end }}{{ . }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('outer', 'O')
|
||||
.withArgument('inner', 'I'),
|
||||
expected: [
|
||||
'II',
|
||||
'{{ with $inner }}{{ . }}{{ . }}{{ end }}O',
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('pipe behavior', () => {
|
||||
runner.expectPipeHits({
|
||||
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
|
||||
parameterName: 'argument',
|
||||
parameterValue: 'value',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user