Add object property validation in parser #369

This commit introduces stricter type validation across the application
to reject objects with unexpected properties, enhancing the robustness
and predictability of data handling.

Changes include:

- Implement a common utility to validate object types.
- Refactor across various parsers and data handlers to utilize the new
  validations.
- Update error messages for better clarity and troubleshooting.
This commit is contained in:
undergroundwires
2024-06-13 22:26:57 +02:00
parent c138f74460
commit 6ecfa9b954
43 changed files with 1215 additions and 466 deletions

View File

@@ -5,13 +5,13 @@ export type EnumType = number | string;
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType> export type EnumVariable<T extends EnumType, TEnumValue extends EnumType>
= { [key in T]: TEnumValue }; = { [key in T]: TEnumValue };
export interface IEnumParser<TEnum> { export interface EnumParser<TEnum> {
parseEnum(value: string, propertyName: string): TEnum; parseEnum(value: string, propertyName: string): TEnum;
} }
export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>( export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): IEnumParser<TEnumValue> { ): EnumParser<TEnumValue> {
return { return {
parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable), parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable),
}; };

View File

@@ -1,40 +1,48 @@
import type { CollectionData } from '@/application/collections/'; import type { CollectionData } from '@/application/collections/';
import type { IApplication } from '@/domain/IApplication'; import type { IApplication } from '@/domain/IApplication';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import WindowsData from '@/application/collections/windows.yaml'; import WindowsData from '@/application/collections/windows.yaml';
import MacOsData from '@/application/collections/macos.yaml'; import MacOsData from '@/application/collections/macos.yaml';
import LinuxData from '@/application/collections/linux.yaml'; import LinuxData from '@/application/collections/linux.yaml';
import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser'; import { parseProjectDetails, type ProjectDetailsParser } from '@/application/Parser/ProjectDetailsParser';
import { Application } from '@/domain/Application'; import { Application } from '@/domain/Application';
import type { IAppMetadata } from '@/infrastructure/EnvironmentVariables/IAppMetadata'; import { parseCategoryCollection, type CategoryCollectionParser } from './CategoryCollectionParser';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication( export function parseApplication(
categoryParser = parseCategoryCollection, collectionsData: readonly CollectionData[] = PreParsedCollections,
projectDetailsParser = parseProjectDetails, utilities: ApplicationParserUtilities = DefaultUtilities,
metadata: IAppMetadata = EnvironmentVariablesFactory.Current.instance,
collectionsData = PreParsedCollections,
): IApplication { ): IApplication {
validateCollectionsData(collectionsData); validateCollectionsData(collectionsData, utilities.validator);
const projectDetails = projectDetailsParser(metadata); const projectDetails = utilities.parseProjectDetails();
const collections = collectionsData.map( const collections = collectionsData.map(
(collection) => categoryParser(collection, projectDetails), (collection) => utilities.parseCategoryCollection(collection, projectDetails),
); );
const app = new Application(projectDetails, collections); const app = new Application(projectDetails, collections);
return app; return app;
} }
export type CategoryCollectionParserType
= (file: CollectionData, projectDetails: ProjectDetails) => ICategoryCollection;
const PreParsedCollections: readonly CollectionData[] = [ const PreParsedCollections: readonly CollectionData[] = [
WindowsData, MacOsData, LinuxData, WindowsData, MacOsData, LinuxData,
]; ];
function validateCollectionsData(collections: readonly CollectionData[]) { function validateCollectionsData(
if (!collections.length) { collections: readonly CollectionData[],
throw new Error('missing collections'); validator: TypeValidator,
) {
validator.assertNonEmptyCollection({
value: collections,
valueName: 'collections',
});
} }
interface ApplicationParserUtilities {
readonly parseCategoryCollection: CategoryCollectionParser;
readonly validator: TypeValidator;
readonly parseProjectDetails: ProjectDetailsParser;
} }
const DefaultUtilities: ApplicationParserUtilities = {
parseCategoryCollection,
parseProjectDetails,
validator: createTypeValidator(),
};

View File

@@ -3,33 +3,73 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection'; import { CategoryCollection } from '@/domain/CategoryCollection';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { createEnumParser } from '../Common/Enum'; import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory } from './Executable/CategoryParser'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities'; import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities';
export function parseCategoryCollection( export const parseCategoryCollection: CategoryCollectionParser = (
content,
projectDetails,
utilities: CategoryCollectionParserUtilities = DefaultUtilities,
) => {
validateCollection(content, utilities.validator);
const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails);
const collectionUtilities = utilities.createUtilities(content.functions, scripting);
const categories = content.actions.map(
(action) => utilities.parseCategory(action, collectionUtilities),
);
const os = utilities.osParser.parseEnum(content.os, 'os');
const collection = utilities.createCategoryCollection({
os, actions: categories, scripting,
});
return collection;
};
export type CategoryCollectionFactory = (
...parameters: ConstructorParameters<typeof CategoryCollection>
) => ICategoryCollection;
export interface CategoryCollectionParser {
(
content: CollectionData, content: CollectionData,
projectDetails: ProjectDetails, projectDetails: ProjectDetails,
osParser = createEnumParser(OperatingSystem), utilities?: CategoryCollectionParserUtilities,
createUtilities: CategoryCollectionSpecificUtilitiesFactory = createCollectionUtilities, ): ICategoryCollection;
): ICategoryCollection {
validate(content);
const scripting = new ScriptingDefinitionParser()
.parse(content.scripting, projectDetails);
const utilities = createUtilities(content.functions, scripting);
const categories = content.actions.map((action) => parseCategory(action, utilities));
const os = osParser.parseEnum(content.os, 'os');
const collection = new CategoryCollection(
os,
categories,
scripting,
);
return collection;
} }
function validate(content: CollectionData): void { function validateCollection(
if (!content.actions.length) { content: CollectionData,
throw new Error('content does not define any action'); validator: TypeValidator,
): void {
validator.assertObject({
value: content,
valueName: 'collection',
allowedProperties: [
'os', 'scripting', 'actions', 'functions',
],
});
validator.assertNonEmptyCollection({
value: content.actions,
valueName: '"actions" in collection',
});
} }
interface CategoryCollectionParserUtilities {
readonly osParser: EnumParser<OperatingSystem>;
readonly validator: TypeValidator;
readonly parseScriptingDefinition: ScriptingDefinitionParser;
readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory;
readonly parseCategory: CategoryParser;
readonly createCategoryCollection: CategoryCollectionFactory;
} }
const DefaultUtilities: CategoryCollectionParserUtilities = {
osParser: createEnumParser(OperatingSystem),
validator: createTypeValidator(),
parseScriptingDefinition,
createUtilities: createCollectionUtilities,
parseCategory,
createCategoryCollection: (...args) => new CategoryCollection(...args),
};

View File

@@ -0,0 +1,96 @@
import type { PropertyKeys } from '@/TypeHelpers';
import { isNullOrUndefined, isArray, isPlainObject } from '@/TypeHelpers';
export interface TypeValidator {
assertObject<T>(assertion: ObjectAssertion<T>): void;
assertNonEmptyCollection(assertion: NonEmptyCollectionAssertion): void;
}
export interface NonEmptyCollectionAssertion {
readonly value: unknown;
readonly valueName: string;
}
export interface ObjectAssertion<T> {
readonly value: T | unknown;
readonly valueName: string;
readonly allowedProperties?: readonly PropertyKeys<T>[];
}
export function createTypeValidator(): TypeValidator {
return {
assertObject: (assertion) => {
assertDefined(assertion.value, assertion.valueName);
assertPlainObject(assertion.value, assertion.valueName);
assertNoEmptyProperties(assertion.value, assertion.valueName);
if (assertion.allowedProperties !== undefined) {
const allowedProperties = assertion.allowedProperties.map((p) => p as string);
assertAllowedProperties(assertion.value, assertion.valueName, allowedProperties);
}
},
assertNonEmptyCollection: (assertion) => {
assertDefined(assertion.value, assertion.valueName);
assertArray(assertion.value, assertion.valueName);
assertNonEmpty(assertion.value, assertion.valueName);
},
};
}
function assertDefined<T>(
value: T,
valueName: string,
): asserts value is NonNullable<T> {
if (isNullOrUndefined(value)) {
throw new Error(`'${valueName}' is missing.`);
}
}
function assertPlainObject(
value: unknown,
valueName: string,
): asserts value is object {
if (!isPlainObject(value)) {
throw new Error(`'${valueName}' is not an object.`);
}
}
function assertNoEmptyProperties(
value: object,
valueName: string,
): void {
if (Object.keys(value).length === 0) {
throw new Error(`'${valueName}' is an empty object without properties.`);
}
}
function assertAllowedProperties(
value: object,
valueName: string,
allowedProperties: readonly string[],
): void {
const properties = Object.keys(value).map((p) => p as string);
const disallowedProperties = properties.filter(
(prop) => !allowedProperties.map((p) => p as string).includes(prop),
);
if (disallowedProperties.length > 0) {
throw new Error(`'${valueName}' has disallowed properties: ${disallowedProperties.join(', ')}.`);
}
}
function assertArray(
value: unknown,
valueName: string,
): asserts value is Array<unknown> {
if (!isArray(value)) {
throw new Error(`'${valueName}' should be of type 'array', but is of type '${typeof value}'.`);
}
}
function assertNonEmpty(
value: Array<unknown>,
valueName: string,
): void {
if (value.length === 0) {
throw new Error(`'${valueName}' cannot be an empty array.`);
}
}

View File

