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,36 @@
|
||||
export interface DataValidationTestScenario<T> {
|
||||
readonly description: string;
|
||||
readonly data: T;
|
||||
readonly expectedPass: boolean;
|
||||
readonly expectedMessage?: string;
|
||||
}
|
||||
|
||||
export function generateDataValidationTestScenarios<T>(
|
||||
...conditionBasedScenarios: DataValidationConditionBasedTestScenario<T>[]
|
||||
): DataValidationTestScenario<T>[] {
|
||||
return conditionBasedScenarios.flatMap((conditionScenario) => [
|
||||
conditionScenario.expectFail.map((failDefinition): DataValidationTestScenario<T> => ({
|
||||
description: `fails: "${failDefinition.description}"`,
|
||||
data: failDefinition.data,
|
||||
expectedPass: false,
|
||||
expectedMessage: conditionScenario.assertErrorMessage,
|
||||
})),
|
||||
conditionScenario.expectPass.map((passDefinition): DataValidationTestScenario<T> => ({
|
||||
description: `passes: "${passDefinition.description}"`,
|
||||
data: passDefinition.data,
|
||||
expectedPass: true,
|
||||
expectedMessage: conditionScenario.assertErrorMessage,
|
||||
})),
|
||||
].flat());
|
||||
}
|
||||
|
||||
interface DataValidationConditionBasedTestScenario<T> {
|
||||
readonly assertErrorMessage?: string;
|
||||
readonly expectPass: readonly DataValidationScenarioDefinition<T>[];
|
||||
readonly expectFail: readonly DataValidationScenarioDefinition<T>[];
|
||||
}
|
||||
|
||||
interface DataValidationScenarioDefinition<T> {
|
||||
readonly description: string;
|
||||
readonly data: T;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { it } from 'vitest';
|
||||
import type { ExecutableValidator, ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
|
||||
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
|
||||
import { ExecutableValidatorStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import type { ExecutableData } from '@/application/collections/';
|
||||
import type { FunctionKeys } from '@/TypeHelpers';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { indentText } from '@tests/shared/Text';
|
||||
|
||||
type ValidationTestFunction<TExpectation> = (
|
||||
factory: ExecutableValidatorFactory,
|
||||
) => TExpectation;
|
||||
|
||||
interface ValidNameExpectation {
|
||||
readonly expectedNameToValidate: string;
|
||||
readonly expectedErrorContext: ExecutableErrorContext;
|
||||
}
|
||||
|
||||
export function itValidatesName(
|
||||
test: ValidationTestFunction<ValidNameExpectation>,
|
||||
) {
|
||||
it('validates for name', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
test(factoryStub);
|
||||
// assert
|
||||
const call = validator.callHistory.find((c) => c.methodName === 'assertValidName');
|
||||
expectExists(call);
|
||||
});
|
||||
it('validates for name with correct name', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = test(factoryStub);
|
||||
// assert
|
||||
const expectedName = expectation.expectedNameToValidate;
|
||||
const names = validator.callHistory
|
||||
.filter((c) => c.methodName === 'assertValidName')
|
||||
.flatMap((c) => c.args[0]);
|
||||
expect(names).to.include(expectedName);
|
||||
});
|
||||
it('validates for name with correct context', () => {
|
||||
expectCorrectContextForFunctionCall({
|
||||
methodName: 'assertValidName',
|
||||
act: test,
|
||||
expectContext: (expectation) => expectation.expectedErrorContext,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface ValidDataExpectation {
|
||||
readonly expectedDataToValidate: ExecutableData;
|
||||
readonly expectedErrorContext: ExecutableErrorContext;
|
||||
}
|
||||
|
||||
export function itValidatesDefinedData(
|
||||
test: ValidationTestFunction<ValidDataExpectation>,
|
||||
) {
|
||||
it('validates data', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
test(factoryStub);
|
||||
// assert
|
||||
const call = validator.callHistory.find((c) => c.methodName === 'assertDefined');
|
||||
expectExists(call);
|
||||
});
|
||||
it('validates data with correct data', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub();
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = test(factoryStub);
|
||||
// assert
|
||||
const expectedData = expectation.expectedDataToValidate;
|
||||
const calls = validator.callHistory.filter((c) => c.methodName === 'assertDefined');
|
||||
const names = calls.flatMap((c) => c.args[0]);
|
||||
expect(names).to.include(expectedData);
|
||||
});
|
||||
it('validates data with correct context', () => {
|
||||
expectCorrectContextForFunctionCall({
|
||||
methodName: 'assertDefined',
|
||||
act: test,
|
||||
expectContext: (expectation) => expectation.expectedErrorContext,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface AssertionExpectation {
|
||||
readonly expectedErrorMessage: string;
|
||||
readonly expectedErrorContext: ExecutableErrorContext;
|
||||
}
|
||||
|
||||
export function itAsserts(
|
||||
testScenario: {
|
||||
readonly test: ValidationTestFunction<AssertionExpectation>,
|
||||
readonly expectedConditionResult: boolean;
|
||||
},
|
||||
) {
|
||||
it('asserts with correct message', () => {
|
||||
// arrange
|
||||
const validator = new ExecutableValidatorStub()
|
||||
.withAssertThrowsOnFalseCondition(false);
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = testScenario.test(factoryStub);
|
||||
// assert
|
||||
const expectedError = expectation.expectedErrorMessage;
|
||||
const calls = validator.callHistory.filter((c) => c.methodName === 'assert');
|
||||
const actualMessages = calls.map((call) => {
|
||||
const [, message] = call.args;
|
||||
return message;
|
||||
});
|
||||
expect(actualMessages).to.include(expectedError, formatAssertionMessage([
|
||||
'Assertion failed: The expected error message was not triggered.',
|
||||
`Expected: "${expectedError}"`,
|
||||
'Actual messages (none match expected):',
|
||||
indentText(actualMessages.map((message) => `- ${message}`).join('\n')),
|
||||
]));
|
||||
});
|
||||
it('asserts with correct context', () => {
|
||||
expectCorrectContextForFunctionCall({
|
||||
methodName: 'assert',
|
||||
act: testScenario.test,
|
||||
expectContext: (expectation) => expectation.expectedErrorContext,
|
||||
});
|
||||
});
|
||||
it('asserts with correct condition result', () => {
|
||||
// arrange
|
||||
const expectedEvaluationResult = testScenario.expectedConditionResult;
|
||||
const validator = new ExecutableValidatorStub()
|
||||
.withAssertThrowsOnFalseCondition(false);
|
||||
const factoryStub: ExecutableValidatorFactory = () => validator;
|
||||
// act
|
||||
const expectation = testScenario.test(factoryStub);
|
||||
// assert
|
||||
const assertCalls = validator.callHistory
|
||||
.filter((call) => call.methodName === 'assert');
|
||||
expect(assertCalls).to.have.length.greaterThan(0);
|
||||
const assertCallsWithMessage = assertCalls
|
||||
.filter((call) => {
|
||||
const [, message] = call.args;
|
||||
return message === expectation.expectedErrorMessage;
|
||||
});
|
||||
expect(assertCallsWithMessage).to.have.length.greaterThan(0);
|
||||
const evaluationResults = assertCallsWithMessage
|
||||
.map((call) => {
|
||||
const [predicate] = call.args;
|
||||
return predicate as (() => boolean);
|
||||
})
|
||||
.map((predicate) => predicate());
|
||||
expect(evaluationResults).to.include(expectedEvaluationResult);
|
||||
});
|
||||
}
|
||||
|
||||
function expectCorrectContextForFunctionCall<T>(testScenario: {
|
||||
methodName: FunctionKeys<ExecutableValidator>,
|
||||
act: ValidationTestFunction<T>,
|
||||
expectContext: (actionResult: T) => ExecutableErrorContext,
|
||||
}) {
|
||||
// arrange
|
||||
const { methodName } = testScenario;
|
||||
const createdValidators = new Array<{
|
||||
readonly validator: ExecutableValidatorStub;
|
||||
readonly context: ExecutableErrorContext;
|
||||
}>();
|
||||
const factoryStub: ExecutableValidatorFactory = (context) => {
|
||||
const validator = new ExecutableValidatorStub()
|
||||
.withAssertThrowsOnFalseCondition(false);
|
||||
createdValidators.push(({
|
||||
validator,
|
||||
context,
|
||||
}));
|
||||
return validator;
|
||||
};
|
||||
// act
|
||||
const actionResult = testScenario.act(factoryStub);
|
||||
// assert
|
||||
const expectedContext = testScenario.expectContext(actionResult);
|
||||
const providedContexts = createdValidators
|
||||
.filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName))
|
||||
.map((v) => v.context);
|
||||
expectDeepIncludes( // to.deep.include is not working
|
||||
providedContexts,
|
||||
expectedContext,
|
||||
formatAssertionMessage([
|
||||
'Error context mismatch.',
|
||||
'Provided contexts do not include the expected context.',
|
||||
'Expected context:',
|
||||
indentText(JSON.stringify(expectedContext, undefined, 2)),
|
||||
'Provided contexts:',
|
||||
indentText(JSON.stringify(providedContexts, undefined, 2)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function expectDeepIncludes<T>(
|
||||
array: readonly T[],
|
||||
item: T,
|
||||
message: string,
|
||||
) {
|
||||
const serializeItem = (c) => JSON.stringify(c);
|
||||
const serializedContexts = array.map((c) => serializeItem(c));
|
||||
const serializedExpectedContext = serializeItem(item);
|
||||
expect(serializedContexts).to.include(serializedExpectedContext, formatAssertionMessage([
|
||||
'Error context mismatch.',
|
||||
'Provided contexts do not include the expected context.',
|
||||
'Expected context:',
|
||||
indentText(JSON.stringify(message, undefined, 2)),
|
||||
'Provided contexts:',
|
||||
indentText(JSON.stringify(message, undefined, 2)),
|
||||
]));
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
|
||||
import type { ExecutableData } from '@/application/collections/';
|
||||
import { createExecutableErrorContextStub } from '@tests/unit/shared/Stubs/ExecutableErrorContextStub';
|
||||
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
|
||||
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||
import { ContextualExecutableValidator, createExecutableDataValidator, type ExecutableValidator } from '@/application/Parser/Executable/Validation/ExecutableValidator';
|
||||
import type { ExecutableContextErrorMessageCreator } from '@/application/Parser/Executable/Validation/ExecutableErrorContextMessage';
|
||||
import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
|
||||
describe('createExecutableDataValidator', () => {
|
||||
it(`returns an instance of ${ContextualExecutableValidator.name}`, () => {
|
||||
// arrange
|
||||
const context = createExecutableErrorContextStub();
|
||||
// act
|
||||
const validator = createExecutableDataValidator(context);
|
||||
// assert
|
||||
expect(validator).to.be.instanceOf(ContextualExecutableValidator);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextualExecutableValidator', () => {
|
||||
describe('assertValidName', () => {
|
||||
describe('throws when name is invalid', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly invalidName: unknown;
|
||||
readonly expectedMessage: string;
|
||||
}[] = [
|
||||
...getAbsentStringTestCases().map((testCase) => ({
|
||||
description: `missing name (${testCase.valueName})`,
|
||||
invalidName: testCase.absentValue,
|
||||
expectedMessage: 'missing name',
|
||||
})),
|
||||
{
|
||||
description: 'invalid type',
|
||||
invalidName: 33,
|
||||
expectedMessage: 'Name (33) is not a string but number.',
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({ description, invalidName, expectedMessage }) => {
|
||||
describe(`given "${description}"`, () => {
|
||||
itThrowsCorrectly({
|
||||
// act
|
||||
throwingAction: (sut) => {
|
||||
sut.assertValidName(invalidName as string);
|
||||
},
|
||||
// assert
|
||||
expectedMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('does not throw when name is valid', () => {
|
||||
// arrange
|
||||
const validName = 'validName';
|
||||
const sut = new ValidatorBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.assertValidName(validName);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('assertDefined', () => {
|
||||
describe('throws when data is missing', () => {
|
||||
// arrange
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly invalidData: unknown;
|
||||
}[] = [
|
||||
...getAbsentObjectTestCases().map((testCase) => ({
|
||||
description: `absent object (${testCase.valueName})`,
|
||||
invalidData: testCase.absentValue,
|
||||
})),
|
||||
{
|
||||
description: 'empty object',
|
||||
invalidData: {},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({ description, invalidData }) => {
|
||||
describe(`given "${description}"`, () => {
|
||||
const expectedMessage = 'missing executable data';
|
||||
itThrowsCorrectly({
|
||||
// act
|
||||
throwingAction: (sut: ExecutableValidator) => {
|
||||
sut.assertDefined(invalidData as ExecutableData);
|
||||
},
|
||||
// assert
|
||||
expectedMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('does not throw if data is defined', () => {
|
||||
// arrange
|
||||
const data = new CategoryDataStub();
|
||||
const sut = new ValidatorBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.assertDefined(data);
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('assert', () => {
|
||||
describe('throws if validation fails', () => {
|
||||
const falsePredicate = () => false;
|
||||
const expectedErrorMessage = 'expected error';
|
||||
// assert
|
||||
itThrowsCorrectly({
|
||||
// act
|
||||
throwingAction: (sut: ExecutableValidator) => {
|
||||
sut.assert(falsePredicate, expectedErrorMessage);
|
||||
},
|
||||
// assert
|
||||
expectedMessage: expectedErrorMessage,
|
||||
});
|
||||
});
|
||||
it('does not throw if validation succeeds', () => {
|
||||
// arrange
|
||||
const truePredicate = () => true;
|
||||
const sut = new ValidatorBuilder()
|
||||
.build();
|
||||
// act
|
||||
const act = () => sut.assert(truePredicate, 'ignored error');
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
});
|
||||
});
|
||||
describe('createContextualErrorMessage', () => {
|
||||
it('creates using the correct error message', () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'expected error';
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = (message) => message;
|
||||
const sut = new ValidatorBuilder()
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const actualErrorMessage = sut.createContextualErrorMessage(expectedErrorMessage);
|
||||
// assert
|
||||
expect(actualErrorMessage).to.equal(expectedErrorMessage);
|
||||
});
|
||||
it('creates using the correct context', () => {
|
||||
// arrange
|
||||
const expectedContext = createExecutableErrorContextStub();
|
||||
let actualContext: ExecutableErrorContext | undefined;
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = (_, context) => {
|
||||
actualContext = context;
|
||||
return '';
|
||||
};
|
||||
const sut = new ValidatorBuilder()
|
||||
.withContext(expectedContext)
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
sut.createContextualErrorMessage('unimportant');
|
||||
// assert
|
||||
expect(actualContext).to.equal(expectedContext);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ValidationThrowingFunction = (
|
||||
sut: ContextualExecutableValidator,
|
||||
) => void;
|
||||
|
||||
interface ValidationThrowingTestScenario {
|
||||
readonly throwingAction: ValidationThrowingFunction,
|
||||
readonly expectedMessage: string;
|
||||
}
|
||||
|
||||
function itThrowsCorrectly(
|
||||
testScenario: ValidationThrowingTestScenario,
|
||||
): void {
|
||||
it('throws an error', () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'Injected error message';
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = () => expectedErrorMessage;
|
||||
const sut = new ValidatorBuilder()
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const action = () => testScenario.throwingAction(sut);
|
||||
// assert
|
||||
expect(action).to.throw();
|
||||
});
|
||||
it('throws with the correct error message', () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = testScenario.expectedMessage;
|
||||
const errorMessageBuilder: ExecutableContextErrorMessageCreator = (message) => message;
|
||||
const sut = new ValidatorBuilder()
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const action = () => testScenario.throwingAction(sut);
|
||||
// assert
|
||||
const actualErrorMessage = collectExceptionMessage(action);
|
||||
expect(actualErrorMessage).to.equal(expectedErrorMessage);
|
||||
});
|
||||
it('throws with the correct context', () => {
|
||||
// arrange
|
||||
const expectedContext = createExecutableErrorContextStub();
|
||||
const serializeContext = (context: ExecutableErrorContext) => JSON.stringify(context);
|
||||
const errorMessageBuilder:
|
||||
ExecutableContextErrorMessageCreator = (_, context) => serializeContext(context);
|
||||
const sut = new ValidatorBuilder()
|
||||
.withContext(expectedContext)
|
||||
.withErrorMessageCreator(errorMessageBuilder)
|
||||
.build();
|
||||
// act
|
||||
const action = () => testScenario.throwingAction(sut);
|
||||
// assert
|
||||
const expectedSerializedContext = serializeContext(expectedContext);
|
||||
const actualSerializedContext = collectExceptionMessage(action);
|
||||
expect(expectedSerializedContext).to.equal(actualSerializedContext);
|
||||
});
|
||||
}
|
||||
|
||||
class ValidatorBuilder {
|
||||
private errorContext: ExecutableErrorContext = createExecutableErrorContextStub();
|
||||
|
||||
private errorMessageCreator: ExecutableContextErrorMessageCreator = () => `[${ValidatorBuilder.name}] stub error message`;
|
||||
|
||||
public withErrorMessageCreator(errorMessageCreator: ExecutableContextErrorMessageCreator): this {
|
||||
this.errorMessageCreator = errorMessageCreator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withContext(errorContext: ExecutableErrorContext): this {
|
||||
this.errorContext = errorContext;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ContextualExecutableValidator {
|
||||
return new ContextualExecutableValidator(
|
||||
this.errorContext,
|
||||
this.errorMessageCreator,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user