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:
undergroundwires
2024-06-12 12:36:40 +02:00
parent 8becc7dbc4
commit c138f74460
230 changed files with 1120 additions and 1039 deletions

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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);
}
}