diff --git a/src/application/Common/CustomError.ts b/src/application/Common/CustomError.ts index 8a31884df..eff52bf6e 100644 --- a/src/application/Common/CustomError.ts +++ b/src/application/Common/CustomError.ts @@ -1,4 +1,4 @@ -import { isFunction } from '@/TypeHelpers'; +import { isFunction, type ConstructorArguments } from '@/TypeHelpers'; /* Provides a unified and resilient way to extend errors across platforms. @@ -12,8 +12,8 @@ import { isFunction } from '@/TypeHelpers'; > https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work */ export abstract class CustomError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message, options); + constructor(...args: ConstructorArguments<typeof Error>) { + super(...args); fixPrototype(this, new.target.prototype); ensureStackTrace(this); diff --git a/src/application/Parser/CategoryParser.ts b/src/application/Parser/CategoryParser.ts index ccaff8e8d..1eaa3fbc8 100644 --- a/src/application/Parser/CategoryParser.ts +++ b/src/application/Parser/CategoryParser.ts @@ -3,10 +3,12 @@ import type { } from '@/application/collections/'; import { Script } from '@/domain/Script'; import { Category } from '@/domain/Category'; -import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; -import { parseDocs } from './DocumentationParser'; -import { parseScript } from './Script/ScriptParser'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import type { ICategory } from '@/domain/ICategory'; +import { parseDocs, type DocsParser } from './DocumentationParser'; +import { parseScript, type ScriptParser } from './Script/ScriptParser'; +import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from './NodeValidation/NodeDataValidator'; +import { NodeDataType } from './NodeValidation/NodeDataType'; import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext'; let categoryIdCounter = 0; @@ -14,96 +16,108 @@ let categoryIdCounter = 0; export function parseCategory( category: CategoryData, context: ICategoryCollectionParseContext, - factory: CategoryFactoryType = CategoryFactory, + utilities: CategoryParserUtilities = DefaultCategoryParserUtilities, ): Category { return parseCategoryRecursively({ categoryData: category, context, - factory, + utilities, }); } -interface ICategoryParseContext { - readonly categoryData: CategoryData, - readonly context: ICategoryCollectionParseContext, - readonly factory: CategoryFactoryType, - readonly parentCategory?: CategoryData, +interface CategoryParseContext { + readonly categoryData: CategoryData; + readonly context: ICategoryCollectionParseContext; + readonly parentCategory?: CategoryData; + readonly utilities: CategoryParserUtilities; } -function parseCategoryRecursively(context: ICategoryParseContext): Category | never { - ensureValidCategory(context.categoryData, context.parentCategory); - const children: ICategoryChildren = { - subCategories: new Array<Category>(), - subScripts: new Array<Script>(), +function parseCategoryRecursively( + context: CategoryParseContext, +): Category | never { + const validator = ensureValidCategory(context); + const children: CategoryChildren = { + subcategories: new Array<Category>(), + subscripts: new Array<Script>(), }; for (const data of context.categoryData.children) { parseNode({ nodeData: data, children, parent: context.categoryData, - factory: context.factory, + utilities: context.utilities, context: context.context, }); } try { - return context.factory( - /* id: */ categoryIdCounter++, - /* name: */ context.categoryData.category, - /* docs: */ parseDocs(context.categoryData), - /* categories: */ children.subCategories, - /* scripts: */ children.subScripts, + return context.utilities.createCategory({ + id: categoryIdCounter++, + name: context.categoryData.category, + docs: context.utilities.parseDocs(context.categoryData), + subcategories: children.subcategories, + scripts: children.subscripts, + }); + } catch (error) { + throw context.utilities.wrapError( + error, + validator.createContextualErrorMessage('Failed to parse category.'), ); - } catch (err) { - return new NodeValidator({ - type: NodeType.Category, - selfNode: context.categoryData, - parentNode: context.parentCategory, - }).throw(err.message); } } -function ensureValidCategory(category: CategoryData, parentCategory?: CategoryData) { - new NodeValidator({ - type: NodeType.Category, - selfNode: category, - parentNode: parentCategory, - }) - .assertDefined(category) - .assertValidName(category.category) - .assert( - () => category.children.length > 0, - `"${category.category}" has no children.`, - ); +function ensureValidCategory( + context: CategoryParseContext, +): NodeDataValidator { + const category = context.categoryData; + const validator: NodeDataValidator = context.utilities.createValidator({ + type: NodeDataType.Category, + selfNode: context.categoryData, + parentNode: context.parentCategory, + }); + validator.assertDefined(category); + validator.assertValidName(category.category); + validator.assert( + () => Boolean(category.children) && category.children.length > 0, + `"${category.category}" has no children.`, + ); + return validator; } -interface ICategoryChildren { - subCategories: Category[]; - subScripts: Script[]; +interface CategoryChildren { + readonly subcategories: Category[]; + readonly subscripts: Script[]; } -interface INodeParseContext { +interface NodeParseContext { readonly nodeData: CategoryOrScriptData; - readonly children: ICategoryChildren; + readonly children: CategoryChildren; readonly parent: CategoryData; - readonly factory: CategoryFactoryType; readonly context: ICategoryCollectionParseContext; + + readonly utilities: CategoryParserUtilities; } -function parseNode(context: INodeParseContext) { - const validator = new NodeValidator({ selfNode: context.nodeData, parentNode: context.parent }); + +function parseNode(context: NodeParseContext) { + const validator: NodeDataValidator = context.utilities.createValidator({ + selfNode: context.nodeData, + parentNode: context.parent, + }); validator.assertDefined(context.nodeData); + validator.assert( + () => isCategory(context.nodeData) || isScript(context.nodeData), + 'Node is neither a category or a script.', + ); if (isCategory(context.nodeData)) { const subCategory = parseCategoryRecursively({ categoryData: context.nodeData, context: context.context, - factory: context.factory, parentCategory: context.parent, + utilities: context.utilities, }); - context.children.subCategories.push(subCategory); - } else if (isScript(context.nodeData)) { - const script = parseScript(context.nodeData, context.context); - context.children.subScripts.push(script); - } else { - validator.throw('Node is neither a category or a script.'); + context.children.subcategories.push(subCategory); + } else { // A script + const script = context.utilities.parseScript(context.nodeData, context.context); + context.children.subscripts.push(script); } } @@ -123,11 +137,35 @@ function hasCall(data: unknown) { return hasProperty(data, 'call'); } -function hasProperty(object: unknown, propertyName: string) { +function hasProperty( + object: unknown, + propertyName: string, +): object is NonNullable<object> { + if (typeof object !== 'object') { + return false; + } + if (object === null) { // `typeof object` is `null` + return false; + } return Object.prototype.hasOwnProperty.call(object, propertyName); } -export type CategoryFactoryType = ( - ...parameters: ConstructorParameters<typeof Category>) => Category; +export type CategoryFactory = ( + ...parameters: ConstructorParameters<typeof Category> +) => ICategory; + +interface CategoryParserUtilities { + readonly createCategory: CategoryFactory; + readonly wrapError: ErrorWithContextWrapper; + readonly createValidator: NodeDataValidatorFactory; + readonly parseScript: ScriptParser; + readonly parseDocs: DocsParser; +} -const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters); +const DefaultCategoryParserUtilities: CategoryParserUtilities = { + createCategory: (...parameters) => new Category(...parameters), + wrapError: wrapErrorWithAdditionalContext, + createValidator: createNodeDataValidator, + parseScript, + parseDocs, +}; diff --git a/src/application/Parser/ContextualError.ts b/src/application/Parser/ContextualError.ts new file mode 100644 index 000000000..5c945620b --- /dev/null +++ b/src/application/Parser/ContextualError.ts @@ -0,0 +1,42 @@ +import { CustomError } from '@/application/Common/CustomError'; + +export interface ErrorWithContextWrapper { + ( + error: Error, + additionalContext: string, + ): Error; +} + +export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = ( + error: Error, + additionalContext: string, +) => { + return (error instanceof ContextualError ? error : new ContextualError(error)) + .withAdditionalContext(additionalContext); +}; + +/* AggregateError is similar but isn't well-serialized or displayed (via console.log) by browsers */ +class ContextualError extends CustomError { + private readonly additionalContext = new Array<string>(); + + constructor( + public readonly innerError: Error, + ) { + super(); + } + + public withAdditionalContext(additionalContext: string): this { + this.additionalContext.push(additionalContext); + return this; + } + + public get message(): string { // toString() is not used when Chromium logs it on console + return [ + '\n', + this.innerError.message, + '\n', + 'Additional context:', + ...this.additionalContext.map((context, index) => `${index + 1}: ${context}`), + ].join('\n'); + } +} diff --git a/src/application/Parser/DocumentationParser.ts b/src/application/Parser/DocumentationParser.ts index 2a177fb20..9ac70fcd9 100644 --- a/src/application/Parser/DocumentationParser.ts +++ b/src/application/Parser/DocumentationParser.ts @@ -1,7 +1,7 @@ import type { DocumentableData, DocumentationData } from '@/application/collections/'; import { isString, isArray } from '@/TypeHelpers'; -export function parseDocs(documentable: DocumentableData): readonly string[] { +export const parseDocs: DocsParser = (documentable) => { const { docs } = documentable; if (!docs) { return []; @@ -9,6 +9,12 @@ export function parseDocs(documentable: DocumentableData): readonly string[] { let result = new DocumentationContainer(); result = addDocs(docs, result); return result.getAll(); +}; + +export interface DocsParser { + ( + documentable: DocumentableData, + ): readonly string[]; } function addDocs( diff --git a/src/application/Parser/NodeValidation/NodeDataError.ts b/src/application/Parser/NodeValidation/NodeDataError.ts deleted file mode 100644 index 970498f94..000000000 --- a/src/application/Parser/NodeValidation/NodeDataError.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CustomError } from '@/application/Common/CustomError'; -import { NodeType } from './NodeType'; -import type { NodeData } from './NodeData'; - -export class NodeDataError extends CustomError { - constructor(message: string, public readonly context: INodeDataErrorContext) { - super(createMessage(message, context)); - } -} - -export interface INodeDataErrorContext { - readonly type?: NodeType; - readonly selfNode: NodeData; - readonly parentNode?: NodeData; -} - -function createMessage(errorMessage: string, context: INodeDataErrorContext) { - let message = ''; - if (context.type !== undefined) { - message += `${NodeType[context.type]}: `; - } - message += errorMessage; - message += `\n${dump(context)}`; - return message; -} - -function dump(context: INodeDataErrorContext): string { - const printJson = (obj: unknown) => JSON.stringify(obj, undefined, 2); - let output = `Self: ${printJson(context.selfNode)}`; - if (context.parentNode) { - output += `\nParent: ${printJson(context.parentNode)}`; - } - return output; -} diff --git a/src/application/Parser/NodeValidation/NodeDataErrorContext.ts b/src/application/Parser/NodeValidation/NodeDataErrorContext.ts new file mode 100644 index 000000000..f5e6fe852 --- /dev/null +++ b/src/application/Parser/NodeValidation/NodeDataErrorContext.ts @@ -0,0 +1,25 @@ +import type { CategoryData, ScriptData } from '@/application/collections/'; +import { NodeDataType } from './NodeDataType'; +import type { NodeData } from './NodeData'; + +export type NodeDataErrorContext = { + readonly parentNode?: CategoryData; +} & (CategoryNodeErrorContext | ScriptNodeErrorContext | UnknownNodeErrorContext); + +export type CategoryNodeErrorContext = { + readonly type: NodeDataType.Category; + readonly selfNode: CategoryData; + readonly parentNode?: CategoryData; +}; + +export type ScriptNodeErrorContext = { + readonly type: NodeDataType.Script; + readonly selfNode: ScriptData; + readonly parentNode?: CategoryData; +}; + +export type UnknownNodeErrorContext = { + readonly type?: undefined; + readonly selfNode: NodeData; + readonly parentNode?: CategoryData; +}; diff --git a/src/application/Parser/NodeValidation/NodeDataErrorContextMessage.ts b/src/application/Parser/NodeValidation/NodeDataErrorContextMessage.ts new file mode 100644 index 000000000..70956697e --- /dev/null +++ b/src/application/Parser/NodeValidation/NodeDataErrorContextMessage.ts @@ -0,0 +1,35 @@ +import { NodeDataType } from './NodeDataType'; +import type { NodeDataErrorContext } from './NodeDataErrorContext'; +import type { NodeData } from './NodeData'; + +export interface NodeContextErrorMessageCreator { + ( + errorMessage: string, + context: NodeDataErrorContext, + ): string; +} + +export const createNodeContextErrorMessage: NodeContextErrorMessageCreator = ( + errorMessage, + context, +) => { + let message = ''; + if (context.type !== undefined) { + message += `${NodeDataType[context.type]}: `; + } + message += errorMessage; + message += `\n${getErrorContextDetails(context)}`; + return message; +}; + +function getErrorContextDetails(context: NodeDataErrorContext): string { + let output = `Self: ${printNodeDataAsJson(context.selfNode)}`; + if (context.parentNode) { + output += `\nParent: ${printNodeDataAsJson(context.parentNode)}`; + } + return output; +} + +function printNodeDataAsJson(node: NodeData): string { + return JSON.stringify(node, undefined, 2); +} diff --git a/src/application/Parser/NodeValidation/NodeDataType.ts b/src/application/Parser/NodeValidation/NodeDataType.ts new file mode 100644 index 000000000..cc4dac96e --- /dev/null +++ b/src/application/Parser/NodeValidation/NodeDataType.ts @@ -0,0 +1,4 @@ +export enum NodeDataType { + Script, + Category, +} diff --git a/src/application/Parser/NodeValidation/NodeDataValidator.ts b/src/application/Parser/NodeValidation/NodeDataValidator.ts new file mode 100644 index 000000000..757a3e217 --- /dev/null +++ b/src/application/Parser/NodeValidation/NodeDataValidator.ts @@ -0,0 +1,69 @@ +import { isString } from '@/TypeHelpers'; +import { type NodeDataErrorContext } from './NodeDataErrorContext'; +import { createNodeContextErrorMessage, type NodeContextErrorMessageCreator } from './NodeDataErrorContextMessage'; +import type { NodeData } from './NodeData'; + +export interface NodeDataValidatorFactory { + (context: NodeDataErrorContext): NodeDataValidator; +} + +export interface NodeDataValidator { + assertValidName(nameValue: string): void; + assertDefined( + node: NodeData | undefined, + ): asserts node is NonNullable<NodeData> & void; + assert( + validationPredicate: () => boolean, + errorMessage: string, + ): asserts validationPredicate is (() => true); + createContextualErrorMessage(errorMessage: string): string; +} + +export const createNodeDataValidator +: NodeDataValidatorFactory = (context) => new ContextualNodeDataValidator(context); + +export class ContextualNodeDataValidator implements NodeDataValidator { + constructor( + private readonly context: NodeDataErrorContext, + private readonly createErrorMessage + : NodeContextErrorMessageCreator = createNodeContextErrorMessage, + ) { + + } + + public assertValidName(nameValue: string): void { + this.assert(() => Boolean(nameValue), 'missing name'); + this.assert( + () => isString(nameValue), + `Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`, + ); + } + + public assertDefined( + node: NodeData, + ): asserts node is NonNullable<NodeData> { + this.assert( + () => node !== undefined && node !== null && Object.keys(node).length > 0, + 'missing node data', + ); + } + + public assert( + validationPredicate: () => boolean, + errorMessage: string, + ): asserts validationPredicate is (() => true) { + if (!validationPredicate()) { + this.throw(errorMessage); + } + } + + public createContextualErrorMessage(errorMessage: string): string { + return this.createErrorMessage(errorMessage, this.context); + } + + private throw(errorMessage: string): never { + throw new Error( + this.createContextualErrorMessage(errorMessage), + ); + } +} diff --git a/src/application/Parser/NodeValidation/NodeType.ts b/src/application/Parser/NodeValidation/NodeType.ts deleted file mode 100644 index 648db72d2..000000000 --- a/src/application/Parser/NodeValidation/NodeType.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum NodeType { - Script, - Category, -} diff --git a/src/application/Parser/NodeValidation/NodeValidator.ts b/src/application/Parser/NodeValidation/NodeValidator.ts deleted file mode 100644 index 37860210b..000000000 --- a/src/application/Parser/NodeValidation/NodeValidator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { isString } from '@/TypeHelpers'; -import { type INodeDataErrorContext, NodeDataError } from './NodeDataError'; -import type { NodeData } from './NodeData'; - -export class NodeValidator { - constructor(private readonly context: INodeDataErrorContext) { - - } - - public assertValidName(nameValue: string) { - return this - .assert( - () => Boolean(nameValue), - 'missing name', - ) - .assert( - () => isString(nameValue), - `Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`, - ); - } - - public assertDefined(node: NodeData) { - return this.assert( - () => node !== undefined && node !== null && Object.keys(node).length > 0, - 'missing node data', - ); - } - - public assert(validationPredicate: () => boolean, errorMessage: string) { - if (!validationPredicate()) { - this.throw(errorMessage); - } - return this; - } - - public throw(errorMessage: string): never { - throw new NodeDataError(errorMessage, this.context); - } -} diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts index 6f89b2603..8614ca64b 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts @@ -7,15 +7,18 @@ import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parame import type { IExpression } from './IExpression'; export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string; + export class Expression implements IExpression { public readonly parameters: IReadOnlyFunctionParameterCollection; - constructor( - public readonly position: ExpressionPosition, - public readonly evaluator: ExpressionEvaluator, - parameters?: IReadOnlyFunctionParameterCollection, - ) { - this.parameters = parameters ?? new FunctionParameterCollection(); + public readonly position: ExpressionPosition; + + public readonly evaluator: ExpressionEvaluator; + + constructor(parameters: ExpressionInitParameters) { + this.parameters = parameters.parameters ?? new FunctionParameterCollection(); + this.evaluator = parameters.evaluator; + this.position = parameters.position; } public evaluate(context: IExpressionEvaluationContext): string { @@ -26,6 +29,12 @@ export class Expression implements IExpression { } } +export interface ExpressionInitParameters { + readonly position: ExpressionPosition, + readonly evaluator: ExpressionEvaluator, + readonly parameters?: IReadOnlyFunctionParameterCollection, +} + function validateThatAllRequiredParametersAreSatisfied( parameters: IReadOnlyFunctionParameterCollection, args: IReadOnlyFunctionCallArgumentCollection, diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.ts index 10eb73ae6..6eac6b3da 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.ts @@ -1,8 +1,13 @@ import { ExpressionPosition } from './ExpressionPosition'; -export function createPositionFromRegexFullMatch( - match: RegExpMatchArray, -): ExpressionPosition { +export interface ExpressionPositionFactory { + ( + match: RegExpMatchArray, + ): ExpressionPosition +} + +export const createPositionFromRegexFullMatch +: ExpressionPositionFactory = (match) => { const startPos = match.index; if (startPos === undefined) { throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`); @@ -13,4 +18,4 @@ export function createPositionFromRegexFullMatch( } const endPos = startPos + fullMatch.length; return new ExpressionPosition(startPos, endPos); -} +}; diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts index 16b8663dc..1949df037 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts @@ -3,10 +3,10 @@ import { WithParser } from '../SyntaxParsers/WithParser'; import type { IExpression } from '../Expression/IExpression'; import type { IExpressionParser } from './IExpressionParser'; -const Parsers = [ +const Parsers: readonly IExpressionParser[] = [ new ParameterSubstitutionParser(), new WithParser(), -]; +] as const; export class CompositeExpressionParser implements IExpressionParser { public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) { diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts index 8a88c860d..6e8d0e7da 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts @@ -1,53 +1,127 @@ +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { Expression, type ExpressionEvaluator } from '../../Expression/Expression'; -import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection'; -import { createPositionFromRegexFullMatch } from '../../Expression/ExpressionPositionFactory'; +import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory'; +import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory'; import type { IExpressionParser } from '../IExpressionParser'; import type { IExpression } from '../../Expression/IExpression'; import type { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter'; +import type { IFunctionParameterCollection, IReadOnlyFunctionParameterCollection } from '../../../Function/Parameter/IFunctionParameterCollection'; + +export interface RegexParserUtilities { + readonly wrapError: ErrorWithContextWrapper; + readonly createPosition: ExpressionPositionFactory; + readonly createExpression: ExpressionFactory; + readonly createParameterCollection: FunctionParameterCollectionFactory; +} export abstract class RegexParser implements IExpressionParser { protected abstract readonly regex: RegExp; + public constructor( + private readonly utilities: RegexParserUtilities = DefaultRegexParserUtilities, + ) { + + } + public findExpressions(code: string): IExpression[] { return Array.from(this.findRegexExpressions(code)); } - protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression; + protected abstract buildExpression(match: RegExpMatchArray): PrimitiveExpression; private* findRegexExpressions(code: string): Iterable<IExpression> { if (!code) { - throw new Error('missing code'); + throw new Error( + this.buildErrorMessageWithContext({ errorMessage: 'missing code', code: 'EMPTY' }), + ); } - const matches = code.matchAll(this.regex); + const createErrorContext = (message: string): ErrorContext => ({ code, errorMessage: message }); + const matches = this.doOrRethrow( + () => code.matchAll(this.regex), + createErrorContext('Failed to match regex.'), + ); for (const match of matches) { - const primitiveExpression = this.buildExpression(match); - const position = this.doOrRethrow(() => createPositionFromRegexFullMatch(match), 'invalid script position', code); - const parameters = createParameters(primitiveExpression); - const expression = new Expression(position, primitiveExpression.evaluator, parameters); + const primitiveExpression = this.doOrRethrow( + () => this.buildExpression(match), + createErrorContext('Failed to build expression.'), + ); + const position = this.doOrRethrow( + () => this.utilities.createPosition(match), + createErrorContext('Failed to create position.'), + ); + const parameters = this.doOrRethrow( + () => createParameters( + primitiveExpression, + this.utilities.createParameterCollection(), + ), + createErrorContext('Failed to create parameters.'), + ); + const expression = this.doOrRethrow( + () => this.utilities.createExpression({ + position, + evaluator: primitiveExpression.evaluator, + parameters, + }), + createErrorContext('Failed to create expression.'), + ); yield expression; } } - private doOrRethrow<T>(action: () => T, errorText: string, code: string): T { + private doOrRethrow<T>( + action: () => T, + context: ErrorContext, + ): T { try { return action(); } catch (error) { - throw new Error(`[${this.constructor.name}] ${errorText}: ${error.message}\nRegex: ${this.regex}\nCode: ${code}`); + throw this.utilities.wrapError( + error, + this.buildErrorMessageWithContext(context), + ); } } + + private buildErrorMessageWithContext(context: ErrorContext): string { + return [ + context.errorMessage, + `Class name: ${this.constructor.name}`, + `Regex pattern used: ${this.regex}`, + `Code: ${context.code}`, + ].join('\n'); + } +} + +interface ErrorContext { + readonly errorMessage: string, + readonly code: string, } function createParameters( - expression: IPrimitiveExpression, -): FunctionParameterCollection { + expression: PrimitiveExpression, + parameterCollection: IFunctionParameterCollection, +): IReadOnlyFunctionParameterCollection { return (expression.parameters || []) .reduce((parameters, parameter) => { parameters.addParameter(parameter); return parameters; - }, new FunctionParameterCollection()); + }, parameterCollection); } -export interface IPrimitiveExpression { - evaluator: ExpressionEvaluator; - parameters?: readonly IFunctionParameter[]; +export interface PrimitiveExpression { + readonly evaluator: ExpressionEvaluator; + readonly parameters?: readonly IFunctionParameter[]; } + +export interface ExpressionFactory { + ( + ...args: ConstructorParameters<typeof Expression> + ): IExpression; +} + +const DefaultRegexParserUtilities: RegexParserUtilities = { + wrapError: wrapErrorWithAdditionalContext, + createPosition: createPositionFromRegexFullMatch, + createExpression: (...args) => new Expression(...args), + createParameterCollection: createFunctionParameterCollection, +}; diff --git a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts index 352647506..ef23b1d6a 100644 --- a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts @@ -1,5 +1,5 @@ import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; -import { RegexParser, type IPrimitiveExpression } from '../Parser/Regex/RegexParser'; +import { RegexParser, type PrimitiveExpression } from '../Parser/Regex/RegexParser'; import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; export class ParameterSubstitutionParser extends RegexParser { @@ -12,7 +12,7 @@ export class ParameterSubstitutionParser extends RegexParser { .expectExpressionEnd() .buildRegExp(); - protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { + protected buildExpression(match: RegExpMatchArray): PrimitiveExpression { const parameterName = match[1]; const pipeline = match[2]; return { diff --git a/src/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts b/src/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts index cee4f7900..595530032 100644 --- a/src/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts +++ b/src/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts @@ -8,7 +8,7 @@ export class FunctionCallArgument implements IFunctionCallArgument { ) { ensureValidParameterName(parameterName); if (!argumentValue) { - throw new Error(`missing argument value for "${parameterName}"`); + throw new Error(`Missing argument value for the parameter "${parameterName}".`); } } } diff --git a/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/AdaptiveFunctionCallCompiler.ts b/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/AdaptiveFunctionCallCompiler.ts index e479001de..1ac0f3390 100644 --- a/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/AdaptiveFunctionCallCompiler.ts +++ b/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/AdaptiveFunctionCallCompiler.ts @@ -72,7 +72,7 @@ function throwIfUnexpectedParametersExist( // eslint-disable-next-line prefer-template `Function "${functionName}" has unexpected parameter(s) provided: ` + `"${unexpectedParameters.join('", "')}"` - + '. Expected parameter(s): ' - + (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'), + + '.\nExpected parameter(s): ' + + (expectedParameters.length ? `"${expectedParameters.join('", "')}".` : 'none'), ); } diff --git a/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.ts b/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.ts index 3a70dd49b..5e737eac4 100644 --- a/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.ts +++ b/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.ts @@ -6,11 +6,14 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/ import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import { ParsedFunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/ParsedFunctionCall'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ArgumentCompiler } from './ArgumentCompiler'; export class NestedFunctionArgumentCompiler implements ArgumentCompiler { constructor( private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(), + private readonly wrapError: ErrorWithContextWrapper + = wrapErrorWithAdditionalContext, ) { } public createCompiledNestedCall( @@ -22,18 +25,26 @@ export class NestedFunctionArgumentCompiler implements ArgumentCompiler { nestedFunction, parentFunction.args, context, - this.expressionsCompiler, + { + expressionsCompiler: this.expressionsCompiler, + wrapError: this.wrapError, + }, ); const compiledCall = new ParsedFunctionCall(nestedFunction.functionName, compiledArgs); return compiledCall; } } +interface ArgumentCompilationUtilities { + readonly expressionsCompiler: IExpressionsCompiler, + readonly wrapError: ErrorWithContextWrapper; +} + function compileNestedFunctionArguments( nestedFunction: FunctionCall, parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection, context: FunctionCallCompilationContext, - expressionsCompiler: IExpressionsCompiler, + utilities: ArgumentCompilationUtilities, ): IReadOnlyFunctionCallArgumentCollection { const requiredParameterNames = context .allFunctions @@ -47,7 +58,7 @@ function compileNestedFunctionArguments( paramName, nestedFunction, parentFunctionArgs, - expressionsCompiler, + utilities, ), })) // Filter out arguments with absent values @@ -89,13 +100,13 @@ function compileArgument( parameterName: string, nestedFunction: FunctionCall, parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection, - expressionsCompiler: IExpressionsCompiler, + utilities: ArgumentCompilationUtilities, ): string { try { const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName); - return expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs); - } catch (err) { - throw new AggregateError([err], `Error when compiling argument for "${parameterName}"`); + return utilities.expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs); + } catch (error) { + throw utilities.wrapError(error, `Error when compiling argument for "${parameterName}"`); } } diff --git a/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/NestedFunctionCallCompiler.ts b/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/NestedFunctionCallCompiler.ts index 8680f9a08..0c8375cb5 100644 --- a/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/NestedFunctionCallCompiler.ts +++ b/src/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/NestedFunctionCallCompiler.ts @@ -1,14 +1,21 @@ -import { type CallFunctionBody, FunctionBodyType, type ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; +import { + type CallFunctionBody, FunctionBodyType, + type ISharedFunction, +} from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { NestedFunctionArgumentCompiler } from './Argument/NestedFunctionArgumentCompiler'; import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy'; import type { ArgumentCompiler } from './Argument/ArgumentCompiler'; export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy { public constructor( - private readonly argumentCompiler: ArgumentCompiler = new NestedFunctionArgumentCompiler(), + private readonly argumentCompiler: ArgumentCompiler + = new NestedFunctionArgumentCompiler(), + private readonly wrapError: ErrorWithContextWrapper + = wrapErrorWithAdditionalContext, ) { } @@ -29,8 +36,11 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy { const compiledNestedCall = context.singleCallCompiler .compileSingleCall(compiledParentCall, context); return compiledNestedCall; - } catch (err) { - throw new AggregateError([err], `Error with call to "${nestedCall.functionName}" function from "${callToFunction.functionName}" function`); + } catch (error) { + throw this.wrapError( + error, + `Failed to call '${nestedCall.functionName}' (callee function) from '${callToFunction.functionName}' (caller function).`, + ); } }).flat(); } diff --git a/src/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory.ts b/src/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory.ts new file mode 100644 index 000000000..0fd227fd4 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory.ts @@ -0,0 +1,12 @@ +import { FunctionParameterCollection } from './FunctionParameterCollection'; +import type { IFunctionParameterCollection } from './IFunctionParameterCollection'; + +export interface FunctionParameterCollectionFactory { + ( + ...args: ConstructorParameters<typeof FunctionParameterCollection> + ): IFunctionParameterCollection; +} + +export const createFunctionParameterCollection: FunctionParameterCollectionFactory = (...args) => { + return new FunctionParameterCollection(...args); +}; diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts b/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts index c2f6d9b88..17ba56989 100644 --- a/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts +++ b/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts @@ -15,7 +15,7 @@ export class SharedFunctionCollection implements ISharedFunctionCollection { if (!name) { throw Error('missing function name'); } const func = this.functionsByName.get(name); if (!func) { - throw new Error(`called function is not defined "${name}"`); + throw new Error(`Called function is not defined: "${name}"`); } return func; } diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts b/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts index 9cdb41732..7d2aa345e 100644 --- a/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts +++ b/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts @@ -1,5 +1,6 @@ import type { - FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction, + FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, + CallInstruction, ParameterDefinitionData, } from '@/application/collections/'; import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; @@ -7,20 +8,30 @@ import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmp import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { SharedFunctionCollection } from './SharedFunctionCollection'; import { FunctionParameter } from './Parameter/FunctionParameter'; -import { FunctionParameterCollection } from './Parameter/FunctionParameterCollection'; import { parseFunctionCalls } from './Call/FunctionCallParser'; +import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from './Parameter/FunctionParameterCollectionFactory'; import type { ISharedFunctionCollection } from './ISharedFunctionCollection'; import type { ISharedFunctionsParser } from './ISharedFunctionsParser'; import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection'; import type { ISharedFunction } from './ISharedFunction'; +const DefaultSharedFunctionsParsingUtilities: SharedFunctionsParsingUtilities = { + wrapError: wrapErrorWithAdditionalContext, + createParameter: (...args) => new FunctionParameter(...args), + codeValidator: CodeValidator.instance, + createParameterCollection: createFunctionParameterCollection, +}; + export class SharedFunctionsParser implements ISharedFunctionsParser { public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser(); - constructor(private readonly codeValidator: ICodeValidator = CodeValidator.instance) { } + constructor( + private readonly utilities = DefaultSharedFunctionsParsingUtilities, + ) { } public parseFunctions( functions: readonly FunctionData[], @@ -32,7 +43,7 @@ export class SharedFunctionsParser implements ISharedFunctionsParser { } ensureValidFunctions(functions); return functions - .map((func) => parseFunction(func, syntax, this.codeValidator)) + .map((func) => parseFunction(func, syntax, this.utilities)) .reduce((acc, func) => { acc.addFunction(func); return acc; @@ -40,15 +51,26 @@ export class SharedFunctionsParser implements ISharedFunctionsParser { } } +interface SharedFunctionsParsingUtilities { + readonly wrapError: ErrorWithContextWrapper; + readonly createParameter: FunctionParameterFactory; + readonly codeValidator: ICodeValidator; + readonly createParameterCollection: FunctionParameterCollectionFactory; +} + +export type FunctionParameterFactory = ( + ...args: ConstructorParameters<typeof FunctionParameter> +) => FunctionParameter; + function parseFunction( data: FunctionData, syntax: ILanguageSyntax, - validator: ICodeValidator, + utilities: SharedFunctionsParsingUtilities, ): ISharedFunction { const { name } = data; - const parameters = parseParameters(data); + const parameters = parseParameters(data, utilities); if (hasCode(data)) { - validateCode(data, syntax, validator); + validateCode(data, syntax, utilities.codeValidator); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); } // Has call @@ -71,22 +93,38 @@ function validateCode( ); } -function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection { +function parseParameters( + data: FunctionData, + utilities: SharedFunctionsParsingUtilities, +): IReadOnlyFunctionParameterCollection { return (data.parameters || []) - .map((parameter) => { - try { - return new FunctionParameter( - parameter.name, - parameter.optional || false, - ); - } catch (err) { - throw new Error(`"${data.name}": ${err.message}`); - } - }) + .map((parameter) => createFunctionParameter( + data.name, + parameter, + utilities, + )) .reduce((parameters, parameter) => { parameters.addParameter(parameter); return parameters; - }, new FunctionParameterCollection()); + }, utilities.createParameterCollection()); +} + +function createFunctionParameter( + functionName: string, + parameterData: ParameterDefinitionData, + utilities: SharedFunctionsParsingUtilities, +): FunctionParameter { + try { + return utilities.createParameter( + parameterData.name, + parameterData.optional || false, + ); + } catch (err) { + throw utilities.wrapError( + err, + `Failed to create parameter: ${parameterData.name} for function "${functionName}"`, + ); + } } function hasCode(data: FunctionData): data is CodeFunctionData { diff --git a/src/application/Parser/Script/Compiler/ScriptCompiler.ts b/src/application/Parser/Script/Compiler/ScriptCompiler.ts index 2e516118d..84fbdcb8a 100644 --- a/src/application/Parser/Script/Compiler/ScriptCompiler.ts +++ b/src/application/Parser/Script/Compiler/ScriptCompiler.ts @@ -1,10 +1,11 @@ import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; import type { IScriptCode } from '@/domain/IScriptCode'; -import { ScriptCode } from '@/domain/ScriptCode'; import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { createScriptCode, type ScriptCodeFactory } from '@/domain/ScriptCodeFactory'; import { SharedFunctionsParser } from './Function/SharedFunctionsParser'; import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler'; import { parseFunctionCalls } from './Function/Call/FunctionCallParser'; @@ -23,6 +24,8 @@ export class ScriptCompiler implements IScriptCompiler { sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance, private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance, private readonly codeValidator: ICodeValidator = CodeValidator.instance, + private readonly wrapError: ErrorWithContextWrapper = wrapErrorWithAdditionalContext, + private readonly scriptCodeFactory: ScriptCodeFactory = createScriptCode, ) { this.functions = sharedFunctionsParser.parseFunctions(functions, syntax); } @@ -39,12 +42,12 @@ export class ScriptCompiler implements IScriptCompiler { const calls = parseFunctionCalls(script.call); const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions); validateCompiledCode(compiledCode, this.codeValidator); - return new ScriptCode( + return this.scriptCodeFactory( compiledCode.code, compiledCode.revertCode, ); } catch (error) { - throw Error(`Script "${script.name}" ${error.message}`); + throw this.wrapError(error, `Failed to compile script: ${script.name}`); } } } diff --git a/src/application/Parser/Script/ScriptParser.ts b/src/application/Parser/Script/ScriptParser.ts index 9db5c64be..bc3ebc5a1 100644 --- a/src/application/Parser/Script/ScriptParser.ts +++ b/src/application/Parser/Script/ScriptParser.ts @@ -4,37 +4,52 @@ import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syn import { Script } from '@/domain/Script'; import { RecommendationLevel } from '@/domain/RecommendationLevel'; import type { IScriptCode } from '@/domain/IScriptCode'; -import { ScriptCode } from '@/domain/ScriptCode'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; -import { parseDocs } from '../DocumentationParser'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory'; +import { createScriptCode } from '@/domain/ScriptCodeFactory'; +import type { IScript } from '@/domain/IScript'; +import { parseDocs, type DocsParser } from '../DocumentationParser'; import { createEnumParser, type IEnumParser } from '../../Common/Enum'; -import { NodeType } from '../NodeValidation/NodeType'; -import { NodeValidator } from '../NodeValidation/NodeValidator'; +import { NodeDataType } from '../NodeValidation/NodeDataType'; +import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from '../NodeValidation/NodeDataValidator'; import { CodeValidator } from './Validation/CodeValidator'; import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines'; import type { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext'; -export function parseScript( - data: ScriptData, - context: ICategoryCollectionParseContext, - levelParser = createEnumParser(RecommendationLevel), - scriptFactory: ScriptFactoryType = ScriptFactory, - codeValidator: ICodeValidator = CodeValidator.instance, -): Script { - const validator = new NodeValidator({ type: NodeType.Script, selfNode: data }); +export interface ScriptParser { + ( + data: ScriptData, + context: ICategoryCollectionParseContext, + utilities?: ScriptParserUtilities, + ): IScript; +} + +export const parseScript: ScriptParser = ( + data, + context, + utilities = DefaultScriptParserUtilities, +) => { + const validator = utilities.createValidator({ + type: NodeDataType.Script, + selfNode: data, + }); validateScript(data, validator); try { - const script = scriptFactory( - /* name: */ data.name, - /* code: */ parseCode(data, context, codeValidator), - /* docs: */ parseDocs(data), - /* level: */ parseLevel(data.recommend, levelParser), - ); + const script = utilities.createScript({ + name: data.name, + code: parseCode(data, context, utilities.codeValidator, utilities.createCode), + docs: utilities.parseDocs(data), + level: parseLevel(data.recommend, utilities.levelParser), + }); return script; - } catch (err) { - return validator.throw(err.message); + } catch (error) { + throw utilities.wrapError( + error, + validator.createContextualErrorMessage('Failed to parse script.'), + ); } -} +}; function parseLevel( level: string | undefined, @@ -50,18 +65,19 @@ function parseCode( script: ScriptData, context: ICategoryCollectionParseContext, codeValidator: ICodeValidator, + createCode: ScriptCodeFactory, ): IScriptCode { if (context.compiler.canCompile(script)) { return context.compiler.compile(script); } const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled - const code = new ScriptCode(codeScript.code, codeScript.revertCode); + const code = createCode(codeScript.code, codeScript.revertCode); validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax); return code; } function validateHardcodedCodeWithoutCalls( - scriptCode: ScriptCode, + scriptCode: IScriptCode, validator: ICodeValidator, syntax: ILanguageSyntax, ) { @@ -77,25 +93,48 @@ function validateHardcodedCodeWithoutCalls( function validateScript( script: ScriptData, - validator: NodeValidator, + validator: NodeDataValidator, ): asserts script is NonNullable<ScriptData> { - validator - .assertDefined(script) - .assertValidName(script.name) - .assert( - () => Boolean((script as CodeScriptData).code || (script as CallScriptData).call), - 'Neither "call" or "code" is defined.', - ) - .assert( - () => !((script as CodeScriptData).code && (script as CallScriptData).call), - 'Both "call" and "code" are defined.', - ) - .assert( - () => !((script as CodeScriptData).revertCode && (script as CallScriptData).call), - 'Both "call" and "revertCode" are defined.', - ); + validator.assertDefined(script); + validator.assertValidName(script.name); + validator.assert( + () => Boolean((script as CodeScriptData).code || (script as CallScriptData).call), + 'Neither "call" or "code" is defined.', + ); + validator.assert( + () => !((script as CodeScriptData).code && (script as CallScriptData).call), + 'Both "call" and "code" are defined.', + ); + validator.assert( + () => !((script as CodeScriptData).revertCode && (script as CallScriptData).call), + 'Both "call" and "revertCode" are defined.', + ); } -export type ScriptFactoryType = (...parameters: ConstructorParameters<typeof Script>) => Script; +interface ScriptParserUtilities { + readonly levelParser: IEnumParser<RecommendationLevel>; + readonly createScript: ScriptFactory; + readonly codeValidator: ICodeValidator; + readonly wrapError: ErrorWithContextWrapper; + readonly createValidator: NodeDataValidatorFactory; + readonly createCode: ScriptCodeFactory; + readonly parseDocs: DocsParser; +} + +export type ScriptFactory = ( + ...parameters: ConstructorParameters<typeof Script> +) => IScript; + +const createScript: ScriptFactory = (...parameters) => { + return new Script(...parameters); +}; -const ScriptFactory: ScriptFactoryType = (...parameters) => new Script(...parameters); +const DefaultScriptParserUtilities: ScriptParserUtilities = { + levelParser: createEnumParser(RecommendationLevel), + createScript, + codeValidator: CodeValidator.instance, + wrapError: wrapErrorWithAdditionalContext, + createValidator: createNodeDataValidator, + createCode: createScriptCode, + parseDocs, +}; diff --git a/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts b/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts index 82ef30107..fc7aa24ff 100644 --- a/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts +++ b/src/application/Parser/ScriptingDefinition/CodeSubstituter.ts @@ -5,6 +5,7 @@ import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expres import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection'; import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument'; +import type { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser'; import type { ICodeSubstituter } from './ICodeSubstituter'; export class CodeSubstituter implements ICodeSubstituter { @@ -29,7 +30,9 @@ export class CodeSubstituter implements ICodeSubstituter { } function createSubstituteCompiler(): IExpressionsCompiler { - const parsers = [new ParameterSubstitutionParser()]; + const parsers: readonly IExpressionParser[] = [ + new ParameterSubstitutionParser(), + ] as const; const parser = new CompositeExpressionParser(parsers); const expressionCompiler = new ExpressionsCompiler(parser); return expressionCompiler; diff --git a/src/domain/Category.ts b/src/domain/Category.ts index 38e68b1f1..b325b20d4 100644 --- a/src/domain/Category.ts +++ b/src/domain/Category.ts @@ -5,15 +5,21 @@ import type { IScript } from './IScript'; export class Category extends BaseEntity<number> implements ICategory { private allSubScripts?: ReadonlyArray<IScript> = undefined; - constructor( - id: number, - public readonly name: string, - public readonly docs: ReadonlyArray<string>, - public readonly subCategories: ReadonlyArray<ICategory>, - public readonly scripts: ReadonlyArray<IScript>, - ) { - super(id); - validateCategory(this); + public readonly name: string; + + public readonly docs: ReadonlyArray<string>; + + public readonly subCategories: ReadonlyArray<ICategory>; + + public readonly scripts: ReadonlyArray<IScript>; + + constructor(parameters: CategoryInitParameters) { + super(parameters.id); + validateParameters(parameters); + this.name = parameters.name; + this.docs = parameters.docs; + this.subCategories = parameters.subcategories; + this.scripts = parameters.scripts; } public includes(script: IScript): boolean { @@ -28,6 +34,14 @@ export class Category extends BaseEntity<number> implements ICategory { } } +export interface CategoryInitParameters { + readonly id: number; + readonly name: string; + readonly docs: ReadonlyArray<string>; + readonly subcategories: ReadonlyArray<ICategory>; + readonly scripts: ReadonlyArray<IScript>; +} + function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> { return [ ...category.scripts, @@ -35,11 +49,11 @@ function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> { ]; } -function validateCategory(category: ICategory) { - if (!category.name) { +function validateParameters(parameters: CategoryInitParameters) { + if (!parameters.name) { throw new Error('missing name'); } - if (category.subCategories.length === 0 && category.scripts.length === 0) { + if (parameters.subcategories.length === 0 && parameters.scripts.length === 0) { throw new Error('A category must have at least one sub-category or script'); } } diff --git a/src/domain/Script.ts b/src/domain/Script.ts index 79ba3cfb8..7440aa4c0 100644 --- a/src/domain/Script.ts +++ b/src/domain/Script.ts @@ -4,14 +4,21 @@ import type { IScript } from './IScript'; import type { IScriptCode } from './IScriptCode'; export class Script extends BaseEntity<string> implements IScript { - constructor( - public readonly name: string, - public readonly code: IScriptCode, - public readonly docs: ReadonlyArray<string>, - public readonly level?: RecommendationLevel, - ) { - super(name); - validateLevel(level); + public readonly name: string; + + public readonly code: IScriptCode; + + public readonly docs: ReadonlyArray<string>; + + public readonly level?: RecommendationLevel; + + constructor(parameters: ScriptInitParameters) { + super(parameters.name); + this.name = parameters.name; + this.code = parameters.code; + this.docs = parameters.docs; + this.level = parameters.level; + validateLevel(parameters.level); } public canRevert(): boolean { @@ -19,6 +26,13 @@ export class Script extends BaseEntity<string> implements IScript { } } +export interface ScriptInitParameters { + readonly name: string; + readonly code: IScriptCode; + readonly docs: ReadonlyArray<string>; + readonly level?: RecommendationLevel; +} + function validateLevel(level?: RecommendationLevel) { if (level !== undefined && !(level in RecommendationLevel)) { throw new Error(`invalid level: ${level}`); diff --git a/src/domain/ScriptCodeFactory.ts b/src/domain/ScriptCodeFactory.ts new file mode 100644 index 000000000..303a4df79 --- /dev/null +++ b/src/domain/ScriptCodeFactory.ts @@ -0,0 +1,10 @@ +import { ScriptCode } from './ScriptCode'; +import type { IScriptCode } from './IScriptCode'; + +export interface ScriptCodeFactory { + ( + ...args: ConstructorParameters<typeof ScriptCode> + ): IScriptCode; +} + +export const createScriptCode: ScriptCodeFactory = (...args) => new ScriptCode(...args); diff --git a/tests/unit/application/Parser/CategoryParser.spec.ts b/tests/unit/application/Parser/CategoryParser.spec.ts index f7dd352e4..e1de049f5 100644 --- a/tests/unit/application/Parser/CategoryParser.spec.ts +++ b/tests/unit/application/Parser/CategoryParser.spec.ts @@ -1,258 +1,395 @@ import { describe, it, expect } from 'vitest'; import type { CategoryData, CategoryOrScriptData } from '@/application/collections/'; -import { type CategoryFactoryType, parseCategory } from '@/application/Parser/CategoryParser'; -import { parseScript } from '@/application/Parser/Script/ScriptParser'; -import { parseDocs } from '@/application/Parser/DocumentationParser'; -import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub'; +import { type CategoryFactory, parseCategory } from '@/application/Parser/CategoryParser'; +import { type ScriptParser } from '@/application/Parser/Script/ScriptParser'; +import { type DocsParser } from '@/application/Parser/DocumentationParser'; import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub'; -import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub'; -import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; -import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner'; +import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; -import { Category } from '@/domain/Category'; -import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub'; +import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; +import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator'; +import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub'; +import type { CategoryNodeErrorContext, UnknownNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext'; +import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; +import { createCategoryFactorySpy } from '@tests/unit/shared/Stubs/CategoryFactoryStub'; +import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; +import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { indentText } from '@tests/shared/Text'; +import { itThrowsContextualError } from './ContextualErrorTester'; +import { itValidatesName, itValidatesDefinedData, itAsserts } from './NodeDataValidationTester'; +import { generateDataValidationTestScenarios } from './DataValidationTestScenarioGenerator'; describe('CategoryParser', () => { describe('parseCategory', () => { - describe('invalid category data', () => { - describe('validates script data', () => { - describe('satisfies shared node tests', () => { - new NodeValidationTestRunner() - .testInvalidNodeName((invalidName) => { - return createTest( - new CategoryDataStub().withName(invalidName), - ); - }) - .testMissingNodeData((node) => { - return createTest(node as CategoryData); - }); + describe('validation', () => { + describe('validates for name', () => { + // arrange + const expectedName = 'expected category name to be validated'; + const category = new CategoryDataStub() + .withName(expectedName); + const expectedContext: CategoryNodeErrorContext = { + type: NodeDataType.Category, + selfNode: category, + }; + itValidatesName((validatorFactory) => { + // act + new TestBuilder() + .withData(category) + .withValidatorFactory(validatorFactory) + .parseCategory(); + // assert + return { + expectedNameToValidate: expectedName, + expectedErrorContext: expectedContext, + }; }); - describe('throws when category children is absent', () => { - itEachAbsentCollectionValue<CategoryOrScriptData>((absentValue) => { - // arrange - const categoryName = 'test'; - const expectedMessage = `"${categoryName}" has no children.`; - const category = new CategoryDataStub() - .withName(categoryName) - .withChildren(absentValue); + }); + describe('validates for defined data', () => { + // arrange + const category = new CategoryDataStub(); + const expectedContext: CategoryNodeErrorContext = { + type: NodeDataType.Category, + selfNode: category, + }; + itValidatesDefinedData( + (validatorFactory) => { // act - const test = createTest(category); + new TestBuilder() + .withData(category) + .withValidatorFactory(validatorFactory) + .parseCategory(); // assert - expectThrowsNodeError(test, expectedMessage); - }, { excludeUndefined: true, excludeNull: true }); + return { + expectedDataToValidate: category, + expectedErrorContext: expectedContext, + }; + }, + ); + }); + describe('validates that category has some children', () => { + const categoryName = 'test'; + const testScenarios = generateDataValidationTestScenarios<CategoryData>({ + expectFail: getAbsentCollectionTestCases<CategoryOrScriptData>().map(({ + valueName, absentValue: absentCollectionValue, + }) => ({ + description: `with \`${valueName}\` value as children`, + data: new CategoryDataStub() + .withName(categoryName) + .withChildren(absentCollectionValue as unknown as CategoryOrScriptData[]), + })), + expectPass: [{ + description: 'has single children', + data: new CategoryDataStub() + .withName(categoryName) + .withChildren([createScriptDataWithCode()]), + }], }); - describe('throws when category child is missing', () => { - new NodeValidationTestRunner() - .testMissingNodeData((missingNode) => { - // arrange - const invalidChildNode = missingNode; - const parent = new CategoryDataStub() - .withName('parent') - .withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]); - return ({ + testScenarios.forEach(({ + description, expectedPass, data: categoryData, + }) => { + describe(description, () => { + itAsserts({ + expectedConditionResult: expectedPass, + test: (validatorFactory) => { + const expectedMessage = `"${categoryName}" has no children.`; + const expectedContext: CategoryNodeErrorContext = { + type: NodeDataType.Category, + selfNode: categoryData, + }; // act - act: () => new TestBuilder() - .withData(parent) - .parseCategory(), + try { + new TestBuilder() + .withData(categoryData) + .withValidatorFactory(validatorFactory) + .parseCategory(); + } catch { /* It may throw due to assertions not being evaluated */ } // assert - expectedContext: { - selfNode: invalidChildNode, + return { + expectedErrorMessage: expectedMessage, + expectedErrorContext: expectedContext, + }; + }, + }); + }); + }); + }); + describe('validates that a child is a category or a script', () => { + // arrange + const testScenarios = generateDataValidationTestScenarios<CategoryOrScriptData>({ + expectFail: [{ + description: 'child has incorrect properties', + data: { property: 'non-empty-value' } as unknown as CategoryOrScriptData, + }], + expectPass: [ + { + description: 'child is a category', + data: new CategoryDataStub(), + }, + { + description: 'child is a script with call', + data: createScriptDataWithCall(), + }, + { + description: 'child is a script with code', + data: createScriptDataWithCode(), + }, + ], + }); + testScenarios.forEach(({ + description, expectedPass, data: childData, + }) => { + describe(description, () => { + itAsserts({ + expectedConditionResult: expectedPass, + test: (validatorFactory) => { + const expectedError = 'Node is neither a category or a script.'; + const parent = new CategoryDataStub() + .withName('parent') + .withChildren([new CategoryDataStub().withName('valid child'), childData]); + const expectedContext: UnknownNodeErrorContext = { + selfNode: childData, parentNode: parent, - }, - }); + }; + // act + new TestBuilder() + .withData(parent) + .withValidatorFactory(validatorFactory) + .parseCategory(); + // assert + return { + expectedErrorMessage: expectedError, + expectedErrorContext: expectedContext, + }; + }, }); + }); }); - it('throws when node is neither a category or a script', () => { + }); + describe('validates children recursively', () => { + describe('validates (1th-level) child data', () => { // arrange - const expectedError = 'Node is neither a category or a script.'; - const invalidChildNode = { property: 'non-empty-value' } as never as CategoryOrScriptData; + const expectedName = 'child category'; + const child = new CategoryDataStub() + .withName(expectedName); const parent = new CategoryDataStub() .withName('parent') - .withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]); - // act - const test: ITestScenario = { - // act - act: () => new TestBuilder() - .withData(parent) - .parseCategory(), - // assert - expectedContext: { - selfNode: invalidChildNode, - parentNode: parent, - }, + .withChildren([child]); + const expectedContext: UnknownNodeErrorContext = { + selfNode: child, + parentNode: parent, }; - // assert - expectThrowsNodeError(test, expectedError); - }); - describe('throws when category child is invalid category', () => { - new NodeValidationTestRunner().testInvalidNodeName((invalidName) => { - // arrange - const invalidChildNode = new CategoryDataStub() - .withName(invalidName); - const parent = new CategoryDataStub() - .withName('parent') - .withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]); - return ({ + itValidatesDefinedData( + (validatorFactory) => { // act - act: () => new TestBuilder() + new TestBuilder() .withData(parent) - .parseCategory(), + .withValidatorFactory(validatorFactory) + .parseCategory(); // assert - expectedContext: { - type: NodeType.Category, - selfNode: invalidChildNode, - parentNode: parent, - }, - }); - }); - }); - function createTest(category: CategoryData): ITestScenario { - return { - act: () => new TestBuilder() - .withData(category) - .parseCategory(), - expectedContext: { - type: NodeType.Category, - selfNode: category, + return { + expectedDataToValidate: child, + expectedErrorContext: expectedContext, + }; }, + ); + }); + describe('validates that (2nd-level) child name', () => { + // arrange + const expectedName = 'grandchild category'; + const grandChild = new CategoryDataStub() + .withName(expectedName); + const child = new CategoryDataStub() + .withChildren([grandChild]) + .withName('child category'); + const parent = new CategoryDataStub() + .withName('parent') + .withChildren([child]); + const expectedContext: CategoryNodeErrorContext = { + type: NodeDataType.Category, + selfNode: grandChild, + parentNode: child, }; - } + itValidatesName((validatorFactory) => { + // act + new TestBuilder() + .withData(parent) + .withValidatorFactory(validatorFactory) + .parseCategory(); + // assert + return { + expectedNameToValidate: expectedName, + expectedErrorContext: expectedContext, + }; + }); + }); }); - it(`rethrows exception if ${Category.name} cannot be constructed`, () => { - // arrange - const expectedError = 'category creation failed'; - const factoryMock: CategoryFactoryType = () => { throw new Error(expectedError); }; - const data = new CategoryDataStub(); - // act - const act = () => new TestBuilder() - .withData(data) - .withFactory(factoryMock) - .parseCategory(); - // expect - expectThrowsNodeError({ - act, - expectedContext: { - type: NodeType.Category, - selfNode: data, - }, - }, expectedError); + }); + describe('rethrows exception if category factory fails', () => { + // arrange + const givenData = new CategoryDataStub(); + const expectedContextMessage = 'Failed to parse category.'; + const expectedError = new Error(); + // act & assert + itThrowsContextualError({ + throwingAction: (wrapError) => { + const validatorStub = new NodeDataValidatorStub(); + validatorStub.createContextualErrorMessage = (message) => message; + const factoryMock: CategoryFactory = () => { + throw expectedError; + }; + new TestBuilder() + .withCategoryFactory(factoryMock) + .withValidatorFactory(() => validatorStub) + .withErrorWrapper(wrapError) + .withData(givenData) + .parseCategory(); + }, + expectedWrappedError: expectedError, + expectedContextMessage, }); }); - it('returns expected docs', () => { + it('parses docs correctly', () => { // arrange const url = 'https://privacy.sexy'; - const expected = parseDocs({ docs: url }); - const category = new CategoryDataStub() + const categoryData = new CategoryDataStub() .withDocs(url); + const parseDocs: DocsParser = (data) => { + return [ + `parsed docs: ${JSON.stringify(data)}`, + ]; + }; + const expectedDocs = parseDocs(categoryData); + const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); // act - const actual = new TestBuilder() - .withData(category) - .parseCategory() - .docs; + const actualCategory = new TestBuilder() + .withData(categoryData) + .withCategoryFactory(categoryFactorySpy) + .withDocsParser(parseDocs) + .parseCategory(); // assert - expect(actual).to.deep.equal(expected); + const actualDocs = getInitParameters(actualCategory)?.docs; + expect(actualDocs).to.deep.equal(expectedDocs); }); describe('parses expected subscript', () => { - it('single script with code', () => { + it('parses single script correctly', () => { // arrange - const script = createScriptDataWithCode(); - const context = new CategoryCollectionParseContextStub(); - const expected = [parseScript(script, context)]; - const category = new CategoryDataStub() - .withChildren([script]); - // act - const actual = new TestBuilder() - .withData(category) - .withContext(context) - .parseCategory() - .scripts; - // assert - expect(actual).to.deep.equal(expected); - }); - it('single script with function call', () => { - // arrange - const script = createScriptDataWithCall(); - const compiler = new ScriptCompilerStub() - .withCompileAbility(script); - const context = new CategoryCollectionParseContextStub() - .withCompiler(compiler); - const expected = [parseScript(script, context)]; - const category = new CategoryDataStub() - .withChildren([script]); + const expectedScript = new ScriptStub('expected script'); + const scriptParser = new ScriptParserStub(); + const childScriptData = createScriptDataWithCode(); + const categoryData = new CategoryDataStub() + .withChildren([childScriptData]); + scriptParser.setupParsedResultForData(childScriptData, expectedScript); + const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); // act - const actual = new TestBuilder() - .withData(category) - .withContext(context) - .parseCategory() - .scripts; + const actualCategory = new TestBuilder() + .withData(categoryData) + .withScriptParser(scriptParser.get()) + .withCategoryFactory(categoryFactorySpy) + .parseCategory(); // assert - expect(actual).to.deep.equal(expected); + const actualScripts = getInitParameters(actualCategory)?.scripts; + expectExists(actualScripts); + expect(actualScripts).to.have.lengthOf(1); + const actualScript = actualScripts[0]; + expect(actualScript).to.equal(expectedScript); }); - it('multiple scripts with function call and code', () => { + it('parses multiple scripts correctly', () => { // arrange - const callableScript = createScriptDataWithCall(); - const scripts = [callableScript, createScriptDataWithCode()]; - const category = new CategoryDataStub() - .withChildren(scripts); - const compiler = new ScriptCompilerStub() - .withCompileAbility(callableScript); - const context = new CategoryCollectionParseContextStub() - .withCompiler(compiler); - const expected = scripts.map((script) => parseScript(script, context)); + const expectedScripts = [ + new ScriptStub('expected-first-script'), + new ScriptStub('expected-second-script'), + ]; + const childrenData = [ + createScriptDataWithCall(), + createScriptDataWithCode(), + ]; + const scriptParser = new ScriptParserStub(); + childrenData.forEach((_, index) => { + scriptParser.setupParsedResultForData(childrenData[index], expectedScripts[index]); + }); + const categoryData = new CategoryDataStub() + .withChildren(childrenData); + const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); // act - const actual = new TestBuilder() - .withData(category) - .withContext(context) - .parseCategory() - .scripts; + const actualCategory = new TestBuilder() + .withScriptParser(scriptParser.get()) + .withData(categoryData) + .withCategoryFactory(categoryFactorySpy) + .parseCategory(); // assert - expect(actual).to.deep.equal(expected); + const actualParsedScripts = getInitParameters(actualCategory)?.scripts; + expectExists(actualParsedScripts); + expect(actualParsedScripts.length).to.equal(expectedScripts.length); + expect(actualParsedScripts).to.have.members(expectedScripts); }); - it('script is created with right context', () => { // test through script validation logic + it('parses all scripts with correct context', () => { // arrange - const commentDelimiter = 'should not throw'; - const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`; - const parseContext = new CategoryCollectionParseContextStub() - .withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter)); - const category = new CategoryDataStub() - .withChildren([ - new CategoryDataStub() - .withName('sub-category') - .withChildren([ - createScriptDataWithoutCallOrCodes() - .withCode(duplicatedCode), - ]), - ]); + const expectedParseContext = new CategoryCollectionParseContextStub(); + const scriptParser = new ScriptParserStub(); + const childrenData = [ + createScriptDataWithCode(), + createScriptDataWithCode(), + createScriptDataWithCode(), + ]; + const categoryData = new CategoryDataStub() + .withChildren(childrenData); + const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); // act - const act = () => new TestBuilder() - .withData(category) - .withContext(parseContext) - .parseCategory() - .scripts; + const actualCategory = new TestBuilder() + .withData(categoryData) + .withContext(expectedParseContext) + .withScriptParser(scriptParser.get()) + .withCategoryFactory(categoryFactorySpy) + .parseCategory(); // assert - expect(act).to.not.throw(); + const actualParsedScripts = getInitParameters(actualCategory)?.scripts; + expectExists(actualParsedScripts); + const actualParseContexts = actualParsedScripts.map( + (s) => scriptParser.getParseParameters(s)[1], + ); + expect( + actualParseContexts.every( + (actualParseContext) => actualParseContext === expectedParseContext, + ), + formatAssertionMessage([ + `Expected all elements to be ${JSON.stringify(expectedParseContext)}`, + 'All elements:', + indentText(JSON.stringify(actualParseContexts)), + ]), + ).to.equal(true); }); }); it('returns expected subcategories', () => { // arrange - const expected = [new CategoryDataStub() - .withName('test category') - .withChildren([createScriptDataWithCode()]), - ]; - const category = new CategoryDataStub() + const expectedChildCategory = new CategoryStub(33); + const childCategoryData = new CategoryDataStub() + .withName('expected child category') + .withChildren([createScriptDataWithCode()]); + const categoryData = new CategoryDataStub() .withName('category name') - .withChildren(expected); + .withChildren([childCategoryData]); + const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy(); // act - const actual = new TestBuilder() - .withData(category) - .parseCategory() - .subCategories; + const actualCategory = new TestBuilder() + .withData(categoryData) + .withCategoryFactory((parameters) => { + if (parameters.name === childCategoryData.category) { + return expectedChildCategory; + } + return categoryFactorySpy(parameters); + }) + .parseCategory(); // assert - expect(actual).to.have.lengthOf(1); - expect(actual[0].name).to.equal(expected[0].category); - expect(actual[0].scripts.length).to.equal(expected[0].children.length); + const actualSubcategories = getInitParameters(actualCategory)?.subcategories; + expectExists(actualSubcategories); + expect(actualSubcategories).to.have.lengthOf(1); + expect(actualSubcategories[0]).to.equal(expectedChildCategory); }); }); }); @@ -262,24 +399,62 @@ class TestBuilder { private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub(); - private factory?: CategoryFactoryType = undefined; + private categoryFactory: CategoryFactory = () => new CategoryStub(33); + + private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get(); + + private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub; + + private docsParser: DocsParser = () => ['docs']; + + private scriptParser: ScriptParser = new ScriptParserStub().get(); public withData(data: CategoryData) { this.data = data; return this; } - public withContext(context: ICategoryCollectionParseContext) { + public withContext(context: ICategoryCollectionParseContext): this { this.context = context; return this; } - public withFactory(factory: CategoryFactoryType) { - this.factory = factory; + public withCategoryFactory(categoryFactory: CategoryFactory): this { + this.categoryFactory = categoryFactory; + return this; + } + + public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this { + this.validatorFactory = validatorFactory; + return this; + } + + public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this { + this.errorWrapper = errorWrapper; + return this; + } + + public withScriptParser(scriptParser: ScriptParser): this { + this.scriptParser = scriptParser; + return this; + } + + public withDocsParser(docsParser: DocsParser): this { + this.docsParser = docsParser; return this; } public parseCategory() { - return parseCategory(this.data, this.context, this.factory); + return parseCategory( + this.data, + this.context, + { + createCategory: this.categoryFactory, + wrapError: this.errorWrapper, + createValidator: this.validatorFactory, + parseScript: this.scriptParser, + parseDocs: this.docsParser, + }, + ); } } diff --git a/tests/unit/application/Parser/ContextualError.spec.ts b/tests/unit/application/Parser/ContextualError.spec.ts new file mode 100644 index 000000000..b19da6519 --- /dev/null +++ b/tests/unit/application/Parser/ContextualError.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { CustomError } from '@/application/Common/CustomError'; +import { wrapErrorWithAdditionalContext } from '@/application/Parser/ContextualError'; + +describe('wrapErrorWithAdditionalContext', () => { + it('preserves the original error when wrapped', () => { + // arrange + const expectedError = new Error(); + const context = new TestContext() + .withError(expectedError); + // act + const error = context.wrap(); + // assert + const actualError = extractInnerErrorFromContextualError(error); + expect(actualError).to.equal(expectedError); + }); + it('maintains the original error when re-wrapped', () => { + // arrange + const expectedError = new Error(); + + // act + const firstError = new TestContext() + .withError(expectedError) + .withAdditionalContext('first error') + .wrap(); + const secondError = new TestContext() + .withError(firstError) + .withAdditionalContext('second error') + .wrap(); + + // assert + const actualError = extractInnerErrorFromContextualError(secondError); + expect(actualError).to.equal(expectedError); + }); + it(`the object extends ${CustomError.name}`, () => { + // arrange + const expected = CustomError; + // act + const error = new TestContext() + .wrap(); + // assert + expect(error).to.be.an.instanceof(expected); + }); + describe('error message construction', () => { + it('includes the message from the original error', () => { + // arrange + const expectedOriginalErrorMessage = 'Message from the inner error'; + + // act + const error = new TestContext() + .withError(new Error(expectedOriginalErrorMessage)) + .wrap(); + + // assert + expect(error.message).contains(expectedOriginalErrorMessage); + }); + it('appends provided additional context to the error message', () => { + // arrange + const expectedAdditionalContext = 'Expected additional context message'; + + // act + const error = new TestContext() + .withAdditionalContext(expectedAdditionalContext) + .wrap(); + + // assert + expect(error.message).contains(expectedAdditionalContext); + }); + it('appends multiple contexts to the error message in sequential order', () => { + // arrange + const expectedFirstContext = 'First context'; + const expectedSecondContext = 'Second context'; + + // act + const firstError = new TestContext() + .withAdditionalContext(expectedFirstContext) + .wrap(); + const secondError = new TestContext() + .withError(firstError) + .withAdditionalContext(expectedSecondContext) + .wrap(); + + // assert + const messageLines = secondError.message.split('\n'); + expect(messageLines).to.contain(`1: ${expectedFirstContext}`); + expect(messageLines).to.contain(`2: ${expectedSecondContext}`); + }); + }); +}); + +class TestContext { + private error: Error = new Error(); + + private additionalContext = `[${TestContext.name}] additional context`; + + public withError(error: Error) { + this.error = error; + return this; + } + + public withAdditionalContext(additionalContext: string) { + this.additionalContext = additionalContext; + return this; + } + + public wrap(): ReturnType<typeof wrapErrorWithAdditionalContext> { + return wrapErrorWithAdditionalContext( + this.error, + this.additionalContext, + ); + } +} + +function extractInnerErrorFromContextualError(error: Error): Error { + const innerErrorProperty = 'innerError'; + if (!(innerErrorProperty in error)) { + throw new Error(`${innerErrorProperty} property is missing`); + } + const actualError = error[innerErrorProperty]; + return actualError as Error; +} diff --git a/tests/unit/application/Parser/ContextualErrorTester.ts b/tests/unit/application/Parser/ContextualErrorTester.ts new file mode 100644 index 000000000..1c502c57b --- /dev/null +++ b/tests/unit/application/Parser/ContextualErrorTester.ts @@ -0,0 +1,53 @@ +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { indentText } from '@tests/shared/Text'; +import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; + +interface ContextualErrorTestScenario { + readonly throwingAction: (wrapError: ErrorWithContextWrapper) => void; + readonly expectedWrappedError: Error; + readonly expectedContextMessage: string; +} + +export function itThrowsContextualError( + testScenario: ContextualErrorTestScenario, +) { + it('throws wrapped error', () => { + // arrange + const expectedError = new Error(); + const wrapperStub = new ErrorWrapperStub() + .withError(expectedError); + // act + const act = () => testScenario.throwingAction(wrapperStub.get()); + // assert + expect(act).to.throw(expectedError); + }); + it('wraps internal error', () => { + // arrange + const expectedInternalError = testScenario.expectedWrappedError; + const wrapperStub = new ErrorWrapperStub(); + // act + try { + testScenario.throwingAction(wrapperStub.get()); + } catch { /* Swallow */ } + // assert + expect(wrapperStub.lastError).to.deep.equal(expectedInternalError); + }); + it('includes expected context', () => { + // arrange + const { expectedContextMessage: expectedContext } = testScenario; + const wrapperStub = new ErrorWrapperStub(); + // act + try { + testScenario.throwingAction(wrapperStub.get()); + } catch { /* Swallow */ } + // assert + expectExists(wrapperStub.lastContext); + expect(wrapperStub.lastContext).to.equal(expectedContext, formatAssertionMessage([ + 'Unexpected additional context (additional message added to the wrapped error).', + `Actual additional context:\n${indentText(wrapperStub.lastContext)}`, + `Expected additional context:\n${indentText(expectedContext)}`, + ])); + }); +} diff --git a/tests/unit/application/Parser/DataValidationTestScenarioGenerator.ts b/tests/unit/application/Parser/DataValidationTestScenarioGenerator.ts new file mode 100644 index 000000000..e8d193846 --- /dev/null +++ b/tests/unit/application/Parser/DataValidationTestScenarioGenerator.ts @@ -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; +} diff --git a/tests/unit/application/Parser/NodeDataValidationTester.ts b/tests/unit/application/Parser/NodeDataValidationTester.ts new file mode 100644 index 000000000..f6836b981 --- /dev/null +++ b/tests/unit/application/Parser/NodeDataValidationTester.ts @@ -0,0 +1,213 @@ +import { it } from 'vitest'; +import type { NodeDataValidator, NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator'; +import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext'; +import { NodeDataValidatorStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub'; +import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import type { CategoryOrScriptData } from '@/application/collections/'; +import type { FunctionKeys } from '@/TypeHelpers'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { indentText } from '@tests/shared/Text'; + +type NodeValidationTestFunction<TExpectation> = ( + factory: NodeDataValidatorFactory, +) => TExpectation; + +interface ValidNameExpectation { + readonly expectedNameToValidate: string; + readonly expectedErrorContext: NodeDataErrorContext; +} + +export function itValidatesName( + test: NodeValidationTestFunction<ValidNameExpectation>, +) { + it('validates for name', () => { + // arrange + const validator = new NodeDataValidatorStub(); + const factoryStub: NodeDataValidatorFactory = () => 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 NodeDataValidatorStub(); + const factoryStub: NodeDataValidatorFactory = () => 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: CategoryOrScriptData; + readonly expectedErrorContext: NodeDataErrorContext; +} + +export function itValidatesDefinedData( + test: NodeValidationTestFunction<ValidDataExpectation>, +) { + it('validates data', () => { + // arrange + const validator = new NodeDataValidatorStub(); + const factoryStub: NodeDataValidatorFactory = () => 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 NodeDataValidatorStub(); + const factoryStub: NodeDataValidatorFactory = () => 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: NodeDataErrorContext; +} + +export function itAsserts( + testScenario: { + readonly test: NodeValidationTestFunction<AssertionExpectation>, + readonly expectedConditionResult: boolean; + }, +) { + it('asserts with correct message', () => { + // arrange + const validator = new NodeDataValidatorStub() + .withAssertThrowsOnFalseCondition(false); + const factoryStub: NodeDataValidatorFactory = () => 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); + }); + 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 NodeDataValidatorStub() + .withAssertThrowsOnFalseCondition(false); + const factoryStub: NodeDataValidatorFactory = () => 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<NodeDataValidator>, + act: NodeValidationTestFunction<T>, + expectContext: (actionResult: T) => NodeDataErrorContext, +}) { + // arrange + const { methodName } = testScenario; + const createdValidators = new Array<{ + readonly validator: NodeDataValidatorStub; + readonly context: NodeDataErrorContext; + }>(); + const factoryStub: NodeDataValidatorFactory = (context) => { + const validator = new NodeDataValidatorStub() + .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)), + ])); +} diff --git a/tests/unit/application/Parser/NodeValidation/NodeDataError.spec.ts b/tests/unit/application/Parser/NodeValidation/NodeDataError.spec.ts deleted file mode 100644 index befb5e475..000000000 --- a/tests/unit/application/Parser/NodeValidation/NodeDataError.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { type INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError'; -import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; -import { CustomError } from '@/application/Common/CustomError'; - -describe('NodeDataError', () => { - it('sets message as expected', () => { - // arrange - const message = 'message'; - const context = new NodeDataErrorContextStub(); - const expected = `[${NodeType[context.type]}] ${message}`; - // act - const sut = new NodeDataErrorBuilder() - .withContext(context) - .withMessage(expected) - .build(); - // assert - expect(sut.message).to.include(expected); - }); - it('sets context as expected', () => { - // arrange - const expected = new NodeDataErrorContextStub(); - // act - const sut = new NodeDataErrorBuilder() - .withContext(expected) - .build(); - // assert - expect(sut.context).to.equal(expected); - }); - it('extends CustomError', () => { - // arrange - const expected = CustomError; - // act - const sut = new NodeDataErrorBuilder() - .build(); - // assert - expect(sut).to.be.an.instanceof(expected); - }); -}); - -class NodeDataErrorBuilder { - private message = 'error'; - - private context: INodeDataErrorContext = new NodeDataErrorContextStub(); - - public withContext(context: INodeDataErrorContext) { - this.context = context; - return this; - } - - public withMessage(message: string) { - this.message = message; - return this; - } - - public build(): NodeDataError { - return new NodeDataError(this.message, this.context); - } -} diff --git a/tests/unit/application/Parser/NodeValidation/NodeDataValidator.spec.ts b/tests/unit/application/Parser/NodeValidation/NodeDataValidator.spec.ts new file mode 100644 index 000000000..29357e75f --- /dev/null +++ b/tests/unit/application/Parser/NodeValidation/NodeDataValidator.spec.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from 'vitest'; +import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub'; +import type { NodeData } from '@/application/Parser/NodeValidation/NodeData'; +import { createNodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub'; +import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext'; +import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; +import { ContextualNodeDataValidator, createNodeDataValidator, type NodeDataValidator } from '@/application/Parser/NodeValidation/NodeDataValidator'; +import type { NodeContextErrorMessageCreator } from '@/application/Parser/NodeValidation/NodeDataErrorContextMessage'; +import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; + +describe('createNodeDataValidator', () => { + it(`returns an instance of ${ContextualNodeDataValidator.name}`, () => { + // arrange + const context = createNodeDataErrorContextStub(); + // act + const validator = createNodeDataValidator(context); + // assert + expect(validator).to.be.instanceOf(ContextualNodeDataValidator); + }); +}); + +describe('NodeDataValidator', () => { + 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 NodeValidatorBuilder() + .build(); + // act + const act = () => sut.assertValidName(validName); + // assert + expect(act).to.not.throw(); + }); + }); + describe('assertDefined', () => { + describe('throws when node 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 node data'; + itThrowsCorrectly({ + // act + throwingAction: (sut: NodeDataValidator) => { + sut.assertDefined(invalidData as NodeData); + }, + // assert + expectedMessage, + }); + }); + }); + }); + it('does not throw if node data is defined', () => { + // arrange + const definedNode = new CategoryDataStub(); + const sut = new NodeValidatorBuilder() + .build(); + // act + const act = () => sut.assertDefined(definedNode); + // 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: NodeDataValidator) => { + sut.assert(falsePredicate, expectedErrorMessage); + }, + // assert + expectedMessage: expectedErrorMessage, + }); + }); + it('does not throw if validation succeeds', () => { + // arrange + const truePredicate = () => true; + const sut = new NodeValidatorBuilder() + .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: NodeContextErrorMessageCreator = (message) => message; + const sut = new NodeValidatorBuilder() + .withErrorMessageCreator(errorMessageBuilder) + .build(); + // act + const actualErrorMessage = sut.createContextualErrorMessage(expectedErrorMessage); + // assert + expect(actualErrorMessage).to.equal(expectedErrorMessage); + }); + it('creates using the correct context', () => { + // arrange + const expectedContext = createNodeDataErrorContextStub(); + let actualContext: NodeDataErrorContext | undefined; + const errorMessageBuilder: NodeContextErrorMessageCreator = (_, context) => { + actualContext = context; + return ''; + }; + const sut = new NodeValidatorBuilder() + .withContext(expectedContext) + .withErrorMessageCreator(errorMessageBuilder) + .build(); + // act + sut.createContextualErrorMessage('unimportant'); + // assert + expect(actualContext).to.equal(expectedContext); + }); + }); +}); + +type ValidationThrowingFunction = ( + sut: ContextualNodeDataValidator, +) => 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: NodeContextErrorMessageCreator = () => expectedErrorMessage; + const sut = new NodeValidatorBuilder() + .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: NodeContextErrorMessageCreator = (message) => message; + const sut = new NodeValidatorBuilder() + .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 = createNodeDataErrorContextStub(); + const serializeContext = (context: NodeDataErrorContext) => JSON.stringify(context); + const errorMessageBuilder: + NodeContextErrorMessageCreator = (_, context) => serializeContext(context); + const sut = new NodeValidatorBuilder() + .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 NodeValidatorBuilder { + private errorContext: NodeDataErrorContext = createNodeDataErrorContextStub(); + + private errorMessageCreator: NodeContextErrorMessageCreator = () => `[${NodeValidatorBuilder.name}] stub error message`; + + public withErrorMessageCreator(errorMessageCreator: NodeContextErrorMessageCreator): this { + this.errorMessageCreator = errorMessageCreator; + return this; + } + + public withContext(errorContext: NodeDataErrorContext): this { + this.errorContext = errorContext; + return this; + } + + public build(): ContextualNodeDataValidator { + return new ContextualNodeDataValidator( + this.errorContext, + this.errorMessageCreator, + ); + } +} diff --git a/tests/unit/application/Parser/NodeValidation/NodeValidator.spec.ts b/tests/unit/application/Parser/NodeValidation/NodeValidator.spec.ts deleted file mode 100644 index ab9a989c7..000000000 --- a/tests/unit/application/Parser/NodeValidation/NodeValidator.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError'; -import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator'; -import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError'; -import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub'; -import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub'; -import type { NodeData } from '@/application/Parser/NodeValidation/NodeData'; -import { NodeValidationTestRunner } from './NodeValidatorTestRunner'; - -describe('NodeValidator', () => { - describe('assertValidName', () => { - describe('throws if invalid', () => { - // arrange - const context = new NodeDataErrorContextStub(); - const sut = new NodeValidator(context); - // act - const act = (invalidName: string) => sut.assertValidName(invalidName); - // assert - new NodeValidationTestRunner() - .testInvalidNodeName((invalidName) => ({ - act: () => act(invalidName), - expectedContext: context, - })); - }); - it('does not throw if valid', () => { - // arrange - const validName = 'validName'; - const sut = new NodeValidator(new NodeDataErrorContextStub()); - // act - const act = () => sut.assertValidName(validName); - // assert - expect(act).to.not.throw(); - }); - }); - describe('assertDefined', () => { - describe('throws if missing', () => { - // arrange - const context = new NodeDataErrorContextStub(); - const sut = new NodeValidator(context); - // act - const act = (undefinedNode: NodeData) => sut.assertDefined(undefinedNode); - // assert - new NodeValidationTestRunner() - .testMissingNodeData((invalidName) => ({ - act: () => act(invalidName), - expectedContext: context, - })); - }); - it('does not throw if defined', () => { - // arrange - const definedNode = mockNode(); - const sut = new NodeValidator(new NodeDataErrorContextStub()); - // act - const act = () => sut.assertDefined(definedNode); - // assert - expect(act).to.not.throw(); - }); - }); - describe('assert', () => { - it('throws expected error if condition is false', () => { - // arrange - const message = 'error'; - const falsePredicate = () => false; - const context = new NodeDataErrorContextStub(); - const expected = new NodeDataError(message, context); - const sut = new NodeValidator(context); - // act - const act = () => sut.assert(falsePredicate, message); - // assert - expectDeepThrowsError(act, expected); - }); - it('does not throw if condition is true', () => { - // arrange - const truePredicate = () => true; - const sut = new NodeValidator(new NodeDataErrorContextStub()); - // act - const act = () => sut.assert(truePredicate, 'ignored error'); - // assert - expect(act).to.not.throw(); - }); - }); - describe('throw', () => { - it('throws expected error', () => { - // arrange - const message = 'error'; - const context = new NodeDataErrorContextStub(); - const expected = new NodeDataError(message, context); - const sut = new NodeValidator(context); - // act - const act = () => sut.throw(message); - // assert - expectDeepThrowsError(act, expected); - }); - }); -}); - -function mockNode() { - return new CategoryDataStub(); -} diff --git a/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts b/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts deleted file mode 100644 index 29d144721..000000000 --- a/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it } from 'vitest'; -import { NodeDataError, type INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError'; -import type { NodeData } from '@/application/Parser/NodeValidation/NodeData'; -import { getAbsentObjectTestCases, getAbsentStringTestCases, itEachAbsentTestCase } from '@tests/unit/shared/TestCases/AbsentTests'; -import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError'; - -export interface ITestScenario { - readonly act: () => void; - readonly expectedContext: INodeDataErrorContext; -} - -export class NodeValidationTestRunner { - public testInvalidNodeName( - testBuildPredicate: (invalidName: string) => ITestScenario, - ) { - describe('throws given invalid names', () => { - // arrange - const testCases = [ - ...getAbsentStringTestCases().map((testCase) => ({ - testName: `missing name (${testCase.valueName})`, - nameValue: testCase.absentValue, - expectedMessage: 'missing name', - })), - { - testName: 'invalid type', - nameValue: 33, - expectedMessage: 'Name (33) is not a string but number.', - }, - ]; - for (const testCase of testCases) { - it(`given "${testCase.testName}"`, () => { - const test = testBuildPredicate(testCase.nameValue as never); - expectThrowsNodeError(test, testCase.expectedMessage); - }); - } - }); - return this; - } - - public testMissingNodeData( - testBuildPredicate: (missingNode: NodeData) => ITestScenario, - ) { - describe('throws given missing node data', () => { - itEachAbsentTestCase([ - ...getAbsentObjectTestCases(), - { - valueName: 'empty object', - absentValue: {}, - }, - ], (absentValue) => { - // arrange - const expectedError = 'missing node data'; - // act - const test = testBuildPredicate(absentValue as NodeData); - // assert - expectThrowsNodeError(test, expectedError); - }); - }); - return this; - } - - public runThrowingCase( - testCase: { - readonly name: string, - readonly scenario: ITestScenario, - readonly expectedMessage: string - }, - ) { - it(testCase.name, () => { - expectThrowsNodeError(testCase.scenario, testCase.expectedMessage); - }); - return this; - } -} - -export function expectThrowsNodeError( - test: ITestScenario, - expectedMessage: string, -) { - // arrange - const expected = new NodeDataError(expectedMessage, test.expectedContext); - // act - const act = () => test.act(); - // assert - expectDeepThrowsError(act, expected); - return this; -} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts index e62641ded..31203f347 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts @@ -229,7 +229,11 @@ class ExpressionBuilder { } public build() { - return new Expression(this.position, this.evaluator, this.parameters); + return new Expression({ + position: this.position, + evaluator: this.evaluator, + parameters: this.parameters, + }); } private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`; diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressiontPositionFactory.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.spec.ts similarity index 84% rename from tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressiontPositionFactory.spec.ts rename to tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.spec.ts index 038e6efef..ce7e66e51 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressiontPositionFactory.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory.spec.ts @@ -1,22 +1,21 @@ import { describe, it, expect } from 'vitest'; import { createPositionFromRegexFullMatch } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; +import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests'; describe('ExpressionPositionFactory', () => { describe('createPositionFromRegexFullMatch', () => { - it(`creates ${ExpressionPosition.name} instance`, () => { + describe('it is a transient factory', () => { // arrange - const expectedType = ExpressionPosition; - const fakeMatch = createRegexMatch({ - fullMatch: 'matched string', - matchIndex: 5, - }); + const fakeMatch = createRegexMatch(); // act - const position = createPositionFromRegexFullMatch(fakeMatch); + const create = () => createPositionFromRegexFullMatch(fakeMatch); // assert - expect(position).to.be.instanceOf(expectedType); + itIsTransientFactory({ + getter: create, + expectedType: ExpressionPosition, + }); }); - it('creates a position with the correct start position', () => { // arrange const expectedStartPosition = 5; @@ -63,10 +62,8 @@ describe('ExpressionPositionFactory', () => { describe('invalid values', () => { it('throws an error if match.index is undefined', () => { // arrange - const fakeMatch = createRegexMatch({ - fullMatch: 'matched string', - matchIndex: undefined, - }); + const fakeMatch = createRegexMatch(); + fakeMatch.index = undefined; const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`; // act const act = () => createPositionFromRegexFullMatch(fakeMatch); @@ -94,9 +91,9 @@ function createRegexMatch(options?: { readonly capturingGroups?: readonly string[], readonly matchIndex?: number, }): RegExpMatchArray { - const fullMatch = options?.fullMatch ?? 'fake match'; + const fullMatch = options?.fullMatch ?? 'default fake match'; const capturingGroups = options?.capturingGroups ?? []; const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups]; - fakeMatch.index = options?.matchIndex; + fakeMatch.index = options?.matchIndex ?? 0; return fakeMatch; } diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts index a8163a193..807ce2735 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts @@ -1,168 +1,438 @@ import { describe, it, expect } from 'vitest'; -import type { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; -import { type IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser'; +import type { + ExpressionEvaluator, ExpressionInitParameters, +} from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; +import { + type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities, +} from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; -import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +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/Script/Compiler/Expressions/Expression/IExpression'; +import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub'; +import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection'; +import type { ExpressionPositionFactory } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { indentText } from '@tests/shared/Text'; +import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory'; describe('RegexParser', () => { describe('findExpressions', () => { - describe('throws when code is absent', () => { - itEachAbsentStringValue((absentValue) => { + 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 expectedError = 'missing code'; - const sut = new RegexParserConcrete(/unimportant/); - // act - const act = () => sut.findExpressions(absentValue); - // assert - expect(act).to.throw(expectedError); - }, { excludeNull: true, excludeUndefined: true }); - }); - it('throws when position is invalid', () => { - // arrange - const regexMatchingEmpty = /^/gm; /* expressions cannot be empty */ - const code = 'unimportant'; - const expectedErrorParts = [ - `[${RegexParserConcrete.constructor.name}]`, - 'invalid script position', - `Regex: ${regexMatchingEmpty}`, - `Code: ${code}`, - ]; - const sut = new RegexParserConcrete(regexMatchingEmpty); - // act - let errorMessage: string | undefined; - try { - sut.findExpressions(code); - } catch (err) { - errorMessage = err.message; - } - // assert - expectExists(errorMessage); - const error = errorMessage; // workaround for ts(18048): possibly 'undefined' - expect( - expectedErrorParts.every((part) => error.includes(part)), - `Expected parts: ${expectedErrorParts.join(', ')}` - + `Actual error: ${errorMessage}`, - ); + 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('matches regex as expected', () => { + describe('handles matched regex correctly', () => { // arrange - const testCases = [ + const testScenarios: readonly { + readonly description: string; + readonly regex: RegExp; + readonly code: string; + }[] = [ { - name: 'returns no result when regex does not match', + description: 'non-matching regex', regex: /hello/g, code: 'world', }, { - name: 'returns expected when regex matches single', + description: 'single regex match', regex: /hello/g, code: 'hello world', }, { - name: 'returns expected when regex matches multiple', + description: 'multiple regex matches', regex: /l/g, code: 'hello world', }, ]; - for (const testCase of testCases) { - it(testCase.name, () => { - const expected = Array.from(testCase.code.matchAll(testCase.regex)); - const matches = new Array<RegExpMatchArray>(); - const builder = (m: RegExpMatchArray): IPrimitiveExpression => { - matches.push(m); - return mockPrimitiveExpression(); - }; - const sut = new RegexParserConcrete(testCase.regex, builder); - // act - const expressions = sut.findExpressions(testCase.code); - // assert - expect(expressions).to.have.lengthOf(matches.length); - expect(matches).to.deep.equal(expected); + 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 as expected', () => { + it('sets evaluator correctly from expression', () => { // arrange - const expected = getEvaluatorStub(); - const regex = /hello/g; - const code = 'hello'; - const builder = (): IPrimitiveExpression => ({ - evaluator: expected, + const { createExpression, getInitParameters } = createExpressionFactorySpy(); + const expectedEvaluate = createEvaluatorStub(); + const { code, regex } = createCodeAndRegexMatchingOnce(); + const builder = (): PrimitiveExpression => ({ + evaluator: expectedEvaluate, + }); + const sut = new RegexParserConcrete({ + regex, + builder, + utilities: { + createExpression, + }, }); - const sut = new RegexParserConcrete(regex, builder); // act const expressions = sut.findExpressions(code); // assert expect(expressions).to.have.lengthOf(1); - expect(expressions[0].evaluate === expected); + const actualEvaluate = getInitParameters(expressions[0])?.evaluator; + expect(actualEvaluate).to.equal(expectedEvaluate); }); - it('sets parameters as expected', () => { + it('sets parameters correctly from expression', () => { // arrange - const expected = [ - new FunctionParameterStub().withName('parameter1').withOptionality(true), - new FunctionParameterStub().withName('parameter2').withOptionality(false), + 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 = (): IPrimitiveExpression => ({ - evaluator: getEvaluatorStub(), - parameters: expected, + 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, + }, }); - const sut = new RegexParserConcrete(regex, builder); // act const expressions = sut.findExpressions(code); // assert expect(expressions).to.have.lengthOf(1); - expect(expressions[0].parameters.all).to.deep.equal(expected); - }); - it('sets expected position', () => { - // arrange - const code = 'mate date in state is fate'; - const regex = /ate/g; - const expected = [ - new ExpressionPosition(1, 4), - new ExpressionPosition(6, 9), - new ExpressionPosition(15, 18), - new ExpressionPosition(23, 26), - ]; - const sut = new RegexParserConcrete(regex); - // act - const expressions = sut.findExpressions(code); - // assert - const actual = expressions.map((e) => e.position); - expect(actual).to.deep.equal(expected); + const actualParameters = getInitParameters(expressions[0])?.parameters; + expect(actualParameters).to.equal(parameterCollection); + expect(actualParameters?.all).to.deep.equal(expectedParameters); }); }); }); -function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression { +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: getEvaluatorStub(), + evaluator: createEvaluatorStub(), }); } -function getEvaluatorStub(): ExpressionEvaluator { - return () => `[${getEvaluatorStub.name}] evaluated code`; +function createEvaluatorStub(): ExpressionEvaluator { + return () => `[${createEvaluatorStub.name}] evaluated code`; } -function mockPrimitiveExpression(): IPrimitiveExpression { +function createPrimitiveExpressionStub(): PrimitiveExpression { return { - evaluator: getEvaluatorStub(), + 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( - regex: RegExp, - private readonly builder = mockBuilder(), - ) { - super(); - this.regex = regex; + 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): IPrimitiveExpression { + protected buildExpression(match: RegExpMatchArray): PrimitiveExpression { return this.builder(match); } } diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.spec.ts index 793ca4ac4..720aed9d8 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument.spec.ts @@ -17,7 +17,7 @@ describe('FunctionCallArgument', () => { itEachAbsentStringValue((absentValue) => { // arrange const parameterName = 'paramName'; - const expectedError = `missing argument value for "${parameterName}"`; + const expectedError = `Missing argument value for the parameter "${parameterName}".`; const argumentValue = absentValue; // act const act = () => new FunctionCallArgumentBuilder() diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler.spec.ts index 54f489423..8a8b0a53c 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file */ import { describe, it, expect } from 'vitest'; import { FunctionCallSequenceCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler'; -import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler'; import type { CodeSegmentMerger } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/CodeSegmentMerger'; @@ -17,7 +17,7 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists'; describe('FunctionCallSequenceCompiler', () => { describe('instance', () => { - itIsSingleton({ + itIsSingletonFactory({ getter: () => FunctionCallSequenceCompiler.instance, expectedType: FunctionCallSequenceCompiler, }); diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/NestedFunctionCallCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/NestedFunctionCallCompiler.spec.ts index 2a3086771..04fe955cc 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/NestedFunctionCallCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/NestedFunctionCallCompiler.spec.ts @@ -9,7 +9,9 @@ import { SingleCallCompilerStub } from '@tests/unit/shared/Stubs/SingleCallCompi import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; -import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError'; +import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; describe('NestedFunctionCallCompiler', () => { describe('canCompile', () => { @@ -43,12 +45,12 @@ describe('NestedFunctionCallCompiler', () => { // arrange const argumentCompiler = new ArgumentCompilerStub(); const expectedContext = new FunctionCallCompilationContextStub(); - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); + const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const compiler = new NestedFunctionCallCompilerBuilder() .withArgumentCompiler(argumentCompiler) .build(); // act - compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); + compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); // assert const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall'); expect(calls).have.lengthOf(1); @@ -59,33 +61,37 @@ describe('NestedFunctionCallCompiler', () => { // arrange const argumentCompiler = new ArgumentCompilerStub(); const expectedContext = new FunctionCallCompilationContextStub(); - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); + const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); + const expectedParentCall = callToFrontFunc; const compiler = new NestedFunctionCallCompilerBuilder() .withArgumentCompiler(argumentCompiler) .build(); // act - compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); + compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); // assert const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall'); expect(calls).have.lengthOf(1); const [,actualParentCall] = calls[0].args; - expect(actualParentCall).to.equal(callToFrontFunc); + expect(actualParentCall).to.equal(expectedParentCall); }); it('uses correct nested call', () => { // arrange const argumentCompiler = new ArgumentCompilerStub(); const expectedContext = new FunctionCallCompilationContextStub(); - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); + const { + frontFunction, callToDeepFunc, callToFrontFunc, + } = createSingleFuncCallingAnotherFunc(); + const expectedNestedCall = callToDeepFunc; const compiler = new NestedFunctionCallCompilerBuilder() .withArgumentCompiler(argumentCompiler) .build(); // act - compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); + compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); // assert const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall'); expect(calls).have.lengthOf(1); const [actualNestedCall] = calls[0].args; - expect(actualNestedCall).to.deep.equal(callToFrontFunc); + expect(actualNestedCall).to.deep.equal(expectedNestedCall); }); }); describe('re-compilation with compiled args', () => { @@ -94,11 +100,11 @@ describe('NestedFunctionCallCompiler', () => { const singleCallCompilerStub = new SingleCallCompilerStub(); const expectedContext = new FunctionCallCompilationContextStub() .withSingleCallCompiler(singleCallCompilerStub); - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); + const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const compiler = new NestedFunctionCallCompilerBuilder() .build(); // act - compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); + compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); // assert const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall'); expect(calls).have.lengthOf(1); @@ -113,12 +119,12 @@ describe('NestedFunctionCallCompiler', () => { const singleCallCompilerStub = new SingleCallCompilerStub(); const context = new FunctionCallCompilationContextStub() .withSingleCallCompiler(singleCallCompilerStub); - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); + const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); const compiler = new NestedFunctionCallCompilerBuilder() .withArgumentCompiler(argumentCompilerStub) .build(); // act - compiler.compileFunction(frontFunc, callToFrontFunc, context); + compiler.compileFunction(frontFunction, callToFrontFunc, context); // assert const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall'); expect(calls).have.lengthOf(1); @@ -140,9 +146,9 @@ describe('NestedFunctionCallCompiler', () => { .withScenario({ givenNestedFunctionCall: callToDeepFunc1, result: callToDeepFunc1 }) .withScenario({ givenNestedFunctionCall: callToDeepFunc2, result: callToDeepFunc2 }); const expectedFlattenedCodes = [...singleCallCompilationScenario.values()].flat(); - const frontFunc = createSharedFunctionStubWithCalls() + const frontFunction = createSharedFunctionStubWithCalls() .withCalls(callToDeepFunc1, callToDeepFunc2); - const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name); + const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name); const singleCallCompilerStub = new SingleCallCompilerStub() .withCallCompilationScenarios(singleCallCompilationScenario); const expectedContext = new FunctionCallCompilationContextStub() @@ -151,73 +157,105 @@ describe('NestedFunctionCallCompiler', () => { .withArgumentCompiler(argumentCompiler) .build(); // act - const actualCodes = compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext); + const actualCodes = compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext); // assert expect(actualCodes).have.lengthOf(expectedFlattenedCodes.length); expect(actualCodes).to.have.members(expectedFlattenedCodes); }); describe('error handling', () => { - it('handles argument compiler errors', () => { + describe('rethrows error from argument compiler', () => { // arrange - const argumentCompilerError = new Error('Test error'); + const expectedInnerError = new Error(`Expected error from ${ArgumentCompilerStub.name}`); + const calleeFunctionName = 'expectedCalleeFunctionName'; + const callerFunctionName = 'expectedCallerFunctionName'; + const expectedErrorMessage = buildRethrowErrorMessage({ + callee: calleeFunctionName, + caller: callerFunctionName, + }); + const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({ + frontFunctionName: callerFunctionName, + deepFunctionName: calleeFunctionName, + }); const argumentCompilerStub = new ArgumentCompilerStub(); argumentCompilerStub.createCompiledNestedCall = () => { - throw argumentCompilerError; + throw expectedInnerError; }; - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); - const expectedError = new AggregateError( - [argumentCompilerError], - `Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`, - ); - const compiler = new NestedFunctionCallCompilerBuilder() - .withArgumentCompiler(argumentCompilerStub) - .build(); - // act - const act = () => compiler.compileFunction( - frontFunc, - callToFrontFunc, - new FunctionCallCompilationContextStub(), - ); - // assert - expectDeepThrowsError(act, expectedError); + const builder = new NestedFunctionCallCompilerBuilder() + .withArgumentCompiler(argumentCompilerStub); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .build() + .compileFunction( + frontFunction, + callToFrontFunc, + new FunctionCallCompilationContextStub(), + ); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); }); - it('handles single call compiler errors', () => { + describe('rethrows error from single call compiler', () => { // arrange - const singleCallCompilerError = new Error('Test error'); + const expectedInnerError = new Error(`Expected error from ${SingleCallCompilerStub.name}`); + const calleeFunctionName = 'expectedCalleeFunctionName'; + const callerFunctionName = 'expectedCallerFunctionName'; + const expectedErrorMessage = buildRethrowErrorMessage({ + callee: calleeFunctionName, + caller: callerFunctionName, + }); + const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({ + frontFunctionName: callerFunctionName, + deepFunctionName: calleeFunctionName, + }); const singleCallCompiler = new SingleCallCompilerStub(); singleCallCompiler.compileSingleCall = () => { - throw singleCallCompilerError; + throw expectedInnerError; }; const context = new FunctionCallCompilationContextStub() .withSingleCallCompiler(singleCallCompiler); - const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc(); - const expectedError = new AggregateError( - [singleCallCompilerError], - `Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`, - ); - const compiler = new NestedFunctionCallCompilerBuilder() - .build(); - // act - const act = () => compiler.compileFunction( - frontFunc, - callToFrontFunc, - context, - ); - // assert - expectDeepThrowsError(act, expectedError); + const builder = new NestedFunctionCallCompilerBuilder(); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .build() + .compileFunction( + frontFunction, + callToFrontFunc, + context, + ); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); }); }); }); }); -function createSingleFuncCallingAnotherFunc() { - const deepFunc = createSharedFunctionStubWithCode(); - const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunc.name); - const frontFunc = createSharedFunctionStubWithCalls().withCalls(callToDeepFunc); - const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name); +function createSingleFuncCallingAnotherFunc( + functionNames?: { + readonly frontFunctionName?: string; + readonly deepFunctionName?: string; + }, +) { + const deepFunction = createSharedFunctionStubWithCode() + .withName(functionNames?.deepFunctionName ?? 'deep-function (is called by front-function)'); + const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunction.name); + const frontFunction = createSharedFunctionStubWithCalls() + .withCalls(callToDeepFunc) + .withName(functionNames?.frontFunctionName ?? 'front-function (calls deep-function)'); + const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name); return { - deepFunc, - frontFunc, + deepFunction, + frontFunction, callToFrontFunc, callToDeepFunc, }; @@ -226,14 +264,31 @@ function createSingleFuncCallingAnotherFunc() { class NestedFunctionCallCompilerBuilder { private argumentCompiler: ArgumentCompiler = new ArgumentCompilerStub(); + private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub; + public withArgumentCompiler(argumentCompiler: ArgumentCompiler): this { this.argumentCompiler = argumentCompiler; return this; } + public withErrorWrapper(wrapError: ErrorWithContextWrapper): this { + this.wrapError = wrapError; + return this; + } + public build(): NestedFunctionCallCompiler { return new NestedFunctionCallCompiler( this.argumentCompiler, + this.wrapError, ); } } + +function buildRethrowErrorMessage( + functionNames: { + readonly caller: string; + readonly callee: string; + }, +) { + return `Failed to call '${functionNames.callee}' (callee function) from '${functionNames.caller}' (caller function).`; +} diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/AdaptiveFunctionCallCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/AdaptiveFunctionCallCompiler.spec.ts index 08820d574..70b86529f 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/AdaptiveFunctionCallCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/AdaptiveFunctionCallCompiler.spec.ts @@ -11,6 +11,7 @@ import type { FunctionCallCompilationContext } from '@/application/Parser/Script import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub'; import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler'; +import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; describe('AdaptiveFunctionCallCompiler', () => { describe('compileSingleCall', () => { @@ -28,40 +29,40 @@ describe('AdaptiveFunctionCallCompiler', () => { functionParameters: ['expected-parameter'], callParameters: ['unexpected-parameter'], expectedError: - `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"` - + '. Expected parameter(s): "expected-parameter"', + `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".` + + '\nExpected parameter(s): "expected-parameter"', }, { description: 'provided: multiple unexpected parameters, when: different one is expected', functionParameters: ['expected-parameter'], callParameters: ['unexpected-parameter1', 'unexpected-parameter2'], expectedError: - `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2"` - + '. Expected parameter(s): "expected-parameter"', + `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2".` + + '\nExpected parameter(s): "expected-parameter"', }, { description: 'provided: an unexpected parameter, when: multiple parameters are expected', functionParameters: ['expected-parameter1', 'expected-parameter2'], callParameters: ['expected-parameter1', 'expected-parameter2', 'unexpected-parameter'], expectedError: - `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"` - + '. Expected parameter(s): "expected-parameter1", "expected-parameter2"', + `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".` + + '\nExpected parameter(s): "expected-parameter1", "expected-parameter2"', }, { description: 'provided: an unexpected parameter, when: none required', functionParameters: [], callParameters: ['unexpected-call-parameter'], expectedError: - `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter"` - + '. Expected parameter(s): none', + `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter".` + + '\nExpected parameter(s): none', }, { description: 'provided: expected and unexpected parameter, when: one of them is expected', functionParameters: ['expected-parameter'], callParameters: ['expected-parameter', 'unexpected-parameter'], expectedError: - `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"` - + '. Expected parameter(s): "expected-parameter"', + `Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".` + + '\nExpected parameter(s): "expected-parameter"', }, ]; testCases.forEach(({ @@ -88,7 +89,8 @@ describe('AdaptiveFunctionCallCompiler', () => { // act const act = () => builder.compileSingleCall(); // assert - expect(act).to.throw(expectedError); + const errorMessage = collectExceptionMessage(act); + expect(errorMessage).to.include(expectedError); }); }); }); diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.spec.ts index 45752ab36..1a2e355c9 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/Strategies/Argument/NestedFunctionArgumentCompiler.spec.ts @@ -7,38 +7,44 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/ import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub'; import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub'; import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub'; -import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError'; import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub'; import { createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub'; import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub'; import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub'; +import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; describe('NestedFunctionArgumentCompiler', () => { describe('createCompiledNestedCall', () => { - it('should handle error from expressions compiler', () => { + describe('rethrows error from expressions compiler', () => { // arrange + const expectedInnerError = new Error('child-'); const parameterName = 'parameterName'; + const expectedErrorMessage = `Error when compiling argument for "${parameterName}"`; const nestedCall = new FunctionCallStub() .withFunctionName('nested-function-call') .withArgumentCollection(new FunctionCallArgumentCollectionStub() .withArgument(parameterName, 'unimportant-value')); const parentCall = new FunctionCallStub() .withFunctionName('parent-function-call'); - const expressionsCompilerError = new Error('child-'); - const expectedError = new AggregateError( - [expressionsCompilerError], - `Error when compiling argument for "${parameterName}"`, - ); const expressionsCompiler = new ExpressionsCompilerStub(); - expressionsCompiler.compileExpressions = () => { throw expressionsCompilerError; }; + expressionsCompiler.compileExpressions = () => { throw expectedInnerError; }; const builder = new NestedFunctionArgumentCompilerBuilder() .withParentFunctionCall(parentCall) .withNestedFunctionCall(nestedCall) .withExpressionsCompiler(expressionsCompiler); - // act - const act = () => builder.createCompiledNestedCall(); - // assert - expectDeepThrowsError(act, expectedError); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .createCompiledNestedCall(); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); }); describe('compilation', () => { describe('without arguments', () => { @@ -258,6 +264,8 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler { private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub(); + private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub; + public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this { this.expressionsCompiler = expressionsCompiler; return this; @@ -278,8 +286,16 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler { return this; } + public withErrorWrapper(wrapError: ErrorWithContextWrapper): this { + this.wrapError = wrapError; + return this; + } + public createCompiledNestedCall(): FunctionCall { - const compiler = new NestedFunctionArgumentCompiler(this.expressionsCompiler); + const compiler = new NestedFunctionArgumentCompiler( + this.expressionsCompiler, + this.wrapError, + ); return compiler.createCompiledNestedCall( this.nestedFunctionCall, this.parentFunctionCall, diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection.spec.ts index 6a628ee43..78eee79ee 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection.spec.ts @@ -7,8 +7,8 @@ describe('FunctionParameterCollection', () => { // arrange const expected = [ new FunctionParameterStub().withName('1'), - new FunctionParameterStub().withName('2').withOptionality(true), - new FunctionParameterStub().withName('3').withOptionality(false), + new FunctionParameterStub().withName('2').withOptional(true), + new FunctionParameterStub().withName('3').withOptional(false), ]; const sut = new FunctionParameterCollection(); for (const parameter of expected) { diff --git a/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory.spec.ts new file mode 100644 index 000000000..99ad4ec43 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection'; +import { createFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory'; +import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests'; + +describe('FunctionParameterCollectionFactory', () => { + describe('createFunctionParameterCollection', () => { + describe('it is a transient factory', () => { + itIsTransientFactory({ + getter: () => createFunctionParameterCollection(), + expectedType: FunctionParameterCollection, + }); + }); + it('returns an empty collection', () => { + // arrange + const expectedInitialParametersCount = 0; + // act + const collection = createFunctionParameterCollection(); + // assert + expect(collection.all).to.have.lengthOf(expectedInitialParametersCount); + }); + }); +}); diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts index 87fdba86c..f5019f2ba 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts @@ -35,7 +35,7 @@ describe('SharedFunctionCollection', () => { it('throws if function does not exist', () => { // arrange const name = 'unique-name'; - const expectedError = `called function is not defined "${name}"`; + const expectedError = `Called function is not defined: "${name}"`; const func = createSharedFunctionStubWithCode() .withName('unexpected-name'); const sut = new SharedFunctionCollection(); diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts index 29426618e..85c685a81 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionsParser.spec.ts @@ -1,25 +1,29 @@ import { describe, it, expect } from 'vitest'; import type { FunctionData, CodeInstruction } from '@/application/collections/'; import type { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; -import { SharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser'; +import { SharedFunctionsParser, type FunctionParameterFactory } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser'; import { createFunctionDataWithCode, createFunctionDataWithoutCallOrCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub'; -import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; -import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; -import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub'; +import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; +import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; +import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory'; +import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub'; import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType'; describe('SharedFunctionsParser', () => { describe('instance', () => { - itIsSingleton({ + itIsSingletonFactory({ getter: () => SharedFunctionsParser.instance, expectedType: SharedFunctionsParser, }); @@ -127,7 +131,7 @@ describe('SharedFunctionsParser', () => { }); }); describe('throws when parameters type is not as expected', () => { - const testCases = [ + const testScenarios = [ { state: 'when not an array', invalidType: 5, @@ -137,7 +141,7 @@ describe('SharedFunctionsParser', () => { invalidType: ['a', { a: 'b' }], }, ]; - for (const testCase of testCases) { + for (const testCase of testScenarios) { it(testCase.state, () => { // arrange const func = createFunctionDataWithCode() @@ -170,25 +174,37 @@ describe('SharedFunctionsParser', () => { rules: expectedRules, }); }); - it('rethrows including function name when FunctionParameter throws', () => { - // arrange - const invalidParameterName = 'invalid function p@r4meter name'; - const functionName = 'functionName'; - const message = collectExceptionMessage( - () => new FunctionParameter(invalidParameterName, false), - ); - const expectedError = `"${functionName}": ${message}`; - const functionData = createFunctionDataWithCode() - .withName(functionName) - .withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName)); - - // act - const act = () => new ParseFunctionsCallerWithDefaults() - .withFunctions([functionData]) - .parseFunctions(); - - // assert - expect(act).to.throw(expectedError); + describe('parameter creation', () => { + // TODO: Factory tests, creates with given name + // TODO: Factory tests, creates with given optional:false + // TODO: Factory tests, creates with given optional:true + // TODO: Factory tests, creates with given optional:undefined + describe('rethrows including function name when creating parameter throws', () => { + // arrange + const invalidParameterName = 'invalid-function-parameter-name'; + const functionName = 'functionName'; + const expectedErrorMessage = `Failed to create parameter: ${invalidParameterName} for function "${functionName}"`; + const expectedInnerError = new Error('injected error'); + const parameterFactory: FunctionParameterFactory = () => { + throw expectedInnerError; + }; + const functionData = createFunctionDataWithCode() + .withName(functionName) + .withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName)); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + new ParseFunctionsCallerWithDefaults() + .withFunctions([functionData]) + .withFunctionParameterFactory(parameterFactory) + .withErrorWrapper(wrapError) + .parseFunctions(); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); + }); }); }); describe('given empty functions, returns empty collection', () => { @@ -282,6 +298,18 @@ class ParseFunctionsCallerWithDefaults { private functions: readonly FunctionData[] = [createFunctionDataWithCode()]; + private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub; + + private parameterFactory: FunctionParameterFactory = ( + name: string, + isOptional: boolean, + ) => new FunctionParameterStub() + .withName(name) + .withOptional(isOptional); + + private parameterCollectionFactory + : FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub(); + public withSyntax(syntax: ILanguageSyntax) { this.syntax = syntax; return this; @@ -297,8 +325,32 @@ class ParseFunctionsCallerWithDefaults { return this; } + public withErrorWrapper(wrapError: ErrorWithContextWrapper): this { + this.wrapError = wrapError; + return this; + } + + public withFunctionParameterFactory(parameterFactory: FunctionParameterFactory): this { + this.parameterFactory = parameterFactory; + return this; + } + + public withParameterCollectionFactory( + parameterCollectionFactory: FunctionParameterCollectionFactory, + ): this { + this.parameterCollectionFactory = parameterCollectionFactory; + return this; + } + public parseFunctions() { - const sut = new SharedFunctionsParser(this.codeValidator); + const sut = new SharedFunctionsParser( + { + codeValidator: this.codeValidator, + wrapError: this.wrapError, + createParameter: this.parameterFactory, + createParameterCollection: this.parameterCollectionFactory, + }, + ); return sut.parseFunctions(this.functions, this.syntax); } } diff --git a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts index 43c096ae8..5a35b8bbe 100644 --- a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts @@ -1,9 +1,7 @@ import { describe, it, expect } from 'vitest'; import type { FunctionData } from '@/application/collections/'; -import { ScriptCode } from '@/domain/ScriptCode'; import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; import type { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser'; -import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; @@ -17,8 +15,13 @@ import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICod import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; -import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; +import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; +import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory'; +import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub'; +import { itThrowsContextualError } from '../../ContextualErrorTester'; describe('ScriptCompiler', () => { describe('canCompile', () => { @@ -58,31 +61,59 @@ describe('ScriptCompiler', () => { // assert expect(act).to.throw(expectedError); }); - it('returns code as expected', () => { - // arrange - const expected: CompiledCode = { - code: 'expected-code', - revertCode: 'expected-revert-code', - }; - const call = new FunctionCallDataStub(); - const script = createScriptDataWithCall(call); - const functions = [createFunctionDataWithCode().withName('existing-func')]; - const compiledFunctions = new SharedFunctionCollectionStub(); - const functionParserMock = new SharedFunctionsParserStub(); - functionParserMock.setup(functions, compiledFunctions); - const callCompilerMock = new FunctionCallCompilerStub(); - callCompilerMock.setup(parseFunctionCalls(call), compiledFunctions, expected); - const sut = new ScriptCompilerBuilder() - .withFunctions(...functions) - .withSharedFunctionsParser(functionParserMock) - .withFunctionCallCompiler(callCompilerMock) - .build(); - // act - const code = sut.compile(script); - // assert - expect(code.execute).to.equal(expected.code); - expect(code.revert).to.equal(expected.revertCode); + describe('code construction', () => { + it('returns code from the factory', () => { + // arrange + const expectedCode = new ScriptCodeStub(); + const scriptCodeFactory = () => expectedCode; + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .withScriptCodeFactory(scriptCodeFactory) + .build(); + // act + const actualCode = sut.compile(createScriptDataWithCall()); + // assert + expect(actualCode).to.equal(expectedCode); + }); + it('creates code correctly', () => { + // arrange + const expectedCode = 'expected-code'; + const expectedRevertCode = 'expected-revert-code'; + let actualCode: string | undefined; + let actualRevertCode: string | undefined; + const scriptCodeFactory = (code: string, revertCode: string) => { + actualCode = code; + actualRevertCode = revertCode; + return new ScriptCodeStub(); + }; + const call = new FunctionCallDataStub(); + const script = createScriptDataWithCall(call); + const functions = [createFunctionDataWithCode().withName('existing-func')]; + const compiledFunctions = new SharedFunctionCollectionStub(); + const functionParserMock = new SharedFunctionsParserStub(); + functionParserMock.setup(functions, compiledFunctions); + const callCompilerMock = new FunctionCallCompilerStub(); + callCompilerMock.setup( + parseFunctionCalls(call), + compiledFunctions, + new CompiledCodeStub() + .withCode(expectedCode) + .withRevertCode(expectedRevertCode), + ); + const sut = new ScriptCompilerBuilder() + .withFunctions(...functions) + .withSharedFunctionsParser(functionParserMock) + .withFunctionCallCompiler(callCompilerMock) + .withScriptCodeFactory(scriptCodeFactory) + .build(); + // act + sut.compile(script); + // assert + expect(actualCode).to.equal(expectedCode); + expect(actualRevertCode).to.equal(expectedRevertCode); + }); }); + describe('parses functions as expected', () => { it('parses functions with expected syntax', () => { // arrange @@ -116,49 +147,57 @@ describe('ScriptCompiler', () => { expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions); }); }); - it('rethrows error with script name', () => { + describe('rethrows error with script name', () => { // arrange const scriptName = 'scriptName'; - const innerError = 'innerError'; - const expectedError = `Script "${scriptName}" ${innerError}`; + const expectedErrorMessage = `Failed to compile script: ${scriptName}`; + const expectedInnerError = new Error(); const callCompiler: FunctionCallCompiler = { - compileFunctionCalls: () => { throw new Error(innerError); }, + compileFunctionCalls: () => { throw expectedInnerError; }, }; const scriptData = createScriptDataWithCall() .withName(scriptName); - const sut = new ScriptCompilerBuilder() + const builder = new ScriptCompilerBuilder() .withSomeFunctions() - .withFunctionCallCompiler(callCompiler) - .build(); - // act - const act = () => sut.compile(scriptData); - // assert - expect(act).to.throw(expectedError); + .withFunctionCallCompiler(callCompiler); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .build() + .compile(scriptData); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); }); - it('rethrows error from ScriptCode with script name', () => { + describe('rethrows error from script code factory with script name', () => { // arrange const scriptName = 'scriptName'; - const syntax = new LanguageSyntaxStub(); - const invalidCode = new CompiledCodeStub() - .withCode('' /* invalid code (empty string) */); - const realExceptionMessage = collectExceptionMessage( - () => new ScriptCode(invalidCode.code, invalidCode.revertCode), - ); - const expectedError = `Script "${scriptName}" ${realExceptionMessage}`; - const callCompiler: FunctionCallCompiler = { - compileFunctionCalls: () => invalidCode, + const expectedErrorMessage = `Failed to compile script: ${scriptName}`; + const expectedInnerError = new Error(); + const scriptCodeFactory: ScriptCodeFactory = () => { + throw expectedInnerError; }; const scriptData = createScriptDataWithCall() .withName(scriptName); - const sut = new ScriptCompilerBuilder() + const builder = new ScriptCompilerBuilder() .withSomeFunctions() - .withFunctionCallCompiler(callCompiler) - .withSyntax(syntax) - .build(); - // act - const act = () => sut.compile(scriptData); - // assert - expect(act).to.throw(expectedError); + .withScriptCodeFactory(scriptCodeFactory); + itThrowsContextualError({ + // act + throwingAction: (wrapError) => { + builder + .withErrorWrapper(wrapError) + .build() + .compile(scriptData); + }, + // assert + expectedWrappedError: expectedInnerError, + expectedContextMessage: expectedErrorMessage, + }); }); it('validates compiled code as expected', () => { // arrange @@ -166,17 +205,27 @@ describe('ScriptCompiler', () => { NoEmptyLines, // Allow duplicated lines to enable calling same function multiple times ]; + const expectedExecuteCode = 'execute code to be validated'; + const expectedRevertCode = 'revert code to be validated'; const scriptData = createScriptDataWithCall(); const validator = new CodeValidatorStub(); const sut = new ScriptCompilerBuilder() .withSomeFunctions() .withCodeValidator(validator) + .withFunctionCallCompiler( + new FunctionCallCompilerStub() + .withDefaultCompiledCode( + new CompiledCodeStub() + .withCode(expectedExecuteCode) + .withRevertCode(expectedRevertCode), + ), + ) .build(); // act - const compilationResult = sut.compile(scriptData); + sut.compile(scriptData); // assert validator.assertHistory({ - validatedCodes: [compilationResult.execute, compilationResult.revert], + validatedCodes: [expectedExecuteCode, expectedRevertCode], rules: expectedRules, }); }); @@ -200,6 +249,12 @@ class ScriptCompilerBuilder { private codeValidator: ICodeValidator = new CodeValidatorStub(); + private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub; + + private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({ + defaultCodePrefix: ScriptCompilerBuilder.name, + }); + public withFunctions(...functions: FunctionData[]): this { this.functions = functions; return this; @@ -244,6 +299,16 @@ class ScriptCompilerBuilder { return this; } + public withErrorWrapper(wrapError: ErrorWithContextWrapper): this { + this.wrapError = wrapError; + return this; + } + + public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this { + this.scriptCodeFactory = scriptCodeFactory; + return this; + } + public build(): ScriptCompiler { if (!this.functions) { throw new Error('Function behavior not defined'); @@ -254,6 +319,8 @@ class ScriptCompilerBuilder { this.sharedFunctionsParser, this.callCompiler, this.codeValidator, + this.wrapError, + this.scriptCodeFactory, ); } } diff --git a/tests/unit/application/Parser/Script/ScriptParser.spec.ts b/tests/unit/application/Parser/Script/ScriptParser.spec.ts index 0152981c5..db18fa282 100644 --- a/tests/unit/application/Parser/Script/ScriptParser.spec.ts +++ b/tests/unit/application/Parser/Script/ScriptParser.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import type { ScriptData } from '@/application/collections/'; -import { parseScript, type ScriptFactoryType } from '@/application/Parser/Script/ScriptParser'; -import { parseDocs } from '@/application/Parser/DocumentationParser'; +import { parseScript, type ScriptFactory } from '@/application/Parser/Script/ScriptParser'; +import { type DocsParser } from '@/application/Parser/DocumentationParser'; import { RecommendationLevel } from '@/domain/RecommendationLevel'; import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; @@ -11,54 +11,88 @@ import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; -import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner'; import { Script } from '@/domain/Script'; import type { IEnumParser } from '@/application/Common/Enum'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; +import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator'; +import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub'; +import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; +import type { ScriptNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext'; +import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory'; +import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub'; +import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; +import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub'; +import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { itThrowsContextualError } from '../ContextualErrorTester'; +import { itAsserts, itValidatesDefinedData, itValidatesName } from '../NodeDataValidationTester'; +import { generateDataValidationTestScenarios } from '../DataValidationTestScenarioGenerator'; describe('ScriptParser', () => { describe('parseScript', () => { - it('parses name as expected', () => { + it('parses name correctly', () => { // arrange const expected = 'test-expected-name'; - const script = createScriptDataWithCode() + const scriptData = createScriptDataWithCode() .withName(expected); + const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); // act - const actual = new TestBuilder() - .withData(script) + const actualScript = new TestContext() + .withData(scriptData) + .withScriptFactory(scriptFactorySpy) .parseScript(); // assert - expect(actual.name).to.equal(expected); + const actualName = getInitParameters(actualScript)?.name; + expect(actualName).to.equal(expected); }); - it('parses docs as expected', () => { + it('parses docs correctly', () => { // arrange - const docs = ['https://expected-doc1.com', 'https://expected-doc2.com']; - const script = createScriptDataWithCode() - .withDocs(docs); - const expected = parseDocs(script); + const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com']; + const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); + const scriptData = createScriptDataWithCode() + .withDocs(expectedDocs); + const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs; // act - const actual = new TestBuilder() - .withData(script) + const actualScript = new TestContext() + .withData(scriptData) + .withScriptFactory(scriptFactorySpy) + .withDocsParser(docsParser) .parseScript(); // assert - expect(actual.docs).to.deep.equal(expected); + const actualDocs = getInitParameters(actualScript)?.docs; + expect(actualDocs).to.deep.equal(expectedDocs); + }); + it('gets script from the factory', () => { + // arrange + const expectedScript = new ScriptStub('expected-script'); + const scriptFactory: ScriptFactory = () => expectedScript; + // act + const actualScript = new TestContext() + .withScriptFactory(scriptFactory) + .parseScript(); + // assert + expect(actualScript).to.equal(expectedScript); }); describe('level', () => { - describe('accepts absent level', () => { + describe('generated `undefined` level if given absent value', () => { itEachAbsentStringValue((absentValue) => { // arrange - const script = createScriptDataWithCode() + const expectedLevel = undefined; + const scriptData = createScriptDataWithCode() .withRecommend(absentValue); + const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); // act - const actual = new TestBuilder() - .withData(script) + const actualScript = new TestContext() + .withData(scriptData) + .withScriptFactory(scriptFactorySpy) .parseScript(); // assert - expect(actual.level).to.equal(undefined); + const actualLevel = getInitParameters(actualScript)?.level; + expect(actualLevel).to.equal(expectedLevel); }, { excludeNull: true }); }); it('parses level as expected', () => { @@ -66,63 +100,94 @@ describe('ScriptParser', () => { const expectedLevel = RecommendationLevel.Standard; const expectedName = 'level'; const levelText = 'standard'; - const script = createScriptDataWithCode() + const scriptData = createScriptDataWithCode() .withRecommend(levelText); const parserMock = new EnumParserStub<RecommendationLevel>() .setup(expectedName, levelText, expectedLevel); + const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); // act - const actual = new TestBuilder() - .withData(script) + const actualScript = new TestContext() + .withData(scriptData) .withParser(parserMock) + .withScriptFactory(scriptFactorySpy) .parseScript(); // assert - expect(actual.level).to.equal(expectedLevel); + const actualLevel = getInitParameters(actualScript)?.level; + expect(actualLevel).to.equal(expectedLevel); }); }); describe('code', () => { - it('parses "execute" as expected', () => { + it('creates from script code factory', () => { // arrange - const expected = 'expected-code'; - const script = createScriptDataWithCode() - .withCode(expected); + const expectedCode = new ScriptCodeStub(); + const scriptCodeFactory: ScriptCodeFactory = () => expectedCode; + const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); // act - const parsed = new TestBuilder() - .withData(script) + const actualScript = new TestContext() + .withScriptCodeFactory(scriptCodeFactory) + .withScriptFactory(scriptFactorySpy) .parseScript(); // assert - const actual = parsed.code.execute; - expect(actual).to.equal(expected); + const actualCode = getInitParameters(actualScript)?.code; + expect(expectedCode).to.equal(actualCode); }); - it('parses "revert" as expected', () => { - // arrange - const expected = 'expected-revert-code'; - const script = createScriptDataWithCode() - .withRevertCode(expected); - // act - const parsed = new TestBuilder() - .withData(script) - .parseScript(); - // assert - const actual = parsed.code.revert; - expect(actual).to.equal(expected); + describe('parses code correctly', () => { + it('parses "execute" as expected', () => { + // arrange + const expectedCode = 'expected-code'; + let actualCode: string | undefined; + const scriptCodeFactory: ScriptCodeFactory = (code) => { + actualCode = code; + return new ScriptCodeStub(); + }; + const scriptData = createScriptDataWithCode() + .withCode(expectedCode); + // act + new TestContext() + .withData(scriptData) + .withScriptCodeFactory(scriptCodeFactory) + .parseScript(); + // assert + expect(actualCode).to.equal(expectedCode); + }); + it('parses "revert" as expected', () => { + // arrange + const expectedRevertCode = 'expected-revert-code'; + const scriptData = createScriptDataWithCode() + .withRevertCode(expectedRevertCode); + let actualRevertCode: string | undefined; + const scriptCodeFactory: ScriptCodeFactory = (_, revertCode) => { + actualRevertCode = revertCode; + return new ScriptCodeStub(); + }; + // act + new TestContext() + .withData(scriptData) + .withScriptCodeFactory(scriptCodeFactory) + .parseScript(); + // assert + expect(actualRevertCode).to.equal(expectedRevertCode); + }); }); describe('compiler', () => { - it('gets code from compiler', () => { + it('compiles the code through the compiler', () => { // arrange - const expected = new ScriptCodeStub(); + const expectedCode = new ScriptCodeStub(); const script = createScriptDataWithCode(); const compiler = new ScriptCompilerStub() - .withCompileAbility(script, expected); + .withCompileAbility(script, expectedCode); const parseContext = new CategoryCollectionParseContextStub() .withCompiler(compiler); + const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy(); // act - const parsed = new TestBuilder() + const actualScript = new TestContext() .withData(script) .withContext(parseContext) + .withScriptFactory(scriptFactorySpy) .parseScript(); // assert - const actual = parsed.code; - expect(actual).to.equal(expected); + const actualCode = getInitParameters(actualScript)?.code; + expect(actualCode).to.equal(expectedCode); }); }); describe('syntax', () => { @@ -135,7 +200,7 @@ describe('ScriptParser', () => { const script = createScriptDataWithoutCallOrCodes() .withCode(duplicatedCode); // act - const act = () => new TestBuilder() + const act = () => new TestContext() .withData(script) .withContext(parseContext); // assert @@ -149,18 +214,26 @@ describe('ScriptParser', () => { NoEmptyLines, NoDuplicatedLines, ]; + const expectedCode = 'expected code to be validated'; + const expectedRevertCode = 'expected revert code to be validated'; + const expectedCodeCalls = [ + expectedCode, + expectedRevertCode, + ]; const validator = new CodeValidatorStub(); - const script = createScriptDataWithCode() - .withCode('expected code to be validated') - .withRevertCode('expected revert code to be validated'); + const scriptCodeFactory = createScriptCodeFactoryStub({ + scriptCode: new ScriptCodeStub() + .withExecute(expectedCode) + .withRevert(expectedRevertCode), + }); // act - new TestBuilder() - .withData(script) + new TestContext() + .withScriptCodeFactory(scriptCodeFactory) .withCodeValidator(validator) .parseScript(); // assert validator.assertHistory({ - validatedCodes: [script.code, script.revertCode], + validatedCodes: expectedCodeCalls, rules: expectedRules, }); }); @@ -175,7 +248,7 @@ describe('ScriptParser', () => { const parseContext = new CategoryCollectionParseContextStub() .withCompiler(compiler); // act - new TestBuilder() + new TestContext() .withData(script) .withCodeValidator(validator) .withContext(parseContext) @@ -188,111 +261,250 @@ describe('ScriptParser', () => { }); }); }); - describe('invalid script data', () => { - describe('validates script data', () => { + describe('validation', () => { + describe('validates for name', () => { // arrange - const createTest = (script: ScriptData): ITestScenario => ({ - act: () => new TestBuilder() + const expectedName = 'expected script name to be validated'; + const script = createScriptDataWithCall() + .withName(expectedName); + const expectedContext: ScriptNodeErrorContext = { + type: NodeDataType.Script, + selfNode: script, + }; + itValidatesName((validatorFactory) => { + // act + new TestContext() .withData(script) - .parseScript(), - expectedContext: { - type: NodeType.Script, - selfNode: script, - }, + .withValidatorFactory(validatorFactory) + .parseScript(); + // assert + return { + expectedNameToValidate: expectedName, + expectedErrorContext: expectedContext, + }; }); - // act and assert - new NodeValidationTestRunner() - .testInvalidNodeName((invalidName) => { - return createTest( - createScriptDataWithCall().withName(invalidName), - ); - }) - .testMissingNodeData((node) => { - return createTest(node as ScriptData); - }) - .runThrowingCase({ - name: 'throws when both function call and code are defined', - scenario: createTest( - createScriptDataWithCall().withCode('code'), - ), - expectedMessage: 'Both "call" and "code" are defined.', - }) - .runThrowingCase({ - name: 'throws when both function call and revertCode are defined', - scenario: createTest( - createScriptDataWithCall().withRevertCode('revert-code'), - ), - expectedMessage: 'Both "call" and "revertCode" are defined.', - }) - .runThrowingCase({ - name: 'throws when neither call or revertCode are defined', - scenario: createTest( - createScriptDataWithoutCallOrCodes(), - ), - expectedMessage: 'Neither "call" or "code" is defined.', - }); }); - it(`rethrows exception if ${Script.name} cannot be constructed`, () => { + describe('validates for defined data', () => { // arrange - const expectedError = 'script creation failed'; - const factoryMock: ScriptFactoryType = () => { throw new Error(expectedError); }; - const data = createScriptDataWithCode(); - // act - const act = () => new TestBuilder() - .withData(data) - .withFactory(factoryMock) - .parseScript(); - // expect - expectThrowsNodeError({ - act, - expectedContext: { - type: NodeType.Script, - selfNode: data, + const expectedScript = createScriptDataWithCall(); + const expectedContext: ScriptNodeErrorContext = { + type: NodeDataType.Script, + selfNode: expectedScript, + }; + itValidatesDefinedData( + (validatorFactory) => { + // act + new TestContext() + .withData(expectedScript) + .withValidatorFactory(validatorFactory) + .parseScript(); + // assert + return { + expectedDataToValidate: expectedScript, + expectedErrorContext: expectedContext, + }; + }, + ); + }); + describe('validates data', () => { + // arrange + const testScenarios = generateDataValidationTestScenarios<ScriptData>( + { + assertErrorMessage: 'Neither "call" or "code" is defined.', + expectFail: [{ + description: 'with no call or code', + data: createScriptDataWithoutCallOrCodes(), + }], + expectPass: [ + { + description: 'with call', + data: createScriptDataWithCall(), + }, + { + description: 'with code', + data: createScriptDataWithCode(), + }, + ], }, - }, expectedError); + { + assertErrorMessage: 'Both "call" and "revertCode" are defined.', + expectFail: [{ + description: 'with both call and revertCode', + data: createScriptDataWithCall() + .withRevertCode('revert-code'), + }], + expectPass: [ + { + description: 'with call, without revertCode', + data: createScriptDataWithCall() + .withRevertCode(undefined), + }, + { + description: 'with revertCode, without call', + data: createScriptDataWithCode() + .withRevertCode('revert code'), + }, + ], + }, + { + assertErrorMessage: 'Both "call" and "code" are defined.', + expectFail: [{ + description: 'with both call and code', + data: createScriptDataWithCall() + .withCode('code'), + }], + expectPass: [ + { + description: 'with call, without code', + data: createScriptDataWithCall() + .withCode(''), + }, + { + description: 'with code, without call', + data: createScriptDataWithCode() + .withCode('code'), + }, + ], + }, + ); + testScenarios.forEach(({ + description, expectedPass, data: scriptData, expectedMessage, + }) => { + describe(description, () => { + itAsserts({ + expectedConditionResult: expectedPass, + test: (validatorFactory) => { + const expectedContext: ScriptNodeErrorContext = { + type: NodeDataType.Script, + selfNode: scriptData, + }; + // act + new TestContext() + .withData(scriptData) + .withValidatorFactory(validatorFactory) + .parseScript(); + // assert + expectExists(expectedMessage); + return { + expectedErrorMessage: expectedMessage, + expectedErrorContext: expectedContext, + }; + }, + }); + }); + }); + }); + }); + describe('rethrows exception if script factory fails', () => { + // arrange + const givenData = createScriptDataWithCode(); + const expectedContextMessage = 'Failed to parse script'; + const expectedError = new Error(); + const validatorFactory: NodeDataValidatorFactory = () => { + const validatorStub = new NodeDataValidatorStub(); + validatorStub.createContextualErrorMessage = (message) => message; + return validatorStub; + }; + // act & assert + itThrowsContextualError({ + throwingAction: (wrapError) => { + const factoryMock: ScriptFactory = () => { + throw expectedError; + }; + new TestContext() + .withScriptFactory(factoryMock) + .withErrorWrapper(wrapError) + .withValidatorFactory(validatorFactory) + .withData(givenData) + .parseScript(); + }, + expectedWrappedError: expectedError, + expectedContextMessage, }); }); }); }); -class TestBuilder { +class TestContext { private data: ScriptData = createScriptDataWithCode(); private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub(); - private parser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>() + private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>() .setupDefaultValue(RecommendationLevel.Standard); - private factory?: ScriptFactoryType = undefined; + private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy; private codeValidator: ICodeValidator = new CodeValidatorStub(); - public withCodeValidator(codeValidator: ICodeValidator) { + private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get(); + + private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub; + + private docsParser: DocsParser = () => ['docs']; + + private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({ + defaultCodePrefix: TestContext.name, + }); + + public withCodeValidator(codeValidator: ICodeValidator): this { this.codeValidator = codeValidator; return this; } - public withData(data: ScriptData) { + public withData(data: ScriptData): this { this.data = data; return this; } - public withContext(context: ICategoryCollectionParseContext) { + public withContext(context: ICategoryCollectionParseContext): this { this.context = context; return this; } - public withParser(parser: IEnumParser<RecommendationLevel>) { - this.parser = parser; + public withParser(parser: IEnumParser<RecommendationLevel>): this { + this.levelParser = parser; + return this; + } + + public withScriptFactory(scriptFactory: ScriptFactory): this { + this.scriptFactory = scriptFactory; + return this; + } + + public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this { + this.validatorFactory = validatorFactory; + return this; + } + + public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this { + this.errorWrapper = errorWrapper; + return this; + } + + public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this { + this.scriptCodeFactory = scriptCodeFactory; return this; } - public withFactory(factory: ScriptFactoryType) { - this.factory = factory; + public withDocsParser(docsParser: DocsParser): this { + this.docsParser = docsParser; return this; } public parseScript(): Script { - return parseScript(this.data, this.context, this.parser, this.factory, this.codeValidator); + return parseScript( + this.data, + this.context, + { + levelParser: this.levelParser, + createScript: this.scriptFactory, + codeValidator: this.codeValidator, + wrapError: this.errorWrapper, + createValidator: this.validatorFactory, + createCode: this.scriptCodeFactory, + parseDocs: this.docsParser, + }, + ); } } diff --git a/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts b/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts index 3b0bac0b8..56764995a 100644 --- a/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts +++ b/tests/unit/application/Parser/Script/Validation/CodeValidator.spec.ts @@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub'; import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import type { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine'; import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule'; describe('CodeValidator', () => { describe('instance', () => { - itIsSingleton({ + itIsSingletonFactory({ getter: () => CodeValidator.instance, expectedType: CodeValidator, }); diff --git a/tests/unit/domain/Category.spec.ts b/tests/unit/domain/Category.spec.ts index 1c7ecc1eb..ac09d5bca 100644 --- a/tests/unit/domain/Category.spec.ts +++ b/tests/unit/domain/Category.spec.ts @@ -3,50 +3,68 @@ import { Category } from '@/domain/Category'; import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import type { ICategory, IScript } from '@/domain/ICategory'; describe('Category', () => { describe('ctor', () => { - describe('throws when name is absent', () => { + describe('throws error if name is absent', () => { itEachAbsentStringValue((absentValue) => { // arrange const expectedError = 'missing name'; const name = absentValue; // act - const construct = () => new Category(5, name, [], [new CategoryStub(5)], []); + const construct = () => new CategoryBuilder() + .withName(name) + .build(); // assert expect(construct).to.throw(expectedError); }, { excludeNull: true, excludeUndefined: true }); }); - it('throws when has no children', () => { + it('throws error if no children are present', () => { + // arrange const expectedError = 'A category must have at least one sub-category or script'; - const construct = () => new Category(5, 'category', [], [], []); + const scriptChildren: readonly IScript[] = []; + const categoryChildren: readonly ICategory[] = []; + // act + const construct = () => new CategoryBuilder() + .withSubcategories(categoryChildren) + .withScripts(scriptChildren) + .build(); + // assert expect(construct).to.throw(expectedError); }); }); describe('getAllScriptsRecursively', () => { - it('gets child scripts', () => { + it('retrieves direct child scripts', () => { // arrange - const expected = [new ScriptStub('1'), new ScriptStub('2')]; - const sut = new Category(0, 'category', [], [], expected); + const expectedScripts = [new ScriptStub('1'), new ScriptStub('2')]; + const sut = new CategoryBuilder() + .withScripts(expectedScripts) + .build(); // act const actual = sut.getAllScriptsRecursively(); // assert - expect(actual).to.have.deep.members(expected); + expect(actual).to.have.deep.members(expectedScripts); }); - it('gets child categories', () => { + it('retrieves scripts from direct child categories', () => { // arrange const expectedScriptIds = ['1', '2', '3', '4']; const categories = [ new CategoryStub(31).withScriptIds('1', '2'), new CategoryStub(32).withScriptIds('3', '4'), ]; - const sut = new Category(0, 'category', [], categories, []); + const sut = new CategoryBuilder() + .withScripts([]) + .withSubcategories(categories) + .build(); // act - const actualIds = sut.getAllScriptsRecursively().map((s) => s.id); + const actualIds = sut + .getAllScriptsRecursively() + .map((s) => s.id); // assert expect(actualIds).to.have.deep.members(expectedScriptIds); }); - it('gets child scripts and categories', () => { + it('retrieves scripts from both direct children and child categories', () => { // arrange const expectedScriptIds = ['1', '2', '3', '4', '5', '6']; const categories = [ @@ -54,13 +72,18 @@ describe('Category', () => { new CategoryStub(32).withScriptIds('3', '4'), ]; const scripts = [new ScriptStub('5'), new ScriptStub('6')]; - const sut = new Category(0, 'category', [], categories, scripts); + const sut = new CategoryBuilder() + .withSubcategories(categories) + .withScripts(scripts) + .build(); // act - const actualIds = sut.getAllScriptsRecursively().map((s) => s.id); + const actualIds = sut + .getAllScriptsRecursively() + .map((s) => s.id); // assert expect(actualIds).to.have.deep.members(expectedScriptIds); }); - it('gets child categories recursively', () => { + it('retrieves scripts from nested categories recursively', () => { // arrange const expectedScriptIds = ['1', '2', '3', '4', '5', '6']; const categories = [ @@ -83,45 +106,111 @@ describe('Category', () => { ), ]; // assert - const sut = new Category(0, 'category', [], categories, []); + const sut = new CategoryBuilder() + .withScripts([]) + .withSubcategories(categories) + .build(); // act - const actualIds = sut.getAllScriptsRecursively().map((s) => s.id); + const actualIds = sut + .getAllScriptsRecursively() + .map((s) => s.id); // assert expect(actualIds).to.have.deep.members(expectedScriptIds); }); }); describe('includes', () => { - it('return false when does not include', () => { + it('returns false for scripts not included', () => { // assert + const expectedResult = false; const script = new ScriptStub('3'); - const sut = new Category(0, 'category', [], [new CategoryStub(33).withScriptIds('1', '2')], []); + const childCategory = new CategoryStub(33) + .withScriptIds('1', '2'); + const sut = new CategoryBuilder() + .withSubcategories([childCategory]) + .build(); // act const actual = sut.includes(script); // assert - expect(actual).to.equal(false); + expect(actual).to.equal(expectedResult); }); - it('return true when includes as subscript', () => { + it('returns true for scripts directly included', () => { // assert + const expectedResult = true; const script = new ScriptStub('3'); - const sut = new Category(0, 'category', [], [ - new CategoryStub(33).withScript(script).withScriptIds('non-related'), - ], []); + const childCategory = new CategoryStub(33) + .withScript(script) + .withScriptIds('non-related'); + const sut = new CategoryBuilder() + .withSubcategories([childCategory]) + .build(); // act const actual = sut.includes(script); // assert - expect(actual).to.equal(true); + expect(actual).to.equal(expectedResult); }); - it('return true when includes as nested category script', () => { + it('returns true for scripts included in nested categories', () => { // assert + const expectedResult = true; const script = new ScriptStub('3'); - const innerCategory = new CategoryStub(22) + const childCategory = new CategoryStub(22) .withScriptIds('non-related') .withCategory(new CategoryStub(33).withScript(script)); - const sut = new Category(11, 'category', [], [innerCategory], []); + const sut = new CategoryBuilder() + .withSubcategories([childCategory]) + .build(); // act const actual = sut.includes(script); // assert - expect(actual).to.equal(true); + expect(actual).to.equal(expectedResult); }); }); }); + +class CategoryBuilder { + private id = 3264; + + private name = 'test-script'; + + private docs: ReadonlyArray<string> = []; + + private subcategories: ReadonlyArray<ICategory> = []; + + private scripts: ReadonlyArray<IScript> = [ + new ScriptStub(`[${CategoryBuilder.name}] script`), + ]; + + public withId(id: number): this { + this.id = id; + return this; + } + + public withName(name: string): this { + this.name = name; + return this; + } + + public withDocs(docs: ReadonlyArray<string>): this { + this.docs = docs; + return this; + } + + public withScripts(scripts: ReadonlyArray<IScript>): this { + this.scripts = scripts; + return this; + } + + public withSubcategories(subcategories: ReadonlyArray<ICategory>): this { + this.subcategories = subcategories; + return this; + } + + public build(): Category { + return new Category({ + id: this.id, + name: this.name, + docs: this.docs, + subcategories: this.subcategories, + scripts: this.scripts, + }); + } +} diff --git a/tests/unit/domain/Script.spec.ts b/tests/unit/domain/Script.spec.ts index f27d11563..0ae0c049b 100644 --- a/tests/unit/domain/Script.spec.ts +++ b/tests/unit/domain/Script.spec.ts @@ -8,7 +8,7 @@ import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; describe('Script', () => { describe('ctor', () => { describe('scriptCode', () => { - it('sets as expected', () => { + it('assigns code correctly', () => { // arrange const expected = new ScriptCodeStub(); const sut = new ScriptBuilder() @@ -43,7 +43,7 @@ describe('Script', () => { }); }); describe('level', () => { - it('cannot construct with invalid wrong value', () => { + it('throws when constructed with invalid level', () => { // arrange const invalidValue: RecommendationLevel = 55 as never; const expectedError = 'invalid level'; @@ -54,7 +54,7 @@ describe('Script', () => { // assert expect(construct).to.throw(expectedError); }); - it('sets undefined as expected', () => { + it('handles undefined level correctly', () => { // arrange const expected = undefined; // act @@ -64,7 +64,7 @@ describe('Script', () => { // assert expect(sut.level).to.equal(expected); }); - it('sets as expected', () => { + it('correctly assigns valid recommendation levels', () => { // arrange for (const expected of getEnumValues(RecommendationLevel)) { // act @@ -78,7 +78,7 @@ describe('Script', () => { }); }); describe('docs', () => { - it('sets as expected', () => { + it('correctly assigns docs', () => { // arrange const expected = ['doc1', 'doc2']; // act @@ -130,11 +130,11 @@ class ScriptBuilder { } public build(): Script { - return new Script( - this.name, - this.code, - this.docs, - this.level, - ); + return new Script({ + name: this.name, + code: this.code, + docs: this.docs, + level: this.level, + }); } } diff --git a/tests/unit/domain/ScriptCodeFactory.spec.ts b/tests/unit/domain/ScriptCodeFactory.spec.ts new file mode 100644 index 000000000..feb1e54bc --- /dev/null +++ b/tests/unit/domain/ScriptCodeFactory.spec.ts @@ -0,0 +1,52 @@ +import { createScriptCode } from '@/domain/ScriptCodeFactory'; + +describe('ScriptCodeFactory', () => { + describe('createScriptCode', () => { + it('generates script code with given `code`', () => { + // arrange + const expectedCode = 'expected code'; + const context = new TestContext() + .withCode(expectedCode); + // act + const code = context.createScriptCode(); + // assert + const actualCode = code.execute; + expect(actualCode).to.equal(expectedCode); + }); + + it('generates script code with given `revertCode`', () => { + // arrange + const expectedRevertCode = 'expected revert code'; + const context = new TestContext() + .withRevertCode(expectedRevertCode); + // act + const code = context.createScriptCode(); + // assert + const actualRevertCode = code.revert; + expect(actualRevertCode).to.equal(expectedRevertCode); + }); + }); +}); + +class TestContext { + private code = `[${TestContext}] code`; + + private revertCode = `[${TestContext}] revertCode`; + + public withCode(code: string): this { + this.code = code; + return this; + } + + public withRevertCode(revertCode: string): this { + this.revertCode = revertCode; + return this; + } + + public createScriptCode(): ReturnType<typeof createScriptCode> { + return createScriptCode( + this.code, + this.revertCode, + ); + } +} diff --git a/tests/unit/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory.ts b/tests/unit/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory.ts index 6f8976622..bd443a20c 100644 --- a/tests/unit/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory.ts +++ b/tests/unit/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory.ts @@ -1,7 +1,7 @@ import { describe, } from 'vitest'; -import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { EnvironmentVariablesFactory, type EnvironmentVariablesValidator } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; import { ViteEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentVariables'; import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; @@ -9,7 +9,7 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists'; describe('EnvironmentVariablesFactory', () => { describe('instance', () => { - itIsSingleton({ + itIsSingletonFactory({ getter: () => EnvironmentVariablesFactory.Current.instance, expectedType: ViteEnvironmentVariables, }); diff --git a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts index d9f16e627..767594aaa 100644 --- a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts +++ b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts @@ -3,8 +3,9 @@ import { VueDependencyInjectionApiStub } from '@tests/unit/shared/Stubs/VueDepen import { InjectionKeys } from '@/presentation/injectionSymbols'; import { provideDependencies, type VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider'; import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub'; -import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import type { IApplicationContext } from '@/application/Context/IApplicationContext'; +import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests'; describe('DependencyProvider', () => { describe('provideDependencies', () => { @@ -46,19 +47,22 @@ function createTransientTests() { const registeredObject = api.inject(injectionKey); expect(registeredObject).to.be.instanceOf(Function); }); - it('should return different instances for transient dependency', () => { + describe('should return different instances for transient dependency', () => { // arrange const api = new VueDependencyInjectionApiStub(); - // act new ProvideDependenciesBuilder() .withApi(api) .provideDependencies(); - // expect - const registeredObject = api.inject(injectionKey); - const factory = registeredObject as () => unknown; - const firstResult = factory(); - const secondResult = factory(); - expect(firstResult).to.not.equal(secondResult); + // act + const getFactoryResult = () => { + const registeredObject = api.inject(injectionKey); + const factory = registeredObject as () => unknown; + return factory(); + }; + // assert + itIsTransientFactory({ + getter: getFactoryResult, + }); }); }; } @@ -87,7 +91,7 @@ function createSingletonTests() { // act const getRegisteredInstance = () => api.inject(injectionKey); // assert - itIsSingleton({ + itIsSingletonFactory({ getter: getRegisteredInstance, }); }); diff --git a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts index 5e99c92ed..aee90dbc5 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory.spec.ts @@ -6,8 +6,7 @@ import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollect import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { getCategoryNodeId, getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; -import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; +import { type NodeMetadata, NodeType } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; describe('ReverterFactory', () => { describe('getReverter', () => { diff --git a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts index b26925666..1ea61c427 100644 --- a/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts +++ b/tests/unit/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter.spec.ts @@ -8,7 +8,7 @@ import { getCategoryId, getCategoryNodeId, getScriptId, getScriptNodeId, parseAllCategories, parseSingleCategory, } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; +import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; import type { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; @@ -109,7 +109,7 @@ function isReversible(category: ICategory): boolean { } function expectSameCategory(node: NodeMetadata, category: ICategory): void { - expect(node.type).to.equal(NodeType.Category, getErrorMessage('type')); + expect(node.type).to.equal(NodeDataType.Category, getErrorMessage('type')); expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id')); expect(node.docs).to.equal(category.docs, getErrorMessage('docs')); expect(node.text).to.equal(category.name, getErrorMessage('name')); @@ -136,7 +136,7 @@ function expectSameCategory(node: NodeMetadata, category: ICategory): void { } function expectSameScript(node: NodeMetadata, script: IScript): void { - expect(node.type).to.equal(NodeType.Script, getErrorMessage('type')); + expect(node.type).to.equal(NodeDataType.Script, getErrorMessage('type')); expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id')); expect(node.docs).to.equal(script.docs, getErrorMessage('docs')); expect(node.text).to.equal(script.name, getErrorMessage('name')); diff --git a/tests/unit/presentation/components/Shared/Hooks/Log/ClientLoggerFactory.spec.ts b/tests/unit/presentation/components/Shared/Hooks/Log/ClientLoggerFactory.spec.ts index 73be59138..2a15a9eae 100644 --- a/tests/unit/presentation/components/Shared/Hooks/Log/ClientLoggerFactory.spec.ts +++ b/tests/unit/presentation/components/Shared/Hooks/Log/ClientLoggerFactory.spec.ts @@ -3,14 +3,14 @@ import { describe, it } from 'vitest'; import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import type { Logger } from '@/application/Common/Log/Logger'; import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; -import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; +import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { ClientLoggerFactory, type LoggerCreationFunction, type WindowAccessor } from '@/presentation/components/Shared/Hooks/Log/ClientLoggerFactory'; describe('ClientLoggerFactory', () => { describe('Current', () => { describe('singleton behavior', () => { - itIsSingleton({ + itIsSingletonFactory({ getter: () => ClientLoggerFactory.Current, expectedType: ClientLoggerFactory, }); diff --git a/tests/unit/shared/ExceptionCollector.ts b/tests/unit/shared/ExceptionCollector.ts index ec791c091..f25feba00 100644 --- a/tests/unit/shared/ExceptionCollector.ts +++ b/tests/unit/shared/ExceptionCollector.ts @@ -1,12 +1,18 @@ export function collectExceptionMessage(action: () => unknown): string { - let message: string | undefined; + return collectException(action).message; +} + +function collectException( + action: () => unknown, +): Error { + let error: Error | undefined; try { action(); - } catch (e) { - message = e.message; + } catch (err) { + error = err; } - if (!message) { + if (!error) { throw new Error('action did not throw'); } - return message; + return error; } diff --git a/tests/unit/shared/Stubs/CategoryFactoryStub.ts b/tests/unit/shared/Stubs/CategoryFactoryStub.ts new file mode 100644 index 000000000..2fb0d8782 --- /dev/null +++ b/tests/unit/shared/Stubs/CategoryFactoryStub.ts @@ -0,0 +1,19 @@ +import type { CategoryFactory } from '@/application/Parser/CategoryParser'; +import type { CategoryInitParameters } from '@/domain/Category'; +import type { ICategory } from '@/domain/ICategory'; +import { CategoryStub } from './CategoryStub'; + +export function createCategoryFactorySpy(): { + readonly categoryFactorySpy: CategoryFactory; + getInitParameters: (category: ICategory) => CategoryInitParameters | undefined; +} { + const createdCategories = new Map<ICategory, CategoryInitParameters>(); + return { + categoryFactorySpy: (parameters) => { + const category = new CategoryStub(55); + createdCategories.set(category, parameters); + return category; + }, + getInitParameters: (category) => createdCategories.get(category), + }; +} diff --git a/tests/unit/shared/Stubs/CodeValidatorStub.ts b/tests/unit/shared/Stubs/CodeValidatorStub.ts index 553677a9b..0df804777 100644 --- a/tests/unit/shared/Stubs/CodeValidatorStub.ts +++ b/tests/unit/shared/Stubs/CodeValidatorStub.ts @@ -19,16 +19,16 @@ export class CodeValidatorStub implements ICodeValidator { }); } - public assertHistory(expected: { + public assertHistory(expectation: { validatedCodes: readonly (string | undefined)[], rules: readonly Constructible<ICodeValidationRule>[], }) { - expect(this.callHistory).to.have.lengthOf(expected.validatedCodes.length); + expect(this.callHistory).to.have.lengthOf(expectation.validatedCodes.length); const actualValidatedCodes = this.callHistory.map((args) => args.code); - expect(actualValidatedCodes.sort()).deep.equal([...expected.validatedCodes].sort()); + expect(actualValidatedCodes.sort()).deep.equal([...expectation.validatedCodes].sort()); for (const call of this.callHistory) { const actualRules = call.rules.map((rule) => rule.constructor); - expect(actualRules.sort()).to.deep.equal([...expected.rules].sort()); + expect(actualRules.sort()).to.deep.equal([...expectation.rules].sort()); } } } diff --git a/tests/unit/shared/Stubs/ErrorWithContextWrapperStub.ts b/tests/unit/shared/Stubs/ErrorWithContextWrapperStub.ts new file mode 100644 index 000000000..10aadeae3 --- /dev/null +++ b/tests/unit/shared/Stubs/ErrorWithContextWrapperStub.ts @@ -0,0 +1,4 @@ +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; + +export const errorWithContextWrapperStub +: ErrorWithContextWrapper = (error, message) => new Error(`[stubbed error wrapper] ${error.message} + ${message}`); diff --git a/tests/unit/shared/Stubs/ErrorWrapperStub.ts b/tests/unit/shared/Stubs/ErrorWrapperStub.ts new file mode 100644 index 000000000..080d5b376 --- /dev/null +++ b/tests/unit/shared/Stubs/ErrorWrapperStub.ts @@ -0,0 +1,67 @@ +import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; + +export class ErrorWrapperStub { + private errorToReturn: Error | undefined; + + private parameters?: Parameters<ErrorWithContextWrapper>; + + public get lastError(): Error | undefined { + if (!this.parameters) { + return undefined; + } + return getError(this.parameters); + } + + public get lastContext(): string | undefined { + if (!this.parameters) { + return undefined; + } + return getAdditionalContext(this.parameters); + } + + public withError(error: Error): this { + this.errorToReturn = error; + return this; + } + + public get(): ErrorWithContextWrapper { + return (...args) => { + this.parameters = args; + if (this.errorToReturn) { + return this.errorToReturn; + } + return new Error( + `[${ErrorWrapperStub.name}] Error wrapped with additional context.` + + `\nAdditional context: ${getAdditionalContext(args)}` + + `\nWrapped error message: ${getError(args).message}` + + `\nWrapped error stack trace:\n${getLimitedStackTrace(getError(args), 5)}`, + ); + }; + } +} + +function getAdditionalContext( + parameters: Parameters<ErrorWithContextWrapper>, +): string { + return parameters[1]; +} + +function getError( + parameters: Parameters<ErrorWithContextWrapper>, +): Error { + return parameters[0]; +} + +function getLimitedStackTrace( + error: Error, + limit: number, +): string { + const { stack } = error; + if (!stack) { + return 'No stack trace available'; + } + return stack + .split('\n') + .slice(0, limit + 1) + .join('\n'); +} diff --git a/tests/unit/shared/Stubs/FunctionCallCompilerStub.ts b/tests/unit/shared/Stubs/FunctionCallCompilerStub.ts index 03a0a3d8c..4c21ca187 100644 --- a/tests/unit/shared/Stubs/FunctionCallCompilerStub.ts +++ b/tests/unit/shared/Stubs/FunctionCallCompilerStub.ts @@ -2,22 +2,33 @@ import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler'; import type { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; +import { CompiledCodeStub } from './CompiledCodeStub'; -interface IScenario { - calls: FunctionCall[]; - functions: ISharedFunctionCollection; - result: CompiledCode; +interface FunctionCallCompilationTestScenario { + readonly calls: FunctionCall[]; + readonly functions: ISharedFunctionCollection; + readonly result: CompiledCode; } export class FunctionCallCompilerStub implements FunctionCallCompiler { - public scenarios = new Array<IScenario>(); + public scenarios = new Array<FunctionCallCompilationTestScenario>(); + + private defaultCompiledCode: CompiledCode = new CompiledCodeStub() + .withCode(`[${FunctionCallCompilerStub.name}] function code`) + .withRevertCode(`[${FunctionCallCompilerStub.name}] function revert code`); public setup( calls: FunctionCall[], functions: ISharedFunctionCollection, result: CompiledCode, - ) { + ): this { this.scenarios.push({ calls, functions, result }); + return this; + } + + public withDefaultCompiledCode(defaultCompiledCode: CompiledCode): this { + this.defaultCompiledCode = defaultCompiledCode; + return this; } public compileFunctionCalls( @@ -29,10 +40,7 @@ export class FunctionCallCompilerStub implements FunctionCallCompiler { if (predefined) { return predefined.result; } - return { - code: 'function code [FunctionCallCompilerStub]', - revertCode: 'function revert code [FunctionCallCompilerStub]', - }; + return this.defaultCompiledCode; } } diff --git a/tests/unit/shared/Stubs/FunctionParameterCollectionStub.ts b/tests/unit/shared/Stubs/FunctionParameterCollectionStub.ts index db3575e49..e4eb62fe5 100644 --- a/tests/unit/shared/Stubs/FunctionParameterCollectionStub.ts +++ b/tests/unit/shared/Stubs/FunctionParameterCollectionStub.ts @@ -16,7 +16,7 @@ export class FunctionParameterCollectionStub implements IFunctionParameterCollec public withParameterName(parameterName: string, isOptional = true) { const parameter = new FunctionParameterStub() .withName(parameterName) - .withOptionality(isOptional); + .withOptional(isOptional); this.addParameter(parameter); return this; } diff --git a/tests/unit/shared/Stubs/FunctionParameterStub.ts b/tests/unit/shared/Stubs/FunctionParameterStub.ts index 5f65c17d8..2ddb7df16 100644 --- a/tests/unit/shared/Stubs/FunctionParameterStub.ts +++ b/tests/unit/shared/Stubs/FunctionParameterStub.ts @@ -10,7 +10,7 @@ export class FunctionParameterStub implements IFunctionParameter { return this; } - public withOptionality(isOptional: boolean) { + public withOptional(isOptional: boolean) { this.isOptional = isOptional; return this; } diff --git a/tests/unit/shared/Stubs/NodeDataErrorContextStub.ts b/tests/unit/shared/Stubs/NodeDataErrorContextStub.ts index be2ea6763..eb49c784c 100644 --- a/tests/unit/shared/Stubs/NodeDataErrorContextStub.ts +++ b/tests/unit/shared/Stubs/NodeDataErrorContextStub.ts @@ -1,12 +1,11 @@ -import type { NodeData } from '@/application/Parser/NodeValidation/NodeData'; -import type { INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError'; -import { NodeType } from '@/application/Parser/NodeValidation/NodeType'; +import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext'; +import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType'; import { CategoryDataStub } from './CategoryDataStub'; -export class NodeDataErrorContextStub implements INodeDataErrorContext { - public readonly type: NodeType = NodeType.Script; - - public readonly selfNode: NodeData = new CategoryDataStub(); - - public readonly parentNode?: NodeData; +export function createNodeDataErrorContextStub(): NodeDataErrorContext { + return { + type: NodeDataType.Category, + selfNode: new CategoryDataStub(), + parentNode: new CategoryDataStub(), + }; } diff --git a/tests/unit/shared/Stubs/NodeDataValidatorStub.ts b/tests/unit/shared/Stubs/NodeDataValidatorStub.ts new file mode 100644 index 000000000..983b8e147 --- /dev/null +++ b/tests/unit/shared/Stubs/NodeDataValidatorStub.ts @@ -0,0 +1,57 @@ +import type { NodeData } from '@/application/Parser/NodeValidation/NodeData'; +import type { NodeDataValidator, NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; + +export const createNodeDataValidatorFactoryStub +: NodeDataValidatorFactory = () => new NodeDataValidatorStub(); + +export class NodeDataValidatorStub + extends StubWithObservableMethodCalls<NodeDataValidator> + implements NodeDataValidator { + private assertThrowsOnFalseCondition = true; + + public withAssertThrowsOnFalseCondition(enableAssertThrows: boolean): this { + this.assertThrowsOnFalseCondition = enableAssertThrows; + return this; + } + + public assertValidName(nameValue: string): this { + this.registerMethodCall({ + methodName: 'assertValidName', + args: [nameValue], + }); + return this; + } + + public assertDefined(node: NodeData): this { + this.registerMethodCall({ + methodName: 'assertDefined', + args: [node], + }); + return this; + } + + public assert( + validationPredicate: () => boolean, + errorMessage: string, + ): this { + this.registerMethodCall({ + methodName: 'assert', + args: [validationPredicate, errorMessage], + }); + if (this.assertThrowsOnFalseCondition) { + if (!validationPredicate()) { + throw new Error(`[${NodeDataValidatorStub.name}] Assert validation failed: ${errorMessage}`); + } + } + return this; + } + + public createContextualErrorMessage(errorMessage: string): string { + this.registerMethodCall({ + methodName: 'createContextualErrorMessage', + args: [errorMessage], + }); + return `${NodeDataValidatorStub.name}: ${errorMessage}`; + } +} diff --git a/tests/unit/shared/Stubs/ScriptCodeFactoryStub.ts b/tests/unit/shared/Stubs/ScriptCodeFactoryStub.ts new file mode 100644 index 000000000..9675740e0 --- /dev/null +++ b/tests/unit/shared/Stubs/ScriptCodeFactoryStub.ts @@ -0,0 +1,22 @@ +import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory'; +import type { IScriptCode } from '@/domain/IScriptCode'; +import { ScriptCodeStub } from './ScriptCodeStub'; + +export function createScriptCodeFactoryStub( + options?: Partial<StubOptions>, +): ScriptCodeFactory { + let defaultCodePrefix = 'createScriptCodeFactoryStub'; + if (options?.defaultCodePrefix) { + defaultCodePrefix += ` > ${options?.defaultCodePrefix}`; + } + return ( + () => options?.scriptCode ?? new ScriptCodeStub() + .withExecute(`[${defaultCodePrefix}] default code`) + .withRevert(`[${defaultCodePrefix}] revert code`) + ); +} + +interface StubOptions { + readonly scriptCode?: IScriptCode; + readonly defaultCodePrefix?: string; +} diff --git a/tests/unit/shared/Stubs/ScriptCodeStub.ts b/tests/unit/shared/Stubs/ScriptCodeStub.ts index dc9f879f4..d77a7413c 100644 --- a/tests/unit/shared/Stubs/ScriptCodeStub.ts +++ b/tests/unit/shared/Stubs/ScriptCodeStub.ts @@ -1,9 +1,9 @@ import type { IScriptCode } from '@/domain/IScriptCode'; export class ScriptCodeStub implements IScriptCode { - public execute = 'default execute code'; + public execute = `[${ScriptCodeStub.name}] default execute code`; - public revert = 'default revert code'; + public revert = `[${ScriptCodeStub.name}] default revert code`; public withExecute(code: string) { this.execute = code; diff --git a/tests/unit/shared/Stubs/ScriptFactoryStub.ts b/tests/unit/shared/Stubs/ScriptFactoryStub.ts new file mode 100644 index 000000000..b3a28f62b --- /dev/null +++ b/tests/unit/shared/Stubs/ScriptFactoryStub.ts @@ -0,0 +1,19 @@ +import type { ScriptFactory } from '@/application/Parser/Script/ScriptParser'; +import type { IScript } from '@/domain/IScript'; +import type { ScriptInitParameters } from '@/domain/Script'; +import { ScriptStub } from './ScriptStub'; + +export function createScriptFactorySpy(): { + readonly scriptFactorySpy: ScriptFactory; + getInitParameters: (category: IScript) => ScriptInitParameters | undefined; +} { + const createdScripts = new Map<IScript, ScriptInitParameters>(); + return { + scriptFactorySpy: (parameters) => { + const script = new ScriptStub('script from factory stub'); + createdScripts.set(script, parameters); + return script; + }, + getInitParameters: (script) => createdScripts.get(script), + }; +} diff --git a/tests/unit/shared/Stubs/ScriptParserStub.ts b/tests/unit/shared/Stubs/ScriptParserStub.ts new file mode 100644 index 000000000..217dbd211 --- /dev/null +++ b/tests/unit/shared/Stubs/ScriptParserStub.ts @@ -0,0 +1,37 @@ +import type { ScriptParser } from '@/application/Parser/Script/ScriptParser'; +import type { IScript } from '@/domain/IScript'; +import type { ScriptData } from '@/application/collections/'; +import { ScriptStub } from './ScriptStub'; + +export class ScriptParserStub { + private readonly parsedScripts = new Map<IScript, Parameters<ScriptParser>>(); + + private readonly setupScripts = new Map<ScriptData, IScript>(); + + public get(): ScriptParser { + return (...parameters) => { + const [scriptData] = parameters; + const script = this.setupScripts.get(scriptData) + ?? new ScriptStub( + `[${ScriptParserStub.name}] parsed script stub number ${this.parsedScripts.size + 1}`, + ); + this.parsedScripts.set(script, parameters); + return script; + }; + } + + public getParseParameters( + script: IScript, + ): Parameters<ScriptParser> { + const parameters = this.parsedScripts.get(script); + if (!parameters) { + throw new Error('Script has never been parsed.'); + } + return parameters; + } + + public setupParsedResultForData(scriptData: ScriptData, parsedResult: IScript): this { + this.setupScripts.set(scriptData, parsedResult); + return this; + } +} diff --git a/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts b/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts index e008277c4..5d8f0d8fb 100644 --- a/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts +++ b/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts @@ -5,12 +5,12 @@ import type { FunctionKeys } from '@/TypeHelpers'; export abstract class StubWithObservableMethodCalls<T> { public readonly callHistory = new Array<MethodCall<T>>(); + private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>(); + public get methodCalls(): IEventSource<MethodCall<T>> { return this.notifiableMethodCalls; } - private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>(); - protected registerMethodCall(name: MethodCall<T>) { this.callHistory.push(name); this.notifiableMethodCalls.notify(name); diff --git a/tests/unit/shared/TestCases/SingletonTests.ts b/tests/unit/shared/TestCases/SingletonFactoryTests.ts similarity index 84% rename from tests/unit/shared/TestCases/SingletonTests.ts rename to tests/unit/shared/TestCases/SingletonFactoryTests.ts index 5bab0586d..52779a458 100644 --- a/tests/unit/shared/TestCases/SingletonTests.ts +++ b/tests/unit/shared/TestCases/SingletonFactoryTests.ts @@ -1,12 +1,12 @@ import { it, expect } from 'vitest'; import type { Constructible } from '@/TypeHelpers'; -interface ISingletonTestData<T> { +interface SingletonTestData<T> { readonly getter: () => T; readonly expectedType?: Constructible<T>; } -export function itIsSingleton<T>(test: ISingletonTestData<T>): void { +export function itIsSingletonFactory<T>(test: SingletonTestData<T>): void { if (test.expectedType !== undefined) { it('gets the expected type', () => { // act diff --git a/tests/unit/shared/TestCases/TransientFactoryTests.ts b/tests/unit/shared/TestCases/TransientFactoryTests.ts new file mode 100644 index 000000000..afdfb7b35 --- /dev/null +++ b/tests/unit/shared/TestCases/TransientFactoryTests.ts @@ -0,0 +1,24 @@ +import type { Constructible } from '@/TypeHelpers'; + +interface TransientFactoryTestData<T> { + readonly getter: () => T; + readonly expectedType?: Constructible<T>; +} + +export function itIsTransientFactory<T>(test: TransientFactoryTestData<T>): void { + if (test.expectedType !== undefined) { + it('gets the expected type', () => { + // act + const instance = test.getter(); + // assert + expect(instance).to.be.instanceOf(test.expectedType); + }); + } + it('multiple calls get different instances', () => { + // act + const instance1 = test.getter(); + const instance2 = test.getter(); + // assert + expect(instance1).to.not.equal(instance2); + }); +}