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

View File

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

View File

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

View File

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