@@ -1,7 +1,7 @@
import type { import type {
CategoryData, ScriptData, ExecutableData, CategoryData, ScriptData, ExecutableData,
} from '@/application/collections/'; } from '@/application/collections/';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { Category } from '@/domain/Executables/Category/Category'; import type { Category } from '@/domain/Executables/Category/Category';
import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory'; import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
@@ -13,16 +13,24 @@ import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSp
let categoryIdCounter = 0; let categoryIdCounter = 0;
export function parseCategory( export const parseCategory: CategoryParser = (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionUtilities: CategoryCollectionSpecificUtilities,
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
): Category { ) => {
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
collectionUtilities, collectionUtilities,
categoryUtilities, categoryUtilities,
}); });
};
export interface CategoryParser {
(
category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities,
categoryUtilities?: CategoryParserUtilities,
): Category;
} }
interface CategoryParseContext { interface CategoryParseContext {
@@ -41,7 +49,7 @@ function parseCategoryRecursively(
subscripts: new Array<Script>(), subscripts: new Array<Script>(),
}; };
for (const data of context.categoryData.children) { for (const data of context.categoryData.children) {
parseExecutable({ parseUnknownExecutable({
data, data,
children, children,
parent: context.categoryData, parent: context.categoryData,
@@ -74,12 +82,18 @@ function ensureValidCategory(
self: context.categoryData, self: context.categoryData,
parentCategory: context.parentCategory, parentCategory: context.parentCategory,
}); });
validator.assertDefined(category); validator.assertType((v) => v.assertObject({
value: category,
valueName: category.category ?? 'category',
allowedProperties: [
'docs', 'children', 'category',
],
}));
validator.assertValidName(category.category); validator.assertValidName(category.category);
validator.assert( validator.assertType((v) => v.assertNonEmptyCollection({
() => Boolean(category.children) && category.children.length > 0, value: category.children,
`"${category.category}" has no children.`, valueName: category.category,
); }));
return validator; return validator;
} }
@@ -96,12 +110,15 @@ interface ExecutableParseContext {
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
function parseExecutable(context: ExecutableParseContext) { function parseUnknownExecutable(context: ExecutableParseContext) {
const validator: ExecutableValidator = context.categoryUtilities.createValidator({ const validator: ExecutableValidator = context.categoryUtilities.createValidator({
self: context.data, self: context.data,
parentCategory: context.parent, parentCategory: context.parent,
}); });
validator.assertDefined(context.data); validator.assertType((v) => v.assertObject({
value: context.data,
valueName: 'Executable',
}));
validator.assert( validator.assert(
() => isCategory(context.data) || isScript(context.data), () => isCategory(context.data) || isScript(context.data),
'Executable is neither a category or a script.', 'Executable is neither a category or a script.',

View File

@@ -50,5 +50,5 @@ class DocumentationContainer {
} }
function throwInvalidType(): never { function throwInvalidType(): never {
throw new Error('docs field (documentation) must be an array of strings'); throw new Error('docs field (documentation) must be a single string or an array of strings.');
} }

View File

@@ -1,4 +1,4 @@
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { Expression, type ExpressionEvaluator } from '../../Expression/Expression'; import { Expression, type ExpressionEvaluator } from '../../Expression/Expression';
import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory'; import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory';
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory'; import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory';

View File

@@ -6,7 +6,7 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import { ParsedFunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/ParsedFunctionCall'; import { ParsedFunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/ParsedFunctionCall';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ArgumentCompiler } from './ArgumentCompiler'; import type { ArgumentCompiler } from './ArgumentCompiler';
export class NestedFunctionArgumentCompiler implements ArgumentCompiler { export class NestedFunctionArgumentCompiler implements ArgumentCompiler {

View File

@@ -5,7 +5,7 @@ import {
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { NestedFunctionArgumentCompiler } from './Argument/NestedFunctionArgumentCompiler'; import { NestedFunctionArgumentCompiler } from './Argument/NestedFunctionArgumentCompiler';
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy'; import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
import type { ArgumentCompiler } from './Argument/ArgumentCompiler'; import type { ArgumentCompiler } from './Argument/ArgumentCompiler';

View File

@@ -8,7 +8,7 @@ import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection'; import { SharedFunctionCollection } from './SharedFunctionCollection';
import { FunctionParameter } from './Parameter/FunctionParameter'; import { FunctionParameter } from './Parameter/FunctionParameter';

View File

@@ -4,7 +4,7 @@ import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Val
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { SharedFunctionsParser } from './Function/SharedFunctionsParser'; import { SharedFunctionsParser } from './Function/SharedFunctionsParser';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler'; import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';

View File

@@ -5,11 +5,11 @@ import { CollectionScript } from '@/domain/Executables/Script/CollectionScript';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type IEnumParser } from '@/application/Common/Enum'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { parseDocs, type DocsParser } from '../DocumentationParser'; import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType'; import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
@@ -28,23 +28,28 @@ export interface ScriptParser {
export const parseScript: ScriptParser = ( export const parseScript: ScriptParser = (
data, data,
collectionUtilities, collectionUtilities,
utilities = DefaultScriptParserUtilities, scriptUtilities = DefaultUtilities,
) => { ) => {
const validator = utilities.createValidator({ const validator = scriptUtilities.createValidator({
type: ExecutableType.Script, type: ExecutableType.Script,
self: data, self: data,
}); });
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = utilities.createScript({ const script = scriptUtilities.createScript({
name: data.name, name: data.name,
code: parseCode(data, collectionUtilities, utilities.codeValidator, utilities.createCode), code: parseCode(
docs: utilities.parseDocs(data), data,
level: parseLevel(data.recommend, utilities.levelParser), collectionUtilities,
scriptUtilities.codeValidator,
scriptUtilities.createCode,
),
docs: scriptUtilities.parseDocs(data),
level: parseLevel(data.recommend, scriptUtilities.levelParser),
}); });
return script; return script;
} catch (error) { } catch (error) {
throw utilities.wrapError( throw scriptUtilities.wrapError(
error, error,
validator.createContextualErrorMessage('Failed to parse script.'), validator.createContextualErrorMessage('Failed to parse script.'),
); );
@@ -53,7 +58,7 @@ export const parseScript: ScriptParser = (
function parseLevel( function parseLevel(
level: string | undefined, level: string | undefined,
parser: IEnumParser<RecommendationLevel>, parser: EnumParser<RecommendationLevel>,
): RecommendationLevel | undefined { ): RecommendationLevel | undefined {
if (!level) { if (!level) {
return undefined; return undefined;
@@ -95,7 +100,13 @@ function validateScript(
script: ScriptData, script: ScriptData,
validator: ExecutableValidator, validator: ExecutableValidator,
): asserts script is NonNullable<ScriptData> { ): asserts script is NonNullable<ScriptData> {
validator.assertDefined(script); validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
value: script,
valueName: script.name ?? 'script',
allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
],
}));
validator.assertValidName(script.name); validator.assertValidName(script.name);
validator.assert( validator.assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call), () => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
@@ -112,7 +123,7 @@ function validateScript(
} }
interface ScriptParserUtilities { interface ScriptParserUtilities {
readonly levelParser: IEnumParser<RecommendationLevel>; readonly levelParser: EnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory; readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator; readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
@@ -129,7 +140,7 @@ const createScript: ScriptFactory = (...parameters) => {
return new CollectionScript(...parameters); return new CollectionScript(...parameters);
}; };
const DefaultScriptParserUtilities: ScriptParserUtilities = { const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel), levelParser: createEnumParser(RecommendationLevel),
createScript, createScript,
codeValidator: CodeValidator.instance, codeValidator: CodeValidator.instance,

View File

@@ -1,5 +1,5 @@
import { isString } from '@/TypeHelpers'; import { isString } from '@/TypeHelpers';
import type { ExecutableData } from '@/application/collections/'; import { createTypeValidator, type TypeValidator } from '../../Common/TypeValidator';
import { type ExecutableErrorContext } from './ExecutableErrorContext'; import { type ExecutableErrorContext } from './ExecutableErrorContext';
import { createExecutableContextErrorMessage, type ExecutableContextErrorMessageCreator } from './ExecutableErrorContextMessage'; import { createExecutableContextErrorMessage, type ExecutableContextErrorMessageCreator } from './ExecutableErrorContextMessage';
@@ -7,11 +7,11 @@ export interface ExecutableValidatorFactory {
(context: ExecutableErrorContext): ExecutableValidator; (context: ExecutableErrorContext): ExecutableValidator;
} }
type AssertTypeFunction = (validator: TypeValidator) => void;
export interface ExecutableValidator { export interface ExecutableValidator {
assertValidName(nameValue: string): void; assertValidName(nameValue: string): void;
assertDefined( assertType(assert: AssertTypeFunction): void;
data: ExecutableData | undefined,
): asserts data is NonNullable<ExecutableData> & void;
assert( assert(
validationPredicate: () => boolean, validationPredicate: () => boolean,
errorMessage: string, errorMessage: string,
@@ -27,6 +27,7 @@ export class ContextualExecutableValidator implements ExecutableValidator {
private readonly context: ExecutableErrorContext, private readonly context: ExecutableErrorContext,
private readonly createErrorMessage private readonly createErrorMessage
: ExecutableContextErrorMessageCreator = createExecutableContextErrorMessage, : ExecutableContextErrorMessageCreator = createExecutableContextErrorMessage,
private readonly validator: TypeValidator = createTypeValidator(),
) { ) {
} }
@@ -39,13 +40,12 @@ export class ContextualExecutableValidator implements ExecutableValidator {
); );
} }
public assertDefined( public assertType(assert: AssertTypeFunction): void {
data: ExecutableData, try {
): asserts data is NonNullable<ExecutableData> { assert(this.validator);
this.assert( } catch (error) {
() => data !== undefined && data !== null && Object.keys(data).length > 0, this.throw(error.message);
'missing executable data', }
);
} }
public assert( public assert(

View File

@@ -24,6 +24,10 @@ parseProjectDetails(
); );
} }
export interface ProjectDetailsParser {
(): ProjectDetails;
}
export type ProjectDetailsFactory = ( export type ProjectDetailsFactory = (
...args: ConstructorArguments<typeof GitHubProjectDetails> ...args: ConstructorArguments<typeof GitHubProjectDetails>
) => ProjectDetails; ) => ProjectDetails;

View File

@@ -3,28 +3,54 @@ import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptingDefinition } from '@/domain/ScriptingDefinition'; import { ScriptingDefinition } from '@/domain/ScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { createEnumParser } from '../../Common/Enum'; import { createEnumParser, type EnumParser } from '../../Common/Enum';
import { createTypeValidator, type TypeValidator } from '../Common/TypeValidator';
import { CodeSubstituter } from './CodeSubstituter'; import { CodeSubstituter } from './CodeSubstituter';
import type { ICodeSubstituter } from './ICodeSubstituter'; import type { ICodeSubstituter } from './ICodeSubstituter';
export class ScriptingDefinitionParser { export const parseScriptingDefinition: ScriptingDefinitionParser = (
constructor( definition,
private readonly languageParser = createEnumParser(ScriptingLanguage), projectDetails,
private readonly codeSubstituter: ICodeSubstituter = new CodeSubstituter(), utilities: ScriptingDefinitionParserUtilities = DefaultUtilities,
) { ) => {
} validateData(definition, utilities.validator);
const language = utilities.languageParser.parseEnum(definition.language, 'language');
public parse( const startCode = utilities.codeSubstituter.substitute(definition.startCode, projectDetails);
definition: ScriptingDefinitionData, const endCode = utilities.codeSubstituter.substitute(definition.endCode, projectDetails);
projectDetails: ProjectDetails,
): IScriptingDefinition {
const language = this.languageParser.parseEnum(definition.language, 'language');
const startCode = this.codeSubstituter.substitute(definition.startCode, projectDetails);
const endCode = this.codeSubstituter.substitute(definition.endCode, projectDetails);
return new ScriptingDefinition( return new ScriptingDefinition(
language, language,
startCode, startCode,
endCode, endCode,
); );
};
export interface ScriptingDefinitionParser {
(
definition: ScriptingDefinitionData,
projectDetails: ProjectDetails,
utilities?: ScriptingDefinitionParserUtilities,
): IScriptingDefinition;
} }
function validateData(
data: ScriptingDefinitionData,
validator: TypeValidator,
): void {
validator.assertObject({
value: data,
valueName: 'scripting definition',
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
});
} }
interface ScriptingDefinitionParserUtilities {
readonly languageParser: EnumParser<ScriptingLanguage>;
readonly codeSubstituter: ICodeSubstituter;
readonly validator: TypeValidator;
}
const DefaultUtilities: ScriptingDefinitionParserUtilities = {
languageParser: createEnumParser(ScriptingLanguage),
codeSubstituter: new CodeSubstituter(),
validator: createTypeValidator(),
};

View File

@@ -8,19 +8,27 @@ import type { IScriptingDefinition } from './IScriptingDefinition';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './ICategoryCollection';
export class CategoryCollection implements ICategoryCollection { export class CategoryCollection implements ICategoryCollection {
public readonly os: OperatingSystem;
public readonly actions: ReadonlyArray<Category>;
public readonly scripting: IScriptingDefinition;
public get totalScripts(): number { return this.queryable.allScripts.length; } public get totalScripts(): number { return this.queryable.allScripts.length; }
public get totalCategories(): number { return this.queryable.allCategories.length; } public get totalCategories(): number { return this.queryable.allCategories.length; }
private readonly queryable: IQueryableCollection; private readonly queryable: QueryableCollection;
constructor( constructor(
public readonly os: OperatingSystem, parameters: CategoryCollectionInitParameters,
public readonly actions: ReadonlyArray<Category>,
public readonly scripting: IScriptingDefinition,
) { ) {
this.queryable = makeQueryable(actions); this.os = parameters.os;
assertInRange(os, OperatingSystem); this.actions = parameters.actions;
this.scripting = parameters.scripting;
this.queryable = makeQueryable(this.actions);
assertInRange(this.os, OperatingSystem);
ensureValid(this.queryable); ensureValid(this.queryable);
ensureNoDuplicates(this.queryable.allCategories); ensureNoDuplicates(this.queryable.allCategories);
ensureNoDuplicates(this.queryable.allScripts); ensureNoDuplicates(this.queryable.allScripts);
@@ -72,13 +80,19 @@ function ensureNoDuplicates<TKey>(entities: ReadonlyArray<IEntity<TKey>>) {
} }
} }
interface IQueryableCollection { export interface CategoryCollectionInitParameters {
allCategories: Category[]; readonly os: OperatingSystem;
allScripts: Script[]; readonly actions: ReadonlyArray<Category>;
scriptsByLevel: Map<RecommendationLevel, readonly Script[]>; readonly scripting: IScriptingDefinition;
} }
function ensureValid(application: IQueryableCollection) { interface QueryableCollection {
readonly allCategories: Category[];
readonly allScripts: Script[];
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
}
function ensureValid(application: QueryableCollection) {
ensureValidCategories(application.allCategories); ensureValidCategories(application.allCategories);
ensureValidScripts(application.allScripts); ensureValidScripts(application.allScripts);
} }
@@ -128,7 +142,7 @@ function flattenApplication(
function makeQueryable( function makeQueryable(
actions: ReadonlyArray<Category>, actions: ReadonlyArray<Category>,
): IQueryableCollection { ): QueryableCollection {
const flattened = flattenApplication(actions); const flattened = flattenApplication(actions);
return { return {
allCategories: flattened[0], allCategories: flattened[0],

View File

@@ -0,0 +1,47 @@
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
/**
* Asserts that an array deeply includes a specified item by comparing JSON-serialized versions.
* Designed to be used as the Chai methods 'to.deep.include' and 'to.deep.contain' do not work.
*/
export function expectDeepIncludes<T>(
arrayToSearch: readonly T[],
expectedItem: T,
) {
const serializedItemsFromArray = arrayToSearch.map((c) => jsonSerializeForComparison(c));
const serializedExpectedItem = jsonSerializeForComparison(expectedItem);
expect(serializedItemsFromArray).to.include(serializedExpectedItem, formatAssertionMessage([
'Mismatch in expected items.',
'The provided array does not include the expected item.',
'Expected item:',
indentText(serializeItemForDisplay(expectedItem)),
`Provided items (total: ${arrayToSearch.length}):`,
indentText(serializeArrayForDisplay(arrayToSearch)),
]));
}
function jsonSerializeForComparison(obj: unknown): string {
return JSON.stringify(obj);
}
function serializeArrayForDisplay<T>(array: readonly T[]): string {
return array.map((item) => indentText(serializeItemForDisplay(item))).join('\n-\n');
}
function serializeItemForDisplay(item: unknown): string {
const typeDescription = getTypeDescription(item);
const jsonSerializedItem = JSON.stringify(item, null, 2);
return `${typeDescription}\n${jsonSerializedItem}`;
}
function getTypeDescription(item: unknown): string {
// Basic type detection using typeof
let type = typeof item;
// More specific type detection for object types using Object.prototype.toString
if (type === 'object') {
const preciseType = Object.prototype.toString.call(item);
type = preciseType.replace(/^\[object (\S+)\]$/, '$1');
}
return `Type: ${type}`;
}

View File

@@ -1,20 +1,20 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { CollectionData } from '@/application/collections/'; import type { CollectionData } from '@/application/collections/';
import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser'; import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser';
import { type CategoryCollectionParserType, parseApplication } from '@/application/Parser/ApplicationParser'; import { parseApplication } from '@/application/Parser/ApplicationParser';
import type { IAppMetadata } from '@/infrastructure/EnvironmentVariables/IAppMetadata';
import WindowsData from '@/application/collections/windows.yaml'; import WindowsData from '@/application/collections/windows.yaml';
import MacOsData from '@/application/collections/macos.yaml'; import MacOsData from '@/application/collections/macos.yaml';
import LinuxData from '@/application/collections/linux.yaml'; import LinuxData from '@/application/collections/linux.yaml';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub'; import { CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub';
import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { CategoryCollectionParserStub } from '@tests/unit/shared/Stubs/CategoryCollectionParserStub'; import { CategoryCollectionParserStub } from '@tests/unit/shared/Stubs/CategoryCollectionParserStub';
import { ProjectDetailsParserStub } from '@tests/unit/shared/Stubs/ProjectDetailsParserStub'; import { ProjectDetailsParserStub } from '@tests/unit/shared/Stubs/ProjectDetailsParserStub';
import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'; import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub';
import type { CategoryCollectionParser } from '@/application/Parser/CategoryCollectionParser';
import type { NonEmptyCollectionAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
describe('ApplicationParser', () => { describe('ApplicationParser', () => {
describe('parseApplication', () => { describe('parseApplication', () => {
@@ -71,45 +71,21 @@ describe('ApplicationParser', () => {
)).to.equal(true); )).to.equal(true);
}); });
}); });
describe('metadata', () => {
it('used to parse expected metadata', () => {
// arrange
const expectedMetadata = new AppMetadataStub();
const projectDetailsParser = new ProjectDetailsParserStub();
// act
new ApplicationParserBuilder()
.withMetadata(expectedMetadata)
.withProjectDetailsParser(projectDetailsParser.getStub())
.parseApplication();
// assert
expect(projectDetailsParser.arguments).to.have.lengthOf(1);
expect(projectDetailsParser.arguments[0]).to.equal(expectedMetadata);
});
it('defaults to metadata from factory', () => {
// arrange
const expectedMetadata: IAppMetadata = EnvironmentVariablesFactory.Current.instance;
const projectDetailsParser = new ProjectDetailsParserStub();
// act
new ApplicationParserBuilder()
.withMetadata(undefined) // force using default
.withProjectDetailsParser(projectDetailsParser.getStub())
.parseApplication();
// assert
expect(projectDetailsParser.arguments).to.have.lengthOf(1);
expect(projectDetailsParser.arguments[0]).to.equal(expectedMetadata);
});
});
describe('collectionsData', () => { describe('collectionsData', () => {
describe('set as expected', () => { describe('set as expected', () => {
// arrange // arrange
const testCases = [ const testScenarios: {
readonly description: string;
readonly input: readonly CollectionData[];
readonly output: readonly ICategoryCollection[];
}[] = [
{ {
name: 'single collection', description: 'single collection',
input: [new CollectionDataStub()], input: [new CollectionDataStub()],
output: [new CategoryCollectionStub().withOs(OperatingSystem.macOS)], output: [new CategoryCollectionStub().withOs(OperatingSystem.macOS)],
}, },
{ {
name: 'multiple collections', description: 'multiple collections',
input: [ input: [
new CollectionDataStub().withOs('windows'), new CollectionDataStub().withOs('windows'),
new CollectionDataStub().withOs('macos'), new CollectionDataStub().withOs('macos'),
@@ -121,22 +97,24 @@ describe('ApplicationParser', () => {
}, },
]; ];
// act // act
for (const testCase of testCases) { testScenarios.forEach(({
it(testCase.name, () => { description, input, output,
}) => {
it(description, () => {
let categoryParserStub = new CategoryCollectionParserStub(); let categoryParserStub = new CategoryCollectionParserStub();
for (let i = 0; i < testCase.input.length; i++) { for (let i = 0; i < input.length; i++) {
categoryParserStub = categoryParserStub categoryParserStub = categoryParserStub
.withReturnValue(testCase.input[i], testCase.output[i]); .withReturnValue(input[i], output[i]);
} }
const sut = new ApplicationParserBuilder() const sut = new ApplicationParserBuilder()
.withCategoryCollectionParser(categoryParserStub.getStub()) .withCategoryCollectionParser(categoryParserStub.getStub())
.withCollectionsData(testCase.input); .withCollectionsData(input);
// act // act
const app = sut.parseApplication(); const app = sut.parseApplication();
// assert // assert
expect(app.collections).to.deep.equal(testCase.output); expect(app.collections).to.deep.equal(output);
});
}); });
}
}); });
it('defaults to expected data', () => { it('defaults to expected data', () => {
// arrange // arrange
@@ -151,30 +129,21 @@ describe('ApplicationParser', () => {
const actual = categoryParserStub.arguments.map((args) => args.data); const actual = categoryParserStub.arguments.map((args) => args.data);
expect(actual).to.deep.equal(expected); expect(actual).to.deep.equal(expected);
}); });
describe('throws when data is invalid', () => { it('validates non empty array', () => {
// arrange // arrange
const testCases = [ const data = [new CollectionDataStub()];
...getAbsentCollectionTestCases<CollectionData>( const expectedAssertion: NonEmptyCollectionAssertion = {
{ value: data,
excludeUndefined: true, valueName: 'collections',
excludeNull: true, };
}, const validator = new TypeValidatorStub();
).map((testCase) => ({
name: `given absent collection "${testCase.valueName}"`,
value: testCase.absentValue,
expectedError: 'missing collections',
})),
];
for (const testCase of testCases) {
it(testCase.name, () => {
const sut = new ApplicationParserBuilder() const sut = new ApplicationParserBuilder()
.withCollectionsData(testCase.value); .withCollectionsData(data)
.withTypeValidator(validator);
// act // act
const act = () => sut.parseApplication(); sut.parseApplication();
// assert // assert
expect(act).to.throw(testCase.expectedError); validator.expectNonEmptyCollectionAssertion(expectedAssertion);
});
}
}); });
}); });
}); });
@@ -182,17 +151,17 @@ describe('ApplicationParser', () => {
class ApplicationParserBuilder { class ApplicationParserBuilder {
private categoryCollectionParser private categoryCollectionParser
: CategoryCollectionParserType = new CategoryCollectionParserStub().getStub(); : CategoryCollectionParser = new CategoryCollectionParserStub().getStub();
private projectDetailsParser private projectDetailsParser
: typeof parseProjectDetails = new ProjectDetailsParserStub().getStub(); : typeof parseProjectDetails = new ProjectDetailsParserStub().getStub();
private metadata: IAppMetadata | undefined = new AppMetadataStub(); private validator: TypeValidator = new TypeValidatorStub();
private collectionsData: CollectionData[] | undefined = [new CollectionDataStub()]; private collectionsData: readonly CollectionData[] | undefined = [new CollectionDataStub()];
public withCategoryCollectionParser( public withCategoryCollectionParser(
categoryCollectionParser: CategoryCollectionParserType, categoryCollectionParser: CategoryCollectionParser,
): this { ): this {
this.categoryCollectionParser = categoryCollectionParser; this.categoryCollectionParser = categoryCollectionParser;
return this; return this;
@@ -205,24 +174,24 @@ class ApplicationParserBuilder {
return this; return this;
} }
public withMetadata( public withCollectionsData(collectionsData: readonly CollectionData[] | undefined): this {
metadata: IAppMetadata | undefined, this.collectionsData = collectionsData;
): this {
this.metadata = metadata;
return this; return this;
} }
public withCollectionsData(collectionsData: CollectionData[] | undefined): this { public withTypeValidator(validator: TypeValidator): this {
this.collectionsData = collectionsData; this.validator = validator;
return this; return this;
} }
public parseApplication(): ReturnType<typeof parseApplication> { public parseApplication(): ReturnType<typeof parseApplication> {
return parseApplication( return parseApplication(
this.categoryCollectionParser,
this.projectDetailsParser,
this.metadata,
this.collectionsData, this.collectionsData,
{
parseCategoryCollection: this.categoryCollectionParser,
parseProjectDetails: this.projectDetailsParser,
validator: this.validator,
},
); );
} }
} }

View File

@@ -1,119 +1,313 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { IEntity } from '@/infrastructure/Entity/IEntity'; import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import { parseCategoryCollection } from '@/application/Parser/CategoryCollectionParser'; import { parseCategoryCollection, type CategoryCollectionFactory } from '@/application/Parser/CategoryCollectionParser';
import { parseCategory } from '@/application/Parser/Executable/CategoryParser'; import { type CategoryParser } from '@/application/Parser/Executable/CategoryParser';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser';
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'; import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub';
import { getCategoryStub, CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub'; import { getCategoryStub, CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub'; import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub'; import type { CollectionData, ScriptingDefinitionData, FunctionData } from '@/application/collections/';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import type { CategoryData } from '@/application/collections/'; import type { NonEmptyCollectionAssertion, ObjectAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator';
import type { EnumParser } from '@/application/Common/Enum';
import type { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser';
import { ScriptingDefinitionStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionStub';
import type { CategoryCollectionSpecificUtilitiesFactory } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
import { ScriptingDefinitionDataStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionDataStub';
import { CategoryParserStub } from '@tests/unit/shared/Stubs/CategoryParserStub';
import { createCategoryCollectionFactorySpy } from '@tests/unit/shared/Stubs/CategoryCollectionFactoryStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
describe('CategoryCollectionParser', () => { describe('CategoryCollectionParser', () => {
describe('parseCategoryCollection', () => { describe('parseCategoryCollection', () => {
describe('actions', () => { it('validates object', () => {
describe('throws with absent actions', () => {
itEachAbsentCollectionValue<CategoryData>((absentValue) => {
// arrange // arrange
const expectedError = 'content does not define any action'; const data = new CollectionDataStub();
const collection = new CollectionDataStub() const expectedAssertion: ObjectAssertion<CollectionData> = {
.withActions(absentValue); value: data,
const projectDetails = new ProjectDetailsStub(); valueName: 'collection',
allowedProperties: [
'os', 'scripting', 'actions', 'functions',
],
};
const validator = new TypeValidatorStub();
const context = new TestContext()
.withData(data)
.withTypeValidator(validator);
// act // act
const act = () => parseCategoryCollection(collection, projectDetails); context.parseCategoryCollection();
// assert // assert
expect(act).to.throw(expectedError); validator.expectObjectAssertion(expectedAssertion);
}, { excludeUndefined: true, excludeNull: true });
}); });
it('parses actions', () => { describe('actions', () => {
it('validates non-empty collection', () => {
// arrange // arrange
const actions = [getCategoryStub('test1'), getCategoryStub('test2')]; const actions = [getCategoryStub('test1'), getCategoryStub('test2')];
const context = new CategoryCollectionSpecificUtilitiesStub(); const expectedAssertion: NonEmptyCollectionAssertion = {
const expected = [parseCategory(actions[0], context), parseCategory(actions[1], context)]; value: actions,
const collection = new CollectionDataStub() valueName: '"actions" in collection',
.withActions(actions); };
const projectDetails = new ProjectDetailsStub(); const validator = new TypeValidatorStub();
const context = new TestContext()
.withData(new CollectionDataStub().withActions(actions))
.withTypeValidator(validator);
// act // act
const actual = parseCategoryCollection(collection, projectDetails).actions; context.parseCategoryCollection();
// assert // assert
expect(excludingId(actual)).to.be.deep.equal(excludingId(expected)); validator.expectNonEmptyCollectionAssertion(expectedAssertion);
function excludingId<TId>(array: ReadonlyArray<IEntity<TId>>) { });
return array.map((obj) => { it('parses actions correctly', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // arrange
const { id: omitted, ...rest } = obj; const {
return rest; categoryCollectionFactorySpy,
getInitParameters,
} = createCategoryCollectionFactorySpy();
const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')];
const expectedActions = [new CategoryStub(1), new CategoryStub(2)];
const categoryParserStub = new CategoryParserStub()
.withConfiguredParseResult(actionsData[0], expectedActions[0])
.withConfiguredParseResult(actionsData[1], expectedActions[1]);
const collectionData = new CollectionDataStub()
.withActions(actionsData);
const context = new TestContext()
.withData(collectionData)
.withCategoryParser(categoryParserStub.get())
.withCategoryCollectionFactory(categoryCollectionFactorySpy);
// act
const actualCollection = context.parseCategoryCollection();
// assert
const actualActions = getInitParameters(actualCollection)?.actions;
expect(actualActions).to.have.lengthOf(expectedActions.length);
expect(actualActions).to.have.members(expectedActions);
});
describe('utilities', () => {
it('parses actions with correct utilities', () => {
// arrange
const expectedUtilities = new CategoryCollectionSpecificUtilitiesStub();
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = () => {
return expectedUtilities;
};
const actionsData = [getCategoryStub('test1'), getCategoryStub('test2')];
const collectionData = new CollectionDataStub()
.withActions(actionsData);
const categoryParserStub = new CategoryParserStub();
const context = new TestContext()
.withData(collectionData)
.withCollectionUtilitiesFactory(utilitiesFactory)
.withCategoryParser(categoryParserStub.get());
// act
context.parseCategoryCollection();
// assert
const usedUtilities = categoryParserStub.getUsedUtilities();
expect(usedUtilities).to.have.lengthOf(2);
expect(usedUtilities[0]).to.equal(expectedUtilities);
expect(usedUtilities[1]).to.equal(expectedUtilities);
});
describe('construction', () => {
it('creates utilities with correct functions data', () => {
// arrange
const expectedFunctionsData = [createFunctionDataWithCode()];
const collectionData = new CollectionDataStub()
.withFunctions(expectedFunctionsData);
let actualFunctionsData: ReadonlyArray<FunctionData> | undefined;
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (data) => {
actualFunctionsData = data;
return new CategoryCollectionSpecificUtilitiesStub();
};
const context = new TestContext()
.withData(collectionData)
.withCollectionUtilitiesFactory(utilitiesFactory);
// act
context.parseCategoryCollection();
// assert
expect(actualFunctionsData).to.equal(expectedFunctionsData);
});
it('creates utilities with correct scripting definition', () => {
// arrange
const expectedScripting = new ScriptingDefinitionStub();
const scriptingDefinitionParser: ScriptingDefinitionParser = () => {
return expectedScripting;
};
let actualScripting: IScriptingDefinition | undefined;
const utilitiesFactory: CategoryCollectionSpecificUtilitiesFactory = (_, scripting) => {
actualScripting = scripting;
return new CategoryCollectionSpecificUtilitiesStub();
};
const context = new TestContext()
.withCollectionUtilitiesFactory(utilitiesFactory)
.withScriptDefinitionParser(scriptingDefinitionParser);
// act
context.parseCategoryCollection();
// assert
expect(actualScripting).to.equal(expectedScripting);
});
}); });
}
}); });
}); });
describe('scripting definition', () => { describe('scripting definition', () => {
it('parses scripting definition as expected', () => { it('parses correctly', () => {
// arrange // arrange
const collection = new CollectionDataStub(); const {
const projectDetails = new ProjectDetailsStub(); categoryCollectionFactorySpy,
const expected = new ScriptingDefinitionParser() getInitParameters,
.parse(collection.scripting, projectDetails); } = createCategoryCollectionFactorySpy();
const expected = new ScriptingDefinitionStub();
const scriptingDefinitionParser: ScriptingDefinitionParser = () => {
return expected;
};
const context = new TestContext()
.withCategoryCollectionFactory(categoryCollectionFactorySpy)
.withScriptDefinitionParser(scriptingDefinitionParser);
// act // act
const actual = parseCategoryCollection(collection, projectDetails).scripting; const actualCategoryCollection = context.parseCategoryCollection();
// assert // assert
expect(expected).to.deep.equal(actual); const actualScripting = getInitParameters(actualCategoryCollection)?.scripting;
expect(expected).to.equal(actualScripting);
});
it('parses expected data', () => {
// arrange
const expectedData = new ScriptingDefinitionDataStub();
const collection = new CollectionDataStub()
.withScripting(expectedData);
let actualData: ScriptingDefinitionData | undefined;
const scriptingDefinitionParser
: ScriptingDefinitionParser = (data: ScriptingDefinitionData) => {
actualData = data;
return new ScriptingDefinitionStub();
};
const context = new TestContext()
.withScriptDefinitionParser(scriptingDefinitionParser)
.withData(collection);
// act
context.parseCategoryCollection();
// assert
expect(actualData).to.equal(expectedData);
});
it('parses with correct project details', () => {
// arrange
const expectedProjectDetails = new ProjectDetailsStub();
let actualDetails: ProjectDetails | undefined;
const scriptingDefinitionParser
: ScriptingDefinitionParser = (_, details: ProjectDetails) => {
actualDetails = details;
return new ScriptingDefinitionStub();
};
const context = new TestContext()
.withProjectDetails(expectedProjectDetails)
.withScriptDefinitionParser(scriptingDefinitionParser);
// act
context.parseCategoryCollection();
// assert
expect(actualDetails).to.equal(expectedProjectDetails);
}); });
}); });
describe('os', () => { describe('os', () => {
it('parses as expected', () => { it('parses correctly', () => {
// arrange // arrange
const {
categoryCollectionFactorySpy,
getInitParameters,
} = createCategoryCollectionFactorySpy();
const expectedOs = OperatingSystem.macOS; const expectedOs = OperatingSystem.macOS;
const osText = 'macos'; const osText = 'macos';
const expectedName = 'os'; const expectedName = 'os';
const collection = new CollectionDataStub() const collectionData = new CollectionDataStub()
.withOs(osText); .withOs(osText);
const parserMock = new EnumParserStub<OperatingSystem>() const parserMock = new EnumParserStub<OperatingSystem>()
.setup(expectedName, osText, expectedOs); .setup(expectedName, osText, expectedOs);
const projectDetails = new ProjectDetailsStub(); const context = new TestContext()
.withOsParser(parserMock)
.withCategoryCollectionFactory(categoryCollectionFactorySpy)
.withData(collectionData);
// act // act
const actual = parseCategoryCollection(collection, projectDetails, parserMock); const actualCollection = context.parseCategoryCollection();
// assert // assert
expect(actual.os).to.equal(expectedOs); const actualOs = getInitParameters(actualCollection)?.os;
}); expect(actualOs).to.equal(expectedOs);
});
describe('functions', () => {
it('compiles script call with given function', () => {
// arrange
const expectedCode = 'code-from-the-function';
const functionName = 'function-name';
const scriptName = 'script-name';
const script = createScriptDataWithCall()
.withCall(new FunctionCallDataStub().withName(functionName).withParameters({}))
.withName(scriptName);
const func = createFunctionDataWithCode()
.withParametersObject([])
.withName(functionName)
.withCode(expectedCode);
const category = new CategoryDataStub()
.withChildren([script,
createScriptDataWithCode().withName('2')
.withRecommendationLevel(RecommendationLevel.Standard),
createScriptDataWithCode()
.withName('3').withRecommendationLevel(RecommendationLevel.Strict),
]);
const collection = new CollectionDataStub()
.withActions([category])
.withFunctions([func]);
const projectDetails = new ProjectDetailsStub();
// act
const actual = parseCategoryCollection(collection, projectDetails);
// assert
const actualScript = actual.getScript(scriptName);
const actualCode = actualScript.code.execute;
expect(actualCode).to.equal(expectedCode);
}); });
}); });
}); });
}); });
class TestContext {
private data: CollectionData = new CollectionDataStub();
private projectDetails: ProjectDetails = new ProjectDetailsStub();
private validator: TypeValidator = new TypeValidatorStub();
private osParser: EnumParser<OperatingSystem> = new EnumParserStub<OperatingSystem>()
.setupDefaultValue(OperatingSystem.Android);
private collectionUtilitiesFactory
: CategoryCollectionSpecificUtilitiesFactory = () => {
return new CategoryCollectionSpecificUtilitiesStub();
};
private scriptDefinitionParser: ScriptingDefinitionParser = () => new ScriptingDefinitionStub();
private categoryParser: CategoryParser = new CategoryParserStub().get();
private categoryCollectionFactory
: CategoryCollectionFactory = createCategoryCollectionFactorySpy().categoryCollectionFactorySpy;
public withData(data: CollectionData): this {
this.data = data;
return this;
}
public withCategoryParser(categoryParser: CategoryParser): this {
this.categoryParser = categoryParser;
return this;
}
public withCategoryCollectionFactory(categoryCollectionFactory: CategoryCollectionFactory): this {
this.categoryCollectionFactory = categoryCollectionFactory;
return this;
}
public withProjectDetails(projectDetails: ProjectDetails): this {
this.projectDetails = projectDetails;
return this;
}
public withOsParser(osParser: EnumParser<OperatingSystem>): this {
this.osParser = osParser;
return this;
}
public withScriptDefinitionParser(scriptDefinitionParser: ScriptingDefinitionParser): this {
this.scriptDefinitionParser = scriptDefinitionParser;
return this;
}
public withTypeValidator(typeValidator: TypeValidator): this {
this.validator = typeValidator;
return this;
}
public withCollectionUtilitiesFactory(
collectionUtilitiesFactory: CategoryCollectionSpecificUtilitiesFactory,
): this {
this.collectionUtilitiesFactory = collectionUtilitiesFactory;
return this;
}
public parseCategoryCollection(): ReturnType<typeof parseCategoryCollection> {
return parseCategoryCollection(
this.data,
this.projectDetails,
{
osParser: this.osParser,
validator: this.validator,
parseScriptingDefinition: this.scriptDefinitionParser,
createUtilities: this.collectionUtilitiesFactory,
parseCategory: this.categoryParser,
createCategoryCollection: this.categoryCollectionFactory,
},
);
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { CustomError } from '@/application/Common/CustomError'; import { CustomError } from '@/application/Common/CustomError';
import { wrapErrorWithAdditionalContext } from '@/application/Parser/ContextualError'; import { wrapErrorWithAdditionalContext } from '@/application/Parser/Common/ContextualError';
describe('wrapErrorWithAdditionalContext', () => { describe('wrapErrorWithAdditionalContext', () => {
it('preserves the original error when wrapped', () => { it('preserves the original error when wrapped', () => {

View File

@@ -1,4 +1,4 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text'; import { indentText } from '@tests/shared/Text';

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { createTypeValidator } from '@/application/Parser/Common/TypeValidator';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
describe('createTypeValidator', () => {
describe('assertObject', () => {
describe('with valid object', () => {
it('accepts object with allowed properties', () => {
// arrange
const expectedProperties = ['expected1', 'expected2'];
const validValue = createObjectWithProperties(expectedProperties);
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({
value: validValue,
valueName: 'unimportant name',
allowedProperties: expectedProperties,
});
// assert
expect(act).to.not.throw();
});
it('accepts object with extra unspecified properties', () => {
// arrange
const validValue = createObjectWithProperties(['unevaluated property']);
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({
value: validValue,
valueName: 'unimportant name',
});
// assert
expect(act).to.not.throw();
});
});
describe('with invalid object', () => {
describe('throws error for missing object', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const valueName = 'absent object value';
const expectedMessage = `'${valueName}' is missing.`;
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({ value: absentValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
it('throws error for object without properties', () => {
// arrange
const emptyObjectValue: object = {};
const valueName = 'empty object without properties.';
const expectedMessage = `'${valueName}' is an empty object without properties.`;
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({ value: emptyObjectValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
describe('incorrect data type', () => {
// arrange
const testScenarios: readonly {
readonly value: unknown;
readonly valueName: string;
}[] = [
{
value: ['1', '2'],
valueName: 'array of strings',
},
{
value: true,
valueName: 'true boolean',
},
{
value: 35,
valueName: 'number',
},
];
testScenarios.forEach(({ value, valueName }) => {
it(`throws error for ${valueName} passed as object`, () => {
// arrange
const expectedMessage = `'${valueName}' is not an object.`;
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({ value, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
});
it('throws error for object with disallowed properties', () => {
// arrange
const valueName = 'value with unexpected properties';
const unexpectedProperties = ['unexpected-property-1', 'unexpected-property-2'];
const expectedError = `'${valueName}' has disallowed properties: ${unexpectedProperties.join(', ')}.`;
const expectedProperties = ['expected1', 'expected2'];
const value = createObjectWithProperties(
[...expectedProperties, ...unexpectedProperties],
);
const { assertObject } = createTypeValidator();
// act
const act = () => assertObject({
value,
valueName,
allowedProperties: expectedProperties,
});
// assert
expect(act).to.throw(expectedError);
});
});
});
describe('assertNonEmptyCollection', () => {
describe('with valid collection', () => {
it('accepts non-empty collection', () => {
// arrange
const validValue = ['array', 'of', 'strings'];
const { assertNonEmptyCollection } = createTypeValidator();
// act
const act = () => assertNonEmptyCollection({ value: validValue, valueName: 'unimportant name' });
// assert
expect(act).to.not.throw();
});
});
describe('with invalid collection', () => {
describe('throws error for missing collection', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const valueName = 'absent collection value';
const expectedMessage = `'${valueName}' is missing.`;
const { assertNonEmptyCollection } = createTypeValidator();
// act
const act = () => assertNonEmptyCollection({ value: absentValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
it('throws error for empty collection', () => {
// arrange
const emptyArrayValue = [];
const valueName = 'empty collection value';
const expectedMessage = `'${valueName}' cannot be an empty array.`;
const { assertNonEmptyCollection } = createTypeValidator();
// act
const act = () => assertNonEmptyCollection({ value: emptyArrayValue, valueName });
// assert
expect(act).to.throw(expectedMessage);
});
});
});
});
function createObjectWithProperties(properties: readonly string[]): object {
const object = {};
properties.forEach((propertyName) => {
object[propertyName] = 'arbitrary value';
});
return object;
}

View File

@@ -5,10 +5,9 @@ import { type ScriptParser } from '@/application/Parser/Executable/Script/Script
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser'; import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub'; import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub'; import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType'; import { ExecutableType } from '@/application/Parser/Executable/Validation/ExecutableType';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator'; import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
import { ExecutableValidatorStub, createExecutableValidatorFactoryStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub'; import { ExecutableValidatorStub, createExecutableValidatorFactoryStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
@@ -20,8 +19,9 @@ import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub'; import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text'; import { indentText } from '@tests/shared/Text';
import { itThrowsContextualError } from '../ContextualErrorTester'; import type { NonEmptyCollectionAssertion, ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
import { itValidatesName, itValidatesDefinedData, itAsserts } from './Validation/ExecutableValidationTester'; import { itThrowsContextualError } from '../Common/ContextualErrorTester';
import { itValidatesName, itValidatesType, itAsserts } from './Validation/ExecutableValidationTester';
import { generateDataValidationTestScenarios } from './Validation/DataValidationTestScenarioGenerator'; import { generateDataValidationTestScenarios } from './Validation/DataValidationTestScenarioGenerator';
describe('CategoryParser', () => { describe('CategoryParser', () => {
@@ -49,14 +49,18 @@ describe('CategoryParser', () => {
}; };
}); });
}); });
describe('validates for defined data', () => { describe('validates for unknown object', () => {
// arrange // arrange
const category = new CategoryDataStub(); const category = new CategoryDataStub();
const expectedContext: CategoryErrorContext = { const expectedContext: CategoryErrorContext = {
type: ExecutableType.Category, type: ExecutableType.Category,
self: category, self: category,
}; };
itValidatesDefinedData( const expectedAssertion: ObjectAssertion<unknown> = {
value: category,
valueName: 'Executable',
};
itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestBuilder() new TestBuilder()
@@ -65,58 +69,65 @@ describe('CategoryParser', () => {
.parseCategory(); .parseCategory();
// assert // assert
return { return {
expectedDataToValidate: category, assertValidation: (validator) => validator.assertObject(expectedAssertion),
expectedErrorContext: expectedContext, expectedErrorContext: expectedContext,
}; };
}, },
); );
}); });
describe('validates that category has some children', () => { describe('validates for category', () => {
const categoryName = 'test'; // arrange
const testScenarios = generateDataValidationTestScenarios<CategoryData>({ const category = new CategoryDataStub();
expectFail: getAbsentCollectionTestCases<ExecutableData>().map(({
valueName, absentValue: absentCollectionValue,
}) => ({
description: `with \`${valueName}\` value as children`,
data: new CategoryDataStub()
.withName(categoryName)
.withChildren(absentCollectionValue as unknown as ExecutableData[]),
})),
expectPass: [{
description: 'has single children',
data: new CategoryDataStub()
.withName(categoryName)
.withChildren([createScriptDataWithCode()]),
}],
});
testScenarios.forEach(({
description, expectedPass, data: categoryData,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedMessage = `"${categoryName}" has no children.`;
const expectedContext: CategoryErrorContext = { const expectedContext: CategoryErrorContext = {
type: ExecutableType.Category, type: ExecutableType.Category,
self: categoryData, self: category,
}; };
const expectedAssertion: ObjectAssertion<CategoryData> = {
value: category,
valueName: category.category,
allowedProperties: ['docs', 'children', 'category'],
};
itValidatesType(
(validatorFactory) => {
// act // act
try {
new TestBuilder() new TestBuilder()
.withData(categoryData) .withData(category)
.withValidatorFactory(validatorFactory) .withValidatorFactory(validatorFactory)
.parseCategory(); .parseCategory();
} catch { /* It may throw due to assertions not being evaluated */ }
// assert // assert
return { return {
expectedErrorMessage: expectedMessage, assertValidation: (validator) => validator.assertObject(expectedAssertion),
expectedErrorContext: expectedContext, expectedErrorContext: expectedContext,
}; };
}, },
);
}); });
}); describe('validates children for non-empty collection', () => {
}); // arrange
const category = new CategoryDataStub()
.withChildren([createScriptDataWithCode()]);
const expectedContext: CategoryErrorContext = {
type: ExecutableType.Category,
self: category,
};
const expectedAssertion: NonEmptyCollectionAssertion = {
value: category.children,
valueName: category.category,
};
itValidatesType(
(validatorFactory) => {
// act
new TestBuilder()
.withData(category)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
assertValidation: (validator) => validator.assertObject(expectedAssertion),
expectedErrorContext: expectedContext,
};
},
);
}); });
describe('validates that a child is a category or a script', () => { describe('validates that a child is a category or a script', () => {
// arrange // arrange
@@ -171,7 +182,7 @@ describe('CategoryParser', () => {
}); });
}); });
describe('validates children recursively', () => { describe('validates children recursively', () => {
describe('validates (1th-level) child data', () => { describe('validates (1th-level) child type', () => {
// arrange // arrange
const expectedName = 'child category'; const expectedName = 'child category';
const child = new CategoryDataStub() const child = new CategoryDataStub()
@@ -183,7 +194,11 @@ describe('CategoryParser', () => {
self: child, self: child,
parentCategory: parent, parentCategory: parent,
}; };
itValidatesDefinedData( const expectedAssertion: ObjectAssertion<unknown> = {
value: child,
valueName: 'Executable',
};
itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestBuilder() new TestBuilder()
@@ -192,7 +207,7 @@ describe('CategoryParser', () => {
.parseCategory(); .parseCategory();
// assert // assert
return { return {
expectedDataToValidate: child, assertValidation: (validator) => validator.assertObject(expectedAssertion),
expectedErrorContext: expectedContext, expectedErrorContext: expectedContext,
}; };
}, },

View File

@@ -18,7 +18,7 @@ describe('DocumentationParser', () => {
}); });
describe('throws when type is unexpected', () => { describe('throws when type is unexpected', () => {
// arrange // arrange
const expectedTypeError = 'docs field (documentation) must be an array of strings'; const expectedTypeError = 'docs field (documentation) must be a single string or an array of strings.';
const wrongTypedValue = 22 as never; const wrongTypedValue = 22 as never;
const testCases: ReadonlyArray<{ const testCases: ReadonlyArray<{
readonly name: string; readonly name: string;

View File

@@ -8,7 +8,7 @@ import {
import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { ExpressionPosition } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub'; import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub'; import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression'; import type { IExpression } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/IExpression';

View File

@@ -9,8 +9,8 @@ import { SingleCallCompilerStub } from '@tests/unit/shared/Stubs/SingleCallCompi
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionCallCompiler', () => { describe('NestedFunctionCallCompiler', () => {

View File

@@ -11,8 +11,8 @@ import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/Fun
import { createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub'; import { createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub'; import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub'; import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionArgumentCompiler', () => { describe('NestedFunctionArgumentCompiler', () => {

View File

@@ -13,10 +13,10 @@ import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub'; import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory'; import type { FunctionParameterCollectionFactory } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub'; import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType'; import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';

View File

@@ -16,12 +16,12 @@ import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub'; import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub'; import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub'; import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
describe('ScriptCompiler', () => { describe('ScriptCompiler', () => {
describe('canCompile', () => { describe('canCompile', () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { ScriptData } from '@/application/collections/'; import type { ScriptData, CallScriptData, CodeScriptData } from '@/application/collections/';
import { parseScript, type ScriptFactory } from '@/application/Parser/Executable/Script/ScriptParser'; import { parseScript, type ScriptFactory } from '@/application/Parser/Executable/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser'; import { type DocsParser } from '@/application/Parser/Executable/DocumentationParser';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
@@ -8,14 +8,13 @@ import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub'
import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub'; import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub'; import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub'; import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import type { IEnumParser } from '@/application/Common/Enum'; import type { EnumParser } from '@/application/Common/Enum';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub'; import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub'; import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator'; import type { ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
import { ExecutableValidatorStub, createExecutableValidatorFactoryStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub'; import { ExecutableValidatorStub, createExecutableValidatorFactoryStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
@@ -26,9 +25,11 @@ import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCode
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub'; import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub'; import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester'; import { itThrowsContextualError } from '@tests/unit/application/Parser/Common/ContextualErrorTester';
import { CategoryCollectionSpecificUtilitiesStub } from '@tests/unit/shared/Stubs/CategoryCollectionSpecificUtilitiesStub';
import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities'; import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
import { itAsserts, itValidatesDefinedData, itValidatesName } from '../Validation/ExecutableValidationTester'; import type { ObjectAssertion } from '@/application/Parser/Common/TypeValidator';
import { itAsserts, itValidatesType, itValidatesName } from '../Validation/ExecutableValidationTester';
import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator'; import { generateDataValidationTestScenarios } from '../Validation/DataValidationTestScenarioGenerator';
describe('ScriptParser', () => { describe('ScriptParser', () => {
@@ -290,7 +291,14 @@ describe('ScriptParser', () => {
type: ExecutableType.Script, type: ExecutableType.Script,
self: expectedScript, self: expectedScript,
}; };
itValidatesDefinedData( const expectedAssertion: ObjectAssertion<CallScriptData & CodeScriptData> = {
value: expectedScript,
valueName: expectedScript.name,
allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
],
};
itValidatesType(
(validatorFactory) => { (validatorFactory) => {
// act // act
new TestContext() new TestContext()
@@ -301,6 +309,7 @@ describe('ScriptParser', () => {
return { return {
expectedDataToValidate: expectedScript, expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext, expectedErrorContext: expectedContext,
assertValidation: (validator) => validator.assertObject(expectedAssertion),
}; };
}, },
); );
@@ -430,7 +439,7 @@ class TestContext {
private collectionUtilities private collectionUtilities
: CategoryCollectionSpecificUtilities = new CategoryCollectionSpecificUtilitiesStub(); : CategoryCollectionSpecificUtilities = new CategoryCollectionSpecificUtilitiesStub();
private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>() private levelParser: EnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
.setupDefaultValue(RecommendationLevel.Standard); .setupDefaultValue(RecommendationLevel.Standard);
private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy; private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy;
@@ -464,7 +473,7 @@ class TestContext {
return this; return this;
} }
public withParser(parser: IEnumParser<RecommendationLevel>): this { public withParser(parser: EnumParser<RecommendationLevel>): this {
this.levelParser = parser; this.levelParser = parser;
return this; return this;
} }

View File

@@ -3,10 +3,11 @@ import type { ExecutableValidator, ExecutableValidatorFactory } from '@/applicat
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext'; import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
import { ExecutableValidatorStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub'; import { ExecutableValidatorStub } from '@tests/unit/shared/Stubs/ExecutableValidatorStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { ExecutableData } from '@/application/collections/';
import type { FunctionKeys } from '@/TypeHelpers'; import type { FunctionKeys } from '@/TypeHelpers';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text'; import { indentText } from '@tests/shared/Text';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import { expectDeepIncludes } from '@tests/shared/Assertions/ExpectDeepIncludes';
type ValidationTestFunction<TExpectation> = ( type ValidationTestFunction<TExpectation> = (
factory: ExecutableValidatorFactory, factory: ExecutableValidatorFactory,
@@ -52,39 +53,41 @@ export function itValidatesName(
}); });
} }
interface ValidDataExpectation { interface TypeAssertionExpectation {
readonly expectedDataToValidate: ExecutableData;
readonly expectedErrorContext: ExecutableErrorContext; readonly expectedErrorContext: ExecutableErrorContext;
readonly assertValidation: (validator: TypeValidatorStub) => void;
} }
export function itValidatesDefinedData( export function itValidatesType(
test: ValidationTestFunction<ValidDataExpectation>, test: ValidationTestFunction<TypeAssertionExpectation>,
) { ) {
it('validates data', () => { it('validates type', () => {
// arrange // arrange
const validator = new ExecutableValidatorStub(); const validator = new ExecutableValidatorStub();
const factoryStub: ExecutableValidatorFactory = () => validator; const factoryStub: ExecutableValidatorFactory = () => validator;
// act // act
test(factoryStub); test(factoryStub);
// assert // assert
const call = validator.callHistory.find((c) => c.methodName === 'assertDefined'); const call = validator.callHistory.find((c) => c.methodName === 'assertType');
expectExists(call); expectExists(call);
}); });
it('validates data with correct data', () => { it('validates type using specified validator', () => {
// arrange // arrange
const typeValidator = new TypeValidatorStub();
const validator = new ExecutableValidatorStub(); const validator = new ExecutableValidatorStub();
const factoryStub: ExecutableValidatorFactory = () => validator; const factoryStub: ExecutableValidatorFactory = () => validator;
// act // act
const expectation = test(factoryStub); const expectation = test(factoryStub);
// assert // assert
const expectedData = expectation.expectedDataToValidate; const calls = validator.callHistory.filter((c) => c.methodName === 'assertType');
const calls = validator.callHistory.filter((c) => c.methodName === 'assertDefined'); const args = calls.map((c) => c.args as Parameters<ExecutableValidator['assertType']>);
const names = calls.flatMap((c) => c.args[0]); const validateFunctions = args.flatMap((c) => c[0]);
expect(names).to.include(expectedData); validateFunctions.forEach((validate) => validate(typeValidator));
expectation.assertValidation(typeValidator);
}); });
it('validates data with correct context', () => { it('validates type with correct context', () => {
expectCorrectContextForFunctionCall({ expectCorrectContextForFunctionCall({
methodName: 'assertDefined', methodName: 'assertType',
act: test, act: test,
expectContext: (expectation) => expectation.expectedErrorContext, expectContext: (expectation) => expectation.expectedErrorContext,
}); });
@@ -185,34 +188,5 @@ function expectCorrectContextForFunctionCall<T>(testScenario: {
const providedContexts = createdValidators const providedContexts = createdValidators
.filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName)) .filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName))
.map((v) => v.context); .map((v) => v.context);
expectDeepIncludes( // to.deep.include is not working expectDeepIncludes(providedContexts, expectedContext);
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)),
]));
} }

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import type { ExecutableData } from '@/application/collections/';
import { createExecutableErrorContextStub } from '@tests/unit/shared/Stubs/ExecutableErrorContextStub'; import { createExecutableErrorContextStub } from '@tests/unit/shared/Stubs/ExecutableErrorContextStub';
import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext'; import type { ExecutableErrorContext } from '@/application/Parser/Executable/Validation/ExecutableErrorContext';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { ContextualExecutableValidator, createExecutableDataValidator, type ExecutableValidator } from '@/application/Parser/Executable/Validation/ExecutableValidator'; import { ContextualExecutableValidator, createExecutableDataValidator, type ExecutableValidator } from '@/application/Parser/Executable/Validation/ExecutableValidator';
import type { ExecutableContextErrorMessageCreator } from '@/application/Parser/Executable/Validation/ExecutableErrorContextMessage'; import type { ExecutableContextErrorMessageCreator } from '@/application/Parser/Executable/Validation/ExecutableErrorContextMessage';
import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import type { TypeValidator } from '@/application/Parser/Common/TypeValidator';
describe('createExecutableDataValidator', () => { describe('createExecutableDataValidator', () => {
it(`returns an instance of ${ContextualExecutableValidator.name}`, () => { it(`returns an instance of ${ContextualExecutableValidator.name}`, () => {
@@ -63,43 +63,41 @@ describe('ContextualExecutableValidator', () => {
expect(act).to.not.throw(); expect(act).to.not.throw();
}); });
}); });
describe('assertDefined', () => { describe('assertType', () => {
describe('throws when data is missing', () => { describe('rethrows when action throws', () => {
// arrange // arrange
const testScenarios: readonly { const expectedMessage = 'Error thrown by action';
readonly description: string;
readonly invalidData: unknown;
}[] = [
...getAbsentObjectTestCases().map((testCase) => ({
description: `absent object (${testCase.valueName})`,
invalidData: testCase.absentValue,
})),
{
description: 'empty object',
invalidData: {},
},
];
testScenarios.forEach(({ description, invalidData }) => {
describe(`given "${description}"`, () => {
const expectedMessage = 'missing executable data';
itThrowsCorrectly({ itThrowsCorrectly({
// act // act
throwingAction: (sut: ExecutableValidator) => { throwingAction: (sut: ExecutableValidator) => {
sut.assertDefined(invalidData as ExecutableData); sut.assertType(() => {
throw new Error(expectedMessage);
});
}, },
// assert // assert
expectedMessage, expectedMessage,
}); });
}); });
}); it('provides correct validator', () => {
}); // arrange
it('does not throw if data is defined', () => { const expectedValidator = new TypeValidatorStub();
const sut = new ValidatorBuilder()
.withTypeValidator(expectedValidator)
.build();
let actualValidator: TypeValidator | undefined;
// act
sut.assertType((validator) => {
actualValidator = validator;
});
// assert
expect(expectedValidator).to.equal(actualValidator);
});
it('does not throw if action does not throw', () => {
// arrange // arrange
const data = new CategoryDataStub();
const sut = new ValidatorBuilder() const sut = new ValidatorBuilder()
.build(); .build();
// act // act
const act = () => sut.assertDefined(data); const act = () => sut.assertType(() => { /* Does not throw */ });
// assert // assert
expect(act).to.not.throw(); expect(act).to.not.throw();
}); });
@@ -223,6 +221,8 @@ class ValidatorBuilder {
private errorMessageCreator: ExecutableContextErrorMessageCreator = () => `[${ValidatorBuilder.name}] stub error message`; private errorMessageCreator: ExecutableContextErrorMessageCreator = () => `[${ValidatorBuilder.name}] stub error message`;
private typeValidator: TypeValidator = new TypeValidatorStub();
public withErrorMessageCreator(errorMessageCreator: ExecutableContextErrorMessageCreator): this { public withErrorMessageCreator(errorMessageCreator: ExecutableContextErrorMessageCreator): this {
this.errorMessageCreator = errorMessageCreator; this.errorMessageCreator = errorMessageCreator;
return this; return this;
@@ -233,10 +233,16 @@ class ValidatorBuilder {
return this; return this;
} }
public withTypeValidator(typeValidator: TypeValidator): this {
this.typeValidator = typeValidator;
return this;
}
public build(): ContextualExecutableValidator { public build(): ContextualExecutableValidator {
return new ContextualExecutableValidator( return new ContextualExecutableValidator(
this.errorContext, this.errorContext,
this.errorMessageCreator, this.errorMessageCreator,
this.typeValidator,
); );
} }
} }

View File

@@ -1,32 +1,52 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ScriptingDefinitionParser } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser'; import type { EnumParser } from '@/application/Common/Enum';
import type { IEnumParser } from '@/application/Common/Enum';
import type { ICodeSubstituter } from '@/application/Parser/ScriptingDefinition/ICodeSubstituter'; import type { ICodeSubstituter } from '@/application/Parser/ScriptingDefinition/ICodeSubstituter';
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub'; import { ProjectDetailsStub } from '@tests/unit/shared/Stubs/ProjectDetailsStub';
import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub'; import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ScriptingDefinitionDataStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionDataStub'; import { ScriptingDefinitionDataStub } from '@tests/unit/shared/Stubs/ScriptingDefinitionDataStub';
import { CodeSubstituterStub } from '@tests/unit/shared/Stubs/CodeSubstituterStub'; import { CodeSubstituterStub } from '@tests/unit/shared/Stubs/CodeSubstituterStub';
import { parseScriptingDefinition } from '@/application/Parser/ScriptingDefinition/ScriptingDefinitionParser';
import type { ObjectAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator';
import { TypeValidatorStub } from '@tests/unit/shared/Stubs/TypeValidatorStub';
import type { ScriptingDefinitionData } from '@/application/collections/';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
describe('ScriptingDefinitionParser', () => { describe('ScriptingDefinitionParser', () => {
describe('parseScriptingDefinition', () => { describe('parseScriptingDefinition', () => {
it('validates data', () => {
// arrange
const data = new ScriptingDefinitionDataStub();
const expectedAssertion: ObjectAssertion<ScriptingDefinitionData> = {
value: data,
valueName: 'scripting definition',
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
};
const validatorStub = new TypeValidatorStub();
const context = new TestContext()
.withTypeValidator(validatorStub)
.withData(data);
// act
context.parseScriptingDefinition();
// assert
validatorStub.assertObject(expectedAssertion);
});
describe('language', () => { describe('language', () => {
it('parses as expected', () => { it('parses as expected', () => {
// arrange // arrange
const expectedLanguage = ScriptingLanguage.batchfile; const expectedLanguage = ScriptingLanguage.batchfile;
const languageText = 'batchfile'; const languageText = 'batchfile';
const expectedName = 'language'; const expectedName = 'language';
const projectDetails = new ProjectDetailsStub();
const definition = new ScriptingDefinitionDataStub() const definition = new ScriptingDefinitionDataStub()
.withLanguage(languageText); .withLanguage(languageText);
const parserMock = new EnumParserStub<ScriptingLanguage>() const parserMock = new EnumParserStub<ScriptingLanguage>()
.setup(expectedName, languageText, expectedLanguage); .setup(expectedName, languageText, expectedLanguage);
const sut = new ScriptingDefinitionParserBuilder() const context = new TestContext()
.withParser(parserMock) .withParser(parserMock)
.build(); .withData(definition);
// act // act
const actual = sut.parse(definition, projectDetails); const actual = context.parseScriptingDefinition();
// assert // assert
expect(actual.language).to.equal(expectedLanguage); expect(actual.language).to.equal(expectedLanguage);
}); });
@@ -35,56 +55,92 @@ describe('ScriptingDefinitionParser', () => {
// arrange // arrange
const code = 'hello'; const code = 'hello';
const expected = 'substituted'; const expected = 'substituted';
const testCases = [ const testScenarios: readonly {
readonly description: string;
getActualValue(result: IScriptingDefinition): string;
readonly data: ScriptingDefinitionData;
}[] = [
{ {
name: 'startCode', description: 'startCode',
getActualValue: (result: IScriptingDefinition) => result.startCode, getActualValue: (result: IScriptingDefinition) => result.startCode,
data: new ScriptingDefinitionDataStub() data: new ScriptingDefinitionDataStub()
.withStartCode(code), .withStartCode(code),
}, },
{ {
name: 'endCode', description: 'endCode',
getActualValue: (result: IScriptingDefinition) => result.endCode, getActualValue: (result: IScriptingDefinition) => result.endCode,
data: new ScriptingDefinitionDataStub() data: new ScriptingDefinitionDataStub()
.withEndCode(code), .withEndCode(code),
}, },
]; ];
for (const testCase of testCases) { testScenarios.forEach(({
it(testCase.name, () => { description, data, getActualValue,
}) => {
it(description, () => {
const projectDetails = new ProjectDetailsStub(); const projectDetails = new ProjectDetailsStub();
const substituterMock = new CodeSubstituterStub() const substituterMock = new CodeSubstituterStub()
.setup(code, projectDetails, expected); .setup(code, projectDetails, expected);
const sut = new ScriptingDefinitionParserBuilder() const context = new TestContext()
.withSubstituter(substituterMock) .withData(data)
.build(); .withProjectDetails(projectDetails)
.withSubstituter(substituterMock);
// act // act
const definition = sut.parse(testCase.data, projectDetails); const definition = context.parseScriptingDefinition();
// assert // assert
const actual = testCase.getActualValue(definition); const actual = getActualValue(definition);
expect(actual).to.equal(expected); expect(actual).to.equal(expected);
}); });
} });
}); });
}); });
}); });
class ScriptingDefinitionParserBuilder { class TestContext {
private languageParser: IEnumParser<ScriptingLanguage> = new EnumParserStub<ScriptingLanguage>() private languageParser: EnumParser<ScriptingLanguage> = new EnumParserStub<ScriptingLanguage>()
.setupDefaultValue(ScriptingLanguage.shellscript); .setupDefaultValue(ScriptingLanguage.shellscript);
private codeSubstituter: ICodeSubstituter = new CodeSubstituterStub(); private codeSubstituter: ICodeSubstituter = new CodeSubstituterStub();
public withParser(parser: IEnumParser<ScriptingLanguage>) { private validator: TypeValidator = new TypeValidatorStub();
private data: ScriptingDefinitionData = new ScriptingDefinitionDataStub();
private projectDetails: ProjectDetails = new ProjectDetailsStub();
public withData(data: ScriptingDefinitionData): this {
this.data = data;
return this;
}
public withProjectDetails(projectDetails: ProjectDetails): this {
this.projectDetails = projectDetails;
return this;
}
public withParser(parser: EnumParser<ScriptingLanguage>): this {
this.languageParser = parser; this.languageParser = parser;
return this; return this;
} }
public withSubstituter(substituter: ICodeSubstituter) { public withSubstituter(substituter: ICodeSubstituter): this {
this.codeSubstituter = substituter; this.codeSubstituter = substituter;
return this; return this;
} }
public build() { public withTypeValidator(validator: TypeValidator): this {
return new ScriptingDefinitionParser(this.languageParser, this.codeSubstituter); this.validator = validator;
return this;
}
public parseScriptingDefinition() {
return parseScriptingDefinition(
this.data,
this.projectDetails,
{
languageParser: this.languageParser,
codeSubstituter: this.codeSubstituter,
validator: this.validator,
},
);
} }
} }

View File

@@ -318,6 +318,10 @@ class CategoryCollectionBuilder {
} }
public construct(): CategoryCollection { public construct(): CategoryCollection {
return new CategoryCollection(this.os, this.actions, this.scriptingDefinition); return new CategoryCollection({
os: this.os,
actions: this.actions,
scripting: this.scriptingDefinition,
});
} }
} }

View File

@@ -0,0 +1,23 @@
import type { CategoryCollectionFactory } from '@/application/Parser/CategoryCollectionParser';
import type { CategoryCollectionInitParameters } from '@/domain/CategoryCollection';
import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollectionStub } from './CategoryCollectionStub';
export function createCategoryCollectionFactorySpy(): {
readonly categoryCollectionFactorySpy: CategoryCollectionFactory;
getInitParameters: (
category: ICategoryCollection,
) => CategoryCollectionInitParameters | undefined;
} {
const createdCategoryCollections = new Map<
ICategoryCollection, CategoryCollectionInitParameters
>();
return {
categoryCollectionFactorySpy: (parameters) => {
const categoryCollection = new CategoryCollectionStub();
createdCategoryCollections.set(categoryCollection, parameters);
return categoryCollection;
},
getInitParameters: (category) => createdCategoryCollections.get(category),
};
}

View File

@@ -2,8 +2,8 @@ import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/ICategoryCollection';
import { getEnumValues } from '@/application/Common/Enum'; import { getEnumValues } from '@/application/Common/Enum';
import type { CollectionData } from '@/application/collections/'; import type { CollectionData } from '@/application/collections/';
import type { CategoryCollectionParserType } from '@/application/Parser/ApplicationParser';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import type { CategoryCollectionParser } from '@/application/Parser/CategoryCollectionParser';
import { CategoryCollectionStub } from './CategoryCollectionStub'; import { CategoryCollectionStub } from './CategoryCollectionStub';
export class CategoryCollectionParserStub { export class CategoryCollectionParserStub {
@@ -22,7 +22,7 @@ export class CategoryCollectionParserStub {
return this; return this;
} }
public getStub(): CategoryCollectionParserType { public getStub(): CategoryCollectionParser {
return (data: CollectionData, projectDetails: ProjectDetails): ICategoryCollection => { return (data: CollectionData, projectDetails: ProjectDetails): ICategoryCollection => {
this.arguments.push({ data, projectDetails }); this.arguments.push({ data, projectDetails });
const foundReturnValue = this.returnValues.get(data); const foundReturnValue = this.returnValues.get(data);

View File

@@ -0,0 +1,34 @@
import type { CategoryParser } from '@/application/Parser/Executable/CategoryParser';
import type { CategoryData } from '@/application/collections/';
import type { Category } from '@/domain/Executables/Category/Category';
import type { CategoryCollectionSpecificUtilities } from '@/application/Parser/Executable/CategoryCollectionSpecificUtilities';
import { CategoryStub } from './CategoryStub';
export class CategoryParserStub {
private configuredParseResults = new Map<CategoryData, Category>();
private usedUtilities = new Array<CategoryCollectionSpecificUtilities>();
public get(): CategoryParser {
return (category, utilities) => {
const result = this.configuredParseResults.get(category);
this.usedUtilities.push(utilities);
if (result) {
return result;
}
return new CategoryStub(5489);
};
}
public withConfiguredParseResult(
givenCategory: CategoryData,
parsedCategory: Category,
): this {
this.configuredParseResults.set(givenCategory, parsedCategory);
return this;
}
public getUsedUtilities(): readonly CategoryCollectionSpecificUtilities[] {
return this.usedUtilities;
}
}

View File

@@ -1,17 +1,17 @@
import type { IEnumParser } from '@/application/Common/Enum'; import type { EnumParser } from '@/application/Common/Enum';
export class EnumParserStub<T> implements IEnumParser<T> { export class EnumParserStub<T> implements EnumParser<T> {
private readonly scenarios = new Array<{ private readonly scenarios = new Array<{
inputName: string, inputValue: string, outputValue: T }>(); inputName: string, inputValue: string, outputValue: T }>();
private defaultValue: T; private defaultValue: T;
public setup(inputName: string, inputValue: string, outputValue: T) { public setup(inputName: string, inputValue: string, outputValue: T): this {
this.scenarios.push({ inputName, inputValue, outputValue }); this.scenarios.push({ inputName, inputValue, outputValue });
return this; return this;
} }
public setupDefaultValue(outputValue: T) { public setupDefaultValue(outputValue: T): this {
this.defaultValue = outputValue; this.defaultValue = outputValue;
return this; return this;
} }

View File

@@ -1,4 +1,4 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
export const errorWithContextWrapperStub export const errorWithContextWrapperStub
: ErrorWithContextWrapper = (error, message) => new Error(`[stubbed error wrapper] ${error.message} + ${message}`); : ErrorWithContextWrapper = (error, message) => new Error(`[stubbed error wrapper] ${error.message} + ${message}`);

View File

@@ -1,4 +1,4 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; import type { ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
export class ErrorWrapperStub { export class ErrorWrapperStub {
private errorToReturn: Error | undefined; private errorToReturn: Error | undefined;

View File

@@ -1,5 +1,5 @@
import type { ExecutableData } from '@/application/collections/';
import type { ExecutableValidator, ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator'; import type { ExecutableValidator, ExecutableValidatorFactory } from '@/application/Parser/Executable/Validation/ExecutableValidator';
import type { TypeValidator } from '@/application/Parser/Common/TypeValidator';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export const createExecutableValidatorFactoryStub export const createExecutableValidatorFactoryStub
@@ -23,10 +23,10 @@ export class ExecutableValidatorStub
return this; return this;
} }
public assertDefined(data: ExecutableData): this { public assertType(assert: (validator: TypeValidator) => void): this {
this.registerMethodCall({ this.registerMethodCall({
methodName: 'assertDefined', methodName: 'assertType',
args: [data], args: [assert],
}); });
return this; return this;
} }

View File

@@ -0,0 +1,45 @@
import type { NonEmptyCollectionAssertion, ObjectAssertion, TypeValidator } from '@/application/Parser/Common/TypeValidator';
import type { FunctionKeys } from '@/TypeHelpers';
import { expectDeepIncludes } from '@tests/shared/Assertions/ExpectDeepIncludes';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export type UnknownObjectAssertion = ObjectAssertion<unknown>;
export class TypeValidatorStub
extends StubWithObservableMethodCalls<TypeValidator>
implements TypeValidator {
public assertObject<T>(assertion: ObjectAssertion<T>): void {
this.registerMethodCall({
methodName: 'assertObject',
args: [assertion as UnknownObjectAssertion],
});
}
public assertNonEmptyCollection(assertion: NonEmptyCollectionAssertion): void {
this.registerMethodCall({
methodName: 'assertNonEmptyCollection',
args: [assertion],
});
}
public expectObjectAssertion<T>(
expectedAssertion: ObjectAssertion<T>,
): void {
this.expectAssertion('assertObject', expectedAssertion as UnknownObjectAssertion);
}
public expectNonEmptyCollectionAssertion(
expectedAssertion: NonEmptyCollectionAssertion,
): void {
this.expectAssertion('assertNonEmptyCollection', expectedAssertion);
}
private expectAssertion<T extends FunctionKeys<TypeValidator>>(
methodName: T,
expectedAssertion: Parameters<TypeValidator[T]>[0],
): void {
const assertionCalls = this.callHistory.filter((c) => c.methodName === methodName);
const assertionArgs = assertionCalls.map((c) => c.args[0]);
expectDeepIncludes(assertionArgs, expectedAssertion);
}
}